package service import ( "context" "encoding/json" "errors" "fmt" "io" "log" "os" "path/filepath" "sort" "strconv" "strings" "syscall" "unicode/utf8" "github.com/gabriel-vasile/mimetype" "vidarchive/internal/models" "vidarchive/internal/util" ) // resolveBaseLibraryDir returns the absolute library directory a download writes // into, applying the optional per-download OutputDir while rejecting any path // that escapes the library root. func (s *DownloadService) resolveBaseLibraryDir(d *models.Download) (string, error) { if !d.OutputDir.Valid || d.OutputDir.String == "" { return s.cfg.LibraryDir, nil } // Reuse the library service's guard so both entry points enforce the boundary // the same way — it resolves symlinks, which a plain prefix check does not. dir, err := s.librarySvc.ResolveWithinLibrary(d.OutputDir.String) if err != nil { return "", fmt.Errorf("invalid output directory: %w", err) } return dir, nil } // importDownloadedItems moves each downloaded item from the temp dir into the // library and returns the number of items successfully imported. Per-item // failures are logged and skipped (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 the import between items and returns ctx.Err() with the count // imported so far. Importing a long playlist takes real time (a move plus an // ffprobe per file), so a deleted download must not keep filling the library. 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 isn't a bad item: stop instead of logging a warning // for it and every one that follows. if ctx.Err() != nil { return imported, ctx.Err() } log.Printf("warning: failed to import item %s: %v", itemDir, err) continue } imported++ } // The temp dir (and any leftovers from failed imports) is removed by the // caller's deferred cleanup, so partial state never leaks even on a crash. 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: give up // here on a cancel rather than part-way through, which would leave a folder // with some of its files and no marker — or, in overwrite mode, delete the // existing item and not replace it. if err := ctx.Err(); err != nil { return err } // Overwrite mode: replace the existing copy of this video in place rather than // creating a duplicate folder. Removing the old dir lets uniqueDir reuse its // name (or land on the new title if it changed upstream). if mode == "overwrite" && videoID != "" { if existing, ok := s.librarySvc.FindByVideoID(baseLibraryDir, videoID); ok { if rel, err := filepath.Rel(s.cfg.LibraryDir, existing); err == nil { s.librarySvc.evictCachedScan(filepath.ToSlash(rel)) } 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 } } // Probe each media file's duration once, here in the worker (off the request // path), and cache it in the marker so the library never has to probe while // serving pages. Files we can't probe simply get 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 is the // stable item identity; within a single subscription's own directory it is // enough to match items, 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 parses an info.json. A missing, unreadable or malformed file // yields a zero-value struct: every caller treats absent fields as "unknown" // and falls back, so there is nothing to distinguish. 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 { log.Printf("ignoring malformed %s: %v", infoJSONPath, err) return infoJSON{} } return info } // isInfoJSON reports whether a file name is yt-dlp's metadata sidecar. yt-dlp // writes ".info.json" next to the media, but a bare "info.json" is what // an already-imported item holds. func isInfoJSON(name string) bool { return name == "info.json" || strings.HasSuffix(name, ".info.json") } // findInfoJSON returns the path to an info.json directly inside itemDir, or "". func findInfoJSON(itemDir string) string { entries, err := os.ReadDir(itemDir) if err != nil { return "" } for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() if isInfoJSON(name) { return filepath.Join(itemDir, name) } } return "" } // deriveItemName names the imported item after its title, falling back to the // largest media file's base name when there is no usable info.json. func (s *DownloadService) deriveItemName(itemDir string, info infoJSON, mediaFiles []os.DirEntry) string { if info.Title != "" { return sanitizeDirName(info.Title) } var largest os.DirEntry var maxSize int64 for _, f := range mediaFiles { st, err := os.Stat(filepath.Join(itemDir, f.Name())) if err == nil && (largest == nil || st.Size() > maxSize) { largest, maxSize = f, st.Size() } } if largest == nil { largest = mediaFiles[0] } base := strings.TrimSuffix(largest.Name(), filepath.Ext(largest.Name())) return sanitizeDirName(base) } // uniqueDir returns a directory under base that does not exist yet, appending // "-1", "-2", ... until it finds one. A stat error other than "does not exist" // is returned rather than treated as "taken": every candidate would fail the // same way, so the loop would never end. func (s *DownloadService) uniqueDir(base, name string) (string, error) { dir := filepath.Join(base, name) for i := 0; ; i++ { candidate := dir if i > 0 { candidate = fmt.Sprintf("%s-%d", dir, i) } _, err := os.Stat(candidate) if os.IsNotExist(err) { return candidate, nil } if err != nil { return "", fmt.Errorf("choose item directory: %w", err) } } } // moveFile moves src to dst, falling back to copy-and-delete when the two are on // different filesystems. The temp and library directories are independently // configurable, so they can legitimately live on separate mounts — where a plain // rename fails with EXDEV. func moveFile(src, dst string) error { if err := os.Rename(src, dst); err == nil { return nil } else if !errors.Is(err, syscall.EXDEV) { return err } in, err := os.Open(src) if err != nil { return err } defer in.Close() info, err := in.Stat() if err != nil { return err } out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode()) if err != nil { return err } if _, err := io.Copy(out, in); err != nil { out.Close() os.Remove(dst) return err } // Close explicitly: a deferred close would hide a flush error on the copy. if err := out.Close(); err != nil { os.Remove(dst) return err } return os.Remove(src) } func sanitizeDirName(name string) string { name = strings.TrimSpace(name) replacer := strings.NewReplacer( "/", "-", "\\", "-", ":", "-", "*", "-", "?", "-", "\"", "-", "<", "-", ">", "-", "|", "-", ) name = replacer.Replace(name) name = strings.TrimSpace(name) // A name made only of dots resolves to the parent ("..") or to the target // directory itself ("."), so joining it would place the item outside the // library. Titles come from remote metadata, so refuse them here. if strings.Trim(name, ".") == "" { name = "untitled" } return truncateDirName(name) } // maxDirNameBytes leaves room under the 255-byte filesystem component limit for // uniqueDir's "-N" suffix. const maxDirNameBytes = 240 // truncateDirName shortens name to maxDirNameBytes without splitting a rune. // Over the limit, every filesystem call on the name fails with ENAMETOOLONG. func truncateDirName(name string) string { if len(name) <= maxDirNameBytes { return name } cut := maxDirNameBytes for cut > 0 && !utf8.RuneStart(name[cut]) { cut-- } truncated := strings.TrimSpace(name[:cut]) // Trimming can reintroduce the empty/all-dots case the caller already ruled // out. if strings.Trim(truncated, ".") == "" { return "untitled" } return truncated } // probeDuration returns the duration of a media file in whole seconds. The bool // is false when ffprobe is unavailable or the file has no usable duration. func probeDuration(ctx context.Context, ffprobePath, path string) (int, bool) { out, err := util.KillableCommand(ctx, ffprobePath, "-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", path).Output() if err != nil { return 0, false } f, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64) if err != nil || f <= 0 { return 0, false } return int(f + 0.5), true }