package service import ( "encoding/json" "fmt" "log" "math" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "sync" "sync/atomic" "time" "github.com/BurntSushi/toml" "vidarchive/internal/models" "vidarchive/internal/util" ) const itemMarkerName = ".vidarchive-item.toml" const subtitlesDirName = "subtitles" var mediaExts = map[string]struct{}{ ".mp4": {}, ".webm": {}, ".mkv": {}, ".avi": {}, ".mov": {}, ".mp3": {}, ".wav": {}, ".flac": {}, ".aac": {}, ".opus": {}, } var audioExts = map[string]struct{}{ ".mp3": {}, ".wav": {}, ".flac": {}, ".aac": {}, ".opus": {}, } var imageExts = map[string]struct{}{ ".webp": {}, ".jpg": {}, ".jpeg": {}, ".png": {}, ".gif": {}, ".bmp": {}, } // 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 // ffmpegPath/ffprobePath are the binaries used for thumbnail extraction, // subtitle conversion, and media probing. Configurable so non-PATH installs // (e.g. a pinned build) can be pointed at directly. ffmpegPath string ffprobePath string // thumbLocks holds a per-media-file mutex serializing extraction so two // callers never write the same temp file at once. Entries are deliberately // never pruned: dropping one would reintroduce the race it 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. 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, ffmpegPath, ffprobePath string) *LibraryService { return &LibraryService{ libraryDir: libraryDir, ffmpegPath: ffmpegPath, ffprobePath: ffprobePath, 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 } // ResolveWithinLibrary resolves a caller-supplied relative directory against the // library root and rejects anything that escapes it. The directory need not // exist yet, so it is safe to use when choosing a download's output location. func (s *LibraryService) ResolveWithinLibrary(relPath string) (string, error) { return s.resolveItemDir(filepath.Clean(relPath)) } // libraryRoot returns the symlink-resolved library root. func (s *LibraryService) libraryRoot() string { if base, err := filepath.EvalSymlinks(s.libraryDir); err == nil { return base } return filepath.Clean(s.libraryDir) } func (s *LibraryService) resolveItemDir(relPath string) (string, error) { relPath = strings.Trim(relPath, string(filepath.Separator)) base := s.libraryRoot() if relPath == "" || relPath == "." { return base, nil } itemDir := filepath.Join(base, relPath) cleanDir, err := filepath.EvalSymlinks(itemDir) if err != nil { cleanDir = filepath.Clean(itemDir) } 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 { log.Printf("GetAll: invalid path %q: %v", path, err) 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). func (s *LibraryService) GetByRelPath(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.readMetadata(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 { if err := json.Unmarshal(data, &info); err != nil { log.Printf("scanItem: ignoring malformed %s: %v", infoJSONPath, err) } } } // 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 == "" { dirty = backfillString(&metadata.SourceURL, info, "webpage_url", "url") || dirty } if metadata.Description == "" { dirty = backfillString(&metadata.Description, info, "description") || dirty } // Backfill the stable identity (yt-dlp's video id) from info.json so // pre-existing items gain an identity on their next scan. Subscriptions match // and prune items by this id (see FindByVideoID / PruneToIDSet). if metadata.VideoID == "" { dirty = backfillString(&metadata.VideoID, info, "id") || dirty } 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, YtdlpFlags: metadata.YtdlpFlags, MediaFiles: mediaFiles, } if dirty { if err := s.writeMetadata(itemDir, metadata); err != nil { log.Printf("scanItem: failed to persist derived metadata for %s: %v", itemDir, err) } } return item, nil } // backfillString sets *field from the first non-empty string value among // info's keys, reporting whether it changed anything. An empty value in // info.json must not count as a change: the marker would be rewritten on every // scan forever without ever gaining a value. func backfillString(field *string, info map[string]interface{}, keys ...string) bool { for _, k := range keys { if v, ok := infoString(info, k); ok && v != "" { *field = v return true } } return false } func (s *LibraryService) readMetadata(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 _, ok := imageExts[ext]; ok { continue } if _, ok := mediaExts[ext]; !ok { continue } _, isAudio := audioExts[ext] mediaFiles = append(mediaFiles, models.MediaFile{ Filename: name, Filepath: path, IsAudio: isAudio, Duration: -1, }) } 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 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(relPath, filename string) (string, error) { item, err := s.GetByRelPath(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(relPath, filename string) (string, bool) { if filename == "" { return "", false } item, err := s.GetByRelPath(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 (s *LibraryService) findImageAttachment(path string) int { cmd := exec.Command(s.ffprobePath, "-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 } // thumbAttempt is one ffmpeg invocation that may produce a thumbnail at out. type thumbAttempt struct { label string out string args []string } 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" var attempts []thumbAttempt // Collect every attempt's error so a genuine failure surfaces all of them // rather than only the last fallback's stderr. var attemptErrs []string // Prefer an embedded image attachment (e.g. yt-dlp's cover.webp/cover.jpg in // MKV): dump its raw bytes, then transcode to a canonical WebP. if idx := s.findImageAttachment(mf.Filepath); idx >= 0 { if rawPath, err := s.dumpAttachment(mf.Filepath, idx); err != nil { attemptErrs = append(attemptErrs, fmt.Sprintf("attachment-dump: %v", err)) } else { defer os.Remove(rawPath) attempts = append(attempts, thumbAttempt{"attachment-webp", webpPath, []string{"-i", rawPath, "-c:v", "libwebp"}}) } } // Next try an embedded cover art video stream (attached_pic), e.g. mp3/mp4, // falling back to jpeg if libwebp or webp encoding fails. embedded := []string{"-i", mf.Filepath, "-map", "0:v", "-map", "-0:V", "-vframes", "1"} attempts = append(attempts, thumbAttempt{"embedded-webp", webpPath, append(embedded, "-c:v", "libwebp")}, thumbAttempt{"embedded-jpg", jpgPath, append(embedded, "-q:v", "2")}, ) if !mf.IsAudio { seekTime := "00:00:01" if mf.Duration > 0 { seekTime = util.FormatClock(mf.Duration / 2) } frame := []string{"-ss", seekTime, "-i", mf.Filepath, "-vframes", "1"} attempts = append(attempts, thumbAttempt{"frame-webp", webpPath, append(frame, "-c:v", "libwebp")}, thumbAttempt{"frame-jpg", jpgPath, append(frame, "-q:v", "2")}, ) } for _, a := range attempts { path, err := s.tryWriteThumbnail(a.out, a.args) if err == nil { return path, nil } attemptErrs = append(attemptErrs, fmt.Sprintf("%s: %v", a.label, err)) } if mf.IsAudio { return "", fmt.Errorf("audio file has no thumbnail") } return "", fmt.Errorf("all thumbnail extraction attempts failed:\n%s", strings.Join(attemptErrs, "\n")) } // dumpAttachment extracts the raw bytes of attachment stream idx (e.g. the // cover.jpg/cover.webp yt-dlp embeds into MKV with --embed-thumbnail) into a // temp file and returns its path. The caller removes the file. func (s *LibraryService) dumpAttachment(path string, idx int) (string, error) { raw, err := os.CreateTemp("", "vidarchive-attachment-*") if err != nil { return "", err } rawPath := raw.Name() raw.Close() dumpArgs := []string{ fmt.Sprintf("-dump_attachment:t:%d", idx), rawPath, "-i", path, "-y", "-t", "0", "-f", "null", "-", } if out, err := exec.Command(s.ffmpegPath, dumpArgs...).CombinedOutput(); err != nil { os.Remove(rawPath) return "", fmt.Errorf("%v\n%s", err, out) } return rawPath, nil } // tryWriteThumbnail runs ffmpeg with args to produce outputPath. The temp file // keeps the final extension so ffmpeg can infer the output muxer (it cannot for // a bare ".tmp" suffix), then is atomically renamed into place. func (s *LibraryService) tryWriteThumbnail(outputPath string, args []string) (string, error) { outExt := filepath.Ext(outputPath) tmpPath := strings.TrimSuffix(outputPath, outExt) + ".tmp" + outExt os.Remove(tmpPath) cmd := exec.Command(s.ffmpegPath, 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 } func (s *LibraryService) Delete(relPath string) error { itemDir, err := s.resolveItemDir(relPath) if err != nil { return err } // resolveItemDir maps ""/"." to the library root and resolves symlinks, so a // result equal to the root (reachable via a URL-encoded slash, "sub/..", or a // symlink pointing back at the root) must be refused — deleting it would // wipe the entire library. if itemDir == s.libraryRoot() { 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) } // FindByVideoID returns the absolute directory of the item under baseDir whose // marker matches the given yt-dlp video id, scanning only that directory (the // subscription's owned folder). Matching on the id alone is safe here because // each subscription owns a single source, so ids don't collide across // extractors within the folder. ok is false when no match is found or id is // empty. func (s *LibraryService) FindByVideoID(baseDir, id string) (string, bool) { if id == "" { return "", false } var match string // An unreadable base dir simply means no match here. _ = s.eachItemDir(baseDir, "FindByVideoID", func(itemDir string, meta models.ItemMetadata) bool { if meta.VideoID != id { return true } match = itemDir return false }) return match, match != "" } // eachItemDir walks the marked library items directly under baseDir, reading // each one's metadata, and calls fn until it returns false. Entries that aren't // items, or whose metadata can't be read, are skipped — a single bad item must // not abort a scan. logLabel names the caller in those skip messages. func (s *LibraryService) eachItemDir(baseDir, logLabel string, fn func(itemDir string, meta models.ItemMetadata) bool) error { entries, err := os.ReadDir(baseDir) if err != nil { return err } for _, entry := range entries { if !entry.IsDir() || entry.Name() == subtitlesDirName { continue } itemDir := filepath.Join(baseDir, entry.Name()) if _, err := os.Stat(filepath.Join(itemDir, itemMarkerName)); err != nil { continue } meta, err := s.readMetadata(itemDir) if err != nil { log.Printf("%s: skipping %s: %v", logLabel, itemDir, err) continue } if !fn(itemDir, meta) { return nil } } return nil } // PruneToIDSet deletes items directly under baseDir whose identity key is not in // keep. It is used to mirror a subscription's source: entries removed upstream // are removed locally. Items without a known identity key are left untouched (we // never delete something we can't positively identify). Returns the number // removed. func (s *LibraryService) PruneToIDSet(baseDir string, keep map[string]bool) (int, error) { removed := 0 err := s.eachItemDir(baseDir, "PruneToIDSet", func(itemDir string, meta models.ItemMetadata) bool { if meta.VideoID == "" || keep[meta.VideoID] { return true } if rel, err := filepath.Rel(s.libraryDir, itemDir); err == nil { s.evictCachedScan(filepath.ToSlash(rel)) } if err := os.RemoveAll(itemDir); err != nil { log.Printf("warning: prune failed to remove %s: %v", itemDir, err) return true } removed++ return true }) return removed, err } func (s *LibraryService) SubtitleDir(relPath string) string { itemDir, err := s.resolveItemDir(relPath) if err != nil { return "" } return filepath.Join(itemDir, subtitlesDirName) } // GetSubtitlePath returns the .vtt path for a language. It errors when the item // can't be resolved: joining onto an empty dir would yield a bare relative name // that the caller would then serve relative to the process working directory. func (s *LibraryService) GetSubtitlePath(relPath, lang string) (string, error) { dir := s.SubtitleDir(relPath) if dir == "" { return "", fmt.Errorf("resolve subtitle dir for %q", relPath) } return filepath.Join(dir, lang+".vtt"), nil } func (s *LibraryService) GetSubtitles(relPath string) ([]models.SubtitleTrack, error) { item, err := s.GetByRelPath(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", util.URLEncodePath(relPath), lang), }) } return tracks, nil } for _, mf := range item.MediaFiles { if mf.IsAudio { continue } streams, err := s.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 := s.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", util.URLEncodePath(relPath), lang), }) } return tracks, nil } return nil, nil } type subtitleStream struct { Index int Lang string Label string } func (s *LibraryService) extractSubtitleInfo(path string) ([]subtitleStream, error) { cmd := exec.Command(s.ffprobePath, "-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 (s *LibraryService) extractSubtitleToVTT(inputPath, outputPath string, streamIndex int) error { cmd := exec.Command(s.ffmpegPath, "-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(relPath, filename string) (*MediaMetadata, error) { item, err := s.GetByRelPath(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 s.probeMedia(target.Filepath) } func (s *LibraryService) probeMedia(path string) (*MediaMetadata, error) { info, err := os.Stat(path) if err != nil { return nil, err } meta := &MediaMetadata{ FileSize: info.Size(), } cmd := exec.Command(s.ffprobePath, "-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 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 { var num, den float64 if _, err := fmt.Sscanf(rate, "%f/%f", &num, &den); err != 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"` }