main.go (12696B)
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 ) 15 16 var rootDir string 17 18 type FileInfo struct { 19 Name string `json:"name"` 20 Path string `json:"path"` 21 IsDir bool `json:"isDir"` 22 IsVideo bool `json:"isVideo"` 23 CanPlay bool `json:"canPlay"` 24 } 25 26 // Video formats that browsers can typically play natively 27 var nativeFormats = map[string]bool{ 28 ".mp4": true, 29 ".webm": true, 30 ".ogg": true, 31 } 32 33 // All video formats we'll recognize 34 var videoFormats = map[string]bool{ 35 ".mp4": true, 36 ".webm": true, 37 ".ogg": true, 38 ".mkv": true, 39 ".avi": true, 40 ".mov": true, 41 ".wmv": true, 42 ".flv": true, 43 ".m4v": true, 44 ".mpg": true, 45 ".mpeg": true, 46 ".3gp": true, 47 } 48 49 func main() { 50 dir := flag.String("d", ".", "Directory to serve") 51 port := flag.String("p", "8080", "Port to listen on") 52 flag.Parse() 53 54 var err error 55 rootDir, err = filepath.Abs(*dir) 56 if err != nil { 57 log.Fatal("Invalid directory:", err) 58 } 59 60 if _, err := os.Stat(rootDir); os.IsNotExist(err) { 61 log.Fatal("Directory does not exist:", rootDir) 62 } 63 64 log.Printf("Serving directory: %s", rootDir) 65 log.Printf("Server starting on http://localhost:%s", *port) 66 67 http.HandleFunc("/", handleIndex) 68 http.HandleFunc("/api/browse", handleBrowse) 69 http.HandleFunc("/api/video/", handleVideo) 70 http.HandleFunc("/api/stream/", handleStream) 71 72 log.Fatal(http.ListenAndServe(":"+*port, nil)) 73 } 74 75 func handleIndex(w http.ResponseWriter, r *http.Request) { 76 tmpl := `<!DOCTYPE html> 77 <html> 78 <head> 79 <title>Stromboli</title> 80 <style> 81 * { margin: 0; padding: 0; box-sizing: border-box; } 82 body { 83 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 84 background: #1a1a1a; 85 color: #e0e0e0; 86 height: 100vh; 87 display: flex; 88 flex-direction: column; 89 } 90 header { 91 background: #2d2d2d; 92 padding: 1rem 2rem; 93 border-bottom: 2px solid #3d3d3d; 94 } 95 h1 { font-size: 1.5rem; color: #fff; } 96 .container { 97 display: flex; 98 flex: 1; 99 overflow: hidden; 100 } 101 .browser { 102 width: 350px; 103 background: #242424; 104 border-right: 1px solid #3d3d3d; 105 display: flex; 106 flex-direction: column; 107 overflow: hidden; 108 } 109 .breadcrumb { 110 padding: 1rem; 111 background: #2d2d2d; 112 border-bottom: 1px solid #3d3d3d; 113 font-size: 0.9rem; 114 overflow-x: auto; 115 white-space: nowrap; 116 } 117 .breadcrumb span { 118 color: #4a9eff; 119 cursor: pointer; 120 padding: 0.2rem 0.4rem; 121 border-radius: 3px; 122 } 123 .breadcrumb span:hover { background: #3d3d3d; } 124 .file-list { 125 flex: 1; 126 overflow-y: auto; 127 padding: 0.5rem; 128 } 129 .file-item { 130 padding: 0.75rem 1rem; 131 cursor: pointer; 132 border-radius: 4px; 133 margin-bottom: 0.25rem; 134 display: flex; 135 align-items: center; 136 gap: 0.5rem; 137 } 138 .file-item:hover { background: #2d2d2d; } 139 .file-item.active { background: #3d3d3d; } 140 .icon { 141 font-size: 1.2rem; 142 width: 24px; 143 text-align: center; 144 } 145 .player { 146 flex: 1; 147 display: flex; 148 align-items: center; 149 justify-content: center; 150 padding: 2rem; 151 } 152 video { 153 max-width: 100%; 154 max-height: 100%; 155 background: #000; 156 border-radius: 8px; 157 } 158 .empty-state { 159 text-align: center; 160 color: #666; 161 } 162 .empty-state h2 { font-size: 1.5rem; margin-bottom: 0.5rem; } 163 .loading { 164 text-align: center; 165 padding: 2rem; 166 color: #666; 167 } 168 .transcoding-notice { 169 position: absolute; 170 top: 1rem; 171 right: 1rem; 172 background: #ff9800; 173 color: #000; 174 padding: 0.5rem 1rem; 175 border-radius: 4px; 176 font-size: 0.9rem; 177 font-weight: 500; 178 } 179 </style> 180 </head> 181 <body> 182 <header> 183 <h1>Stromboli</h1> 184 </header> 185 <div class="container"> 186 <div class="browser"> 187 <div class="breadcrumb" id="breadcrumb"></div> 188 <div class="file-list" id="fileList"> 189 <div class="loading">Loading...</div> 190 </div> 191 </div> 192 <div class="player" id="player"> 193 <div class="empty-state"> 194 <h2>Select a video to play</h2> 195 <p>Browse the directory tree on the left</p> 196 </div> 197 </div> 198 </div> 199 200 <script> 201 let currentPath = ''; 202 let currentVideo = null; 203 204 function browse(path = '') { 205 currentPath = path; 206 fetch('/api/browse?path=' + encodeURIComponent(path)) 207 .then(r => r.json()) 208 .then(files => { 209 updateBreadcrumb(path); 210 renderFileList(files); 211 }) 212 .catch(err => { 213 document.getElementById('fileList').innerHTML = 214 '<div class="loading">Error loading directory</div>'; 215 }); 216 } 217 218 function updateBreadcrumb(path) { 219 const parts = path ? path.split('/').filter(p => p) : []; 220 const breadcrumb = document.getElementById('breadcrumb'); 221 222 let html = '<span onclick="browse(\'\')">Home</span>'; 223 let accumulated = ''; 224 225 parts.forEach(part => { 226 accumulated += (accumulated ? '/' : '') + part; 227 const thisPath = accumulated; 228 html += ' / <span onclick="browse(\'' + thisPath + '\')">' + part + '</span>'; 229 }); 230 231 breadcrumb.innerHTML = html; 232 } 233 234 function renderFileList(files) { 235 const list = document.getElementById('fileList'); 236 237 if (files.length === 0) { 238 list.innerHTML = '<div class="loading">Empty directory</div>'; 239 return; 240 } 241 242 // Sort: directories first, then files 243 files.sort((a, b) => { 244 if (a.isDir !== b.isDir) return b.isDir - a.isDir; 245 return a.name.localeCompare(b.name); 246 }); 247 248 list.innerHTML = files.map(file => { 249 const icon = file.isDir ? '📁' : (file.isVideo ? '🎬' : '📄'); 250 let onclick = ''; 251 let clickHandler = ''; 252 253 if (file.isDir) { 254 onclick = 'onclick="browse(\'' + file.path + '\')"'; 255 } else if (file.isVideo) { 256 onclick = 'onclick="playVideo(\'' + file.path + '\', ' + file.canPlay + ')"'; 257 } 258 259 return '<div class="file-item" ' + onclick + ' data-path="' + file.path + '">' + 260 '<span class="icon">' + icon + '</span>' + 261 '<span>' + file.name + '</span>' + 262 '</div>'; 263 }).join(''); 264 } 265 266 function playVideo(path, canPlayNatively) { 267 const player = document.getElementById('player'); 268 269 // Highlight selected file 270 document.querySelectorAll('.file-item').forEach(el => { 271 el.classList.toggle('active', el.dataset.path === path); 272 }); 273 274 const videoUrl = canPlayNatively 275 ? '/api/video/' + encodeURIComponent(path) 276 : '/api/stream/' + encodeURIComponent(path); 277 278 const transcodeNotice = canPlayNatively ? '' : 279 '<div class="transcoding-notice">Transcoding...</div>'; 280 281 player.innerHTML = transcodeNotice + 282 '<video controls autoplay>' + 283 '<source src="' + videoUrl + '" type="video/mp4">' + 284 'Your browser does not support the video tag.' + 285 '</video>'; 286 287 currentVideo = path; 288 } 289 290 // Initial load 291 browse(); 292 </script> 293 </body> 294 </html>` 295 296 w.Header().Set("Content-Type", "text/html") 297 fmt.Fprint(w, tmpl) 298 } 299 300 func handleBrowse(w http.ResponseWriter, r *http.Request) { 301 path := r.URL.Query().Get("path") 302 fullPath := filepath.Join(rootDir, path) 303 304 // Security check: ensure we're not escaping the root directory 305 if !strings.HasPrefix(filepath.Clean(fullPath), filepath.Clean(rootDir)) { 306 http.Error(w, "Invalid path", http.StatusBadRequest) 307 return 308 } 309 310 entries, err := os.ReadDir(fullPath) 311 if err != nil { 312 http.Error(w, "Cannot read directory", http.StatusInternalServerError) 313 return 314 } 315 316 var files []FileInfo 317 for _, entry := range entries { 318 info, err := entry.Info() 319 if err != nil { 320 continue 321 } 322 323 // Skip hidden files 324 if strings.HasPrefix(entry.Name(), ".") { 325 continue 326 } 327 328 ext := strings.ToLower(filepath.Ext(entry.Name())) 329 isVideo := videoFormats[ext] 330 canPlay := nativeFormats[ext] 331 332 relativePath := filepath.Join(path, entry.Name()) 333 334 files = append(files, FileInfo{ 335 Name: entry.Name(), 336 Path: relativePath, 337 IsDir: info.IsDir(), 338 IsVideo: isVideo, 339 CanPlay: canPlay, 340 }) 341 } 342 343 w.Header().Set("Content-Type", "application/json") 344 json.NewEncoder(w).Encode(files) 345 } 346 347 func handleVideo(w http.ResponseWriter, r *http.Request) { 348 path := strings.TrimPrefix(r.URL.Path, "/api/video/") 349 fullPath := filepath.Join(rootDir, path) 350 351 // Security check 352 if !strings.HasPrefix(filepath.Clean(fullPath), filepath.Clean(rootDir)) { 353 http.Error(w, "Invalid path", http.StatusBadRequest) 354 return 355 } 356 357 // Serve the file directly 358 http.ServeFile(w, r, fullPath) 359 } 360 361 func handleStream(w http.ResponseWriter, r *http.Request) { 362 path := strings.TrimPrefix(r.URL.Path, "/api/stream/") 363 fullPath := filepath.Join(rootDir, path) 364 365 // Security check 366 if !strings.HasPrefix(filepath.Clean(fullPath), filepath.Clean(rootDir)) { 367 http.Error(w, "Invalid path", http.StatusBadRequest) 368 return 369 } 370 371 // Check if file exists 372 if _, err := os.Stat(fullPath); os.IsNotExist(err) { 373 http.Error(w, "File not found", http.StatusNotFound) 374 return 375 } 376 377 // Set headers for streaming 378 w.Header().Set("Content-Type", "video/mp4") 379 w.Header().Set("Cache-Control", "no-cache") 380 381 // FFmpeg command to transcode to H.264/AAC MP4 382 cmd := exec.Command("ffmpeg", 383 "-re", // Read input at native frame rate 384 "-i", fullPath, 385 "-map", "0:v:0", // First video stream only 386 "-map", "0:a:0", // First audio stream only 387 "-c:v", "libx264", 388 "-preset", "ultrafast", 389 "-tune", "zerolatency", 390 "-crf", "23", 391 "-maxrate", "3M", 392 "-bufsize", "6M", 393 "-pix_fmt", "yuv420p", 394 "-c:a", "aac", 395 "-b:a", "128k", 396 "-ac", "2", // Stereo audio 397 "-movflags", "frag_keyframe+empty_moov+faststart", 398 "-f", "mp4", 399 "-loglevel", "warning", 400 "pipe:1", 401 ) 402 403 // Capture stderr for debugging 404 stderr, err := cmd.StderrPipe() 405 if err != nil { 406 log.Printf("Error creating stderr pipe: %v", err) 407 http.Error(w, "Transcoding error", http.StatusInternalServerError) 408 return 409 } 410 411 // Get stdout pipe 412 stdout, err := cmd.StdoutPipe() 413 if err != nil { 414 log.Printf("Error creating stdout pipe: %v", err) 415 http.Error(w, "Transcoding error", http.StatusInternalServerError) 416 return 417 } 418 419 // Start the command 420 if err := cmd.Start(); err != nil { 421 log.Printf("Error starting ffmpeg: %v", err) 422 http.Error(w, "Transcoding error", http.StatusInternalServerError) 423 return 424 } 425 426 // Log stderr in background 427 go func() { 428 buf := make([]byte, 4096) 429 for { 430 n, err := stderr.Read(buf) 431 if n > 0 { 432 log.Printf("FFmpeg: %s", string(buf[:n])) 433 } 434 if err != nil { 435 break 436 } 437 } 438 }() 439 440 // Monitor for client disconnect and kill ffmpeg if needed 441 done := make(chan bool) 442 go func() { 443 // Copy output to response 444 _, err = io.Copy(w, stdout) 445 if err != nil { 446 log.Printf("Error streaming video: %v", err) 447 } 448 done <- true 449 }() 450 451 // Wait for either completion or context cancellation 452 select { 453 case <-done: 454 // Streaming finished normally 455 case <-r.Context().Done(): 456 // Client disconnected 457 log.Printf("Client disconnected, killing ffmpeg process for: %s", path) 458 if err := cmd.Process.Kill(); err != nil { 459 log.Printf("Error killing ffmpeg: %v", err) 460 } 461 } 462 463 // Wait for command to finish 464 if err := cmd.Wait(); err != nil { 465 // Don't log error if we killed the process intentionally 466 if r.Context().Err() == nil { 467 log.Printf("FFmpeg error: %v", err) 468 } 469 } 470 }