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
970// GetEngagement reads optional comments and a playback heatmap from the item's
971// yt-dlp info sidecar. These fields are intentionally not copied into the
972// marker: the sidecar remains the source of truth and old items simply return
973// empty data when the fields are absent.
974func (s *LibraryService) GetEngagement(relPath string) ([]models.Comment, []models.HeatmapSegment, error) {
975 item, err := s.GetByRelPath(relPath)
976 if err != nil {
977 return nil, nil, err
978 }
979 _, infoPath, err := s.listItemFiles(item.DirPath)
980 if err != nil || infoPath == "" {
981 return nil, nil, err
982 }
983 data, err := os.ReadFile(infoPath)
984 if err != nil {
985 return nil, nil, err
986 }
987
988 var raw struct {
989 Comments []struct {
990 ID string `json:"id"`
991 Parent string `json:"parent"`
992 Author string `json:"author"`
993 Channel string `json:"channel"`
994 Text string `json:"text"`
995 Timestamp float64 `json:"timestamp"`
996 TimeText string `json:"time_text"`
997 LikeCount int `json:"like_count"`
998 AuthorIsUploader bool `json:"author_is_uploader"`
999 } `json:"comments"`
1000 Heatmap []struct {
1001 StartTime float64 `json:"start_time"`
1002 EndTime float64 `json:"end_time"`
1003 Value float64 `json:"value"`
1004 } `json:"heatmap"`
1005 }
1006 if err := json.Unmarshal(data, &raw); err != nil {
1007 return nil, nil, fmt.Errorf("parse engagement info JSON: %w", err)
1008 }
1009
1010 comments := make([]models.Comment, 0, len(raw.Comments))
1011 for _, c := range raw.Comments {
1012 author := c.Author
1013 if author == "" {
1014 author = c.Channel
1015 }
1016 if c.Text == "" && author == "" {
1017 continue
1018 }
1019 comment := models.Comment{
1020 ID: c.ID,
1021 Parent: c.Parent,
1022 Author: author,
1023 Text: c.Text,
1024 Timestamp: int64(c.Timestamp),
1025 TimeText: c.TimeText,
1026 LikeCount: c.LikeCount,
1027 AuthorIsUploader: c.AuthorIsUploader,
1028 }
1029 if comment.TimeText == "" && comment.Timestamp > 0 {
1030 comment.TimeText = time.Unix(comment.Timestamp, 0).UTC().Format("2006-01-02 15:04")
1031 }
1032 comments = append(comments, comment)
1033 }
1034 comments = orderComments(comments)
1035
1036 maxValue := 0.0
1037 for _, h := range raw.Heatmap {
1038 if h.Value > maxValue && !math.IsNaN(h.Value) && !math.IsInf(h.Value, 0) {
1039 maxValue = h.Value
1040 }
1041 }
1042 endTime := 0.0
1043 for _, h := range raw.Heatmap {
1044 if h.EndTime > endTime {
1045 endTime = h.EndTime
1046 }
1047 }
1048 heatmap := make([]models.HeatmapSegment, 0, len(raw.Heatmap))
1049 for _, h := range raw.Heatmap {
1050 if h.EndTime <= h.StartTime || h.StartTime < 0 || h.Value < 0 ||
1051 math.IsNaN(h.StartTime) || math.IsNaN(h.EndTime) || math.IsNaN(h.Value) ||
1052 math.IsInf(h.StartTime, 0) || math.IsInf(h.EndTime, 0) || math.IsInf(h.Value, 0) {
1053 continue
1054 }
1055 width := 0.0
1056 if endTime > 0 {
1057 width = (h.EndTime - h.StartTime) / endTime * 100
1058 }
1059 height := 8.0
1060 if maxValue > 0 {
1061 height += h.Value / maxValue * 92
1062 }
1063 heatmap = append(heatmap, models.HeatmapSegment{
1064 StartTime: h.StartTime,
1065 EndTime: h.EndTime,
1066 Value: h.Value,
1067 Width: width,
1068 Height: height,
1069 })
1070 }
1071 sort.SliceStable(heatmap, func(i, j int) bool {
1072 return heatmap[i].StartTime < heatmap[j].StartTime
1073 })
1074 return comments, heatmap, nil
1075}
1076
1077// orderComments groups replies beneath their parent while preserving the
1078// source order among siblings. Depth is capped for presentation so malformed
1079// or unusually deep reply chains cannot make the UI progressively narrower.
1080func orderComments(comments []models.Comment) []models.Comment {
1081 if len(comments) < 2 {
1082 return comments
1083 }
1084
1085 byID := make(map[string]int, len(comments))
1086 for i, comment := range comments {
1087 if comment.ID != "" {
1088 if _, exists := byID[comment.ID]; !exists {
1089 byID[comment.ID] = i
1090 }
1091 }
1092 }
1093
1094 children := make(map[int][]int)
1095 var roots []int
1096 for i, comment := range comments {
1097 parent := strings.TrimSpace(comment.Parent)
1098 if parent == "" || strings.EqualFold(parent, "root") {
1099 roots = append(roots, i)
1100 continue
1101 }
1102 parentIndex, ok := byID[parent]
1103 if !ok || parentIndex == i {
1104 roots = append(roots, i)
1105 continue
1106 }
1107 children[parentIndex] = append(children[parentIndex], i)
1108 }
1109
1110 ordered := make([]models.Comment, 0, len(comments))
1111 visited := make([]bool, len(comments))
1112 stack := make([]struct {
1113 index int
1114 depth int
1115 }, 0, len(comments))
1116 walk := func(index, depth int) {
1117 stack = append(stack, struct {
1118 index int
1119 depth int
1120 }{index, depth})
1121 for len(stack) > 0 {
1122 last := len(stack) - 1
1123 entry := stack[last]
1124 stack = stack[:last]
1125 if visited[entry.index] {
1126 continue
1127 }
1128 visited[entry.index] = true
1129 comment := comments[entry.index]
1130 if parentIndex, ok := byID[strings.TrimSpace(comment.Parent)]; ok && parentIndex != entry.index {
1131 comment.ReplyTo = comments[parentIndex].Author
1132 }
1133 if entry.depth > 4 {
1134 comment.Depth = 4
1135 } else {
1136 comment.Depth = entry.depth
1137 }
1138 ordered = append(ordered, comment)
1139
1140 childrenForComment := children[entry.index]
1141 for i := len(childrenForComment) - 1; i >= 0; i-- {
1142 stack = append(stack, struct {
1143 index int
1144 depth int
1145 }{childrenForComment[i], entry.depth + 1})
1146 }
1147 }
1148 }
1149
1150 for _, root := range roots {
1151 walk(root, 0)
1152 }
1153 // Cycles or references to invalid parents are rendered as top-level comments
1154 // rather than being dropped.
1155 for i := range comments {
1156 if !visited[i] {
1157 walk(i, 0)
1158 }
1159 }
1160 return ordered
1161}
1162
1163type subtitleStream struct {
1164 Index int
1165 Lang string
1166 Label string
1167}
1168
1169func (s *LibraryService) extractSubtitleInfo(path string) ([]subtitleStream, error) {
1170 cmd := exec.Command(s.ffprobePath,
1171 "-v", "error",
1172 "-show_streams",
1173 "-select_streams", "s",
1174 "-of", "json",
1175 path,
1176 )
1177 output, err := cmd.Output()
1178 if err != nil {
1179 return nil, err
1180 }
1181 var probe struct {
1182 Streams []struct {
1183 Index int `json:"index"`
1184 CodecName string `json:"codec_name"`
1185 Tags struct {
1186 Language string `json:"language"`
1187 Title string `json:"title"`
1188 } `json:"tags"`
1189 } `json:"streams"`
1190 }
1191 if err := json.Unmarshal(output, &probe); err != nil {
1192 return nil, err
1193 }
1194 var streams []subtitleStream
1195 subIndex := 0
1196 for _, stream := range probe.Streams {
1197 switch stream.CodecName {
1198 case "subrip", "ass", "ssa", "webvtt", "mov_text":
1199 lang := stream.Tags.Language
1200 if lang == "" {
1201 lang = fmt.Sprintf("track%d", subIndex)
1202 }
1203 label := stream.Tags.Title
1204 if label == "" {
1205 label = strings.ToUpper(lang)
1206 }
1207 streams = append(streams, subtitleStream{
1208 Index: subIndex,
1209 Lang: lang,
1210 Label: label,
1211 })
1212 subIndex++
1213 }
1214 }
1215 return streams, nil
1216}
1217
1218func (s *LibraryService) extractSubtitleToVTT(inputPath, outputPath string, streamIndex int) error {
1219 cmd := exec.Command(s.ffmpegPath,
1220 "-i", inputPath,
1221 "-map", fmt.Sprintf("0:s:%d", streamIndex),
1222 "-f", "webvtt",
1223 outputPath,
1224 "-y",
1225 )
1226 output, err := cmd.CombinedOutput()
1227 if err != nil {
1228 return fmt.Errorf("ffmpeg subtitle extraction failed: %w\nOutput: %s", err, string(output))
1229 }
1230 return nil
1231}
1232
1233// GetMetadata probes media details for the named file within an item. An empty
1234// filename (or one that doesn't match) falls back to the item's primary media
1235// file, so the detail view shows metadata for whichever file is selected.
1236func (s *LibraryService) GetMetadata(relPath, filename string) (*MediaMetadata, error) {
1237 item, err := s.GetByRelPath(relPath)
1238 if err != nil {
1239 return nil, err
1240 }
1241
1242 var target *models.MediaFile
1243 if filename != "" {
1244 for i := range item.MediaFiles {
1245 if item.MediaFiles[i].Filename == filename {
1246 target = &item.MediaFiles[i]
1247 break
1248 }
1249 }
1250 }
1251 if target == nil {
1252 target = primaryMediaFile(item)
1253 }
1254 if target == nil {
1255 return nil, fmt.Errorf("no media file")
1256 }
1257
1258 return s.probeMedia(target.Filepath)
1259}
1260
1261func (s *LibraryService) probeMedia(path string) (*MediaMetadata, error) {
1262 info, err := os.Stat(path)
1263 if err != nil {
1264 return nil, err
1265 }
1266
1267 meta := &MediaMetadata{
1268 FileSize: info.Size(),
1269 }
1270
1271 cmd := exec.Command(s.ffprobePath,
1272 "-v", "error",
1273 "-show_format",
1274 "-show_streams",
1275 "-of", "json",
1276 path,
1277 )
1278 output, err := cmd.Output()
1279 if err != nil {
1280 return meta, nil
1281 }
1282
1283 var probe ffprobeOutput
1284 if err := json.Unmarshal(output, &probe); err != nil {
1285 return meta, nil
1286 }
1287
1288 if probe.Format.FormatName != "" {
1289 parts := strings.Split(probe.Format.FormatName, ",")
1290 meta.Container = parts[0]
1291 }
1292
1293 for _, stream := range probe.Streams {
1294 switch stream.CodecType {
1295 case "video":
1296 vs := VideoStream{
1297 Codec: stream.CodecName,
1298 Profile: stream.Profile,
1299 Width: stream.Width,
1300 Height: stream.Height,
1301 FPS: parseFPS(stream.RFrameRate),
1302 PixelFormat: stream.PixFmt,
1303 Bitrate: formatBitrate(stream.BitRate),
1304 }
1305 meta.VideoStreams = append(meta.VideoStreams, vs)
1306 if stream.Width > 0 && stream.Height > 0 {
1307 meta.Resolution = fmt.Sprintf("%dx%d", stream.Width, stream.Height)
1308 }
1309 case "audio":
1310 as := AudioStream{
1311 Codec: stream.CodecName,
1312 SampleRate: stream.SampleRate,
1313 Channels: stream.Channels,
1314 ChannelLayout: stream.ChannelLayout,
1315 SampleFormat: stream.SampleFmt,
1316 Bitrate: formatBitrate(stream.BitRate),
1317 Language: stream.Tags.Language,
1318 }
1319 meta.AudioStreams = append(meta.AudioStreams, as)
1320 case "subtitle":
1321 ss := SubtitleStream{
1322 Codec: stream.CodecName,
1323 Language: stream.Tags.Language,
1324 Title: stream.Tags.Title,
1325 }
1326 meta.SubtitleStreams = append(meta.SubtitleStreams, ss)
1327 }
1328 }
1329
1330 return meta, nil
1331}
1332
1333func formatBitrate(bitRate string) string {
1334 if bitRate == "" {
1335 return ""
1336 }
1337 br, err := strconv.ParseInt(bitRate, 10, 64)
1338 if err != nil {
1339 return ""
1340 }
1341 return fmt.Sprintf("%d", br/1000)
1342}
1343
1344func parseFPS(rate string) string {
1345 var num, den float64
1346 if _, err := fmt.Sscanf(rate, "%f/%f", &num, &den); err != nil || den == 0 {
1347 return ""
1348 }
1349 fps := num / den
1350 if fps == math.Trunc(fps) {
1351 return fmt.Sprintf("%.0f", fps)
1352 }
1353 return fmt.Sprintf("%.2f", fps)
1354}
1355
1356type MediaMetadata struct {
1357 Container string
1358 Resolution string
1359 FileSize int64
1360 VideoStreams []VideoStream
1361 AudioStreams []AudioStream
1362 SubtitleStreams []SubtitleStream
1363}
1364
1365type VideoStream struct {
1366 Codec string
1367 Profile string
1368 Width int
1369 Height int
1370 FPS string
1371 PixelFormat string
1372 Bitrate string
1373}
1374
1375type AudioStream struct {
1376 Codec string
1377 SampleRate string
1378 Channels int
1379 ChannelLayout string
1380 SampleFormat string
1381 Bitrate string
1382 Language string
1383}
1384
1385type SubtitleStream struct {
1386 Language string
1387 Title string
1388 Codec string
1389}
1390
1391type ffprobeOutput struct {
1392 Format struct {
1393 FormatName string `json:"format_name"`
1394 BitRate string `json:"bit_rate"`
1395 } `json:"format"`
1396 Streams []ffprobeStream `json:"streams"`
1397}
1398
1399type ffprobeStream struct {
1400 Index int `json:"index"`
1401 CodecName string `json:"codec_name"`
1402 CodecType string `json:"codec_type"`
1403 Profile string `json:"profile"`
1404 Width int `json:"width"`
1405 Height int `json:"height"`
1406 RFrameRate string `json:"r_frame_rate"`
1407 AvgFrameRate string `json:"avg_frame_rate"`
1408 PixFmt string `json:"pix_fmt"`
1409 SampleRate string `json:"sample_rate"`
1410 Channels int `json:"channels"`
1411 ChannelLayout string `json:"channel_layout"`
1412 SampleFmt string `json:"sample_fmt"`
1413 BitRate string `json:"bit_rate"`
1414 Tags struct {
1415 Language string `json:"language"`
1416 Title string `json:"title"`
1417 } `json:"tags"`
1418}
1419