package organize import ( "fmt" "log" "os" "path/filepath" "strings" "git.meatbag.se/varl/subsyt/internal/config" "git.meatbag.se/varl/subsyt/internal/fsops" "git.meatbag.se/varl/subsyt/internal/metadata" "git.meatbag.se/varl/subsyt/internal/nfo" ) type Organizer struct { Paths config.Paths FS fsops.Manager } func (o Organizer) Prepare() error { if err := o.FS.EnsureDir(o.Paths.DownloadRoot); err != nil { return err } if err := o.FS.EnsureDir(o.Paths.ChannelsDir); err != nil { return err } if err := o.FS.EnsureDir(o.Paths.EpisodesDir); err != nil { return err } if err := o.FS.EnsureDir(o.Paths.MediaDir); err != nil { return err } return nil } func (o Organizer) ProcessShows(shows []metadata.ShowAsset) error { for _, show := range shows { showDir := filepath.Join(o.Paths.MediaDir, safeName(show.Show.Title)) if err := o.FS.EnsureDir(showDir); err != nil { return err } if o.FS.DryRun { log.Printf("[dry-run] write tvshow.nfo for %s\n", show.Show.Title) } else { nfo.WriteShowInfo(show.Show, filepath.Join(showDir, "tvshow.nfo")) } for _, img := range show.Images { name := categorizeShowImage(img) if name == "" { continue } dest := filepath.Join(showDir, name) if _, err := os.Stat(dest); err == nil { log.Printf("skip image move, destination exists: %s\n", dest) if err := o.FS.Remove(img); err != nil { return err } continue } if err := o.FS.Move(img, dest); err != nil { return err } } metadata.EnsureBanner(show.Show, showDir, o.FS.DryRun) metadata.EnsureFanart(show.Show, showDir, o.FS.DryRun) if err := o.FS.Remove(show.InfoPath); err != nil { return err } } if err := o.FS.RemoveEmptyDirs(o.Paths.ChannelsDir); err != nil { return err } return nil } func (o Organizer) ProcessEpisodes(episodes []metadata.EpisodeAsset) error { for _, ep := range episodes { showDir := filepath.Join(o.Paths.MediaDir, safeName(ep.Episode.ShowTitle)) seasonDir := filepath.Join(showDir, ep.Episode.Season) if err := o.FS.EnsureDir(seasonDir); err != nil { return err } var destMediaPath string if ep.MediaPath != "" { filename := filepath.Base(ep.MediaPath) destMediaPath = filepath.Join(seasonDir, filename) if err := o.FS.Move(ep.MediaPath, destMediaPath); err != nil { return err } } else { log.Printf("no media file for %s (%s)\n", ep.Episode.Title, ep.InfoPath) } for _, sidecar := range ep.Sidecars { filename := filepath.Base(sidecar) ext := strings.ToLower(filepath.Ext(filename)) if ext == ".jpg" || ext == ".jpeg" { filename = metadata.NormalizeEpisodeThumbnail(filename) } dest := filepath.Join(seasonDir, filename) if _, err := os.Stat(dest); err == nil { log.Printf("skip sidecar move, destination exists: %s\n", dest) if err := o.FS.Remove(sidecar); err != nil { return err } continue } if err := o.FS.Move(sidecar, dest); err != nil { return err } } if destMediaPath != "" { base := strings.TrimSuffix(filepath.Base(destMediaPath), filepath.Ext(destMediaPath)) nfoPath := filepath.Join(seasonDir, fmt.Sprintf("%s.nfo", base)) if o.FS.DryRun { log.Printf("[dry-run] write episode nfo %s\n", nfoPath) } else { nfo.WriteEpisodeNFO(ep.Episode, nfoPath) } } if err := o.FS.Remove(ep.InfoPath); err != nil { return err } } if err := o.FS.RemoveEmptyDirs(o.Paths.EpisodesDir); err != nil { return err } return nil } func safeName(name string) string { replacer := strings.NewReplacer( "/", "", "\\", "", ":", "", "?", "", "*", "", "\"", "", "<", "", ">", "", "|", "", ) sanitized := replacer.Replace(strings.TrimSpace(name)) sanitized = strings.Trim(sanitized, " .-_") if sanitized == "" { return "unknown" } return sanitized } func categorizeShowImage(path string) string { lower := strings.ToLower(filepath.Base(path)) switch { case strings.Contains(lower, "banner"): return "banner.jpg" case strings.Contains(lower, "fanart"): return "fanart.jpg" case strings.Contains(lower, "poster"): return "poster.jpg" default: if strings.HasSuffix(lower, ".jpg") || strings.HasSuffix(lower, ".jpeg") { return "poster.jpg" } } return "" }