library.go
⎇
Raw
1package service
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "log"
8 "math"
9 "net/url"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "sort"
14 "strconv"
15 "strings"
16 "sync"
17 "sync/atomic"
18 "time"
19
20 "github.com/BurntSushi/toml"
21
22 "vidarchive/internal/models"
23)
24
25const itemMarkerName = ".vidarchive-item.toml"
26const subtitlesDirName = "subtitles"
27
28var mediaExts = map[string]bool{
29 ".mp4": true, ".webm": true, ".mkv": true, ".avi": true, ".mov": true,
30 ".mp3": true, ".wav": true, ".flac": true, ".aac": true, ".opus": true,
31}
32
33var imageExts = map[string]bool{
34 ".webp": true, ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".bmp": true,
35}
36
37// maxConcurrentThumbnails caps how many ffmpeg extraction processes may run at
38// once, so a freshly loaded library page (which fires one thumbnail request per
39// visible item) cannot spawn an unbounded ffmpeg storm.
40const maxConcurrentThumbnails = 3
41
42type LibraryService struct {
43 libraryDir string
44 // thumbLocks maps a media filepath -> *sync.Mutex to serialize extraction
45 // per file (so two callers never write the same temp file at once). Entries
46 // are bounded by the number of distinct media files ever requested, not by
47 // request volume; entries for deleted items are not reclaimed. We
48 // deliberately do not prune: dropping a lock would let two extractions of
49 // the same file race on its shared temp path, which is exactly what the lock
50 // prevents.
51 thumbLocks sync.Map
52 thumbSem chan struct{}
53
54 // thumbFailed records media filepaths whose extraction already failed this
55 // run, so we trust ffmpeg's verdict and don't re-run it on every request. It
56 // lives only in memory: a restart clears it and retries extraction (and any
57 // success is then cached on disk as the .thumbnail file).
58 thumbFailed sync.Map
59
60 // extraction counters, for observability/tests.
61 extractInFlight int32
62 extractMaxConcurrent int32
63 extractAttempts int32
64
65 // scanCache memoizes scanned items for a short TTL so the listing page (which
66 // fans out one thumbnail request per media file) and quick auto-refreshes
67 // don't re-parse each item's marker + info.json on every request. Only item
68 // scans are cached — the directory listing itself is always read fresh, so
69 // newly added/removed items and subfolders appear immediately.
70 scanMu sync.Mutex
71 scanCache map[string]scanCacheEntry
72 scanTTL time.Duration
73}
74
75type scanCacheEntry struct {
76 item *models.LibraryItem
77 at time.Time
78}
79
80// scanCacheTTL is how long a scanned item is reused before being re-read.
81const scanCacheTTL = 10 * time.Second
82
83func NewLibraryService(libraryDir string) *LibraryService {
84 return &LibraryService{
85 libraryDir: libraryDir,
86 thumbSem: make(chan struct{}, maxConcurrentThumbnails),
87 scanCache: make(map[string]scanCacheEntry),
88 scanTTL: scanCacheTTL,
89 }
90}
91
92func (s *LibraryService) getCachedScan(relPath string) (*models.LibraryItem, bool) {
93 if s.scanTTL <= 0 {
94 return nil, false
95 }
96 s.scanMu.Lock()
97 defer s.scanMu.Unlock()
98 e, ok := s.scanCache[relPath]
99 if !ok || time.Since(e.at) > s.scanTTL {
100 return nil, false
101 }
102 return e.item, true
103}
104
105func (s *LibraryService) putCachedScan(relPath string, item *models.LibraryItem) {
106 if s.scanTTL <= 0 {
107 return
108 }
109 s.scanMu.Lock()
110 s.scanCache[relPath] = scanCacheEntry{item: item, at: time.Now()}
111 s.scanMu.Unlock()
112}
113
114func (s *LibraryService) evictCachedScan(relPath string) {
115 s.scanMu.Lock()
116 delete(s.scanCache, relPath)
117 s.scanMu.Unlock()
118}
119
120// scannedItem returns a cached scan if fresh, otherwise scans and caches it.
121func (s *LibraryService) scannedItem(itemDir, relPath string) (*models.LibraryItem, error) {
122 if item, ok := s.getCachedScan(relPath); ok {
123 return item, nil
124 }
125 item, err := s.scanItem(itemDir, relPath)
126 if err != nil {
127 return nil, err
128 }
129 s.putCachedScan(relPath, item)
130 return item, nil
131}
132
133func (s *LibraryService) resolveItemDir(relPath string) (string, error) {
134 relPath = strings.Trim(relPath, string(filepath.Separator))
135 if relPath == "" {
136 return s.libraryDir, nil
137 }
138 itemDir := filepath.Join(s.libraryDir, relPath)
139 cleanDir, err := filepath.EvalSymlinks(itemDir)
140 if err != nil {
141 cleanDir = filepath.Clean(itemDir)
142 }
143 base, err := filepath.EvalSymlinks(s.libraryDir)
144 if err != nil {
145 base = filepath.Clean(s.libraryDir)
146 }
147 if !strings.HasPrefix(cleanDir, base+string(filepath.Separator)) && cleanDir != base {
148 return "", fmt.Errorf("invalid path")
149 }
150 // Return the cleaned/symlink-resolved path we just validated, so callers do
151 // I/O on exactly the path that passed the boundary check.
152 return cleanDir, nil
153}
154
155func (s *LibraryService) GetAll(path, sortBy, filter string) ([]*models.LibraryItem, []string, error) {
156 if path != "" && !strings.HasSuffix(path, "/") {
157 path += "/"
158 }
159
160 dir, err := s.resolveItemDir(path)
161 if err != nil {
162 return nil, nil, nil
163 }
164 entries, err := os.ReadDir(dir)
165 if err != nil {
166 if os.IsNotExist(err) {
167 return nil, nil, nil
168 }
169 return nil, nil, err
170 }
171
172 var items []*models.LibraryItem
173 var folders []string
174
175 for _, entry := range entries {
176 if !entry.IsDir() {
177 continue
178 }
179 name := entry.Name()
180 if name == subtitlesDirName {
181 continue
182 }
183 itemDir := filepath.Join(dir, name)
184 relPath := filepath.ToSlash(filepath.Join(path, name))
185
186 markerPath := filepath.Join(itemDir, itemMarkerName)
187 if _, err := os.Stat(markerPath); err == nil {
188 item, err := s.scannedItem(itemDir, relPath)
189 if err != nil {
190 log.Printf("warning: failed to scan item %s: %v", relPath, err)
191 continue
192 }
193 if filter != "" && !strings.Contains(strings.ToLower(item.Name), strings.ToLower(filter)) && !strings.Contains(strings.ToLower(item.RelPath), strings.ToLower(filter)) {
194 continue
195 }
196 items = append(items, item)
197 } else {
198 folders = append(folders, name)
199 }
200 }
201
202 switch sortBy {
203 case "title":
204 sort.Slice(items, func(i, j int) bool { return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name) })
205 case "duration":
206 sort.Slice(items, func(i, j int) bool { return items[i].Duration > items[j].Duration })
207 case "date":
208 fallthrough
209 default:
210 // Stat each item once up front rather than twice per comparison.
211 modTime := make(map[string]int64, len(items))
212 for _, it := range items {
213 if info, err := os.Stat(it.DirPath); err == nil {
214 modTime[it.RelPath] = info.ModTime().UnixNano()
215 }
216 }
217 sort.Slice(items, func(i, j int) bool {
218 return modTime[items[i].RelPath] > modTime[items[j].RelPath]
219 })
220 }
221
222 sort.Strings(folders)
223
224 return items, folders, nil
225}
226
227// GetByRelPath returns the item at relPath, reusing a recent cached scan when
228// available (see scanCache). The context is accepted for cancellation/threading
229// but item caching is keyed on relPath at the service level.
230func (s *LibraryService) GetByRelPath(ctx context.Context, relPath string) (*models.LibraryItem, error) {
231 relPath = strings.Trim(relPath, "/")
232
233 if item, ok := s.getCachedScan(relPath); ok {
234 return item, nil
235 }
236
237 itemDir, err := s.resolveItemDir(relPath)
238 if err != nil {
239 return nil, fmt.Errorf("item not found")
240 }
241 markerPath := filepath.Join(itemDir, itemMarkerName)
242 if _, err := os.Stat(markerPath); err != nil {
243 return nil, fmt.Errorf("item not found")
244 }
245 item, err := s.scanItem(itemDir, relPath)
246 if err != nil {
247 return nil, err
248 }
249 s.putCachedScan(relPath, item)
250 return item, nil
251}
252
253func (s *LibraryService) scanItem(itemDir, relPath string) (*models.LibraryItem, error) {
254 metadata, err := s.readOrCreateMetadata(itemDir)
255 if err != nil {
256 return nil, err
257 }
258
259 mediaFiles, infoJSONPath, err := s.listItemFiles(itemDir)
260 if err != nil {
261 return nil, err
262 }
263
264 var info map[string]interface{}
265 if infoJSONPath != "" {
266 data, err := os.ReadFile(infoJSONPath)
267 if err == nil {
268 _ = json.Unmarshal(data, &info)
269 }
270 }
271
272 // dirty tracks whether we derived any new metadata worth persisting, so a
273 // plain listing or detail view doesn't rewrite the marker file on every read.
274 dirty := false
275
276 if metadata.Name == "" {
277 if title, ok := infoString(info, "title"); ok && title != "" {
278 metadata.Name = title
279 } else if len(mediaFiles) > 0 {
280 metadata.Name = mediaFileStem(mediaFiles[0])
281 } else {
282 metadata.Name = filepath.Base(itemDir)
283 }
284 dirty = true
285 }
286
287 if metadata.SourceURL == "" {
288 // Accept only a non-empty value: a present-but-empty field would otherwise
289 // leave SourceURL "" while flipping dirty on every scan, rewriting the
290 // marker forever.
291 if url, ok := infoString(info, "webpage_url"); ok && url != "" {
292 metadata.SourceURL = url
293 dirty = true
294 } else if url, ok := infoString(info, "url"); ok && url != "" {
295 metadata.SourceURL = url
296 dirty = true
297 }
298 }
299
300 if metadata.Description == "" {
301 // Same guard: an empty "description" in info.json must not mark the marker
302 // dirty on every listing (items whose text lives only in the title).
303 if desc, ok := infoString(info, "description"); ok && desc != "" {
304 metadata.Description = desc
305 dirty = true
306 }
307 }
308
309 // Backfill the stable identity (yt-dlp id + extractor) from info.json so
310 // pre-existing items gain an identity on their next scan. Subscriptions match
311 // and prune items by this key (see videoKey / FindByVideoID).
312 if metadata.VideoID == "" {
313 if id, ok := infoString(info, "id"); ok && id != "" {
314 metadata.VideoID = id
315 dirty = true
316 }
317 }
318 if metadata.Extractor == "" {
319 if ex, ok := infoString(info, "extractor"); ok && ex != "" {
320 metadata.Extractor = ex
321 dirty = true
322 }
323 }
324
325 if metadata.FileDurations == nil {
326 metadata.FileDurations = make(map[string]int)
327 }
328
329 // Per-file durations come from the marker's file_durations map (populated at
330 // import time). For a single-file item we also seed it from info.json's
331 // duration, which covers the common case without a probe. We deliberately do
332 // not run ffprobe here — keeping it off the scan/listing path is the point of
333 // the marker cache. Files without a known duration simply show no badge.
334 for i := range mediaFiles {
335 mf := &mediaFiles[i]
336 if d, ok := metadata.FileDurations[mf.Filename]; ok {
337 mf.Duration = d
338 continue
339 }
340 if len(mediaFiles) == 1 {
341 if d, ok := infoDuration(info); ok && d > 0 {
342 mf.Duration = d
343 metadata.FileDurations[mf.Filename] = d
344 dirty = true
345 }
346 }
347 }
348
349 // The item-level duration is the sum of known per-file durations, used only
350 // for the "duration" sort — there is no single "overall" duration shown.
351 total := 0
352 for _, mf := range mediaFiles {
353 if mf.Duration > 0 {
354 total += mf.Duration
355 }
356 }
357
358 item := &models.LibraryItem{
359 Name: metadata.Name,
360 RelPath: relPath,
361 DirPath: itemDir,
362 SourceURL: metadata.SourceURL,
363 Duration: total,
364 Description: metadata.Description,
365 YtdlpFlags: metadata.YtdlpFlags,
366 MediaFiles: mediaFiles,
367 }
368
369 if dirty {
370 _ = s.writeMetadata(itemDir, metadata)
371 }
372
373 return item, nil
374}
375
376func (s *LibraryService) readOrCreateMetadata(itemDir string) (models.ItemMetadata, error) {
377 markerPath := filepath.Join(itemDir, itemMarkerName)
378 var metadata models.ItemMetadata
379 data, err := os.ReadFile(markerPath)
380 if err == nil {
381 if _, err := toml.Decode(string(data), &metadata); err != nil {
382 log.Printf("warning: failed to parse %s: %v", markerPath, err)
383 }
384 }
385 return metadata, nil
386}
387
388func (s *LibraryService) writeMetadata(itemDir string, metadata models.ItemMetadata) error {
389 markerPath := filepath.Join(itemDir, itemMarkerName)
390 // Write to a uniquely-named temp file and rename so a crash mid-encode can't
391 // leave a truncated marker, and two concurrent writers never collide on a
392 // shared temp path.
393 f, err := os.CreateTemp(itemDir, ".vidarchive-item-*.tmp")
394 if err != nil {
395 return err
396 }
397 tmpPath := f.Name()
398 if err := toml.NewEncoder(f).Encode(metadata); err != nil {
399 f.Close()
400 os.Remove(tmpPath)
401 return err
402 }
403 if err := f.Close(); err != nil {
404 os.Remove(tmpPath)
405 return err
406 }
407 return os.Rename(tmpPath, markerPath)
408}
409
410func (s *LibraryService) listItemFiles(itemDir string) ([]models.MediaFile, string, error) {
411 entries, err := os.ReadDir(itemDir)
412 if err != nil {
413 return nil, "", err
414 }
415
416 var mediaFiles []models.MediaFile
417 var infoJSONFiles []string
418
419 for _, entry := range entries {
420 if entry.IsDir() {
421 continue
422 }
423 name := entry.Name()
424 path := filepath.Join(itemDir, name)
425 ext := strings.ToLower(filepath.Ext(name))
426
427 if name == itemMarkerName {
428 continue
429 }
430 if name == "info.json" || strings.HasSuffix(name, ".info.json") {
431 infoJSONFiles = append(infoJSONFiles, path)
432 continue
433 }
434 if imageExts[ext] {
435 continue
436 }
437 if !mediaExts[ext] {
438 continue
439 }
440
441 isAudio := ext == ".mp3" || ext == ".wav" || ext == ".flac" || ext == ".aac" || ext == ".opus"
442 duration := -1
443
444 mediaFiles = append(mediaFiles, models.MediaFile{
445 Filename: name,
446 Filepath: path,
447 IsAudio: isAudio,
448 Duration: duration,
449 })
450 }
451
452 sort.Slice(mediaFiles, func(i, j int) bool {
453 return mediaFiles[i].Filepath < mediaFiles[j].Filepath
454 })
455
456 var infoJSONPath string
457 if len(infoJSONFiles) > 0 {
458 sort.Strings(infoJSONFiles)
459 infoJSONPath = infoJSONFiles[0]
460 if len(infoJSONFiles) > 1 {
461 log.Printf("warning: multiple info.json files in %s, using %s", itemDir, infoJSONPath)
462 }
463 }
464
465 return mediaFiles, infoJSONPath, nil
466}
467
468func infoString(info map[string]interface{}, key string) (string, bool) {
469 if info == nil {
470 return "", false
471 }
472 // Only accept genuine strings: title/url/description are always strings in
473 // yt-dlp output, and stringifying an arbitrary JSON value (map, slice) would
474 // store junk like "map[...]" into the field.
475 if s, ok := info[key].(string); ok {
476 return s, true
477 }
478 return "", false
479}
480
481func infoDuration(info map[string]interface{}) (int, bool) {
482 if info == nil {
483 return 0, false
484 }
485 v, ok := info["duration"]
486 if !ok {
487 return 0, false
488 }
489 switch n := v.(type) {
490 case float64:
491 return int(n + 0.5), true
492 case int:
493 return n, true
494 case int64:
495 return int(n), true
496 case string:
497 if f, err := strconv.ParseFloat(n, 64); err == nil {
498 return int(f + 0.5), true
499 }
500 }
501 return 0, false
502}
503
504func mediaFileStem(mf models.MediaFile) string {
505 return strings.TrimSuffix(filepath.Base(mf.Filename), filepath.Ext(mf.Filename))
506}
507
508// primaryMediaFile picks the representative file for an item: the largest video
509// file, or — if there are none — the largest file overall. Returns nil for an
510// item with no media files. Used for the item-level thumbnail and as the default
511// target for metadata, keeping those two consistent.
512func primaryMediaFile(item *models.LibraryItem) *models.MediaFile {
513 size := func(mf *models.MediaFile) int64 {
514 if info, err := os.Stat(mf.Filepath); err == nil {
515 return info.Size()
516 }
517 return 0
518 }
519 var best *models.MediaFile
520 for i := range item.MediaFiles {
521 mf := &item.MediaFiles[i]
522 switch {
523 case best == nil:
524 best = mf
525 case best.IsAudio && !mf.IsAudio:
526 // Prefer any video over audio.
527 best = mf
528 case best.IsAudio == mf.IsAudio && size(mf) > size(best):
529 best = mf
530 }
531 }
532 return best
533}
534
535func (s *LibraryService) GetMediaFile(ctx context.Context, relPath, filename string) (string, error) {
536 item, err := s.GetByRelPath(ctx, relPath)
537 if err != nil {
538 return "", err
539 }
540 for _, mf := range item.MediaFiles {
541 if mf.Filename == filename {
542 return mf.Filepath, nil
543 }
544 }
545 return "", fmt.Errorf("media file not found")
546}
547
548// ThumbnailForFile returns the thumbnail for a specific media file within an
549// item, extracting it on demand if needed. The bool is false when no thumbnail
550// is available (file not found, audio-only, or extraction failed) so the caller
551// can serve an icon. An empty/unmatched filename yields false — every thumbnail
552// is keyed to a specific media file.
553func (s *LibraryService) ThumbnailForFile(ctx context.Context, relPath, filename string) (string, bool) {
554 if filename == "" {
555 return "", false
556 }
557 item, err := s.GetByRelPath(ctx, relPath)
558 if err != nil {
559 return "", false
560 }
561 for i := range item.MediaFiles {
562 if item.MediaFiles[i].Filename != filename {
563 continue
564 }
565 mf := item.MediaFiles[i]
566 if path, ok := s.findExistingThumbnail(mf.Filepath); ok {
567 return path, true
568 }
569 return s.ensureThumbnailForFile(mf)
570 }
571 return "", false
572}
573
574// ensureThumbnailForFile returns an existing thumbnail for mf or extracts one,
575// serializing concurrent extraction of the same file via a per-path mutex. It
576// re-checks the disk under the lock so that whichever caller wins the race does
577// the work and the rest reuse the result. A prior in-process failure short-
578// circuits to avoid re-running ffmpeg on every request (see thumbFailed).
579func (s *LibraryService) ensureThumbnailForFile(mf models.MediaFile) (string, bool) {
580 actual, _ := s.thumbLocks.LoadOrStore(mf.Filepath, &sync.Mutex{})
581 lock := actual.(*sync.Mutex)
582 lock.Lock()
583 defer lock.Unlock()
584
585 if path, ok := s.findExistingThumbnail(mf.Filepath); ok {
586 return path, true
587 }
588
589 // Trust a prior failure for this run rather than re-running ffmpeg every
590 // request; a restart clears thumbFailed and retries. Checked after the disk
591 // so a thumbnail that appears later (e.g. added manually) still wins.
592 if _, failed := s.thumbFailed.Load(mf.Filepath); failed {
593 return "", false
594 }
595
596 s.thumbSem <- struct{}{}
597 cur := atomic.AddInt32(&s.extractInFlight, 1)
598 for {
599 max := atomic.LoadInt32(&s.extractMaxConcurrent)
600 if cur <= max || atomic.CompareAndSwapInt32(&s.extractMaxConcurrent, max, cur) {
601 break
602 }
603 }
604 defer func() {
605 atomic.AddInt32(&s.extractInFlight, -1)
606 <-s.thumbSem
607 }()
608
609 atomic.AddInt32(&s.extractAttempts, 1)
610 path, err := s.extractThumbnail(mf)
611 if err != nil {
612 log.Printf("thumbnail extraction failed for %s: %v", mf.Filepath, err)
613 s.thumbFailed.Store(mf.Filepath, struct{}{})
614 return "", false
615 }
616 return path, true
617}
618
619func (s *LibraryService) findExistingThumbnail(path string) (string, bool) {
620 ext := filepath.Ext(path)
621 base := strings.TrimSuffix(path, ext) + ".thumbnail"
622 for _, candidate := range []string{base + ".webp", base + ".jpg", base + ".jpeg", base + ".png"} {
623 if info, err := os.Stat(candidate); err == nil && info.Size() > 0 {
624 return candidate, true
625 }
626 }
627 return "", false
628}
629
630// findImageAttachment returns the ordinal (0-based among attachment streams) of
631// the best image attachment in a container — e.g. the cover.jpg/cover.webp that
632// yt-dlp embeds into MKV with --embed-thumbnail — or -1 if there is none. Such
633// covers are attachment streams, not attached_pic video streams, so they must be
634// dumped with -dump_attachment rather than mapped like a normal stream.
635func findImageAttachment(path string) int {
636 cmd := exec.Command("ffprobe", "-v", "error", "-show_streams", "-of", "json", path)
637 output, err := cmd.Output()
638 if err != nil {
639 return -1
640 }
641 var probe struct {
642 Streams []struct {
643 CodecType string `json:"codec_type"`
644 Tags struct {
645 Mimetype string `json:"mimetype"`
646 Filename string `json:"filename"`
647 } `json:"tags"`
648 } `json:"streams"`
649 }
650 if err := json.Unmarshal(output, &probe); err != nil {
651 return -1
652 }
653
654 best, bestScore, attachmentIdx := -1, 0, 0
655 for _, stream := range probe.Streams {
656 if stream.CodecType != "attachment" {
657 continue
658 }
659 idx := attachmentIdx
660 attachmentIdx++
661 if !strings.HasPrefix(strings.ToLower(stream.Tags.Mimetype), "image/") {
662 continue
663 }
664 score := 20
665 switch name := strings.ToLower(stream.Tags.Filename); {
666 case strings.Contains(name, "cover"):
667 score = 100
668 case strings.Contains(name, "thumbnail"), strings.Contains(name, "thumb"):
669 score = 80
670 case strings.Contains(name, "poster"):
671 score = 60
672 case strings.Contains(name, "art"):
673 score = 40
674 }
675 if score > bestScore {
676 best, bestScore = idx, score
677 }
678 }
679 return best
680}
681
682func (s *LibraryService) extractThumbnail(mf models.MediaFile) (string, error) {
683 ext := filepath.Ext(mf.Filepath)
684 base := strings.TrimSuffix(mf.Filepath, ext) + ".thumbnail"
685 webpPath := base + ".webp"
686 jpgPath := base + ".jpg"
687
688 // Collect every attempt's error so a genuine failure surfaces all of them
689 // rather than only the last fallback's stderr.
690 var attemptErrs []string
691 record := func(label string, err error) {
692 attemptErrs = append(attemptErrs, fmt.Sprintf("%s: %v", label, err))
693 }
694
695 tryWrite := func(outputPath string, args []string) (string, error) {
696 // Keep the final extension on the temp file so ffmpeg can infer the
697 // output muxer (it cannot for a bare ".tmp" suffix), then atomically
698 // rename into place.
699 outExt := filepath.Ext(outputPath)
700 tmpPath := strings.TrimSuffix(outputPath, outExt) + ".tmp" + outExt
701 os.Remove(tmpPath)
702 cmd := exec.Command("ffmpeg", append(args, tmpPath)...)
703 if output, err := cmd.CombinedOutput(); err != nil {
704 os.Remove(tmpPath)
705 return "", fmt.Errorf("ffmpeg failed: %v\n%s", err, string(output))
706 }
707 if info, err := os.Stat(tmpPath); err != nil || info.Size() == 0 {
708 os.Remove(tmpPath)
709 return "", fmt.Errorf("ffmpeg produced empty output")
710 }
711 if err := os.Rename(tmpPath, outputPath); err != nil {
712 os.Remove(tmpPath)
713 return "", err
714 }
715 return outputPath, nil
716 }
717
718 // Prefer an embedded image attachment (e.g. yt-dlp's cover.webp/cover.jpg in
719 // MKV). These are attachment streams, not mappable video streams, so dump the
720 // raw bytes with -dump_attachment then transcode to a canonical WebP.
721 if idx := findImageAttachment(mf.Filepath); idx >= 0 {
722 if raw, err := os.CreateTemp("", "vidarchive-attachment-*"); err == nil {
723 rawPath := raw.Name()
724 raw.Close()
725 defer os.Remove(rawPath)
726 dumpArgs := []string{
727 fmt.Sprintf("-dump_attachment:t:%d", idx), rawPath,
728 "-i", mf.Filepath, "-y", "-t", "0", "-f", "null", "-",
729 }
730 if out, err := exec.Command("ffmpeg", dumpArgs...).CombinedOutput(); err != nil {
731 record("attachment-dump", fmt.Errorf("%v\n%s", err, out))
732 } else if path, err := tryWrite(webpPath, []string{"-i", rawPath, "-c:v", "libwebp"}); err == nil {
733 return path, nil
734 } else {
735 record("attachment-webp", err)
736 }
737 }
738 }
739
740 // Next try an embedded cover art video stream (attached_pic), e.g. mp3/mp4.
741 embeddedArgs := []string{
742 "-i", mf.Filepath,
743 "-map", "0:v",
744 "-map", "-0:V",
745 "-vframes", "1",
746 "-c:v", "libwebp",
747 }
748 if path, err := tryWrite(webpPath, embeddedArgs); err == nil {
749 return path, nil
750 } else {
751 record("embedded-webp", err)
752 }
753
754 // Fallback to jpeg for embedded covers if libwebp or webp encoding failed.
755 embeddedJpgArgs := []string{
756 "-i", mf.Filepath,
757 "-map", "0:v",
758 "-map", "-0:V",
759 "-vframes", "1",
760 "-q:v", "2",
761 }
762 if path, err := tryWrite(jpgPath, embeddedJpgArgs); err == nil {
763 return path, nil
764 } else {
765 record("embedded-jpg", err)
766 }
767
768 if mf.IsAudio {
769 return "", fmt.Errorf("audio file has no thumbnail")
770 }
771
772 seekTime := "00:00:01"
773 if mf.Duration > 0 {
774 midpoint := mf.Duration / 2
775 m := midpoint / 60
776 sec := midpoint % 60
777 h := m / 60
778 m = m % 60
779 if h > 0 {
780 seekTime = fmt.Sprintf("%02d:%02d:%02d", h, m, sec)
781 } else {
782 seekTime = fmt.Sprintf("%02d:%02d", m, sec)
783 }
784 }
785
786 frameArgs := []string{
787 "-ss", seekTime,
788 "-i", mf.Filepath,
789 "-vframes", "1",
790 "-c:v", "libwebp",
791 }
792 if path, err := tryWrite(webpPath, frameArgs); err == nil {
793 return path, nil
794 } else {
795 record("frame-webp", err)
796 }
797
798 frameJpgArgs := []string{
799 "-ss", seekTime,
800 "-i", mf.Filepath,
801 "-vframes", "1",
802 "-q:v", "2",
803 }
804 if path, err := tryWrite(jpgPath, frameJpgArgs); err == nil {
805 return path, nil
806 } else {
807 record("frame-jpg", err)
808 }
809
810 return "", fmt.Errorf("all thumbnail extraction attempts failed:\n%s", strings.Join(attemptErrs, "\n"))
811}
812
813func (s *LibraryService) Delete(relPath string) error {
814 // Guard against an empty/root relPath, which would resolve to the library
815 // root and delete the entire library. This is reachable via a URL-encoded
816 // slash (e.g. POST /library/item/%2Fdelete), so the check must live here.
817 if strings.Trim(relPath, "/") == "" {
818 return fmt.Errorf("refusing to delete library root")
819 }
820 itemDir, err := s.resolveItemDir(relPath)
821 if err != nil {
822 return err
823 }
824 // resolveItemDir maps "" to the library root; double-check we never target it.
825 if cleanDir, err := filepath.EvalSymlinks(itemDir); err == nil {
826 if base, err := filepath.EvalSymlinks(s.libraryDir); err == nil && cleanDir == base {
827 return fmt.Errorf("refusing to delete library root")
828 }
829 }
830 // Evict the cached scan so the deletion is reflected immediately rather than
831 // lingering until the TTL expires.
832 s.evictCachedScan(strings.Trim(relPath, "/"))
833 return os.RemoveAll(itemDir)
834}
835
836// videoKey is the stable per-item identity used to match items across runs:
837// yt-dlp's extractor + id, matching the form used in --download-archive and the
838// "%(extractor)s %(id)s" enumeration. Empty when either component is missing.
839func videoKey(extractor, id string) string {
840 if extractor == "" || id == "" {
841 return ""
842 }
843 return extractor + ":" + id
844}
845
846// FindByVideoID returns the absolute directory of the item under baseDir whose
847// marker matches the given extractor+id, scanning only that directory (the
848// subscription's owned folder). ok is false when no match is found or the key
849// is incomplete.
850func (s *LibraryService) FindByVideoID(baseDir, extractor, id string) (string, bool) {
851 want := videoKey(extractor, id)
852 if want == "" {
853 return "", false
854 }
855 entries, err := os.ReadDir(baseDir)
856 if err != nil {
857 return "", false
858 }
859 for _, entry := range entries {
860 if !entry.IsDir() || entry.Name() == subtitlesDirName {
861 continue
862 }
863 itemDir := filepath.Join(baseDir, entry.Name())
864 if _, err := os.Stat(filepath.Join(itemDir, itemMarkerName)); err != nil {
865 continue
866 }
867 meta, _ := s.readOrCreateMetadata(itemDir)
868 if videoKey(meta.Extractor, meta.VideoID) == want {
869 return itemDir, true
870 }
871 }
872 return "", false
873}
874
875// PruneToIDSet deletes items directly under baseDir whose identity key is not in
876// keep. It is used to mirror a subscription's source: entries removed upstream
877// are removed locally. Items without a known identity key are left untouched (we
878// never delete something we can't positively identify). Returns the number
879// removed.
880func (s *LibraryService) PruneToIDSet(baseDir string, keep map[string]bool) (int, error) {
881 entries, err := os.ReadDir(baseDir)
882 if err != nil {
883 return 0, err
884 }
885 removed := 0
886 for _, entry := range entries {
887 if !entry.IsDir() || entry.Name() == subtitlesDirName {
888 continue
889 }
890 itemDir := filepath.Join(baseDir, entry.Name())
891 if _, err := os.Stat(filepath.Join(itemDir, itemMarkerName)); err != nil {
892 continue
893 }
894 meta, _ := s.readOrCreateMetadata(itemDir)
895 key := videoKey(meta.Extractor, meta.VideoID)
896 if key == "" || keep[key] {
897 continue
898 }
899 if rel, err := filepath.Rel(s.libraryDir, itemDir); err == nil {
900 s.evictCachedScan(filepath.ToSlash(rel))
901 }
902 if err := os.RemoveAll(itemDir); err != nil {
903 log.Printf("warning: prune failed to remove %s: %v", itemDir, err)
904 continue
905 }
906 removed++
907 }
908 return removed, nil
909}
910
911func (s *LibraryService) SubtitleDir(relPath string) string {
912 itemDir, err := s.resolveItemDir(relPath)
913 if err != nil {
914 return ""
915 }
916 return filepath.Join(itemDir, subtitlesDirName)
917}
918
919func (s *LibraryService) GetSubtitlePath(relPath, lang string) string {
920 return filepath.Join(s.SubtitleDir(relPath), lang+".vtt")
921}
922
923func (s *LibraryService) GetSubtitles(ctx context.Context, relPath string) ([]models.SubtitleTrack, error) {
924 item, err := s.GetByRelPath(ctx, relPath)
925 if err != nil {
926 return nil, err
927 }
928
929 cacheDir := s.SubtitleDir(relPath)
930 entries, err := os.ReadDir(cacheDir)
931 if err == nil && len(entries) > 0 {
932 var tracks []models.SubtitleTrack
933 for _, entry := range entries {
934 if entry.IsDir() || filepath.Ext(entry.Name()) != ".vtt" {
935 continue
936 }
937 lang := strings.TrimSuffix(entry.Name(), ".vtt")
938 tracks = append(tracks, models.SubtitleTrack{
939 Lang: lang,
940 Label: lang,
941 Src: fmt.Sprintf("/media/item/%s/subtitles/%s", urlEncodePath(relPath), lang),
942 })
943 }
944 return tracks, nil
945 }
946
947 for _, mf := range item.MediaFiles {
948 if mf.IsAudio {
949 continue
950 }
951 streams, err := extractSubtitleInfo(mf.Filepath)
952 if err != nil || len(streams) == 0 {
953 continue
954 }
955 if err := os.MkdirAll(cacheDir, 0755); err != nil {
956 return nil, err
957 }
958 var tracks []models.SubtitleTrack
959 for _, stream := range streams {
960 lang := stream.Lang
961 if lang == "" {
962 lang = fmt.Sprintf("track%d", stream.Index)
963 }
964 outPath := filepath.Join(cacheDir, lang+".vtt")
965 if err := extractSubtitleToVTT(mf.Filepath, outPath, stream.Index); err != nil {
966 continue
967 }
968 tracks = append(tracks, models.SubtitleTrack{
969 Lang: lang,
970 Label: stream.Label,
971 Src: fmt.Sprintf("/media/item/%s/subtitles/%s", urlEncodePath(relPath), lang),
972 })
973 }
974 return tracks, nil
975 }
976
977 return nil, nil
978}
979
980type subtitleStream struct {
981 Index int
982 Lang string
983 Label string
984}
985
986func extractSubtitleInfo(path string) ([]subtitleStream, error) {
987 cmd := exec.Command("ffprobe",
988 "-v", "error",
989 "-show_streams",
990 "-select_streams", "s",
991 "-of", "json",
992 path,
993 )
994 output, err := cmd.Output()
995 if err != nil {
996 return nil, err
997 }
998 var probe struct {
999 Streams []struct {
1000 Index int `json:"index"`
1001 CodecName string `json:"codec_name"`
1002 Tags struct {
1003 Language string `json:"language"`
1004 Title string `json:"title"`
1005 } `json:"tags"`
1006 } `json:"streams"`
1007 }
1008 if err := json.Unmarshal(output, &probe); err != nil {
1009 return nil, err
1010 }
1011 var streams []subtitleStream
1012 subIndex := 0
1013 for _, stream := range probe.Streams {
1014 switch stream.CodecName {
1015 case "subrip", "ass", "ssa", "webvtt", "mov_text":
1016 lang := stream.Tags.Language
1017 if lang == "" {
1018 lang = fmt.Sprintf("track%d", subIndex)
1019 }
1020 label := stream.Tags.Title
1021 if label == "" {
1022 label = strings.ToUpper(lang)
1023 }
1024 streams = append(streams, subtitleStream{
1025 Index: subIndex,
1026 Lang: lang,
1027 Label: label,
1028 })
1029 subIndex++
1030 }
1031 }
1032 return streams, nil
1033}
1034
1035func extractSubtitleToVTT(inputPath, outputPath string, streamIndex int) error {
1036 cmd := exec.Command("ffmpeg",
1037 "-i", inputPath,
1038 "-map", fmt.Sprintf("0:s:%d", streamIndex),
1039 "-f", "webvtt",
1040 outputPath,
1041 "-y",
1042 )
1043 output, err := cmd.CombinedOutput()
1044 if err != nil {
1045 return fmt.Errorf("ffmpeg subtitle extraction failed: %w\nOutput: %s", err, string(output))
1046 }
1047 return nil
1048}
1049
1050// GetMetadata probes media details for the named file within an item. An empty
1051// filename (or one that doesn't match) falls back to the item's primary media
1052// file, so the detail view shows metadata for whichever file is selected.
1053func (s *LibraryService) GetMetadata(ctx context.Context, relPath, filename string) (*MediaMetadata, error) {
1054 item, err := s.GetByRelPath(ctx, relPath)
1055 if err != nil {
1056 return nil, err
1057 }
1058
1059 var target *models.MediaFile
1060 if filename != "" {
1061 for i := range item.MediaFiles {
1062 if item.MediaFiles[i].Filename == filename {
1063 target = &item.MediaFiles[i]
1064 break
1065 }
1066 }
1067 }
1068 if target == nil {
1069 target = primaryMediaFile(item)
1070 }
1071 if target == nil {
1072 return nil, fmt.Errorf("no media file")
1073 }
1074
1075 return probeMedia(target.Filepath)
1076}
1077
1078func probeMedia(path string) (*MediaMetadata, error) {
1079 info, err := os.Stat(path)
1080 if err != nil {
1081 return nil, err
1082 }
1083
1084 meta := &MediaMetadata{
1085 FileSize: info.Size(),
1086 }
1087
1088 cmd := exec.Command("ffprobe",
1089 "-v", "error",
1090 "-show_format",
1091 "-show_streams",
1092 "-of", "json",
1093 path,
1094 )
1095 output, err := cmd.Output()
1096 if err != nil {
1097 return meta, nil
1098 }
1099
1100 var probe ffprobeOutput
1101 if err := json.Unmarshal(output, &probe); err != nil {
1102 return meta, nil
1103 }
1104
1105 if probe.Format.FormatName != "" {
1106 parts := strings.Split(probe.Format.FormatName, ",")
1107 meta.Container = parts[0]
1108 }
1109
1110 for _, stream := range probe.Streams {
1111 switch stream.CodecType {
1112 case "video":
1113 vs := VideoStream{
1114 Codec: stream.CodecName,
1115 Profile: stream.Profile,
1116 Width: stream.Width,
1117 Height: stream.Height,
1118 FPS: parseFPS(stream.RFrameRate),
1119 PixelFormat: stream.PixFmt,
1120 Bitrate: formatBitrate(stream.BitRate),
1121 }
1122 meta.VideoStreams = append(meta.VideoStreams, vs)
1123 if stream.Width > 0 && stream.Height > 0 {
1124 meta.Resolution = fmt.Sprintf("%dx%d", stream.Width, stream.Height)
1125 }
1126 case "audio":
1127 as := AudioStream{
1128 Codec: stream.CodecName,
1129 SampleRate: stream.SampleRate,
1130 Channels: stream.Channels,
1131 ChannelLayout: stream.ChannelLayout,
1132 SampleFormat: stream.SampleFmt,
1133 Bitrate: formatBitrate(stream.BitRate),
1134 Language: stream.Tags.Language,
1135 }
1136 meta.AudioStreams = append(meta.AudioStreams, as)
1137 case "subtitle":
1138 ss := SubtitleStream{
1139 Codec: stream.CodecName,
1140 Language: stream.Tags.Language,
1141 Title: stream.Tags.Title,
1142 }
1143 meta.SubtitleStreams = append(meta.SubtitleStreams, ss)
1144 }
1145 }
1146
1147 return meta, nil
1148}
1149
1150func urlEncodePath(p string) string {
1151 parts := strings.Split(strings.Trim(p, "/"), "/")
1152 for i, part := range parts {
1153 parts[i] = url.PathEscape(part)
1154 }
1155 return strings.Join(parts, "/")
1156}
1157
1158func formatBitrate(bitRate string) string {
1159 if bitRate == "" {
1160 return ""
1161 }
1162 br, err := strconv.ParseInt(bitRate, 10, 64)
1163 if err != nil {
1164 return ""
1165 }
1166 return fmt.Sprintf("%d", br/1000)
1167}
1168
1169func parseFPS(rate string) string {
1170 if rate == "" || rate == "0/0" {
1171 return ""
1172 }
1173 parts := strings.Split(rate, "/")
1174 if len(parts) != 2 {
1175 return ""
1176 }
1177 num, err1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
1178 den, err2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
1179 if err1 != nil || err2 != nil || den == 0 {
1180 return ""
1181 }
1182 fps := num / den
1183 if fps == math.Trunc(fps) {
1184 return fmt.Sprintf("%.0f", fps)
1185 }
1186 return fmt.Sprintf("%.2f", fps)
1187}
1188
1189type MediaMetadata struct {
1190 Container string
1191 Resolution string
1192 FileSize int64
1193 VideoStreams []VideoStream
1194 AudioStreams []AudioStream
1195 SubtitleStreams []SubtitleStream
1196}
1197
1198type VideoStream struct {
1199 Codec string
1200 Profile string
1201 Width int
1202 Height int
1203 FPS string
1204 PixelFormat string
1205 Bitrate string
1206}
1207
1208type AudioStream struct {
1209 Codec string
1210 SampleRate string
1211 Channels int
1212 ChannelLayout string
1213 SampleFormat string
1214 Bitrate string
1215 Language string
1216}
1217
1218type SubtitleStream struct {
1219 Language string
1220 Title string
1221 Codec string
1222}
1223
1224type ffprobeOutput struct {
1225 Format struct {
1226 FormatName string `json:"format_name"`
1227 BitRate string `json:"bit_rate"`
1228 } `json:"format"`
1229 Streams []ffprobeStream `json:"streams"`
1230}
1231
1232type ffprobeStream struct {
1233 Index int `json:"index"`
1234 CodecName string `json:"codec_name"`
1235 CodecType string `json:"codec_type"`
1236 Profile string `json:"profile"`
1237 Width int `json:"width"`
1238 Height int `json:"height"`
1239 RFrameRate string `json:"r_frame_rate"`
1240 AvgFrameRate string `json:"avg_frame_rate"`
1241 PixFmt string `json:"pix_fmt"`
1242 SampleRate string `json:"sample_rate"`
1243 Channels int `json:"channels"`
1244 ChannelLayout string `json:"channel_layout"`
1245 SampleFmt string `json:"sample_fmt"`
1246 BitRate string `json:"bit_rate"`
1247 Tags struct {
1248 Language string `json:"language"`
1249 Title string `json:"title"`
1250 } `json:"tags"`
1251}
1252