package service import ( "context" "encoding/json" "errors" "fmt" "io" "log/slog" "os" "path/filepath" "sort" "strconv" "strings" "syscall" "unicode/utf8" "github.com/gabriel-vasile/mimetype" "vidarchive/internal/models" "vidarchive/internal/util" ) // resolveBaseLibraryDir applies the optional per-download OutputDir. Both // branches go through the library guard: it resolves symlinks, which a plain // prefix check does not, and the cache keys are derived from the resolved root. func (s *DownloadService) resolveBaseLibraryDir(d *models.Download) (string, error) { if !d.OutputDir.Valid || d.OutputDir.String == "" { return s.librarySvc.ResolveWithinLibrary("") } dir, err := s.librarySvc.ResolveWithinLibrary(d.OutputDir.String) if err != nil { return "", fmt.Errorf("invalid output directory: %w", err) } return dir, nil } // importDownloadedItems returns the number of items imported. Per-item failures // are logged and skipped, so a playlist with a few bad entries still imports the // rest; a non-nil error means the import couldn't even start. A cancel stops // between items and returns ctx.Err() with the count imported so far. func (s *DownloadService) importDownloadedItems(ctx context.Context, d *models.Download, tempDownloadDir, mode, ytdlpFlags string) (int, error) { entries, err := os.ReadDir(tempDownloadDir) if err != nil { return 0, err } baseLibraryDir, err := s.resolveBaseLibraryDir(d) if err != nil { return 0, err } if err := os.MkdirAll(baseLibraryDir, 0o755); err != nil { return 0, err } var itemDirs []string for _, entry := range entries { if !entry.IsDir() { continue } name := entry.Name() if strings.HasPrefix(name, "item-") { itemDirs = append(itemDirs, filepath.Join(tempDownloadDir, name)) } } sort.Strings(itemDirs) imported := 0 for _, itemDir := range itemDirs { if err := ctx.Err(); err != nil { return imported, err } if err := s.importItemDir(ctx, d.URL, itemDir, baseLibraryDir, mode, ytdlpFlags); err != nil { // A cancelled item is not a bad item, and every one after it would warn // too. if ctx.Err() != nil { return imported, ctx.Err() } slog.Warn("import item failed", "dir", itemDir, "err", err) continue } imported++ } // Leftovers from failed imports go with the caller's deferred temp-dir cleanup. return imported, nil } func (s *DownloadService) importItemDir(ctx context.Context, url, itemDir, baseLibraryDir, mode, ytdlpFlags string) error { entries, err := os.ReadDir(itemDir) if err != nil { return err } var mediaFiles []os.DirEntry var infoJSONPath string var subtitleFiles []string for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() path := filepath.Join(itemDir, name) ext := strings.ToLower(filepath.Ext(name)) if isInfoJSON(name) { infoJSONPath = path continue } if ext == ".vtt" || ext == ".srt" || ext == ".ass" || ext == ".ssa" { subtitleFiles = append(subtitleFiles, path) continue } mtype, err := mimetype.DetectFile(path) if err == nil && mtype != nil && (strings.HasPrefix(mtype.String(), "audio/") || strings.HasPrefix(mtype.String(), "video/")) { mediaFiles = append(mediaFiles, entry) } } if len(mediaFiles) == 0 { return fmt.Errorf("no media files found in %s", itemDir) } info := readInfoJSON(infoJSONPath) name := s.deriveItemName(itemDir, info, mediaFiles) videoID := info.ID // Last point at which nothing has been written to the library yet. Cancelling // later leaves a folder with no marker, or in overwrite mode deletes the // existing item without replacing it. if err := ctx.Err(); err != nil { return err } // Removing the old dir avoids a duplicate folder and lets uniqueDir reuse its // name, or take the new title if it changed upstream. if mode == "overwrite" && videoID != "" { if existing, ok := s.librarySvc.FindByVideoID(baseLibraryDir, videoID); ok { s.librarySvc.evictCachedDir(existing) os.RemoveAll(existing) } } targetDir, err := s.uniqueDir(baseLibraryDir, name) if err != nil { return err } if err := os.MkdirAll(targetDir, 0o755); err != nil { return err } if infoJSONPath != "" { if err := moveFile(infoJSONPath, filepath.Join(targetDir, "info.json")); err != nil { return err } } for _, entry := range mediaFiles { if err := moveFile(filepath.Join(itemDir, entry.Name()), filepath.Join(targetDir, entry.Name())); err != nil { return err } } // Probing once here, in the worker, is what keeps ffprobe off the request // path. An unprobeable file simply gets no duration. fileDurations := make(map[string]int) for _, entry := range mediaFiles { if d, ok := probeDuration(ctx, s.cfg.FFprobePath, filepath.Join(targetDir, entry.Name())); ok { fileDurations[entry.Name()] = d } } if len(subtitleFiles) > 0 { subtitlesDir := filepath.Join(targetDir, subtitlesDirName) if err := os.MkdirAll(subtitlesDir, 0o755); err != nil { return err } for _, sf := range subtitleFiles { if err := moveFile(sf, filepath.Join(subtitlesDir, filepath.Base(sf))); err != nil { return err } } } metadata := models.ItemMetadata{ Name: name, SourceURL: url, VideoID: videoID, YtdlpFlags: ytdlpFlags, FileDurations: fileDurations, } return s.librarySvc.writeMetadata(targetDir, metadata) } // infoJSON is the subset of yt-dlp's info.json VidArchive reads. ID alone // identifies an item within one subscription's directory, so the extractor is // not needed. type infoJSON struct { ID string `json:"id"` // Type is yt-dlp's "_type": "playlist" marks a playlist-level sidecar rather // than an item. Type string `json:"_type"` Title string `json:"title"` Description string `json:"description"` WebpageURL string `json:"webpage_url"` } // readInfoJSON yields a zero-value struct for a missing, unreadable or malformed // file: every caller already falls back on an absent field. func readInfoJSON(infoJSONPath string) infoJSON { var info infoJSON if infoJSONPath == "" { return info } data, err := os.ReadFile(infoJSONPath) if err != nil { return info } if err := json.Unmarshal(data, &info); err != nil { slog.Warn("ignoring malformed info.json", "path", infoJSONPath, "err", err) return infoJSON{} } return info } // isInfoJSON accepts both forms: yt-dlp writes "