library.go
⎇
Raw
1package service
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "log"
8 "math"
9 "net/url"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "sort"
14 "strconv"
15 "strings"
16 "sync"
17 "sync/atomic"
18 "time"
19
20 "github.com/BurntSushi/toml"
21
22 "vidarchive/internal/models"
23)
24
25const itemMarkerName = ".vidarchive-item.toml"
26const subtitlesDirName = "subtitles"
27
28var mediaExts = map[string]bool{
29 ".mp4": true, ".webm": true, ".mkv": true, ".avi": true, ".mov": true,
30 ".mp3": true, ".wav": true, ".flac": true, ".aac": true, ".opus": true,
31}
32
33var imageExts = map[string]bool{
34 ".webp": true, ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".bmp": true,
35}
36
37// maxConcurrentThumbnails caps how many ffmpeg extraction processes may run at
38// once, so a freshly loaded library page (which fires one thumbnail request per
39// visible item) cannot spawn an unbounded ffmpeg storm.
40const maxConcurrentThumbnails = 3
41
42type LibraryService struct {
43 libraryDir string
44 // thumbLocks maps a media filepath -> *sync.Mutex to serialize extraction
45 // per file (so two callers never write the same temp file at once). Entries
46 // are bounded by the number of distinct media files ever requested, not by
47 // request volume; entries for deleted items are not reclaimed. We
48 // deliberately do not prune: dropping a lock would let two extractions of
49 // the same file race on its shared temp path, which is exactly what the lock
50 // prevents.
51 thumbLocks sync.Map
52 thumbSem chan struct{}
53
54 // thumbFailed records media filepaths whose extraction already failed this
55 // run, so we trust ffmpeg's verdict and don't re-run it on every request. It
56 // lives only in memory: a restart clears it and retries extraction (and any
57 // success is then cached on disk as the .thumbnail file).
58 thumbFailed sync.Map
59
60 // extraction counters, for observability/tests.
61 extractInFlight int32
62 extractMaxConcurrent int32
63 extractAttempts int32
64
65 // scanCache memoizes scanned items for a short TTL so the listing page (which
66 // fans out one thumbnail request per media file) and quick auto-refreshes
67 // don't re-parse each item's marker + info.json on every request. Only item
68 // scans are cached — the directory listing itself is always read fresh, so
69 // newly added/removed items and subfolders appear immediately.
70 scanMu sync.Mutex
71 scanCache map[string]scanCacheEntry
72 scanTTL time.Duration
73}
74
75type scanCacheEntry struct {
76 item *models.LibraryItem
77 at time.Time
78}
79
80// scanCacheTTL is how long a scanned item is reused before being re-read.
81const scanCacheTTL = 10 * time.Second
82
83func NewLibraryService(libraryDir string) *LibraryService {
84 return &LibraryService{
85 libraryDir: libraryDir,
86 thumbSem: make(chan struct{}, maxConcurrentThumbnails),
87 scanCache: make(map[string]scanCacheEntry),
88 scanTTL: scanCacheTTL,
89 }
90}
91
92func (s *LibraryService) getCachedScan(relPath string) (*models.LibraryItem, bool) {
93 if s.scanTTL <= 0 {
94 return nil, false
95 }
96 s.scanMu.Lock()
97 defer s.scanMu.Unlock()
98 e, ok := s.scanCache[relPath]
99 if !ok || time.Since(e.at) > s.scanTTL {
100 return nil, false
101 }
102 return e.item, true
103}
104
105func (s *LibraryService) putCachedScan(relPath string, item *models.LibraryItem) {
106 if s.scanTTL <= 0 {
107 return
108 }
109 s.scanMu.Lock()
110 s.scanCache[relPath] = scanCacheEntry{item: item, at: time.Now()}
111 s.scanMu.Unlock()
112}
113
114func (s *LibraryService) evictCachedScan(relPath string) {
115 s.scanMu.Lock()
116 delete(s.scanCache, relPath)
117 s.scanMu.Unlock()
118}
119
120// scannedItem returns a cached scan if fresh, otherwise scans and caches it.
121func (s *LibraryService) scannedItem(itemDir, relPath string) (*models.LibraryItem, error) {
122 if item, ok := s.getCachedScan(relPath); ok {
123 return item, nil
124 }
125 item, err := s.scanItem(itemDir, relPath)
126 if err != nil {
127 return nil, err
128 }
129 s.putCachedScan(relPath, item)
130 return item, nil
131}
132
133func (s *LibraryService) resolveItemDir(relPath string) (string, error) {
134 relPath = strings.Trim(relPath, string(filepath.Separator))
135 if relPath == "" {
136 return s.libraryDir, nil
137 }
138 itemDir := filepath.Join(s.libraryDir, relPath)
139 cleanDir, err := filepath.EvalSymlinks(itemDir)
140 if err != nil {
141 cleanDir = filepath.Clean(itemDir)
142 }
143 base, err := filepath.EvalSymlinks(s.libraryDir)
144 if err != nil {
145 base = filepath.Clean(s.libraryDir)
146 }
147 if !strings.HasPrefix(cleanDir, base+string(filepath.Separator)) && cleanDir != base {
148 return "", fmt.Errorf("invalid path")
149 }
150 // Return the cleaned/symlink-resolved path we just validated, so callers do
151 // I/O on exactly the path that passed the boundary check.
152 return cleanDir, nil
153}
154
155func (s *LibraryService) GetAll(path, sortBy, filter string) ([]*models.LibraryItem, []string, error) {
156 if path != "" && !strings.HasSuffix(path, "/") {
157 path += "/"
158 }
159
160 dir, err := s.resolveItemDir(path)
161 if err != nil {
162 return nil, nil, nil
163 }
164 entries, err := os.ReadDir(dir)
165 if err != nil {
166 if os.IsNotExist(err) {
167 return nil, nil, nil
168 }
169 return nil, nil, err
170 }
171
172 var items []*models.LibraryItem
173 var folders []string
174
175 for _, entry := range entries {
176 if !entry.IsDir() {
177 continue
178 }
179 name := entry.Name()
180 if name == subtitlesDirName {
181 continue
182 }
183 itemDir := filepath.Join(dir, name)
184 relPath := filepath.ToSlash(filepath.Join(path, name))
185
186 markerPath := filepath.Join(itemDir, itemMarkerName)
187 if _, err := os.Stat(markerPath); err == nil {
188 item, err := s.scannedItem(itemDir, relPath)
189 if err != nil {
190 log.Printf("warning: failed to scan item %s: %v", relPath, err)
191 continue
192 }
193 if filter != "" && !strings.Contains(strings.ToLower(item.Name), strings.ToLower(filter)) && !strings.Contains(strings.ToLower(item.RelPath), strings.ToLower(filter)) {
194 continue
195 }
196 items = append(items, item)
197 } else {
198 folders = append(folders, name)
199 }
200 }
201
202 switch sortBy {
203 case "title":
204 sort.Slice(items, func(i, j int) bool { return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name) })
205 case "duration":
206 sort.Slice(items, func(i, j int) bool { return items[i].Duration > items[j].Duration })
207 case "date":
208 fallthrough
209 default:
210 // Stat each item once up front rather than twice per comparison.
211 modTime := make(map[string]int64, len(items))
212 for _, it := range items {
213 if info, err := os.Stat(it.DirPath); err == nil {
214 modTime[it.RelPath] = info.ModTime().UnixNano()
215 }
216 }
217 sort.Slice(items, func(i, j int) bool {
218 return modTime[items[i].RelPath] > modTime[items[j].RelPath]
219 })
220 }
221
222 sort.Strings(folders)
223
224 return items, folders, nil
225}
226
227// GetByRelPath returns the item at relPath, reusing a recent cached scan when
228// available (see scanCache). The context is accepted for cancellation/threading
229// but item caching is keyed on relPath at the service level.
230func (s *LibraryService) GetByRelPath(ctx context.Context, relPath string) (*models.LibraryItem, error) {
231 relPath = strings.Trim(relPath, "/")
232
233 if item, ok := s.getCachedScan(relPath); ok {
234 return item, nil
235 }
236
237 itemDir, err := s.resolveItemDir(relPath)
238 if err != nil {
239 return nil, fmt.Errorf("item not found")
240 }
241 markerPath := filepath.Join(itemDir, itemMarkerName)
242 if _, err := os.Stat(markerPath); err != nil {
243 return nil, fmt.Errorf("item not found")
244 }
245 item, err := s.scanItem(itemDir, relPath)
246 if err != nil {
247 return nil, err
248 }
249 s.putCachedScan(relPath, item)
250 return item, nil
251}
252
253func (s *LibraryService) scanItem(itemDir, relPath string) (*models.LibraryItem, error) {
254 metadata, err := s.readOrCreateMetadata(itemDir)
255 if err != nil {
256 return nil, err
257 }
258
259 mediaFiles, infoJSONPath, err := s.listItemFiles(itemDir)
260 if err != nil {
261 return nil, err
262 }
263
264 var info map[string]interface{}
265 if infoJSONPath != "" {
266 data, err := os.ReadFile(infoJSONPath)
267 if err == nil {
268 _ = json.Unmarshal(data, &info)
269 }
270 }
271
272 // dirty tracks whether we derived any new metadata worth persisting, so a
273 // plain listing or detail view doesn't rewrite the marker file on every read.
274 dirty := false
275
276 if metadata.Name == "" {
277 if title, ok := infoString(info, "title"); ok && title != "" {
278 metadata.Name = title
279 } else if len(mediaFiles) > 0 {
280 metadata.Name = mediaFileStem(mediaFiles[0])
281 } else {
282 metadata.Name = filepath.Base(itemDir)
283 }
284 dirty = true
285 }
286
287 if metadata.SourceURL == "" {
288 if url, ok := infoString(info, "webpage_url"); ok {
289 metadata.SourceURL = url
290 dirty = true
291 } else if url, ok := infoString(info, "url"); ok {
292 metadata.SourceURL = url
293 dirty = true
294 }
295 }
296
297 if metadata.Description == "" {
298 if desc, ok := infoString(info, "description"); ok {
299 metadata.Description = desc
300 dirty = true
301 }
302 }
303
304 if metadata.FileDurations == nil {
305 metadata.FileDurations = make(map[string]int)
306 }
307
308 // Per-file durations come from the marker's file_durations map (populated at
309 // import time). For a single-file item we also seed it from info.json's
310 // duration, which covers the common case without a probe. We deliberately do
311 // not run ffprobe here — keeping it off the scan/listing path is the point of
312 // the marker cache. Files without a known duration simply show no badge.
313 for i := range mediaFiles {
314 mf := &mediaFiles[i]
315 if d, ok := metadata.FileDurations[mf.Filename]; ok {
316 mf.Duration = d
317 continue
318 }
319 if len(mediaFiles) == 1 {
320 if d, ok := infoDuration(info); ok && d > 0 {
321 mf.Duration = d
322 metadata.FileDurations[mf.Filename] = d
323 dirty = true
324 }
325 }
326 }
327
328 // The item-level duration is the sum of known per-file durations, used only
329 // for the "duration" sort — there is no single "overall" duration shown.
330 total := 0
331 for _, mf := range mediaFiles {
332 if mf.Duration > 0 {
333 total += mf.Duration
334 }
335 }
336
337 item := &models.LibraryItem{
338 Name: metadata.Name,
339 RelPath: relPath,
340 DirPath: itemDir,
341 SourceURL: metadata.SourceURL,
342 Duration: total,
343 Description: metadata.Description,
344 MediaFiles: mediaFiles,
345 }
346
347 if dirty {
348 _ = s.writeMetadata(itemDir, metadata)
349 }
350
351 return item, nil
352}
353
354func (s *LibraryService) readOrCreateMetadata(itemDir string) (models.ItemMetadata, error) {
355 markerPath := filepath.Join(itemDir, itemMarkerName)
356 var metadata models.ItemMetadata
357 data, err := os.ReadFile(markerPath)
358 if err == nil {
359 if _, err := toml.Decode(string(data), &metadata); err != nil {
360 log.Printf("warning: failed to parse %s: %v", markerPath, err)
361 }
362 }
363 return metadata, nil
364}
365
366func (s *LibraryService) writeMetadata(itemDir string, metadata models.ItemMetadata) error {
367 markerPath := filepath.Join(itemDir, itemMarkerName)
368 // Write to a uniquely-named temp file and rename so a crash mid-encode can't
369 // leave a truncated marker, and two concurrent writers never collide on a
370 // shared temp path.
371 f, err := os.CreateTemp(itemDir, ".vidarchive-item-*.tmp")
372 if err != nil {
373 return err
374 }
375 tmpPath := f.Name()
376 if err := toml.NewEncoder(f).Encode(metadata); err != nil {
377 f.Close()
378 os.Remove(tmpPath)
379 return err
380 }
381 if err := f.Close(); err != nil {
382 os.Remove(tmpPath)
383 return err
384 }
385 return os.Rename(tmpPath, markerPath)
386}
387
388func (s *LibraryService) listItemFiles(itemDir string) ([]models.MediaFile, string, error) {
389 entries, err := os.ReadDir(itemDir)
390 if err != nil {
391 return nil, "", err
392 }
393
394 var mediaFiles []models.MediaFile
395 var infoJSONFiles []string
396
397 for _, entry := range entries {
398 if entry.IsDir() {
399 continue
400 }
401 name := entry.Name()
402 path := filepath.Join(itemDir, name)
403 ext := strings.ToLower(filepath.Ext(name))
404
405 if name == itemMarkerName {
406 continue
407 }
408 if name == "info.json" || strings.HasSuffix(name, ".info.json") {
409 infoJSONFiles = append(infoJSONFiles, path)
410 continue
411 }
412 if imageExts[ext] {
413 continue
414 }
415 if !mediaExts[ext] {
416 continue
417 }
418
419 isAudio := ext == ".mp3" || ext == ".wav" || ext == ".flac" || ext == ".aac" || ext == ".opus"
420 duration := -1
421
422 mediaFiles = append(mediaFiles, models.MediaFile{
423 Filename: name,
424 Filepath: path,
425 IsAudio: isAudio,
426 Duration: duration,
427 })
428 }
429
430 sort.Slice(mediaFiles, func(i, j int) bool {
431 return mediaFiles[i].Filepath < mediaFiles[j].Filepath
432 })
433
434 var infoJSONPath string
435 if len(infoJSONFiles) > 0 {
436 sort.Strings(infoJSONFiles)
437 infoJSONPath = infoJSONFiles[0]
438 if len(infoJSONFiles) > 1 {
439 log.Printf("warning: multiple info.json files in %s, using %s", itemDir, infoJSONPath)
440 }
441 }
442
443 return mediaFiles, infoJSONPath, nil
444}
445
446func infoString(info map[string]interface{}, key string) (string, bool) {
447 if info == nil {
448 return "", false
449 }
450 // Only accept genuine strings: title/url/description are always strings in
451 // yt-dlp output, and stringifying an arbitrary JSON value (map, slice) would
452 // store junk like "map[...]" into the field.
453 if s, ok := info[key].(string); ok {
454 return s, true
455 }
456 return "", false
457}
458
459func infoDuration(info map[string]interface{}) (int, bool) {
460 if info == nil {
461 return 0, false
462 }
463 v, ok := info["duration"]
464 if !ok {
465 return 0, false
466 }
467 switch n := v.(type) {
468 case float64:
469 return int(n + 0.5), true
470 case int:
471 return n, true
472 case int64:
473 return int(n), true
474 case string:
475 if f, err := strconv.ParseFloat(n, 64); err == nil {
476 return int(f + 0.5), true
477 }
478 }
479 return 0, false
480}
481
482func mediaFileStem(mf models.MediaFile) string {
483 return strings.TrimSuffix(filepath.Base(mf.Filename), filepath.Ext(mf.Filename))
484}
485
486// primaryMediaFile picks the representative file for an item: the largest video
487// file, or — if there are none — the largest file overall. Returns nil for an
488// item with no media files. Used for the item-level thumbnail and as the default
489// target for metadata, keeping those two consistent.
490func primaryMediaFile(item *models.LibraryItem) *models.MediaFile {
491 size := func(mf *models.MediaFile) int64 {
492 if info, err := os.Stat(mf.Filepath); err == nil {
493 return info.Size()
494 }
495 return 0
496 }
497 var best *models.MediaFile
498 for i := range item.MediaFiles {
499 mf := &item.MediaFiles[i]
500 switch {
501 case best == nil:
502 best = mf
503 case best.IsAudio && !mf.IsAudio:
504 // Prefer any video over audio.
505 best = mf
506 case best.IsAudio == mf.IsAudio && size(mf) > size(best):
507 best = mf
508 }
509 }
510 return best
511}
512
513func (s *LibraryService) GetMediaFile(ctx context.Context, relPath, filename string) (string, error) {
514 item, err := s.GetByRelPath(ctx, relPath)
515 if err != nil {
516 return "", err
517 }
518 for _, mf := range item.MediaFiles {
519 if mf.Filename == filename {
520 return mf.Filepath, nil
521 }
522 }
523 return "", fmt.Errorf("media file not found")
524}
525
526// ThumbnailForFile returns the thumbnail for a specific media file within an
527// item, extracting it on demand if needed. The bool is false when no thumbnail
528// is available (file not found, audio-only, or extraction failed) so the caller
529// can serve an icon. An empty/unmatched filename yields false — every thumbnail
530// is keyed to a specific media file.
531func (s *LibraryService) ThumbnailForFile(ctx context.Context, relPath, filename string) (string, bool) {
532 if filename == "" {
533 return "", false
534 }
535 item, err := s.GetByRelPath(ctx, relPath)
536 if err != nil {
537 return "", false
538 }
539 for i := range item.MediaFiles {
540 if item.MediaFiles[i].Filename != filename {
541 continue
542 }
543 mf := item.MediaFiles[i]
544 if path, ok := s.findExistingThumbnail(mf.Filepath); ok {
545 return path, true
546 }
547 return s.ensureThumbnailForFile(mf)
548 }
549 return "", false
550}
551
552// ensureThumbnailForFile returns an existing thumbnail for mf or extracts one,
553// serializing concurrent extraction of the same file via a per-path mutex. It
554// re-checks the disk under the lock so that whichever caller wins the race does
555// the work and the rest reuse the result. A prior in-process failure short-
556// circuits to avoid re-running ffmpeg on every request (see thumbFailed).
557func (s *LibraryService) ensureThumbnailForFile(mf models.MediaFile) (string, bool) {
558 actual, _ := s.thumbLocks.LoadOrStore(mf.Filepath, &sync.Mutex{})
559 lock := actual.(*sync.Mutex)
560 lock.Lock()
561 defer lock.Unlock()
562
563 if path, ok := s.findExistingThumbnail(mf.Filepath); ok {
564 return path, true
565 }
566
567 // Trust a prior failure for this run rather than re-running ffmpeg every
568 // request; a restart clears thumbFailed and retries. Checked after the disk
569 // so a thumbnail that appears later (e.g. added manually) still wins.
570 if _, failed := s.thumbFailed.Load(mf.Filepath); failed {
571 return "", false
572 }
573
574 s.thumbSem <- struct{}{}
575 cur := atomic.AddInt32(&s.extractInFlight, 1)
576 for {
577 max := atomic.LoadInt32(&s.extractMaxConcurrent)
578 if cur <= max || atomic.CompareAndSwapInt32(&s.extractMaxConcurrent, max, cur) {
579 break
580 }
581 }
582 defer func() {
583 atomic.AddInt32(&s.extractInFlight, -1)
584 <-s.thumbSem
585 }()
586
587 atomic.AddInt32(&s.extractAttempts, 1)
588 path, err := s.extractThumbnail(mf)
589 if err != nil {
590 log.Printf("thumbnail extraction failed for %s: %v", mf.Filepath, err)
591 s.thumbFailed.Store(mf.Filepath, struct{}{})
592 return "", false
593 }
594 return path, true
595}
596
597func (s *LibraryService) findExistingThumbnail(path string) (string, bool) {
598 ext := filepath.Ext(path)
599 base := strings.TrimSuffix(path, ext) + ".thumbnail"
600 for _, candidate := range []string{base + ".webp", base + ".jpg", base + ".jpeg", base + ".png"} {
601 if info, err := os.Stat(candidate); err == nil && info.Size() > 0 {
602 return candidate, true
603 }
604 }
605 return "", false
606}
607
608// findImageAttachment returns the ordinal (0-based among attachment streams) of
609// the best image attachment in a container — e.g. the cover.jpg/cover.webp that
610// yt-dlp embeds into MKV with --embed-thumbnail — or -1 if there is none. Such
611// covers are attachment streams, not attached_pic video streams, so they must be
612// dumped with -dump_attachment rather than mapped like a normal stream.
613func findImageAttachment(path string) int {
614 cmd := exec.Command("ffprobe", "-v", "error", "-show_streams", "-of", "json", path)
615 output, err := cmd.Output()
616 if err != nil {
617 return -1
618 }
619 var probe struct {
620 Streams []struct {
621 CodecType string `json:"codec_type"`
622 Tags struct {
623 Mimetype string `json:"mimetype"`
624 Filename string `json:"filename"`
625 } `json:"tags"`
626 } `json:"streams"`
627 }
628 if err := json.Unmarshal(output, &probe); err != nil {
629 return -1
630 }
631
632 best, bestScore, attachmentIdx := -1, 0, 0
633 for _, stream := range probe.Streams {
634 if stream.CodecType != "attachment" {
635 continue
636 }
637 idx := attachmentIdx
638 attachmentIdx++
639 if !strings.HasPrefix(strings.ToLower(stream.Tags.Mimetype), "image/") {
640 continue
641 }
642 score := 20
643 switch name := strings.ToLower(stream.Tags.Filename); {
644 case strings.Contains(name, "cover"):
645 score = 100
646 case strings.Contains(name, "thumbnail"), strings.Contains(name, "thumb"):
647 score = 80
648 case strings.Contains(name, "poster"):
649 score = 60
650 case strings.Contains(name, "art"):
651 score = 40
652 }
653 if score > bestScore {
654 best, bestScore = idx, score
655 }
656 }
657 return best
658}
659
660func (s *LibraryService) extractThumbnail(mf models.MediaFile) (string, error) {
661 ext := filepath.Ext(mf.Filepath)
662 base := strings.TrimSuffix(mf.Filepath, ext) + ".thumbnail"
663 webpPath := base + ".webp"
664 jpgPath := base + ".jpg"
665
666 // Collect every attempt's error so a genuine failure surfaces all of them
667 // rather than only the last fallback's stderr.
668 var attemptErrs []string
669 record := func(label string, err error) {
670 attemptErrs = append(attemptErrs, fmt.Sprintf("%s: %v", label, err))
671 }
672
673 tryWrite := func(outputPath string, args []string) (string, error) {
674 // Keep the final extension on the temp file so ffmpeg can infer the
675 // output muxer (it cannot for a bare ".tmp" suffix), then atomically
676 // rename into place.
677 outExt := filepath.Ext(outputPath)
678 tmpPath := strings.TrimSuffix(outputPath, outExt) + ".tmp" + outExt
679 os.Remove(tmpPath)
680 cmd := exec.Command("ffmpeg", append(args, tmpPath)...)
681 if output, err := cmd.CombinedOutput(); err != nil {
682 os.Remove(tmpPath)
683 return "", fmt.Errorf("ffmpeg failed: %v\n%s", err, string(output))
684 }
685 if info, err := os.Stat(tmpPath); err != nil || info.Size() == 0 {
686 os.Remove(tmpPath)
687 return "", fmt.Errorf("ffmpeg produced empty output")
688 }
689 if err := os.Rename(tmpPath, outputPath); err != nil {
690 os.Remove(tmpPath)
691 return "", err
692 }
693 return outputPath, nil
694 }
695
696 // Prefer an embedded image attachment (e.g. yt-dlp's cover.webp/cover.jpg in
697 // MKV). These are attachment streams, not mappable video streams, so dump the
698 // raw bytes with -dump_attachment then transcode to a canonical WebP.
699 if idx := findImageAttachment(mf.Filepath); idx >= 0 {
700 if raw, err := os.CreateTemp("", "vidarchive-attachment-*"); err == nil {
701 rawPath := raw.Name()
702 raw.Close()
703 defer os.Remove(rawPath)
704 dumpArgs := []string{
705 fmt.Sprintf("-dump_attachment:t:%d", idx), rawPath,
706 "-i", mf.Filepath, "-y", "-t", "0", "-f", "null", "-",
707 }
708 if out, err := exec.Command("ffmpeg", dumpArgs...).CombinedOutput(); err != nil {
709 record("attachment-dump", fmt.Errorf("%v\n%s", err, out))
710 } else if path, err := tryWrite(webpPath, []string{"-i", rawPath, "-c:v", "libwebp"}); err == nil {
711 return path, nil
712 } else {
713 record("attachment-webp", err)
714 }
715 }
716 }
717
718 // Next try an embedded cover art video stream (attached_pic), e.g. mp3/mp4.
719 embeddedArgs := []string{
720 "-i", mf.Filepath,
721 "-map", "0:v",
722 "-map", "-0:V",
723 "-vframes", "1",
724 "-c:v", "libwebp",
725 }
726 if path, err := tryWrite(webpPath, embeddedArgs); err == nil {
727 return path, nil
728 } else {
729 record("embedded-webp", err)
730 }
731
732 // Fallback to jpeg for embedded covers if libwebp or webp encoding failed.
733 embeddedJpgArgs := []string{
734 "-i", mf.Filepath,
735 "-map", "0:v",
736 "-map", "-0:V",
737 "-vframes", "1",
738 "-q:v", "2",
739 }
740 if path, err := tryWrite(jpgPath, embeddedJpgArgs); err == nil {
741 return path, nil
742 } else {
743 record("embedded-jpg", err)
744 }
745
746 if mf.IsAudio {
747 return "", fmt.Errorf("audio file has no thumbnail")
748 }
749
750 seekTime := "00:00:01"
751 if mf.Duration > 0 {
752 midpoint := mf.Duration / 2
753 m := midpoint / 60
754 sec := midpoint % 60
755 h := m / 60
756 m = m % 60
757 if h > 0 {
758 seekTime = fmt.Sprintf("%02d:%02d:%02d", h, m, sec)
759 } else {
760 seekTime = fmt.Sprintf("%02d:%02d", m, sec)
761 }
762 }
763
764 frameArgs := []string{
765 "-ss", seekTime,
766 "-i", mf.Filepath,
767 "-vframes", "1",
768 "-c:v", "libwebp",
769 }
770 if path, err := tryWrite(webpPath, frameArgs); err == nil {
771 return path, nil
772 } else {
773 record("frame-webp", err)
774 }
775
776 frameJpgArgs := []string{
777 "-ss", seekTime,
778 "-i", mf.Filepath,
779 "-vframes", "1",
780 "-q:v", "2",
781 }
782 if path, err := tryWrite(jpgPath, frameJpgArgs); err == nil {
783 return path, nil
784 } else {
785 record("frame-jpg", err)
786 }
787
788 return "", fmt.Errorf("all thumbnail extraction attempts failed:\n%s", strings.Join(attemptErrs, "\n"))
789}
790
791func (s *LibraryService) Delete(relPath string) error {
792 // Guard against an empty/root relPath, which would resolve to the library
793 // root and delete the entire library. This is reachable via a URL-encoded
794 // slash (e.g. POST /library/item/%2Fdelete), so the check must live here.
795 if strings.Trim(relPath, "/") == "" {
796 return fmt.Errorf("refusing to delete library root")
797 }
798 itemDir, err := s.resolveItemDir(relPath)
799 if err != nil {
800 return err
801 }
802 // resolveItemDir maps "" to the library root; double-check we never target it.
803 if cleanDir, err := filepath.EvalSymlinks(itemDir); err == nil {
804 if base, err := filepath.EvalSymlinks(s.libraryDir); err == nil && cleanDir == base {
805 return fmt.Errorf("refusing to delete library root")
806 }
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
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