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