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