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