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