package service import ( "encoding/json" "fmt" "io" "log" "math" "net/url" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "sync" "github.com/BurntSushi/toml" "vidarchive/internal/models" ) const itemMarkerName = ".vidarchive-item.toml" const subtitlesDirName = "subtitles" var mediaExts = map[string]bool{ ".mp4": true, ".webm": true, ".mkv": true, ".avi": true, ".mov": true, ".mp3": true, ".wav": true, ".flac": true, ".aac": true, ".opus": true, } var imageExts = map[string]bool{ ".webp": true, ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".bmp": true, } type LibraryService struct { libraryDir string tempDir string thumbOnce sync.Map } func NewLibraryService(libraryDir, tempDir string) *LibraryService { return &LibraryService{libraryDir: libraryDir, tempDir: tempDir} } func (s *LibraryService) resolveItemDir(relPath string) (string, error) { relPath = strings.Trim(relPath, string(filepath.Separator)) if relPath == "" { return s.libraryDir, nil } itemDir := filepath.Join(s.libraryDir, relPath) cleanDir, err := filepath.EvalSymlinks(itemDir) if err != nil { cleanDir = filepath.Clean(itemDir) } base, err := filepath.EvalSymlinks(s.libraryDir) if err != nil { base = filepath.Clean(s.libraryDir) } if !strings.HasPrefix(cleanDir, base+string(filepath.Separator)) && cleanDir != base { return "", fmt.Errorf("invalid path") } return itemDir, nil } func (s *LibraryService) GetAll(path, sortBy, filter string) ([]*models.LibraryItem, []string, error) { if path != "" && !strings.HasSuffix(path, "/") { path += "/" } dir, err := s.resolveItemDir(path) if err != nil { return nil, nil, nil } entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { return nil, nil, nil } return nil, nil, err } var items []*models.LibraryItem var folders []string for _, entry := range entries { if !entry.IsDir() { continue } name := entry.Name() if name == subtitlesDirName { continue } itemDir := filepath.Join(dir, name) relPath := filepath.ToSlash(filepath.Join(path, name)) markerPath := filepath.Join(itemDir, itemMarkerName) if _, err := os.Stat(markerPath); err == nil { item, err := s.scanItem(itemDir, relPath) if err != nil { log.Printf("warning: failed to scan item %s: %v", relPath, err) continue } if filter != "" && !strings.Contains(strings.ToLower(item.Name), strings.ToLower(filter)) && !strings.Contains(strings.ToLower(item.RelPath), strings.ToLower(filter)) { continue } items = append(items, item) } else { folders = append(folders, name) } } switch sortBy { case "title": sort.Slice(items, func(i, j int) bool { return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name) }) case "duration": sort.Slice(items, func(i, j int) bool { return items[i].Duration > items[j].Duration }) case "date": fallthrough default: sort.Slice(items, func(i, j int) bool { ii, _ := os.Stat(filepath.Join(s.libraryDir, items[i].RelPath)) jj, _ := os.Stat(filepath.Join(s.libraryDir, items[j].RelPath)) if ii == nil || jj == nil { return false } return ii.ModTime().After(jj.ModTime()) }) } sort.Strings(folders) return items, folders, nil } func (s *LibraryService) GetByRelPath(relPath string) (*models.LibraryItem, error) { relPath = strings.Trim(relPath, "/") itemDir, err := s.resolveItemDir(relPath) if err != nil { return nil, fmt.Errorf("item not found") } markerPath := filepath.Join(itemDir, itemMarkerName) if _, err := os.Stat(markerPath); err != nil { return nil, fmt.Errorf("item not found") } return s.scanItem(itemDir, relPath) } func (s *LibraryService) scanItem(itemDir, relPath string) (*models.LibraryItem, error) { metadata, err := s.readOrCreateMetadata(itemDir) if err != nil { return nil, err } mediaFiles, imageFiles, infoJSONPath, err := s.listItemFiles(itemDir) if err != nil { return nil, err } var info map[string]interface{} if infoJSONPath != "" { data, err := os.ReadFile(infoJSONPath) if err == nil { _ = json.Unmarshal(data, &info) } } if metadata.Name == "" { if title, ok := infoString(info, "title"); ok && title != "" { metadata.Name = title } else if len(mediaFiles) > 0 { metadata.Name = mediaFileStem(mediaFiles[0]) } else { metadata.Name = filepath.Base(itemDir) } } if metadata.SourceURL == "" { if url, ok := infoString(info, "webpage_url"); ok { metadata.SourceURL = url } else if url, ok := infoString(info, "url"); ok { metadata.SourceURL = url } } if metadata.Description == "" { if desc, ok := infoString(info, "description"); ok { metadata.Description = desc } } if metadata.FileDurations == nil { metadata.FileDurations = make(map[string]int) } if metadata.Duration < 0 { if duration, ok := infoDuration(info); ok && duration > 0 { metadata.Duration = duration } } for i := range mediaFiles { mf := &mediaFiles[i] if mf.Duration < 0 { if d, ok := metadata.FileDurations[mf.Filename]; ok && d > 0 { mf.Duration = d } else if metadata.Duration > 0 { mf.Duration = metadata.Duration metadata.FileDurations[mf.Filename] = mf.Duration } } } if metadata.Duration < 0 { metadata.Duration = commonDuration(mediaFiles) } item := &models.LibraryItem{ Name: metadata.Name, RelPath: relPath, DirPath: itemDir, SourceURL: metadata.SourceURL, Duration: metadata.Duration, Description: metadata.Description, MediaFiles: mediaFiles, } _ = s.writeMetadata(itemDir, metadata) _ = imageFiles return item, nil } func (s *LibraryService) readOrCreateMetadata(itemDir string) (models.ItemMetadata, error) { markerPath := filepath.Join(itemDir, itemMarkerName) var metadata models.ItemMetadata data, err := os.ReadFile(markerPath) if err == nil { if _, err := toml.Decode(string(data), &metadata); err != nil { log.Printf("warning: failed to parse %s: %v", markerPath, err) } } if metadata.Duration == 0 && !hasField(data, "duration") { metadata.Duration = -1 } return metadata, nil } func hasField(data []byte, key string) bool { return strings.Contains(string(data), key+" =") || strings.Contains(string(data), key+"=") } func (s *LibraryService) writeMetadata(itemDir string, metadata models.ItemMetadata) error { markerPath := filepath.Join(itemDir, itemMarkerName) f, err := os.Create(markerPath) if err != nil { return err } defer f.Close() return toml.NewEncoder(f).Encode(metadata) } func (s *LibraryService) listItemFiles(itemDir string) ([]models.MediaFile, []string, string, error) { entries, err := os.ReadDir(itemDir) if err != nil { return nil, nil, "", err } var mediaFiles []models.MediaFile var imageFiles []string var infoJSONFiles []string for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() path := filepath.Join(itemDir, name) ext := strings.ToLower(filepath.Ext(name)) if name == itemMarkerName { continue } if name == "info.json" || strings.HasSuffix(name, ".info.json") { infoJSONFiles = append(infoJSONFiles, path) continue } if imageExts[ext] { imageFiles = append(imageFiles, path) continue } if !mediaExts[ext] { continue } isAudio := ext == ".mp3" || ext == ".wav" || ext == ".flac" || ext == ".aac" || ext == ".opus" duration := -1 mediaFiles = append(mediaFiles, models.MediaFile{ Filename: name, Filepath: path, IsAudio: isAudio, Duration: duration, }) } sort.Slice(mediaFiles, func(i, j int) bool { return mediaFiles[i].Filepath > mediaFiles[j].Filepath }) var infoJSONPath string if len(infoJSONFiles) > 0 { sort.Strings(infoJSONFiles) infoJSONPath = infoJSONFiles[0] if len(infoJSONFiles) > 1 { log.Printf("warning: multiple info.json files in %s, using %s", itemDir, infoJSONPath) } } return mediaFiles, imageFiles, infoJSONPath, nil } func infoString(info map[string]interface{}, key string) (string, bool) { if info == nil { return "", false } v, ok := info[key] if !ok { return "", false } switch s := v.(type) { case string: return s, true default: return fmt.Sprintf("%v", v), true } } func infoDuration(info map[string]interface{}) (int, bool) { if info == nil { return 0, false } v, ok := info["duration"] if !ok { return 0, false } switch n := v.(type) { case float64: return int(n + 0.5), true case int: return n, true case int64: return int(n), true case string: if f, err := strconv.ParseFloat(n, 64); err == nil { return int(f + 0.5), true } } return 0, false } func mediaFileStem(mf models.MediaFile) string { return strings.TrimSuffix(filepath.Base(mf.Filename), filepath.Ext(mf.Filename)) } func commonDuration(files []models.MediaFile) int { if len(files) == 0 { return -1 } d := files[0].Duration if d < 0 { return -1 } for _, f := range files[1:] { if f.Duration != d { return -1 } } return d } func (s *LibraryService) GetMediaFile(relPath, filename string) (string, error) { item, err := s.GetByRelPath(relPath) if err != nil { return "", err } for _, mf := range item.MediaFiles { if mf.Filename == filename { return mf.Filepath, nil } } return "", fmt.Errorf("media file not found") } func (s *LibraryService) ListThumbnails(relPath string) ([]string, error) { item, err := s.GetByRelPath(relPath) if err != nil { return nil, err } itemDir := item.DirPath entries, err := os.ReadDir(itemDir) if err != nil { return nil, err } var images []string for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() if name == itemMarkerName { continue } path := filepath.Join(itemDir, name) if imageExts[strings.ToLower(filepath.Ext(name))] && !strings.Contains(name, ".thumbnail.") { images = append(images, path) continue } if strings.Contains(name, ".thumbnail.") { if info, err := entry.Info(); err == nil && info.Size() > 0 { images = append(images, path) } } } sort.Strings(images) return images, nil } func (s *LibraryService) HasThumbnail(relPath string) bool { thumbs, _ := s.ListThumbnails(relPath) return len(thumbs) > 0 } func (s *LibraryService) EnsureThumbnails(relPath string) { item, err := s.GetByRelPath(relPath) if err != nil { return } for _, mf := range item.MediaFiles { mf := mf go s.ensureThumbnailForFile(mf) } } func (s *LibraryService) ensureThumbnailForFile(mf models.MediaFile) { actual, _ := s.thumbOnce.LoadOrStore(mf.Filepath, new(sync.Once)) once := actual.(*sync.Once) once.Do(func() { if _, ok := s.findExistingThumbnail(mf.Filepath); ok { return } if _, err := s.extractThumbnail(mf); err != nil { log.Printf("thumbnail extraction failed for %s: %v", mf.Filepath, err) } }) } func (s *LibraryService) findExistingThumbnail(path string) (string, bool) { ext := filepath.Ext(path) base := strings.TrimSuffix(path, ext) + ".thumbnail" for _, candidate := range []string{base + ".webp", base + ".jpg", base + ".jpeg", base + ".png"} { if info, err := os.Stat(candidate); err == nil && info.Size() > 0 { return candidate, true } } return "", false } func (s *LibraryService) extractThumbnail(mf models.MediaFile) (string, error) { ext := filepath.Ext(mf.Filepath) base := strings.TrimSuffix(mf.Filepath, ext) + ".thumbnail" tryWrite := func(outputPath string, args []string) (string, error) { tmpPath := outputPath + ".tmp" os.Remove(tmpPath) cmd := exec.Command("ffmpeg", append(args, tmpPath)...) if output, err := cmd.CombinedOutput(); err != nil { os.Remove(tmpPath) return "", fmt.Errorf("ffmpeg failed: %v\n%s", err, string(output)) } if info, err := os.Stat(tmpPath); err != nil || info.Size() == 0 { os.Remove(tmpPath) return "", fmt.Errorf("ffmpeg produced empty output") } if err := os.Rename(tmpPath, outputPath); err != nil { os.Remove(tmpPath) return "", err } return outputPath, nil } // First try to extract an embedded cover art stream. webpPath := base + ".webp" embeddedArgs := []string{ "-i", mf.Filepath, "-map", "0:v", "-map", "-0:V", "-vframes", "1", "-c:v", "libwebp", } if path, err := tryWrite(webpPath, embeddedArgs); err == nil { return path, nil } // Fallback to jpeg for embedded covers if libwebp or webp encoding failed. jpgPath := base + ".jpg" embeddedJpgArgs := []string{ "-i", mf.Filepath, "-map", "0:v", "-map", "-0:V", "-vframes", "1", "-q:v", "2", } if path, err := tryWrite(jpgPath, embeddedJpgArgs); err == nil { return path, nil } if mf.IsAudio { return "", fmt.Errorf("audio file has no thumbnail") } seekTime := "00:00:01" if mf.Duration > 0 { midpoint := mf.Duration / 2 m := midpoint / 60 sec := midpoint % 60 h := m / 60 m = m % 60 if h > 0 { seekTime = fmt.Sprintf("%02d:%02d:%02d", h, m, sec) } else { seekTime = fmt.Sprintf("%02d:%02d", m, sec) } } frameArgs := []string{ "-ss", seekTime, "-i", mf.Filepath, "-vframes", "1", "-c:v", "libwebp", } if path, err := tryWrite(webpPath, frameArgs); err == nil { return path, nil } frameJpgArgs := []string{ "-ss", seekTime, "-i", mf.Filepath, "-vframes", "1", "-q:v", "2", } return tryWrite(jpgPath, frameJpgArgs) } func (s *LibraryService) Delete(relPath string) error { itemDir, err := s.resolveItemDir(relPath) if err != nil { return err } return os.RemoveAll(itemDir) } func (s *LibraryService) SubtitleDir(relPath string) string { itemDir, err := s.resolveItemDir(relPath) if err != nil { return "" } return filepath.Join(itemDir, subtitlesDirName) } func (s *LibraryService) GetSubtitlePath(relPath, lang string) string { return filepath.Join(s.SubtitleDir(relPath), lang+".vtt") } func (s *LibraryService) GetSubtitles(relPath string) ([]models.SubtitleTrack, error) { item, err := s.GetByRelPath(relPath) if err != nil { return nil, err } cacheDir := s.SubtitleDir(relPath) entries, err := os.ReadDir(cacheDir) if err == nil && len(entries) > 0 { var tracks []models.SubtitleTrack for _, entry := range entries { if entry.IsDir() || filepath.Ext(entry.Name()) != ".vtt" { continue } lang := strings.TrimSuffix(entry.Name(), ".vtt") tracks = append(tracks, models.SubtitleTrack{ Lang: lang, Label: lang, Src: fmt.Sprintf("/media/item/%s/subtitles/%s", urlEncodePath(relPath), lang), }) } return tracks, nil } for _, mf := range item.MediaFiles { if mf.IsAudio { continue } streams, err := extractSubtitleInfo(mf.Filepath) if err != nil || len(streams) == 0 { continue } if err := os.MkdirAll(cacheDir, 0755); err != nil { return nil, err } var tracks []models.SubtitleTrack for _, stream := range streams { lang := stream.Lang if lang == "" { lang = fmt.Sprintf("track%d", stream.Index) } outPath := filepath.Join(cacheDir, lang+".vtt") if err := extractSubtitleToVTT(mf.Filepath, outPath, stream.Index); err != nil { continue } tracks = append(tracks, models.SubtitleTrack{ Lang: lang, Label: stream.Label, Src: fmt.Sprintf("/media/item/%s/subtitles/%s", urlEncodePath(relPath), lang), }) } return tracks, nil } return nil, nil } type subtitleStream struct { Index int Lang string Label string } func extractSubtitleInfo(path string) ([]subtitleStream, error) { cmd := exec.Command("ffprobe", "-v", "error", "-show_streams", "-select_streams", "s", "-of", "json", path, ) output, err := cmd.Output() if err != nil { return nil, err } var probe struct { Streams []struct { Index int `json:"index"` CodecName string `json:"codec_name"` Tags struct { Language string `json:"language"` Title string `json:"title"` } `json:"tags"` } `json:"streams"` } if err := json.Unmarshal(output, &probe); err != nil { return nil, err } var streams []subtitleStream subIndex := 0 for _, stream := range probe.Streams { switch stream.CodecName { case "subrip", "ass", "ssa", "webvtt", "mov_text": lang := stream.Tags.Language if lang == "" { lang = fmt.Sprintf("track%d", subIndex) } label := stream.Tags.Title if label == "" { label = strings.ToUpper(lang) } streams = append(streams, subtitleStream{ Index: subIndex, Lang: lang, Label: label, }) subIndex++ } } return streams, nil } func extractSubtitleToVTT(inputPath, outputPath string, streamIndex int) error { cmd := exec.Command("ffmpeg", "-i", inputPath, "-map", fmt.Sprintf("0:s:%d", streamIndex), "-f", "webvtt", outputPath, "-y", ) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("ffmpeg subtitle extraction failed: %w\nOutput: %s", err, string(output)) } return nil } func (s *LibraryService) GetMetadata(relPath string) (*MediaMetadata, error) { item, err := s.GetByRelPath(relPath) if err != nil { return nil, err } var largest *models.MediaFile for i := range item.MediaFiles { if item.MediaFiles[i].IsAudio { continue } if largest == nil { largest = &item.MediaFiles[i] continue } if info, err := os.Stat(item.MediaFiles[i].Filepath); err == nil { if li, err := os.Stat(largest.Filepath); err == nil && info.Size() > li.Size() { largest = &item.MediaFiles[i] } } } if largest == nil { return nil, fmt.Errorf("no video file") } return probeMedia(largest.Filepath) } func probeMedia(path string) (*MediaMetadata, error) { info, err := os.Stat(path) if err != nil { return nil, err } meta := &MediaMetadata{ FileSize: info.Size(), } cmd := exec.Command("ffprobe", "-v", "error", "-show_format", "-show_streams", "-of", "json", path, ) output, err := cmd.Output() if err != nil { return meta, nil } var probe ffprobeOutput if err := json.Unmarshal(output, &probe); err != nil { return meta, nil } if probe.Format.FormatName != "" { parts := strings.Split(probe.Format.FormatName, ",") meta.Container = parts[0] } for _, stream := range probe.Streams { switch stream.CodecType { case "video": vs := VideoStream{ Codec: stream.CodecName, Profile: stream.Profile, Width: stream.Width, Height: stream.Height, FPS: parseFPS(stream.RFrameRate), PixelFormat: stream.PixFmt, Bitrate: formatBitrate(stream.BitRate), } meta.VideoStreams = append(meta.VideoStreams, vs) if stream.Width > 0 && stream.Height > 0 { meta.Resolution = fmt.Sprintf("%dx%d", stream.Width, stream.Height) } case "audio": as := AudioStream{ Codec: stream.CodecName, SampleRate: stream.SampleRate, Channels: stream.Channels, ChannelLayout: stream.ChannelLayout, SampleFormat: stream.SampleFmt, Bitrate: formatBitrate(stream.BitRate), Language: stream.Tags.Language, } meta.AudioStreams = append(meta.AudioStreams, as) case "subtitle": ss := SubtitleStream{ Codec: stream.CodecName, Language: stream.Tags.Language, Title: stream.Tags.Title, } meta.SubtitleStreams = append(meta.SubtitleStreams, ss) } } return meta, nil } func urlEncodePath(p string) string { parts := strings.Split(strings.Trim(p, "/"), "/") for i, part := range parts { parts[i] = url.PathEscape(part) } return strings.Join(parts, "/") } func formatBitrate(bitRate string) string { if bitRate == "" { return "" } br, err := strconv.ParseInt(bitRate, 10, 64) if err != nil { return "" } return fmt.Sprintf("%d", br/1000) } func parseFPS(rate string) string { if rate == "" || rate == "0/0" { return "" } parts := strings.Split(rate, "/") if len(parts) != 2 { return "" } num, err1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64) den, err2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64) if err1 != nil || err2 != nil || den == 0 { return "" } fps := num / den if fps == math.Trunc(fps) { return fmt.Sprintf("%.0f", fps) } return fmt.Sprintf("%.2f", fps) } type MediaMetadata struct { Container string Resolution string FileSize int64 VideoStreams []VideoStream AudioStreams []AudioStream SubtitleStreams []SubtitleStream } type VideoStream struct { Codec string Profile string Width int Height int FPS string PixelFormat string Bitrate string } type AudioStream struct { Codec string SampleRate string Channels int ChannelLayout string SampleFormat string Bitrate string Language string } type SubtitleStream struct { Language string Title string Codec string } type ffprobeOutput struct { Format struct { FormatName string `json:"format_name"` BitRate string `json:"bit_rate"` } `json:"format"` Streams []ffprobeStream `json:"streams"` } type ffprobeStream struct { Index int `json:"index"` CodecName string `json:"codec_name"` CodecType string `json:"codec_type"` Profile string `json:"profile"` Width int `json:"width"` Height int `json:"height"` RFrameRate string `json:"r_frame_rate"` AvgFrameRate string `json:"avg_frame_rate"` PixFmt string `json:"pix_fmt"` SampleRate string `json:"sample_rate"` Channels int `json:"channels"` ChannelLayout string `json:"channel_layout"` SampleFmt string `json:"sample_fmt"` BitRate string `json:"bit_rate"` Tags struct { Language string `json:"language"` Title string `json:"title"` } `json:"tags"` } func copyFile(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.Create(dst) if err != nil { return err } defer out.Close() _, err = io.Copy(out, in) return err }