library.go
⎇
Raw
1package service
2
3import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "log"
8 "math"
9 "net/url"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "sort"
14 "strconv"
15 "strings"
16 "sync"
17
18 "github.com/BurntSushi/toml"
19
20 "vidarchive/internal/models"
21)
22
23const itemMarkerName = ".vidarchive-item.toml"
24const subtitlesDirName = "subtitles"
25
26var mediaExts = map[string]bool{
27 ".mp4": true, ".webm": true, ".mkv": true, ".avi": true, ".mov": true,
28 ".mp3": true, ".wav": true, ".flac": true, ".aac": true, ".opus": true,
29}
30
31var imageExts = map[string]bool{
32 ".webp": true, ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".bmp": true,
33}
34
35type LibraryService struct {
36 libraryDir string
37 tempDir string
38 thumbOnce sync.Map
39}
40
41func NewLibraryService(libraryDir, tempDir string) *LibraryService {
42 return &LibraryService{libraryDir: libraryDir, tempDir: tempDir}
43}
44
45func (s *LibraryService) resolveItemDir(relPath string) (string, error) {
46 relPath = strings.Trim(relPath, string(filepath.Separator))
47 if relPath == "" {
48 return s.libraryDir, nil
49 }
50 itemDir := filepath.Join(s.libraryDir, relPath)
51 cleanDir, err := filepath.EvalSymlinks(itemDir)
52 if err != nil {
53 cleanDir = filepath.Clean(itemDir)
54 }
55 base, err := filepath.EvalSymlinks(s.libraryDir)
56 if err != nil {
57 base = filepath.Clean(s.libraryDir)
58 }
59 if !strings.HasPrefix(cleanDir, base+string(filepath.Separator)) && cleanDir != base {
60 return "", fmt.Errorf("invalid path")
61 }
62 return itemDir, nil
63}
64
65func (s *LibraryService) GetAll(path, sortBy, filter string) ([]*models.LibraryItem, []string, error) {
66 if path != "" && !strings.HasSuffix(path, "/") {
67 path += "/"
68 }
69
70 dir, err := s.resolveItemDir(path)
71 if err != nil {
72 return nil, nil, nil
73 }
74 entries, err := os.ReadDir(dir)
75 if err != nil {
76 if os.IsNotExist(err) {
77 return nil, nil, nil
78 }
79 return nil, nil, err
80 }
81
82 var items []*models.LibraryItem
83 var folders []string
84
85 for _, entry := range entries {
86 if !entry.IsDir() {
87 continue
88 }
89 name := entry.Name()
90 if name == subtitlesDirName {
91 continue
92 }
93 itemDir := filepath.Join(dir, name)
94 relPath := filepath.ToSlash(filepath.Join(path, name))
95
96 markerPath := filepath.Join(itemDir, itemMarkerName)
97 if _, err := os.Stat(markerPath); err == nil {
98 item, err := s.scanItem(itemDir, relPath)
99 if err != nil {
100 log.Printf("warning: failed to scan item %s: %v", relPath, err)
101 continue
102 }
103 if filter != "" && !strings.Contains(strings.ToLower(item.Name), strings.ToLower(filter)) && !strings.Contains(strings.ToLower(item.RelPath), strings.ToLower(filter)) {
104 continue
105 }
106 items = append(items, item)
107 } else {
108 folders = append(folders, name)
109 }
110 }
111
112 switch sortBy {
113 case "title":
114 sort.Slice(items, func(i, j int) bool { return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name) })
115 case "duration":
116 sort.Slice(items, func(i, j int) bool { return items[i].Duration > items[j].Duration })
117 case "date":
118 fallthrough
119 default:
120 sort.Slice(items, func(i, j int) bool {
121 ii, _ := os.Stat(filepath.Join(s.libraryDir, items[i].RelPath))
122 jj, _ := os.Stat(filepath.Join(s.libraryDir, items[j].RelPath))
123 if ii == nil || jj == nil {
124 return false
125 }
126 return ii.ModTime().After(jj.ModTime())
127 })
128 }
129
130 sort.Strings(folders)
131
132 return items, folders, nil
133}
134
135func (s *LibraryService) GetByRelPath(relPath string) (*models.LibraryItem, error) {
136 relPath = strings.Trim(relPath, "/")
137 itemDir, err := s.resolveItemDir(relPath)
138 if err != nil {
139 return nil, fmt.Errorf("item not found")
140 }
141 markerPath := filepath.Join(itemDir, itemMarkerName)
142 if _, err := os.Stat(markerPath); err != nil {
143 return nil, fmt.Errorf("item not found")
144 }
145 return s.scanItem(itemDir, relPath)
146}
147
148func (s *LibraryService) scanItem(itemDir, relPath string) (*models.LibraryItem, error) {
149 metadata, err := s.readOrCreateMetadata(itemDir)
150 if err != nil {
151 return nil, err
152 }
153
154 mediaFiles, imageFiles, infoJSONPath, err := s.listItemFiles(itemDir)
155 if err != nil {
156 return nil, err
157 }
158
159 var info map[string]interface{}
160 if infoJSONPath != "" {
161 data, err := os.ReadFile(infoJSONPath)
162 if err == nil {
163 _ = json.Unmarshal(data, &info)
164 }
165 }
166
167 if metadata.Name == "" {
168 if title, ok := infoString(info, "title"); ok && title != "" {
169 metadata.Name = title
170 } else if len(mediaFiles) > 0 {
171 metadata.Name = mediaFileStem(mediaFiles[0])
172 } else {
173 metadata.Name = filepath.Base(itemDir)
174 }
175 }
176
177 if metadata.SourceURL == "" {
178 if url, ok := infoString(info, "webpage_url"); ok {
179 metadata.SourceURL = url
180 } else if url, ok := infoString(info, "url"); ok {
181 metadata.SourceURL = url
182 }
183 }
184
185 if metadata.Description == "" {
186 if desc, ok := infoString(info, "description"); ok {
187 metadata.Description = desc
188 }
189 }
190
191 if metadata.FileDurations == nil {
192 metadata.FileDurations = make(map[string]int)
193 }
194
195 if metadata.Duration < 0 {
196 if duration, ok := infoDuration(info); ok && duration > 0 {
197 metadata.Duration = duration
198 }
199 }
200
201 for i := range mediaFiles {
202 mf := &mediaFiles[i]
203 if mf.Duration < 0 {
204 if d, ok := metadata.FileDurations[mf.Filename]; ok && d > 0 {
205 mf.Duration = d
206 } else if metadata.Duration > 0 {
207 mf.Duration = metadata.Duration
208 metadata.FileDurations[mf.Filename] = mf.Duration
209 }
210 }
211 }
212
213 if metadata.Duration < 0 {
214 metadata.Duration = commonDuration(mediaFiles)
215 }
216
217 item := &models.LibraryItem{
218 Name: metadata.Name,
219 RelPath: relPath,
220 DirPath: itemDir,
221 SourceURL: metadata.SourceURL,
222 Duration: metadata.Duration,
223 Description: metadata.Description,
224 MediaFiles: mediaFiles,
225 }
226
227 _ = s.writeMetadata(itemDir, metadata)
228
229 _ = imageFiles
230
231 return item, nil
232}
233
234func (s *LibraryService) readOrCreateMetadata(itemDir string) (models.ItemMetadata, error) {
235 markerPath := filepath.Join(itemDir, itemMarkerName)
236 var metadata models.ItemMetadata
237 data, err := os.ReadFile(markerPath)
238 if err == nil {
239 if _, err := toml.Decode(string(data), &metadata); err != nil {
240 log.Printf("warning: failed to parse %s: %v", markerPath, err)
241 }
242 }
243 if metadata.Duration == 0 && !hasField(data, "duration") {
244 metadata.Duration = -1
245 }
246 return metadata, nil
247}
248
249func hasField(data []byte, key string) bool {
250 return strings.Contains(string(data), key+" =") || strings.Contains(string(data), key+"=")
251}
252
253func (s *LibraryService) writeMetadata(itemDir string, metadata models.ItemMetadata) error {
254 markerPath := filepath.Join(itemDir, itemMarkerName)
255 f, err := os.Create(markerPath)
256 if err != nil {
257 return err
258 }
259 defer f.Close()
260 return toml.NewEncoder(f).Encode(metadata)
261}
262
263func (s *LibraryService) listItemFiles(itemDir string) ([]models.MediaFile, []string, string, error) {
264 entries, err := os.ReadDir(itemDir)
265 if err != nil {
266 return nil, nil, "", err
267 }
268
269 var mediaFiles []models.MediaFile
270 var imageFiles []string
271 var infoJSONFiles []string
272
273 for _, entry := range entries {
274 if entry.IsDir() {
275 continue
276 }
277 name := entry.Name()
278 path := filepath.Join(itemDir, name)
279 ext := strings.ToLower(filepath.Ext(name))
280
281 if name == itemMarkerName {
282 continue
283 }
284 if name == "info.json" || strings.HasSuffix(name, ".info.json") {
285 infoJSONFiles = append(infoJSONFiles, path)
286 continue
287 }
288 if imageExts[ext] {
289 imageFiles = append(imageFiles, path)
290 continue
291 }
292 if !mediaExts[ext] {
293 continue
294 }
295
296 isAudio := ext == ".mp3" || ext == ".wav" || ext == ".flac" || ext == ".aac" || ext == ".opus"
297 duration := -1
298
299 mediaFiles = append(mediaFiles, models.MediaFile{
300 Filename: name,
301 Filepath: path,
302 IsAudio: isAudio,
303 Duration: duration,
304 })
305 }
306
307 sort.Slice(mediaFiles, func(i, j int) bool {
308 return mediaFiles[i].Filepath > mediaFiles[j].Filepath
309 })
310
311 var infoJSONPath string
312 if len(infoJSONFiles) > 0 {
313 sort.Strings(infoJSONFiles)
314 infoJSONPath = infoJSONFiles[0]
315 if len(infoJSONFiles) > 1 {
316 log.Printf("warning: multiple info.json files in %s, using %s", itemDir, infoJSONPath)
317 }
318 }
319
320 return mediaFiles, imageFiles, infoJSONPath, nil
321}
322
323func infoString(info map[string]interface{}, key string) (string, bool) {
324 if info == nil {
325 return "", false
326 }
327 v, ok := info[key]
328 if !ok {
329 return "", false
330 }
331 switch s := v.(type) {
332 case string:
333 return s, true
334 default:
335 return fmt.Sprintf("%v", v), true
336 }
337}
338
339func infoDuration(info map[string]interface{}) (int, bool) {
340 if info == nil {
341 return 0, false
342 }
343 v, ok := info["duration"]
344 if !ok {
345 return 0, false
346 }
347 switch n := v.(type) {
348 case float64:
349 return int(n + 0.5), true
350 case int:
351 return n, true
352 case int64:
353 return int(n), true
354 case string:
355 if f, err := strconv.ParseFloat(n, 64); err == nil {
356 return int(f + 0.5), true
357 }
358 }
359 return 0, false
360}
361
362func mediaFileStem(mf models.MediaFile) string {
363 return strings.TrimSuffix(filepath.Base(mf.Filename), filepath.Ext(mf.Filename))
364}
365
366func commonDuration(files []models.MediaFile) int {
367 if len(files) == 0 {
368 return -1
369 }
370 d := files[0].Duration
371 if d < 0 {
372 return -1
373 }
374 for _, f := range files[1:] {
375 if f.Duration != d {
376 return -1
377 }
378 }
379 return d
380}
381
382func (s *LibraryService) GetMediaFile(relPath, filename string) (string, error) {
383 item, err := s.GetByRelPath(relPath)
384 if err != nil {
385 return "", err
386 }
387 for _, mf := range item.MediaFiles {
388 if mf.Filename == filename {
389 return mf.Filepath, nil
390 }
391 }
392 return "", fmt.Errorf("media file not found")
393}
394
395func (s *LibraryService) ListThumbnails(relPath string) ([]string, error) {
396 item, err := s.GetByRelPath(relPath)
397 if err != nil {
398 return nil, err
399 }
400
401 itemDir := item.DirPath
402 entries, err := os.ReadDir(itemDir)
403 if err != nil {
404 return nil, err
405 }
406
407 var images []string
408 for _, entry := range entries {
409 if entry.IsDir() {
410 continue
411 }
412 name := entry.Name()
413 if name == itemMarkerName {
414 continue
415 }
416 path := filepath.Join(itemDir, name)
417 if imageExts[strings.ToLower(filepath.Ext(name))] && !strings.Contains(name, ".thumbnail.") {
418 images = append(images, path)
419 continue
420 }
421 if strings.Contains(name, ".thumbnail.") {
422 if info, err := entry.Info(); err == nil && info.Size() > 0 {
423 images = append(images, path)
424 }
425 }
426 }
427
428 sort.Strings(images)
429 return images, nil
430}
431
432func (s *LibraryService) HasThumbnail(relPath string) bool {
433 thumbs, _ := s.ListThumbnails(relPath)
434 return len(thumbs) > 0
435}
436
437func (s *LibraryService) EnsureThumbnails(relPath string) {
438 item, err := s.GetByRelPath(relPath)
439 if err != nil {
440 return
441 }
442 for _, mf := range item.MediaFiles {
443 mf := mf
444 go s.ensureThumbnailForFile(mf)
445 }
446}
447
448func (s *LibraryService) ensureThumbnailForFile(mf models.MediaFile) {
449 actual, _ := s.thumbOnce.LoadOrStore(mf.Filepath, new(sync.Once))
450 once := actual.(*sync.Once)
451 once.Do(func() {
452 if _, ok := s.findExistingThumbnail(mf.Filepath); ok {
453 return
454 }
455 if _, err := s.extractThumbnail(mf); err != nil {
456 log.Printf("thumbnail extraction failed for %s: %v", mf.Filepath, err)
457 }
458 })
459}
460
461func (s *LibraryService) findExistingThumbnail(path string) (string, bool) {
462 ext := filepath.Ext(path)
463 base := strings.TrimSuffix(path, ext) + ".thumbnail"
464 for _, candidate := range []string{base + ".webp", base + ".jpg", base + ".jpeg", base + ".png"} {
465 if info, err := os.Stat(candidate); err == nil && info.Size() > 0 {
466 return candidate, true
467 }
468 }
469 return "", false
470}
471
472func (s *LibraryService) extractThumbnail(mf models.MediaFile) (string, error) {
473 ext := filepath.Ext(mf.Filepath)
474 base := strings.TrimSuffix(mf.Filepath, ext) + ".thumbnail"
475
476 tryWrite := func(outputPath string, args []string) (string, error) {
477 tmpPath := outputPath + ".tmp"
478 os.Remove(tmpPath)
479 cmd := exec.Command("ffmpeg", append(args, tmpPath)...)
480 if output, err := cmd.CombinedOutput(); err != nil {
481 os.Remove(tmpPath)
482 return "", fmt.Errorf("ffmpeg failed: %v\n%s", err, string(output))
483 }
484 if info, err := os.Stat(tmpPath); err != nil || info.Size() == 0 {
485 os.Remove(tmpPath)
486 return "", fmt.Errorf("ffmpeg produced empty output")
487 }
488 if err := os.Rename(tmpPath, outputPath); err != nil {
489 os.Remove(tmpPath)
490 return "", err
491 }
492 return outputPath, nil
493 }
494
495 // First try to extract an embedded cover art stream.
496 webpPath := base + ".webp"
497 embeddedArgs := []string{
498 "-i", mf.Filepath,
499 "-map", "0:v",
500 "-map", "-0:V",
501 "-vframes", "1",
502 "-c:v", "libwebp",
503 }
504 if path, err := tryWrite(webpPath, embeddedArgs); err == nil {
505 return path, nil
506 }
507
508 // Fallback to jpeg for embedded covers if libwebp or webp encoding failed.
509 jpgPath := base + ".jpg"
510 embeddedJpgArgs := []string{
511 "-i", mf.Filepath,
512 "-map", "0:v",
513 "-map", "-0:V",
514 "-vframes", "1",
515 "-q:v", "2",
516 }
517 if path, err := tryWrite(jpgPath, embeddedJpgArgs); err == nil {
518 return path, nil
519 }
520
521 if mf.IsAudio {
522 return "", fmt.Errorf("audio file has no thumbnail")
523 }
524
525 seekTime := "00:00:01"
526 if mf.Duration > 0 {
527 midpoint := mf.Duration / 2
528 m := midpoint / 60
529 sec := midpoint % 60
530 h := m / 60
531 m = m % 60
532 if h > 0 {
533 seekTime = fmt.Sprintf("%02d:%02d:%02d", h, m, sec)
534 } else {
535 seekTime = fmt.Sprintf("%02d:%02d", m, sec)
536 }
537 }
538
539 frameArgs := []string{
540 "-ss", seekTime,
541 "-i", mf.Filepath,
542 "-vframes", "1",
543 "-c:v", "libwebp",
544 }
545 if path, err := tryWrite(webpPath, frameArgs); err == nil {
546 return path, nil
547 }
548
549 frameJpgArgs := []string{
550 "-ss", seekTime,
551 "-i", mf.Filepath,
552 "-vframes", "1",
553 "-q:v", "2",
554 }
555 return tryWrite(jpgPath, frameJpgArgs)
556}
557
558func (s *LibraryService) Delete(relPath string) error {
559 itemDir, err := s.resolveItemDir(relPath)
560 if err != nil {
561 return err
562 }
563 return os.RemoveAll(itemDir)
564}
565
566func (s *LibraryService) SubtitleDir(relPath string) string {
567 itemDir, err := s.resolveItemDir(relPath)
568 if err != nil {
569 return ""
570 }
571 return filepath.Join(itemDir, subtitlesDirName)
572}
573
574func (s *LibraryService) GetSubtitlePath(relPath, lang string) string {
575 return filepath.Join(s.SubtitleDir(relPath), lang+".vtt")
576}
577
578func (s *LibraryService) GetSubtitles(relPath string) ([]models.SubtitleTrack, error) {
579 item, err := s.GetByRelPath(relPath)
580 if err != nil {
581 return nil, err
582 }
583
584 cacheDir := s.SubtitleDir(relPath)
585 entries, err := os.ReadDir(cacheDir)
586 if err == nil && len(entries) > 0 {
587 var tracks []models.SubtitleTrack
588 for _, entry := range entries {
589 if entry.IsDir() || filepath.Ext(entry.Name()) != ".vtt" {
590 continue
591 }
592 lang := strings.TrimSuffix(entry.Name(), ".vtt")
593 tracks = append(tracks, models.SubtitleTrack{
594 Lang: lang,
595 Label: lang,
596 Src: fmt.Sprintf("/media/item/%s/subtitles/%s", urlEncodePath(relPath), lang),
597 })
598 }
599 return tracks, nil
600 }
601
602 for _, mf := range item.MediaFiles {
603 if mf.IsAudio {
604 continue
605 }
606 streams, err := extractSubtitleInfo(mf.Filepath)
607 if err != nil || len(streams) == 0 {
608 continue
609 }
610 if err := os.MkdirAll(cacheDir, 0755); err != nil {
611 return nil, err
612 }
613 var tracks []models.SubtitleTrack
614 for _, stream := range streams {
615 lang := stream.Lang
616 if lang == "" {
617 lang = fmt.Sprintf("track%d", stream.Index)
618 }
619 outPath := filepath.Join(cacheDir, lang+".vtt")
620 if err := extractSubtitleToVTT(mf.Filepath, outPath, stream.Index); err != nil {
621 continue
622 }
623 tracks = append(tracks, models.SubtitleTrack{
624 Lang: lang,
625 Label: stream.Label,
626 Src: fmt.Sprintf("/media/item/%s/subtitles/%s", urlEncodePath(relPath), lang),
627 })
628 }
629 return tracks, nil
630 }
631
632 return nil, nil
633}
634
635type subtitleStream struct {
636 Index int
637 Lang string
638 Label string
639}
640
641func extractSubtitleInfo(path string) ([]subtitleStream, error) {
642 cmd := exec.Command("ffprobe",
643 "-v", "error",
644 "-show_streams",
645 "-select_streams", "s",
646 "-of", "json",
647 path,
648 )
649 output, err := cmd.Output()
650 if err != nil {
651 return nil, err
652 }
653 var probe struct {
654 Streams []struct {
655 Index int `json:"index"`
656 CodecName string `json:"codec_name"`
657 Tags struct {
658 Language string `json:"language"`
659 Title string `json:"title"`
660 } `json:"tags"`
661 } `json:"streams"`
662 }
663 if err := json.Unmarshal(output, &probe); err != nil {
664 return nil, err
665 }
666 var streams []subtitleStream
667 subIndex := 0
668 for _, stream := range probe.Streams {
669 switch stream.CodecName {
670 case "subrip", "ass", "ssa", "webvtt", "mov_text":
671 lang := stream.Tags.Language
672 if lang == "" {
673 lang = fmt.Sprintf("track%d", subIndex)
674 }
675 label := stream.Tags.Title
676 if label == "" {
677 label = strings.ToUpper(lang)
678 }
679 streams = append(streams, subtitleStream{
680 Index: subIndex,
681 Lang: lang,
682 Label: label,
683 })
684 subIndex++
685 }
686 }
687 return streams, nil
688}
689
690func extractSubtitleToVTT(inputPath, outputPath string, streamIndex int) error {
691 cmd := exec.Command("ffmpeg",
692 "-i", inputPath,
693 "-map", fmt.Sprintf("0:s:%d", streamIndex),
694 "-f", "webvtt",
695 outputPath,
696 "-y",
697 )
698 output, err := cmd.CombinedOutput()
699 if err != nil {
700 return fmt.Errorf("ffmpeg subtitle extraction failed: %w\nOutput: %s", err, string(output))
701 }
702 return nil
703}
704
705func (s *LibraryService) GetMetadata(relPath string) (*MediaMetadata, error) {
706 item, err := s.GetByRelPath(relPath)
707 if err != nil {
708 return nil, err
709 }
710
711 var largest *models.MediaFile
712 for i := range item.MediaFiles {
713 if item.MediaFiles[i].IsAudio {
714 continue
715 }
716 if largest == nil {
717 largest = &item.MediaFiles[i]
718 continue
719 }
720 if info, err := os.Stat(item.MediaFiles[i].Filepath); err == nil {
721 if li, err := os.Stat(largest.Filepath); err == nil && info.Size() > li.Size() {
722 largest = &item.MediaFiles[i]
723 }
724 }
725 }
726
727 if largest == nil {
728 return nil, fmt.Errorf("no video file")
729 }
730
731 return probeMedia(largest.Filepath)
732}
733
734func probeMedia(path string) (*MediaMetadata, error) {
735 info, err := os.Stat(path)
736 if err != nil {
737 return nil, err
738 }
739
740 meta := &MediaMetadata{
741 FileSize: info.Size(),
742 }
743
744 cmd := exec.Command("ffprobe",
745 "-v", "error",
746 "-show_format",
747 "-show_streams",
748 "-of", "json",
749 path,
750 )
751 output, err := cmd.Output()
752 if err != nil {
753 return meta, nil
754 }
755
756 var probe ffprobeOutput
757 if err := json.Unmarshal(output, &probe); err != nil {
758 return meta, nil
759 }
760
761 if probe.Format.FormatName != "" {
762 parts := strings.Split(probe.Format.FormatName, ",")
763 meta.Container = parts[0]
764 }
765
766 for _, stream := range probe.Streams {
767 switch stream.CodecType {
768 case "video":
769 vs := VideoStream{
770 Codec: stream.CodecName,
771 Profile: stream.Profile,
772 Width: stream.Width,
773 Height: stream.Height,
774 FPS: parseFPS(stream.RFrameRate),
775 PixelFormat: stream.PixFmt,
776 Bitrate: formatBitrate(stream.BitRate),
777 }
778 meta.VideoStreams = append(meta.VideoStreams, vs)
779 if stream.Width > 0 && stream.Height > 0 {
780 meta.Resolution = fmt.Sprintf("%dx%d", stream.Width, stream.Height)
781 }
782 case "audio":
783 as := AudioStream{
784 Codec: stream.CodecName,
785 SampleRate: stream.SampleRate,
786 Channels: stream.Channels,
787 ChannelLayout: stream.ChannelLayout,
788 SampleFormat: stream.SampleFmt,
789 Bitrate: formatBitrate(stream.BitRate),
790 Language: stream.Tags.Language,
791 }
792 meta.AudioStreams = append(meta.AudioStreams, as)
793 case "subtitle":
794 ss := SubtitleStream{
795 Codec: stream.CodecName,
796 Language: stream.Tags.Language,
797 Title: stream.Tags.Title,
798 }
799 meta.SubtitleStreams = append(meta.SubtitleStreams, ss)
800 }
801 }
802
803 return meta, nil
804}
805
806func urlEncodePath(p string) string {
807 parts := strings.Split(strings.Trim(p, "/"), "/")
808 for i, part := range parts {
809 parts[i] = url.PathEscape(part)
810 }
811 return strings.Join(parts, "/")
812}
813
814func formatBitrate(bitRate string) string {
815 if bitRate == "" {
816 return ""
817 }
818 br, err := strconv.ParseInt(bitRate, 10, 64)
819 if err != nil {
820 return ""
821 }
822 return fmt.Sprintf("%d", br/1000)
823}
824
825func parseFPS(rate string) string {
826 if rate == "" || rate == "0/0" {
827 return ""
828 }
829 parts := strings.Split(rate, "/")
830 if len(parts) != 2 {
831 return ""
832 }
833 num, err1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
834 den, err2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
835 if err1 != nil || err2 != nil || den == 0 {
836 return ""
837 }
838 fps := num / den
839 if fps == math.Trunc(fps) {
840 return fmt.Sprintf("%.0f", fps)
841 }
842 return fmt.Sprintf("%.2f", fps)
843}
844
845type MediaMetadata struct {
846 Container string
847 Resolution string
848 FileSize int64
849 VideoStreams []VideoStream
850 AudioStreams []AudioStream
851 SubtitleStreams []SubtitleStream
852}
853
854type VideoStream struct {
855 Codec string
856 Profile string
857 Width int
858 Height int
859 FPS string
860 PixelFormat string
861 Bitrate string
862}
863
864type AudioStream struct {
865 Codec string
866 SampleRate string
867 Channels int
868 ChannelLayout string
869 SampleFormat string
870 Bitrate string
871 Language string
872}
873
874type SubtitleStream struct {
875 Language string
876 Title string
877 Codec string
878}
879
880type ffprobeOutput struct {
881 Format struct {
882 FormatName string `json:"format_name"`
883 BitRate string `json:"bit_rate"`
884 } `json:"format"`
885 Streams []ffprobeStream `json:"streams"`
886}
887
888type ffprobeStream struct {
889 Index int `json:"index"`
890 CodecName string `json:"codec_name"`
891 CodecType string `json:"codec_type"`
892 Profile string `json:"profile"`
893 Width int `json:"width"`
894 Height int `json:"height"`
895 RFrameRate string `json:"r_frame_rate"`
896 AvgFrameRate string `json:"avg_frame_rate"`
897 PixFmt string `json:"pix_fmt"`
898 SampleRate string `json:"sample_rate"`
899 Channels int `json:"channels"`
900 ChannelLayout string `json:"channel_layout"`
901 SampleFmt string `json:"sample_fmt"`
902 BitRate string `json:"bit_rate"`
903 Tags struct {
904 Language string `json:"language"`
905 Title string `json:"title"`
906 } `json:"tags"`
907}
908
909func copyFile(src, dst string) error {
910 in, err := os.Open(src)
911 if err != nil {
912 return err
913 }
914 defer in.Close()
915 out, err := os.Create(dst)
916 if err != nil {
917 return err
918 }
919 defer out.Close()
920 _, err = io.Copy(out, in)
921 return err
922}
923