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