stromboli

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs

main.go (20892B)


      1 package main
      2 
      3 import (
      4 	"encoding/json"
      5 	"flag"
      6 	"fmt"
      7 	"io"
      8 	"log"
      9 	"net/http"
     10 	"os"
     11 	"os/exec"
     12 	"path/filepath"
     13 	"strings"
     14 	"sync"
     15 )
     16 
     17 var rootDir string
     18 var (
     19 	transcodeMutex sync.Mutex
     20 	activeCmd      *exec.Cmd
     21 )
     22 
     23 type FileInfo struct {
     24 	Name     string `json:"name"`
     25 	Path     string `json:"path"`
     26 	IsDir    bool   `json:"isDir"`
     27 	IsVideo  bool   `json:"isVideo"`
     28 	CanPlay  bool   `json:"canPlay"`
     29 	NeedsTranscode bool `json:"needsTranscode"`
     30 }
     31 
     32 // Video formats that browsers can typically play natively
     33 var nativeFormats = map[string]bool{
     34 	".mp4":  true,
     35 	".webm": true,
     36 	".ogg":  true,
     37 }
     38 
     39 // All video formats we'll recognize
     40 var videoFormats = map[string]bool{
     41 	".mp4":  true,
     42 	".webm": true,
     43 	".ogg":  true,
     44 	".mkv":  true,
     45 	".avi":  true,
     46 	".mov":  true,
     47 	".wmv":  true,
     48 	".flv":  true,
     49 	".m4v":  true,
     50 	".mpg":  true,
     51 	".mpeg": true,
     52 	".3gp":  true,
     53 }
     54 
     55 func main() {
     56 	dir := flag.String("d", ".", "Directory to serve")
     57 	port := flag.String("p", "8080", "Port to listen on")
     58 	flag.Parse()
     59 
     60 	var err error
     61 	rootDir, err = filepath.Abs(*dir)
     62 	if err != nil {
     63 		log.Fatal("Invalid directory:", err)
     64 	}
     65 
     66 	if _, err := os.Stat(rootDir); os.IsNotExist(err) {
     67 		log.Fatal("Directory does not exist:", rootDir)
     68 	}
     69 
     70 	log.Printf("Serving directory: %s", rootDir)
     71 	log.Printf("Server starting on http://localhost:%s", *port)
     72 
     73 	http.HandleFunc("/", handleIndex)
     74 	http.HandleFunc("/api/browse", handleBrowse)
     75 	http.HandleFunc("/api/video/", handleVideo)
     76 	http.HandleFunc("/api/stream/", handleStream)
     77 
     78 	log.Fatal(http.ListenAndServe(":"+*port, nil))
     79 }
     80 
     81 func handleIndex(w http.ResponseWriter, r *http.Request) {
     82 	tmpl := `<!DOCTYPE html>
     83 <html>
     84 <head>
     85     <title>Stromboli</title>
     86     <style>
     87         * { margin: 0; padding: 0; box-sizing: border-box; }
     88         html, body { width: 100%; height: 100%; overflow: hidden; }
     89         body {
     90             font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
     91             background: #1a1a1a;
     92             color: #e0e0e0;
     93             min-height: 100svh;
     94             display: flex;
     95             flex-direction: column;
     96         }
     97         header {
     98             background: #2d2d2d;
     99             padding: 1rem 2rem;
    100             border-bottom: 2px solid #3d3d3d;
    101         }
    102         h1 { font-size: 1.5rem; color: #fff; }
    103         .container {
    104             display: flex;
    105             flex: 1 1 auto;
    106             min-height: 0;
    107             overflow: hidden;
    108         }
    109         .browser {
    110             width: clamp(240px, 30vw, 350px);
    111             background: #242424;
    112             border-right: 1px solid #3d3d3d;
    113             display: flex;
    114             flex-direction: column;
    115             overflow: hidden;
    116             min-height: 0;
    117         }
    118         .breadcrumb {
    119             padding: 1rem;
    120             background: #2d2d2d;
    121             border-bottom: 1px solid #3d3d3d;
    122             font-size: 0.9rem;
    123             display: flex;
    124             align-items: center;
    125             justify-content: space-between;
    126             gap: 0.5rem;
    127         }
    128         .breadcrumb-path {
    129             flex: 1;
    130             overflow: hidden;
    131             white-space: nowrap;
    132             text-overflow: ellipsis;
    133             min-width: 0;
    134         }
    135         .breadcrumb span {
    136             color: #4a9eff;
    137             cursor: pointer;
    138             padding: 0.2rem 0.4rem;
    139             border-radius: 3px;
    140             text-transform: capitalize;
    141         }
    142         .breadcrumb span:hover { background: #3d3d3d; }
    143         .filter-toggle {
    144             background: #3d3d3d;
    145             border: none;
    146             color: #e0e0e0;
    147             padding: 0.5rem 0.75rem;
    148             border-radius: 4px;
    149             cursor: pointer;
    150             font-size: 0.9rem;
    151             margin-left: 0.5rem;
    152             flex-shrink: 0;
    153         }
    154         .filter-toggle:hover { background: #4d4d4d; }
    155         .filter-toggle.active { background: #4a9eff; color: #000; }
    156         .filter-bar {
    157             padding: 0.75rem 1rem;
    158             background: #2d2d2d;
    159             border-bottom: 1px solid #3d3d3d;
    160             display: none;
    161         }
    162         .filter-bar.visible { display: block; }
    163         .filter-input {
    164             width: 100%;
    165             padding: 0.5rem;
    166             background: #1a1a1a;
    167             border: 1px solid #3d3d3d;
    168             border-radius: 4px;
    169             color: #e0e0e0;
    170             font-size: 0.9rem;
    171         }
    172         .filter-input:focus {
    173             outline: none;
    174             border-color: #4a9eff;
    175         }
    176         .filter-input::placeholder { color: #666; }
    177         .file-list {
    178             flex: 1 1 auto;
    179             overflow-y: auto;
    180             padding: 0.5rem;
    181             min-height: 0;
    182             overscroll-behavior: contain;
    183             -webkit-overflow-scrolling: touch;
    184         }
    185         .file-item {
    186             padding: 0.75rem 1rem;
    187             cursor: pointer;
    188             border-radius: 4px;
    189             margin-bottom: 0.25rem;
    190             display: flex;
    191             align-items: center;
    192             gap: 0.5rem;
    193         }
    194         .file-item:hover { background: #2d2d2d; }
    195         .file-item.active { background: #3d3d3d; }
    196         .icon {
    197             font-size: 1.2rem;
    198             width: 24px;
    199             text-align: center;
    200         }
    201         .player {
    202             flex: 1 1 auto;
    203             display: flex;
    204             align-items: center;
    205             justify-content: center;
    206             padding: 2rem;
    207             min-height: 0;
    208             overflow: hidden;
    209         }
    210         video {
    211             max-width: 100%;
    212             max-height: 100%;
    213             background: #000;
    214             border-radius: 8px;
    215         }
    216         .empty-state {
    217             text-align: center;
    218             color: #666;
    219         }
    220         .empty-state h2 { font-size: 1.5rem; margin-bottom: 0.5rem; }
    221         .loading {
    222             text-align: center;
    223             padding: 2rem;
    224             color: #666;
    225         }
    226         .transcoding-notice {
    227             position: absolute;
    228             top: 1rem;
    229             right: 1rem;
    230             background: #ff9800;
    231             color: #000;
    232             padding: 0.5rem 1rem;
    233             border-radius: 4px;
    234             font-size: 0.9rem;
    235             font-weight: 500;
    236         }
    237 		@media (max-width: 768px) {
    238 			.container {
    239 				flex-direction: column;
    240 			}
    241 
    242 			.browser {
    243 				width: 100%;
    244 				max-height: 40svh;
    245 				border-right: none;
    246 				border-bottom: 1px solid #3d3d3d;
    247 			}
    248 
    249 			.player {
    250 				padding: 1rem;
    251 			}
    252 
    253 			header {
    254 				padding: 0.75rem 1rem;
    255 			}
    256 
    257 			h1 {
    258 				font-size: 1.25rem;
    259 			}
    260 			.file-item {
    261 				padding: 1rem;
    262 				font-size: 1rem;
    263 			}
    264 
    265 			.breadcrumb span {
    266 				padding: 0.4rem 0.6rem;
    267 			}
    268 			.transcoding-notice {
    269 				top: auto;
    270 				bottom: 1rem;
    271 				right: 50%;
    272 				transform: translateX(50%);
    273 				font-size: 0.8rem;
    274 			}
    275 		}
    276     </style>
    277     <meta name="viewport" content="width=device-width, initial-scale=1.0">
    278 </head>
    279 <body>
    280     <header>
    281         <h1>Stromboli</h1>
    282     </header>
    283     <div class="container">
    284         <div class="browser">
    285             <div class="breadcrumb" id="breadcrumb">
    286                 <div class="breadcrumb-path" id="breadcrumbPath"></div>
    287                 <button class="filter-toggle" id="filterToggle" onclick="toggleFilter()">&#x1F50D;</button>
    288             </div>
    289             <div class="filter-bar" id="filterBar">
    290                 <input type="text" class="filter-input" id="filterInput" placeholder="Filter files and folders..." oninput="applyFilter()">
    291             </div>
    292             <div class="file-list" id="fileList">
    293                 <div class="loading">Loading...</div>
    294             </div>
    295         </div>
    296         <div class="player" id="player">
    297             <div class="empty-state">
    298                 <h2>Select a video to play</h2>
    299                 <p>Browse the directory tree on the left</p>
    300             </div>
    301         </div>
    302     </div>
    303 
    304     <script>
    305         let currentPath = '';
    306         let currentVideo = null;
    307         let allFiles = [];
    308         let filterVisible = false;
    309 
    310         function toggleFilter() {
    311             filterVisible = !filterVisible;
    312             const filterBar = document.getElementById('filterBar');
    313             const filterToggle = document.getElementById('filterToggle');
    314             const filterInput = document.getElementById('filterInput');
    315 
    316             if (filterVisible) {
    317                 filterBar.classList.add('visible');
    318                 filterToggle.classList.add('active');
    319                 filterInput.focus();
    320             } else {
    321                 filterBar.classList.remove('visible');
    322                 filterToggle.classList.remove('active');
    323                 filterInput.value = '';
    324                 renderFileList(allFiles);
    325             }
    326         }
    327 
    328         function applyFilter() {
    329             const filterText = document.getElementById('filterInput').value.toLowerCase();
    330 
    331             if (!filterText) {
    332                 renderFileList(allFiles);
    333                 return;
    334             }
    335 
    336             const filtered = allFiles.filter(file =>
    337                 file.name.toLowerCase().includes(filterText)
    338             );
    339 
    340             renderFileList(filtered);
    341         }
    342 
    343         function browse(path = '') {
    344             currentPath = path;
    345             fetch('/api/browse?path=' + encodeURIComponent(path))
    346                 .then(r => r.json())
    347                 .then(files => {
    348                     allFiles = files;
    349                     updateBreadcrumb(path);
    350 
    351                     // Clear filter when changing directories
    352                     document.getElementById('filterInput').value = '';
    353                     renderFileList(files);
    354                 })
    355                 .catch(err => {
    356                     document.getElementById('fileList').innerHTML =
    357                         '<div class="loading">Error loading directory</div>';
    358                 });
    359         }
    360 
    361         function updateBreadcrumb(path) {
    362             const parts = path ? path.split('/').filter(p => p) : [];
    363             const breadcrumbPath = document.getElementById('breadcrumbPath');
    364 
    365             let html = '<span onclick="browse(\'\')">Home</span>';
    366             let accumulated = '';
    367 
    368             parts.forEach(part => {
    369                 accumulated += (accumulated ? '/' : '') + part;
    370                 const thisPath = accumulated;
    371                 html += ' / <span onclick="browse(\'' + thisPath + '\')">' + part + '</span>';
    372             });
    373 
    374             breadcrumbPath.innerHTML = html;
    375         }
    376 
    377         function renderFileList(files) {
    378             const list = document.getElementById('fileList');
    379 
    380             if (files.length === 0) {
    381                 list.innerHTML = '<div class="loading">No matches found</div>';
    382                 return;
    383             }
    384 
    385             // Sort: directories first, then files
    386             files.sort((a, b) => {
    387                 if (a.isDir !== b.isDir) return b.isDir - a.isDir;
    388                 return a.name.localeCompare(b.name);
    389             });
    390 
    391             list.innerHTML = files.map(file => {
    392                 const icon = file.isDir ? '&#x1F4C1;' : (file.isVideo ? '&#x1F3AC;' : '&#x1F4C4;');
    393                 let onclick = '';
    394                 let clickHandler = '';
    395 
    396                 if (file.isDir) {
    397                     onclick = 'onclick="browse(\'' + file.path + '\')"';
    398                 } else if (file.isVideo) {
    399                     onclick = 'onclick="playVideo(\'' + file.path + '\', ' + file.canPlay + ')"';
    400                 }
    401 
    402                 return '<div class="file-item" ' + onclick + ' data-path="' + file.path + '">' +
    403                     '<span class="icon">' + icon + '</span>' +
    404                     '<span>' + file.name + '</span>' +
    405                     '</div>';
    406             }).join('');
    407         }
    408 
    409         function playVideo(path, canPlayNatively) {
    410             const player = document.getElementById('player');
    411             let videoElement = document.getElementById('activeVideo');
    412 
    413             // Highlight selected file
    414             document.querySelectorAll('.file-item').forEach(el => {
    415                 el.classList.toggle('active', el.dataset.path === path);
    416             });
    417 
    418             const videoUrl = canPlayNatively
    419                 ? '/api/video/' + encodeURIComponent(path)
    420                 : '/api/stream/' + encodeURIComponent(path);
    421 
    422             const transcodeNotice = canPlayNatively ? '' :
    423                 '<div class="transcoding-notice">Transcoding...</div>';
    424 
    425             // If video element already exists, just swap the source
    426             if (videoElement) {
    427                 // Update transcode notice
    428                 const existingNotice = player.querySelector('.transcoding-notice');
    429                 if (transcodeNotice && !existingNotice) {
    430                     const noticeDiv = document.createElement('div');
    431                     noticeDiv.className = 'transcoding-notice';
    432                     noticeDiv.textContent = 'Transcoding...';
    433                     player.insertBefore(noticeDiv, videoElement);
    434                 } else if (!transcodeNotice && existingNotice) {
    435                     existingNotice.remove();
    436                 }
    437 
    438                 // Swap the source
    439                 videoElement.src = videoUrl;
    440                 videoElement.load();
    441                 videoElement.play();
    442             } else {
    443                 // First time playing - create the video element
    444                 player.innerHTML = transcodeNotice +
    445                     '<video controls autoplay id="activeVideo">' +
    446                         '<source src="' + videoUrl + '" type="video/mp4">' +
    447                         'Your browser does not support the video tag.' +
    448                     '</video>';
    449 
    450                 videoElement = document.getElementById('activeVideo');
    451 
    452                 // Add event listener for when video ends (only needs to be added once)
    453                 videoElement.addEventListener('ended', function() {
    454                     playNextVideo();
    455                 });
    456             }
    457 
    458             currentVideo = path;
    459         }
    460 
    461         function playNextVideo() {
    462             // Find the current video in the file list
    463             const currentIndex = allFiles.findIndex(f => f.path === currentVideo);
    464 
    465             if (currentIndex === -1) return;
    466 
    467             // Find the next video file after the current one
    468             for (let i = currentIndex + 1; i < allFiles.length; i++) {
    469                 if (allFiles[i].isVideo && !allFiles[i].isDir) {
    470                     // Found next video, play it
    471                     playVideo(allFiles[i].path, allFiles[i].canPlay);
    472 
    473                     // Scroll the file list to show the now-playing video
    474                     const fileItems = document.querySelectorAll('.file-item');
    475                     const nextItem = Array.from(fileItems).find(
    476                         item => item.dataset.path === allFiles[i].path
    477                     );
    478                     if (nextItem) {
    479                         nextItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
    480                     }
    481                     return;
    482                 }
    483             }
    484 
    485             // No next video found
    486             console.log('No more videos to play');
    487         }
    488 
    489         // Initial load
    490         browse();
    491     </script>
    492 </body>
    493 </html>`
    494 
    495 	w.Header().Set("Content-Type", "text/html")
    496 	fmt.Fprint(w, tmpl)
    497 }
    498 
    499 func needsTranscoding(filePath string) bool {
    500 	// Use ffprobe to check audio codec
    501 	cmd := exec.Command("ffprobe",
    502 		"-v", "error",
    503 		"-select_streams", "a:0",
    504 		"-show_entries", "stream=codec_name",
    505 		"-of", "default=noprint_wrappers=1:nokey=1",
    506 		filePath,
    507 	)
    508 
    509 	output, err := cmd.Output()
    510 	if err != nil {
    511 		// If we can't determine, assume it needs transcoding
    512 		return true
    513 	}
    514 
    515 	audioCodec := strings.TrimSpace(string(output))
    516 
    517 	// Browser-compatible audio codecs
    518 	compatibleAudio := map[string]bool{
    519 		"aac":  true,
    520 		"mp3":  true,
    521 		"opus": true,
    522 		"vorbis": true,
    523 	}
    524 
    525 	return !compatibleAudio[audioCodec]
    526 }
    527 
    528 func handleBrowse(w http.ResponseWriter, r *http.Request) {
    529 	path := r.URL.Query().Get("path")
    530 	fullPath := filepath.Join(rootDir, path)
    531 
    532 	// Security check: ensure we're not escaping the root directory
    533 	if !strings.HasPrefix(filepath.Clean(fullPath), filepath.Clean(rootDir)) {
    534 		http.Error(w, "Invalid path", http.StatusBadRequest)
    535 		return
    536 	}
    537 
    538 	entries, err := os.ReadDir(fullPath)
    539 	if err != nil {
    540 		http.Error(w, "Cannot read directory", http.StatusInternalServerError)
    541 		return
    542 	}
    543 
    544 	var files []FileInfo
    545 	for _, entry := range entries {
    546 		info, err := entry.Info()
    547 		if err != nil {
    548 			continue
    549 		}
    550 
    551 		// Skip hidden files
    552 		if strings.HasPrefix(entry.Name(), ".") {
    553 			continue
    554 		}
    555 
    556 		ext := strings.ToLower(filepath.Ext(entry.Name()))
    557 		isVideo := videoFormats[ext]
    558 		canPlay := nativeFormats[ext]
    559 		needsTranscode := false
    560 
    561 		relativePath := filepath.Join(path, entry.Name())
    562 		fullFilePath := filepath.Join(rootDir, relativePath)
    563 
    564 		if canPlay && isVideo && !info.IsDir() {
    565 			needsTranscode = needsTranscoding(fullFilePath)
    566 			if needsTranscode {
    567 				canPlay = false // Mark as needing transcode route
    568 			}
    569 		}
    570 
    571 		files = append(files, FileInfo{
    572 			Name:    entry.Name(),
    573 			Path:    relativePath,
    574 			IsDir:   info.IsDir(),
    575 			IsVideo: isVideo,
    576 			CanPlay: canPlay,
    577 			NeedsTranscode: needsTranscode,
    578 		})
    579 	}
    580 
    581 	w.Header().Set("Content-Type", "application/json")
    582 	json.NewEncoder(w).Encode(files)
    583 }
    584 
    585 func handleVideo(w http.ResponseWriter, r *http.Request) {
    586 	path := strings.TrimPrefix(r.URL.Path, "/api/video/")
    587 	fullPath := filepath.Join(rootDir, path)
    588 
    589 	// Security check
    590 	if !strings.HasPrefix(filepath.Clean(fullPath), filepath.Clean(rootDir)) {
    591 		http.Error(w, "Invalid path", http.StatusBadRequest)
    592 		return
    593 	}
    594 
    595 	// Serve the file directly
    596 	http.ServeFile(w, r, fullPath)
    597 }
    598 
    599 func handleStream(w http.ResponseWriter, r *http.Request) {
    600 	path := strings.TrimPrefix(r.URL.Path, "/api/stream/")
    601 	fullPath := filepath.Join(rootDir, path)
    602 
    603 	// Security check
    604 	if !strings.HasPrefix(filepath.Clean(fullPath), filepath.Clean(rootDir)) {
    605 		http.Error(w, "Invalid path", http.StatusBadRequest)
    606 		return
    607 	}
    608 
    609 	// Check if file exists
    610 	if _, err := os.Stat(fullPath); os.IsNotExist(err) {
    611 		http.Error(w, "File not found", http.StatusNotFound)
    612 		return
    613 	}
    614 
    615 	// Kill any existing transcoding process before starting a new one
    616 	transcodeMutex.Lock()
    617 	if activeCmd != nil && activeCmd.Process != nil {
    618 		log.Printf("Killing existing ffmpeg process to start new transcode")
    619 		activeCmd.Process.Kill()
    620 		activeCmd.Wait() // Wait for it to fully exit
    621 		activeCmd = nil
    622 	}
    623 	transcodeMutex.Unlock()
    624 
    625 	// Set headers for streaming
    626 	w.Header().Set("Content-Type", "video/mp4")
    627 	w.Header().Set("Cache-Control", "no-cache")
    628 
    629 	// FFmpeg command to transcode to H.264/AAC MP4
    630 	cmd := exec.Command("ffmpeg",
    631 		"-re", // Read input at native frame rate
    632 		"-i", fullPath,
    633 		"-map", "0:v:0", // First video stream only
    634 		"-map", "0:a:0", // First audio stream only
    635 		"-c:v", "libx264",
    636 		"-preset", "ultrafast",
    637 		"-tune", "zerolatency",
    638 		"-crf", "23",
    639 		"-maxrate", "3M",
    640 		"-bufsize", "6M",
    641 		"-pix_fmt", "yuv420p",
    642 		"-c:a", "aac",
    643 		"-b:a", "128k",
    644 		"-ac", "2", // Stereo audio
    645 		"-movflags", "frag_keyframe+empty_moov+faststart",
    646 		"-f", "mp4",
    647 		"-loglevel", "warning",
    648 		"pipe:1",
    649 	)
    650 
    651 	// Track this as the active command
    652 	transcodeMutex.Lock()
    653 	activeCmd = cmd
    654 	transcodeMutex.Unlock()
    655 
    656 	// Capture stderr for debugging
    657 	stderr, err := cmd.StderrPipe()
    658 	if err != nil {
    659 		log.Printf("Error creating stderr pipe: %v", err)
    660 		http.Error(w, "Transcoding error", http.StatusInternalServerError)
    661 		return
    662 	}
    663 
    664 	// Get stdout pipe
    665 	stdout, err := cmd.StdoutPipe()
    666 	if err != nil {
    667 		log.Printf("Error creating stdout pipe: %v", err)
    668 		http.Error(w, "Transcoding error", http.StatusInternalServerError)
    669 		return
    670 	}
    671 
    672 	// Start the command
    673 	if err := cmd.Start(); err != nil {
    674 		log.Printf("Error starting ffmpeg: %v", err)
    675 		http.Error(w, "Transcoding error", http.StatusInternalServerError)
    676 		return
    677 	}
    678 
    679 	// Log stderr in background
    680 	go func() {
    681 		buf := make([]byte, 4096)
    682 		for {
    683 			n, err := stderr.Read(buf)
    684 			if n > 0 {
    685 				log.Printf("FFmpeg: %s", string(buf[:n]))
    686 			}
    687 			if err != nil {
    688 				break
    689 			}
    690 		}
    691 	}()
    692 
    693 	// Monitor for client disconnect and kill ffmpeg if needed
    694 	done := make(chan bool)
    695 	go func() {
    696 		// Copy output to response
    697 		_, err = io.Copy(w, stdout)
    698 		if err != nil {
    699 			log.Printf("Error streaming video: %v", err)
    700 		}
    701 		done <- true
    702 	}()
    703 
    704 	// Wait for either completion or context cancellation
    705 	select {
    706 	case <-done:
    707 		// Streaming finished normally
    708 	case <-r.Context().Done():
    709 		// Client disconnected
    710 		log.Printf("Client disconnected, killing ffmpeg process for: %s", path)
    711 		if err := cmd.Process.Kill(); err != nil {
    712 			log.Printf("Error killing ffmpeg: %v", err)
    713 		}
    714 	}
    715 
    716 	// Clean up active command reference
    717 	transcodeMutex.Lock()
    718 	if activeCmd == cmd {
    719 		activeCmd = nil
    720 	}
    721 	transcodeMutex.Unlock()
    722 
    723 	// Wait for command to finish
    724 	if err := cmd.Wait(); err != nil {
    725 		// Don't log error if we killed the process intentionally
    726 		if r.Context().Err() == nil {
    727 			log.Printf("FFmpeg error: %v", err)
    728 		}
    729 	}
    730 }