package service import ( "context" "encoding/json" "fmt" "log" "math" "net/url" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "sync" "sync/atomic" "time" "github.com/BurntSushi/toml" "vidarchive/internal/models" ) const itemMarkerName = ".vidarchive-item.toml" const subtitlesDirName = "subtitles" var mediaExts = map[string]bool{ ".mp4": true, ".webm": true, ".mkv": true, ".avi": true, ".mov": true, ".mp3": true, ".wav": true, ".flac": true, ".aac": true, ".opus": true, } var imageExts = map[string]bool{ ".webp": true, ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".bmp": true, } // maxConcurrentThumbnails caps how many ffmpeg extraction processes may run at // once, so a freshly loaded library page (which fires one thumbnail request per // visible item) cannot spawn an unbounded ffmpeg storm. const maxConcurrentThumbnails = 3 type LibraryService struct { libraryDir string // thumbLocks maps a media filepath -> *sync.Mutex to serialize extraction // per file (so two callers never write the same temp file at once). Entries // are bounded by the number of distinct media files ever requested, not by // request volume; entries for deleted items are not reclaimed. We // deliberately do not prune: dropping a lock would let two extractions of // the same file race on its shared temp path, which is exactly what the lock // prevents. thumbLocks sync.Map thumbSem chan struct{} // thumbFailed records media filepaths whose extraction already failed this // run, so we trust ffmpeg's verdict and don't re-run it on every request. It // lives only in memory: a restart clears it and retries extraction (and any // success is then cached on disk as the .thumbnail file). thumbFailed sync.Map // extraction counters, for observability/tests. extractInFlight int32 extractMaxConcurrent int32 extractAttempts int32 // scanCache memoizes scanned items for a short TTL so the listing page (which // fans out one thumbnail request per media file) and quick auto-refreshes // don't re-parse each item's marker + info.json on every request. Only item // scans are cached — the directory listing itself is always read fresh, so // newly added/removed items and subfolders appear immediately. scanMu sync.Mutex scanCache map[string]scanCacheEntry scanTTL time.Duration } type scanCacheEntry struct { item *models.LibraryItem at time.Time } // scanCacheTTL is how long a scanned item is reused before being re-read. const scanCacheTTL = 10 * time.Second func NewLibraryService(libraryDir string) *LibraryService { return &LibraryService{ libraryDir: libraryDir, thumbSem: make(chan struct{}, maxConcurrentThumbnails), scanCache: make(map[string]scanCacheEntry), scanTTL: scanCacheTTL, } } func (s *LibraryService) getCachedScan(relPath string) (*models.LibraryItem, bool) { if s.scanTTL <= 0 { return nil, false } s.scanMu.Lock() defer s.scanMu.Unlock() e, ok := s.scanCache[relPath] if !ok || time.Since(e.at) > s.scanTTL { return nil, false } return e.item, true } func (s *LibraryService) putCachedScan(relPath string, item *models.LibraryItem) { if s.scanTTL <= 0 { return } s.scanMu.Lock() s.scanCache[relPath] = scanCacheEntry{item: item, at: time.Now()} s.scanMu.Unlock() } func (s *LibraryService) evictCachedScan(relPath string) { s.scanMu.Lock() delete(s.scanCache, relPath) s.scanMu.Unlock() } // scannedItem returns a cached scan if fresh, otherwise scans and caches it. func (s *LibraryService) scannedItem(itemDir, relPath string) (*models.LibraryItem, error) { if item, ok := s.getCachedScan(relPath); ok { return item, nil } item, err := s.scanItem(itemDir, relPath) if err != nil { return nil, err } s.putCachedScan(relPath, item) return item, nil } func (s *LibraryService) resolveItemDir(relPath string) (string, error) { relPath = strings.Trim(relPath, string(filepath.Separator)) if relPath == "" { return s.libraryDir, nil } itemDir := filepath.Join(s.libraryDir, relPath) cleanDir, err := filepath.EvalSymlinks(itemDir) if err != nil { cleanDir = filepath.Clean(itemDir) } base, err := filepath.EvalSymlinks(s.libraryDir) if err != nil { base = filepath.Clean(s.libraryDir) } if !strings.HasPrefix(cleanDir, base+string(filepath.Separator)) && cleanDir != base { return "", fmt.Errorf("invalid path") } // Return the cleaned/symlink-resolved path we just validated, so callers do // I/O on exactly the path that passed the boundary check. return cleanDir, nil } func (s *LibraryService) GetAll(path, sortBy, filter string) ([]*models.LibraryItem, []string, error) { if path != "" && !strings.HasSuffix(path, "/") { path += "/" } dir, err := s.resolveItemDir(path) if err != nil { return nil, nil, nil } entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { return nil, nil, nil } return nil, nil, err } var items []*models.LibraryItem var folders []string for _, entry := range entries { if !entry.IsDir() { continue } name := entry.Name() if name == subtitlesDirName { continue } itemDir := filepath.Join(dir, name) relPath := filepath.ToSlash(filepath.Join(path, name)) markerPath := filepath.Join(itemDir, itemMarkerName) if _, err := os.Stat(markerPath); err == nil { item, err := s.scannedItem(itemDir, relPath) if err != nil { log.Printf("warning: failed to scan item %s: %v", relPath, err) continue } if filter != "" && !strings.Contains(strings.ToLower(item.Name), strings.ToLower(filter)) && !strings.Contains(strings.ToLower(item.RelPath), strings.ToLower(filter)) { continue } items = append(items, item) } else { folders = append(folders, name) } } switch sortBy { case "title": sort.Slice(items, func(i, j int) bool { return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name) }) case "duration": sort.Slice(items, func(i, j int) bool { return items[i].Duration > items[j].Duration }) case "date": fallthrough default: // Stat each item once up front rather than twice per comparison. modTime := make(map[string]int64, len(items)) for _, it := range items { if info, err := os.Stat(it.DirPath); err == nil { modTime[it.RelPath] = info.ModTime().UnixNano() } } sort.Slice(items, func(i, j int) bool { return modTime[items[i].RelPath] > modTime[items[j].RelPath] }) } sort.Strings(folders) return items, folders, nil } // GetByRelPath returns the item at relPath, reusing a recent cached scan when // available (see scanCache). The context is accepted for cancellation/threading // but item caching is keyed on relPath at the service level. func (s *LibraryService) GetByRelPath(ctx context.Context, relPath string) (*models.LibraryItem, error) { relPath = strings.Trim(relPath, "/") if item, ok := s.getCachedScan(relPath); ok { return item, nil } itemDir, err := s.resolveItemDir(relPath) if err != nil { return nil, fmt.Errorf("item not found") } markerPath := filepath.Join(itemDir, itemMarkerName) if _, err := os.Stat(markerPath); err != nil { return nil, fmt.Errorf("item not found") } item, err := s.scanItem(itemDir, relPath) if err != nil { return nil, err } s.putCachedScan(relPath, item) return item, nil } func (s *LibraryService) scanItem(itemDir, relPath string) (*models.LibraryItem, error) { metadata, err := s.readOrCreateMetadata(itemDir) if err != nil { return nil, err } mediaFiles, infoJSONPath, err := s.listItemFiles(itemDir) if err != nil { return nil, err } var info map[string]interface{} if infoJSONPath != "" { data, err := os.ReadFile(infoJSONPath) if err == nil { _ = json.Unmarshal(data, &info) } } // dirty tracks whether we derived any new metadata worth persisting, so a // plain listing or detail view doesn't rewrite the marker file on every read. dirty := false if metadata.Name == "" { if title, ok := infoString(info, "title"); ok && title != "" { metadata.Name = title } else if len(mediaFiles) > 0 { metadata.Name = mediaFileStem(mediaFiles[0]) } else { metadata.Name = filepath.Base(itemDir) } dirty = true } if metadata.SourceURL == "" { if url, ok := infoString(info, "webpage_url"); ok { metadata.SourceURL = url dirty = true } else if url, ok := infoString(info, "url"); ok { metadata.SourceURL = url dirty = true } } if metadata.Description == "" { if desc, ok := infoString(info, "description"); ok { metadata.Description = desc dirty = true } } if metadata.FileDurations == nil { metadata.FileDurations = make(map[string]int) } // Per-file durations come from the marker's file_durations map (populated at // import time). For a single-file item we also seed it from info.json's // duration, which covers the common case without a probe. We deliberately do // not run ffprobe here — keeping it off the scan/listing path is the point of // the marker cache. Files without a known duration simply show no badge. for i := range mediaFiles { mf := &mediaFiles[i] if d, ok := metadata.FileDurations[mf.Filename]; ok { mf.Duration = d continue } if len(mediaFiles) == 1 { if d, ok := infoDuration(info); ok && d > 0 { mf.Duration = d metadata.FileDurations[mf.Filename] = d dirty = true } } } // The item-level duration is the sum of known per-file durations, used only // for the "duration" sort — there is no single "overall" duration shown. total := 0 for _, mf := range mediaFiles { if mf.Duration > 0 { total += mf.Duration } } item := &models.LibraryItem{ Name: metadata.Name, RelPath: relPath, DirPath: itemDir, SourceURL: metadata.SourceURL, Duration: total, Description: metadata.Description, MediaFiles: mediaFiles, } if dirty { _ = s.writeMetadata(itemDir, metadata) } return item, nil } func (s *LibraryService) readOrCreateMetadata(itemDir string) (models.ItemMetadata, error) { markerPath := filepath.Join(itemDir, itemMarkerName) var metadata models.ItemMetadata data, err := os.ReadFile(markerPath) if err == nil { if _, err := toml.Decode(string(data), &metadata); err != nil { log.Printf("warning: failed to parse %s: %v", markerPath, err) } } return metadata, nil } func (s *LibraryService) writeMetadata(itemDir string, metadata models.ItemMetadata) error { markerPath := filepath.Join(itemDir, itemMarkerName) // Write to a uniquely-named temp file and rename so a crash mid-encode can't // leave a truncated marker, and two concurrent writers never collide on a // shared temp path. f, err := os.CreateTemp(itemDir, ".vidarchive-item-*.tmp") if err != nil { return err } tmpPath := f.Name() if err := toml.NewEncoder(f).Encode(metadata); err != nil { f.Close() os.Remove(tmpPath) return err } if err := f.Close(); err != nil { os.Remove(tmpPath) return err } return os.Rename(tmpPath, markerPath) } func (s *LibraryService) listItemFiles(itemDir string) ([]models.MediaFile, string, error) { entries, err := os.ReadDir(itemDir) if err != nil { return nil, "", err } var mediaFiles []models.MediaFile var infoJSONFiles []string for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() path := filepath.Join(itemDir, name) ext := strings.ToLower(filepath.Ext(name)) if name == itemMarkerName { continue } if name == "info.json" || strings.HasSuffix(name, ".info.json") { infoJSONFiles = append(infoJSONFiles, path) continue } if imageExts[ext] { continue } if !mediaExts[ext] { continue } isAudio := ext == ".mp3" || ext == ".wav" || ext == ".flac" || ext == ".aac" || ext == ".opus" duration := -1 mediaFiles = append(mediaFiles, models.MediaFile{ Filename: name, Filepath: path, IsAudio: isAudio, Duration: duration, }) } sort.Slice(mediaFiles, func(i, j int) bool { return mediaFiles[i].Filepath < mediaFiles[j].Filepath }) var infoJSONPath string if len(infoJSONFiles) > 0 { sort.Strings(infoJSONFiles) infoJSONPath = infoJSONFiles[0] if len(infoJSONFiles) > 1 { log.Printf("warning: multiple info.json files in %s, using %s", itemDir, infoJSONPath) } } return mediaFiles, infoJSONPath, nil } func infoString(info map[string]interface{}, key string) (string, bool) { if info == nil { return "", false } // Only accept genuine strings: title/url/description are always strings in // yt-dlp output, and stringifying an arbitrary JSON value (map, slice) would // store junk like "map[...]" into the field. if s, ok := info[key].(string); ok { return s, true } return "", false } func infoDuration(info map[string]interface{}) (int, bool) { if info == nil { return 0, false } v, ok := info["duration"] if !ok { return 0, false } switch n := v.(type) { case float64: return int(n + 0.5), true case int: return n, true case int64: return int(n), true case string: if f, err := strconv.ParseFloat(n, 64); err == nil { return int(f + 0.5), true } } return 0, false } func mediaFileStem(mf models.MediaFile) string { return strings.TrimSuffix(filepath.Base(mf.Filename), filepath.Ext(mf.Filename)) } // primaryMediaFile picks the representative file for an item: the largest video // file, or — if there are none — the largest file overall. Returns nil for an // item with no media files. Used for the item-level thumbnail and as the default // target for metadata, keeping those two consistent. func primaryMediaFile(item *models.LibraryItem) *models.MediaFile { size := func(mf *models.MediaFile) int64 { if info, err := os.Stat(mf.Filepath); err == nil { return info.Size() } return 0 } var best *models.MediaFile for i := range item.MediaFiles { mf := &item.MediaFiles[i] switch { case best == nil: best = mf case best.IsAudio && !mf.IsAudio: // Prefer any video over audio. best = mf case best.IsAudio == mf.IsAudio && size(mf) > size(best): best = mf } } return best } func (s *LibraryService) GetMediaFile(ctx context.Context, relPath, filename string) (string, error) { item, err := s.GetByRelPath(ctx, relPath) if err != nil { return "", err } for _, mf := range item.MediaFiles { if mf.Filename == filename { return mf.Filepath, nil } } return "", fmt.Errorf("media file not found") } // ThumbnailForFile returns the thumbnail for a specific media file within an // item, extracting it on demand if needed. The bool is false when no thumbnail // is available (file not found, audio-only, or extraction failed) so the caller // can serve an icon. An empty/unmatched filename yields false — every thumbnail // is keyed to a specific media file. func (s *LibraryService) ThumbnailForFile(ctx context.Context, relPath, filename string) (string, bool) { if filename == "" { return "", false } item, err := s.GetByRelPath(ctx, relPath) if err != nil { return "", false } for i := range item.MediaFiles { if item.MediaFiles[i].Filename != filename { continue } mf := item.MediaFiles[i] if path, ok := s.findExistingThumbnail(mf.Filepath); ok { return path, true } return s.ensureThumbnailForFile(mf) } return "", false } // ensureThumbnailForFile returns an existing thumbnail for mf or extracts one, // serializing concurrent extraction of the same file via a per-path mutex. It // re-checks the disk under the lock so that whichever caller wins the race does // the work and the rest reuse the result. A prior in-process failure short- // circuits to avoid re-running ffmpeg on every request (see thumbFailed). func (s *LibraryService) ensureThumbnailForFile(mf models.MediaFile) (string, bool) { actual, _ := s.thumbLocks.LoadOrStore(mf.Filepath, &sync.Mutex{}) lock := actual.(*sync.Mutex) lock.Lock() defer lock.Unlock() if path, ok := s.findExistingThumbnail(mf.Filepath); ok { return path, true } // Trust a prior failure for this run rather than re-running ffmpeg every // request; a restart clears thumbFailed and retries. Checked after the disk // so a thumbnail that appears later (e.g. added manually) still wins. if _, failed := s.thumbFailed.Load(mf.Filepath); failed { return "", false } s.thumbSem <- struct{}{} cur := atomic.AddInt32(&s.extractInFlight, 1) for { max := atomic.LoadInt32(&s.extractMaxConcurrent) if cur <= max || atomic.CompareAndSwapInt32(&s.extractMaxConcurrent, max, cur) { break } } defer func() { atomic.AddInt32(&s.extractInFlight, -1) <-s.thumbSem }() atomic.AddInt32(&s.extractAttempts, 1) path, err := s.extractThumbnail(mf) if err != nil { log.Printf("thumbnail extraction failed for %s: %v", mf.Filepath, err) s.thumbFailed.Store(mf.Filepath, struct{}{}) return "", false } return path, true } func (s *LibraryService) findExistingThumbnail(path string) (string, bool) { ext := filepath.Ext(path) base := strings.TrimSuffix(path, ext) + ".thumbnail" for _, candidate := range []string{base + ".webp", base + ".jpg", base + ".jpeg", base + ".png"} { if info, err := os.Stat(candidate); err == nil && info.Size() > 0 { return candidate, true } } return "", false } // findImageAttachment returns the ordinal (0-based among attachment streams) of // the best image attachment in a container — e.g. the cover.jpg/cover.webp that // yt-dlp embeds into MKV with --embed-thumbnail — or -1 if there is none. Such // covers are attachment streams, not attached_pic video streams, so they must be // dumped with -dump_attachment rather than mapped like a normal stream. func findImageAttachment(path string) int { cmd := exec.Command("ffprobe", "-v", "error", "-show_streams", "-of", "json", path) output, err := cmd.Output() if err != nil { return -1 } var probe struct { Streams []struct { CodecType string `json:"codec_type"` Tags struct { Mimetype string `json:"mimetype"` Filename string `json:"filename"` } `json:"tags"` } `json:"streams"` } if err := json.Unmarshal(output, &probe); err != nil { return -1 } best, bestScore, attachmentIdx := -1, 0, 0 for _, stream := range probe.Streams { if stream.CodecType != "attachment" { continue } idx := attachmentIdx attachmentIdx++ if !strings.HasPrefix(strings.ToLower(stream.Tags.Mimetype), "image/") { continue } score := 20 switch name := strings.ToLower(stream.Tags.Filename); { case strings.Contains(name, "cover"): score = 100 case strings.Contains(name, "thumbnail"), strings.Contains(name, "thumb"): score = 80 case strings.Contains(name, "poster"): score = 60 case strings.Contains(name, "art"): score = 40 } if score > bestScore { best, bestScore = idx, score } } return best } func (s *LibraryService) extractThumbnail(mf models.MediaFile) (string, error) { ext := filepath.Ext(mf.Filepath) base := strings.TrimSuffix(mf.Filepath, ext) + ".thumbnail" webpPath := base + ".webp" jpgPath := base + ".jpg" // Collect every attempt's error so a genuine failure surfaces all of them // rather than only the last fallback's stderr. var attemptErrs []string record := func(label string, err error) { attemptErrs = append(attemptErrs, fmt.Sprintf("%s: %v", label, err)) } tryWrite := func(outputPath string, args []string) (string, error) { // Keep the final extension on the temp file so ffmpeg can infer the // output muxer (it cannot for a bare ".tmp" suffix), then atomically // rename into place. outExt := filepath.Ext(outputPath) tmpPath := strings.TrimSuffix(outputPath, outExt) + ".tmp" + outExt os.Remove(tmpPath) cmd := exec.Command("ffmpeg", append(args, tmpPath)...) if output, err := cmd.CombinedOutput(); err != nil { os.Remove(tmpPath) return "", fmt.Errorf("ffmpeg failed: %v\n%s", err, string(output)) } if info, err := os.Stat(tmpPath); err != nil || info.Size() == 0 { os.Remove(tmpPath) return "", fmt.Errorf("ffmpeg produced empty output") } if err := os.Rename(tmpPath, outputPath); err != nil { os.Remove(tmpPath) return "", err } return outputPath, nil } // Prefer an embedded image attachment (e.g. yt-dlp's cover.webp/cover.jpg in // MKV). These are attachment streams, not mappable video streams, so dump the // raw bytes with -dump_attachment then transcode to a canonical WebP. if idx := findImageAttachment(mf.Filepath); idx >= 0 { if raw, err := os.CreateTemp("", "vidarchive-attachment-*"); err == nil { rawPath := raw.Name() raw.Close() defer os.Remove(rawPath) dumpArgs := []string{ fmt.Sprintf("-dump_attachment:t:%d", idx), rawPath, "-i", mf.Filepath, "-y", "-t", "0", "-f", "null", "-", } if out, err := exec.Command("ffmpeg", dumpArgs...).CombinedOutput(); err != nil { record("attachment-dump", fmt.Errorf("%v\n%s", err, out)) } else if path, err := tryWrite(webpPath, []string{"-i", rawPath, "-c:v", "libwebp"}); err == nil { return path, nil } else { record("attachment-webp", err) } } } // Next try an embedded cover art video stream (attached_pic), e.g. mp3/mp4. embeddedArgs := []string{ "-i", mf.Filepath, "-map", "0:v", "-map", "-0:V", "-vframes", "1", "-c:v", "libwebp", } if path, err := tryWrite(webpPath, embeddedArgs); err == nil { return path, nil } else { record("embedded-webp", err) } // Fallback to jpeg for embedded covers if libwebp or webp encoding failed. embeddedJpgArgs := []string{ "-i", mf.Filepath, "-map", "0:v", "-map", "-0:V", "-vframes", "1", "-q:v", "2", } if path, err := tryWrite(jpgPath, embeddedJpgArgs); err == nil { return path, nil } else { record("embedded-jpg", err) } if mf.IsAudio { return "", fmt.Errorf("audio file has no thumbnail") } seekTime := "00:00:01" if mf.Duration > 0 { midpoint := mf.Duration / 2 m := midpoint / 60 sec := midpoint % 60 h := m / 60 m = m % 60 if h > 0 { seekTime = fmt.Sprintf("%02d:%02d:%02d", h, m, sec) } else { seekTime = fmt.Sprintf("%02d:%02d", m, sec) } } frameArgs := []string{ "-ss", seekTime, "-i", mf.Filepath, "-vframes", "1", "-c:v", "libwebp", } if path, err := tryWrite(webpPath, frameArgs); err == nil { return path, nil } else { record("frame-webp", err) } frameJpgArgs := []string{ "-ss", seekTime, "-i", mf.Filepath, "-vframes", "1", "-q:v", "2", } if path, err := tryWrite(jpgPath, frameJpgArgs); err == nil { return path, nil } else { record("frame-jpg", err) } return "", fmt.Errorf("all thumbnail extraction attempts failed:\n%s", strings.Join(attemptErrs, "\n")) } func (s *LibraryService) Delete(relPath string) error { // Guard against an empty/root relPath, which would resolve to the library // root and delete the entire library. This is reachable via a URL-encoded // slash (e.g. POST /library/item/%2Fdelete), so the check must live here. if strings.Trim(relPath, "/") == "" { return fmt.Errorf("refusing to delete library root") } itemDir, err := s.resolveItemDir(relPath) if err != nil { return err } // resolveItemDir maps "" to the library root; double-check we never target it. if cleanDir, err := filepath.EvalSymlinks(itemDir); err == nil { if base, err := filepath.EvalSymlinks(s.libraryDir); err == nil && cleanDir == base { return fmt.Errorf("refusing to delete library root") } } // Evict the cached scan so the deletion is reflected immediately rather than // lingering until the TTL expires. s.evictCachedScan(strings.Trim(relPath, "/")) return os.RemoveAll(itemDir) } func (s *LibraryService) SubtitleDir(relPath string) string { itemDir, err := s.resolveItemDir(relPath) if err != nil { return "" } return filepath.Join(itemDir, subtitlesDirName) } func (s *LibraryService) GetSubtitlePath(relPath, lang string) string { return filepath.Join(s.SubtitleDir(relPath), lang+".vtt") } func (s *LibraryService) GetSubtitles(ctx context.Context, relPath string) ([]models.SubtitleTrack, error) { item, err := s.GetByRelPath(ctx, relPath) if err != nil { return nil, err } cacheDir := s.SubtitleDir(relPath) entries, err := os.ReadDir(cacheDir) if err == nil && len(entries) > 0 { var tracks []models.SubtitleTrack for _, entry := range entries { if entry.IsDir() || filepath.Ext(entry.Name()) != ".vtt" { continue } lang := strings.TrimSuffix(entry.Name(), ".vtt") tracks = append(tracks, models.SubtitleTrack{ Lang: lang, Label: lang, Src: fmt.Sprintf("/media/item/%s/subtitles/%s", urlEncodePath(relPath), lang), }) } return tracks, nil } for _, mf := range item.MediaFiles { if mf.IsAudio { continue } streams, err := extractSubtitleInfo(mf.Filepath) if err != nil || len(streams) == 0 { continue } if err := os.MkdirAll(cacheDir, 0755); err != nil { return nil, err } var tracks []models.SubtitleTrack for _, stream := range streams { lang := stream.Lang if lang == "" { lang = fmt.Sprintf("track%d", stream.Index) } outPath := filepath.Join(cacheDir, lang+".vtt") if err := extractSubtitleToVTT(mf.Filepath, outPath, stream.Index); err != nil { continue } tracks = append(tracks, models.SubtitleTrack{ Lang: lang, Label: stream.Label, Src: fmt.Sprintf("/media/item/%s/subtitles/%s", urlEncodePath(relPath), lang), }) } return tracks, nil } return nil, nil } type subtitleStream struct { Index int Lang string Label string } func extractSubtitleInfo(path string) ([]subtitleStream, error) { cmd := exec.Command("ffprobe", "-v", "error", "-show_streams", "-select_streams", "s", "-of", "json", path, ) output, err := cmd.Output() if err != nil { return nil, err } var probe struct { Streams []struct { Index int `json:"index"` CodecName string `json:"codec_name"` Tags struct { Language string `json:"language"` Title string `json:"title"` } `json:"tags"` } `json:"streams"` } if err := json.Unmarshal(output, &probe); err != nil { return nil, err } var streams []subtitleStream subIndex := 0 for _, stream := range probe.Streams { switch stream.CodecName { case "subrip", "ass", "ssa", "webvtt", "mov_text": lang := stream.Tags.Language if lang == "" { lang = fmt.Sprintf("track%d", subIndex) } label := stream.Tags.Title if label == "" { label = strings.ToUpper(lang) } streams = append(streams, subtitleStream{ Index: subIndex, Lang: lang, Label: label, }) subIndex++ } } return streams, nil } func extractSubtitleToVTT(inputPath, outputPath string, streamIndex int) error { cmd := exec.Command("ffmpeg", "-i", inputPath, "-map", fmt.Sprintf("0:s:%d", streamIndex), "-f", "webvtt", outputPath, "-y", ) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("ffmpeg subtitle extraction failed: %w\nOutput: %s", err, string(output)) } return nil } // GetMetadata probes media details for the named file within an item. An empty // filename (or one that doesn't match) falls back to the item's primary media // file, so the detail view shows metadata for whichever file is selected. func (s *LibraryService) GetMetadata(ctx context.Context, relPath, filename string) (*MediaMetadata, error) { item, err := s.GetByRelPath(ctx, relPath) if err != nil { return nil, err } var target *models.MediaFile if filename != "" { for i := range item.MediaFiles { if item.MediaFiles[i].Filename == filename { target = &item.MediaFiles[i] break } } } if target == nil { target = primaryMediaFile(item) } if target == nil { return nil, fmt.Errorf("no media file") } return probeMedia(target.Filepath) } func probeMedia(path string) (*MediaMetadata, error) { info, err := os.Stat(path) if err != nil { return nil, err } meta := &MediaMetadata{ FileSize: info.Size(), } cmd := exec.Command("ffprobe", "-v", "error", "-show_format", "-show_streams", "-of", "json", path, ) output, err := cmd.Output() if err != nil { return meta, nil } var probe ffprobeOutput if err := json.Unmarshal(output, &probe); err != nil { return meta, nil } if probe.Format.FormatName != "" { parts := strings.Split(probe.Format.FormatName, ",") meta.Container = parts[0] } for _, stream := range probe.Streams { switch stream.CodecType { case "video": vs := VideoStream{ Codec: stream.CodecName, Profile: stream.Profile, Width: stream.Width, Height: stream.Height, FPS: parseFPS(stream.RFrameRate), PixelFormat: stream.PixFmt, Bitrate: formatBitrate(stream.BitRate), } meta.VideoStreams = append(meta.VideoStreams, vs) if stream.Width > 0 && stream.Height > 0 { meta.Resolution = fmt.Sprintf("%dx%d", stream.Width, stream.Height) } case "audio": as := AudioStream{ Codec: stream.CodecName, SampleRate: stream.SampleRate, Channels: stream.Channels, ChannelLayout: stream.ChannelLayout, SampleFormat: stream.SampleFmt, Bitrate: formatBitrate(stream.BitRate), Language: stream.Tags.Language, } meta.AudioStreams = append(meta.AudioStreams, as) case "subtitle": ss := SubtitleStream{ Codec: stream.CodecName, Language: stream.Tags.Language, Title: stream.Tags.Title, } meta.SubtitleStreams = append(meta.SubtitleStreams, ss) } } return meta, nil } func urlEncodePath(p string) string { parts := strings.Split(strings.Trim(p, "/"), "/") for i, part := range parts { parts[i] = url.PathEscape(part) } return strings.Join(parts, "/") } func formatBitrate(bitRate string) string { if bitRate == "" { return "" } br, err := strconv.ParseInt(bitRate, 10, 64) if err != nil { return "" } return fmt.Sprintf("%d", br/1000) } func parseFPS(rate string) string { if rate == "" || rate == "0/0" { return "" } parts := strings.Split(rate, "/") if len(parts) != 2 { return "" } num, err1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64) den, err2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64) if err1 != nil || err2 != nil || den == 0 { return "" } fps := num / den if fps == math.Trunc(fps) { return fmt.Sprintf("%.0f", fps) } return fmt.Sprintf("%.2f", fps) } type MediaMetadata struct { Container string Resolution string FileSize int64 VideoStreams []VideoStream AudioStreams []AudioStream SubtitleStreams []SubtitleStream } type VideoStream struct { Codec string Profile string Width int Height int FPS string PixelFormat string Bitrate string } type AudioStream struct { Codec string SampleRate string Channels int ChannelLayout string SampleFormat string Bitrate string Language string } type SubtitleStream struct { Language string Title string Codec string } type ffprobeOutput struct { Format struct { FormatName string `json:"format_name"` BitRate string `json:"bit_rate"` } `json:"format"` Streams []ffprobeStream `json:"streams"` } type ffprobeStream struct { Index int `json:"index"` CodecName string `json:"codec_name"` CodecType string `json:"codec_type"` Profile string `json:"profile"` Width int `json:"width"` Height int `json:"height"` RFrameRate string `json:"r_frame_rate"` AvgFrameRate string `json:"avg_frame_rate"` PixFmt string `json:"pix_fmt"` SampleRate string `json:"sample_rate"` Channels int `json:"channels"` ChannelLayout string `json:"channel_layout"` SampleFmt string `json:"sample_fmt"` BitRate string `json:"bit_rate"` Tags struct { Language string `json:"language"` Title string `json:"title"` } `json:"tags"` }