stromboli

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

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()">&#x1F50D;</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 ? '&#x1F4C1;' : (file.isVideo ? '&#x1F3AC;' : '&#x1F4C4;');
    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 }