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