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()">🔍</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 ? '📁' : (file.isVideo ? '🎬' : '📄'); 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 }