stromboli

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

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