refactor: split logic into distinct parts
Some checks are pending
build / build (push) Waiting to run

This commit is contained in:
Viktor Varland 2025-10-01 21:21:59 +02:00
parent 1b340e25f3
commit 09134c46c4
Signed by: varl
GPG key ID: 7459F0B410115EE8
14 changed files with 842 additions and 127 deletions

View file

@ -51,7 +51,8 @@ COPY <<-EOT /data/config.json
{ {
"daemon": true, "daemon": true,
"dry_run": false, "dry_run": false,
"out_dir": "/data/vids", "download_dir": "/data/download",
"media_dir": "/data/media",
"http_api": { "http_api": {
"enable": true, "enable": true,
"listen": "0.0.0.0:6901", "listen": "0.0.0.0:6901",

View file

@ -3,10 +3,12 @@
## description ## description
`subsyt` is a wrapper around `yt-dlp`[1] to download youtube channels `subsyt` is a wrapper around `yt-dlp`[1] to download youtube channels
based on a OPML file containing all your subscriptions, sorting the based on a OPML file containing all your subscriptions. Downloads land in
channels into `{show}/{season}` folders, and generates `nfo` files, an isolated staging directory before being organised into
extracts thumbnails, downloads posters, banners, and fanart so the media `{show}/{season}` folders under your configured media library. During the
should plug into media libraries well-enough, e.g. Jellyfin and Kodi. organising step the tool generates `nfo` files, extracts thumbnails,
downloads posters, banners, and fanart so the media should plug into
media libraries well-enough, e.g. Jellyfin and Kodi.
A quick rundown on how to use it: A quick rundown on how to use it:
@ -73,7 +75,8 @@ Full `config.json`:
{ {
"daemon": true, "daemon": true,
"dry_run": true, "dry_run": true,
"out_dir": "./vids", "download_dir": "./vids/_staging",
"media_dir": "./vids",
"provider": { "provider": {
"youtube": { "youtube": {
"verbose": false, "verbose": false,
@ -97,7 +100,8 @@ Minimal `config.json`:
```json ```json
{ {
"out_dir": "./vids", "download_dir": "./vids/_staging",
"media_dir": "./vids",
"provider": { "provider": {
"youtube": { "youtube": {
"cmd": "./yt-dlp", "cmd": "./yt-dlp",
@ -108,6 +112,24 @@ Minimal `config.json`:
} }
``` ```
## migration
Existing deployments that used to read media directly from
`download_dir` should be migrated manually:
1. Stop the daemon or API workers so new downloads pause.
2. Back up your current `media_dir` and staging tree.
3. Move the contents of the legacy `download_dir/shows` and
`download_dir/episodes` directories into the new `media_dir`, sorted
by show and season as desired.
4. Optionally re-run `subsyt` with `dry_run=true` to confirm the new
layout before enabling writes again.
5. Once satisfied, delete the obsolete per-show/per-episode staging
directories under `download_dir`.
The application now keeps raw downloads inside `download_dir` and writes
the organised library exclusively to `media_dir`.
## generate opml ## generate opml
Use this javascript snippet: Use this javascript snippet:

View file

@ -30,7 +30,8 @@ type Http_api struct {
} }
type Config struct { type Config struct {
Out_dir string Download_dir string
Media_dir string
Provider map[string]Provider Provider map[string]Provider
Dry_run bool Dry_run bool
Daemon bool Daemon bool

20
internal/config/paths.go Normal file
View file

@ -0,0 +1,20 @@
package config
import "path/filepath"
type Paths struct {
DownloadRoot string
ChannelsDir string
EpisodesDir string
MediaDir string
}
func (c Config) Paths() Paths {
root := c.Download_dir
return Paths{
DownloadRoot: root,
ChannelsDir: filepath.Join(root, "channels"),
EpisodesDir: filepath.Join(root, "episodes"),
MediaDir: c.Media_dir,
}
}

133
internal/fsops/fsops.go Normal file
View file

@ -0,0 +1,133 @@
package fsops
import (
"errors"
"fmt"
"log"
"os"
"path/filepath"
)
type Manager struct {
DryRun bool
}
func (m Manager) EnsureDir(path string) error {
if path == "" {
return fmt.Errorf("ensure dir: empty path")
}
if m.DryRun {
log.Printf("[dry-run] ensure dir %s\n", path)
return nil
}
if err := os.MkdirAll(path, 0o755); err != nil {
return fmt.Errorf("ensure dir %s: %w", path, err)
}
return nil
}
func (m Manager) Move(src, dst string) error {
if src == "" || dst == "" {
return fmt.Errorf("move: empty source or destination")
}
dstDir := filepath.Dir(dst)
if err := m.EnsureDir(dstDir); err != nil {
return err
}
if m.DryRun {
log.Printf("[dry-run] move %s -> %s\n", src, dst)
return nil
}
if err := os.Rename(src, dst); err != nil {
return fmt.Errorf("move %s -> %s: %w", src, dst, err)
}
return nil
}
func (m Manager) Remove(path string) error {
if path == "" {
return fmt.Errorf("remove: empty path")
}
if m.DryRun {
log.Printf("[dry-run] remove %s\n", path)
return nil
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove %s: %w", path, err)
}
return nil
}
func (m Manager) RemoveAll(path string) error {
if path == "" {
return fmt.Errorf("remove all: empty path")
}
if m.DryRun {
log.Printf("[dry-run] remove tree %s\n", path)
return nil
}
if err := os.RemoveAll(path); err != nil {
return fmt.Errorf("remove all %s: %w", path, err)
}
return nil
}
func (m Manager) RemoveEmptyDirs(root string) error {
if root == "" {
return fmt.Errorf("remove empty dirs: empty root")
}
if _, err := os.Stat(root); errors.Is(err, os.ErrNotExist) {
return nil
} else if err != nil {
return err
}
return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
if path == root {
return nil
}
if !d.IsDir() {
return nil
}
entries, readErr := os.ReadDir(path)
if readErr != nil {
if errors.Is(readErr, os.ErrNotExist) {
return nil
}
return readErr
}
if len(entries) > 0 {
return nil
}
if m.DryRun {
log.Printf("[dry-run] remove empty dir %s\n", path)
return nil
}
if rmErr := os.Remove(path); rmErr != nil && !os.IsNotExist(rmErr) {
return fmt.Errorf("remove empty dir %s: %w", path, rmErr)
}
return nil
})
}

View file

@ -10,32 +10,30 @@ import (
"git.meatbag.se/varl/subsyt/internal/model" "git.meatbag.se/varl/subsyt/internal/model"
) )
func episodeImage(path string) { func NormalizeEpisodeThumbnail(name string) string {
if strings.Contains(path, "-thumb") { lower := strings.ToLower(name)
log.Printf("thumbnail detected '%s'\n", path) if strings.Contains(lower, "-thumb") {
return name
}
ext := filepath.Ext(name)
switch strings.ToLower(ext) {
case ".jpg", ".jpeg":
base := strings.TrimSuffix(name, ext)
return base + "-thumb" + ext
}
return name
}
func EnsureBanner(show model.Show, destDir string, dryRun bool) {
bannerPath := filepath.Join(destDir, "banner.jpg")
if _, err := os.Stat(bannerPath); err == nil {
log.Printf("%s has a banner, skipping download\n", show.Title)
return return
} }
thumb := strings.Replace(path, ".jpg", "-thumb.jpg", 1)
log.Printf("renaming thumbnail from '%s' to '%s'\n", path, thumb)
err := os.Rename(path, thumb)
if err != nil {
log.Printf("failed to rename '%s' to '%s\n'", path, thumb)
}
}
func showPoster(path string, show_dir string) { if dryRun {
poster := filepath.Join(show_dir, "poster.jpg") log.Printf("[dry-run] would download banner for %s\n", show.Title)
log.Printf("renaming show image from '%s' to '%s'\n", path, poster)
err := os.Rename(path, poster)
if err != nil {
log.Printf("failed to rename '%s' to '%s\n'", path, poster)
}
}
func showBanner(show model.Show, showDir string) {
_, err := os.Stat(filepath.Join(showDir, "banner.jpg"))
if err == nil {
log.Printf("%s has a banner, skipping download\n", show.Title)
return return
} }
@ -45,20 +43,26 @@ func showBanner(show model.Show, showDir string) {
log.Println("found banner candidate") log.Println("found banner candidate")
dl.Fetch(dl.Download{ dl.Fetch(dl.Download{
Url: thumb.Url, Url: thumb.Url,
OutDir: showDir, OutDir: destDir,
Name: "banner.jpg", Name: "banner.jpg",
}) })
return
} }
} }
} }
func showFanart(show model.Show, showDir string) { func EnsureFanart(show model.Show, destDir string, dryRun bool) {
_, err := os.Stat(filepath.Join(showDir, "fanart.jpg")) fanartPath := filepath.Join(destDir, "fanart.jpg")
if err == nil { if _, err := os.Stat(fanartPath); err == nil {
log.Printf("%s has fanart, skipping download\n", show.Title) log.Printf("%s has fanart, skipping download\n", show.Title)
return return
} }
if dryRun {
log.Printf("[dry-run] would download fanart for %s\n", show.Title)
return
}
c := model.Thumbnail{} c := model.Thumbnail{}
for index, thumb := range show.Thumbnails { for index, thumb := range show.Thumbnails {
log.Println(index, thumb) log.Println(index, thumb)
@ -68,9 +72,14 @@ func showFanart(show model.Show, showDir string) {
} }
} }
if c.Url == "" {
log.Printf("no fanart candidate for %s\n", show.Title)
return
}
dl.Fetch(dl.Download{ dl.Fetch(dl.Download{
Url: c.Url, Url: c.Url,
OutDir: showDir, OutDir: destDir,
Name: "fanart.jpg", Name: "fanart.jpg",
}) })
} }

View file

@ -1,92 +1,188 @@
package metadata package metadata
import ( import (
"errors"
"fmt" "fmt"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"git.meatbag.se/varl/subsyt/internal/model" "git.meatbag.se/varl/subsyt/internal/model"
"git.meatbag.se/varl/subsyt/internal/nfo"
) )
func findFiles(scanPath string, ext string) ([]string, error) { type ShowAsset struct {
var result []string Show model.Show
InfoPath string
Dir string
Images []string
}
err := filepath.Walk(scanPath, func(path string, info os.FileInfo, err error) error { type EpisodeAsset struct {
Episode model.Episode
InfoPath string
Dir string
MediaPath string
Sidecars []string
}
func findFiles(root, ext string) ([]string, error) {
if root == "" {
return nil, fmt.Errorf("find files: empty root")
}
if _, err := os.Stat(root); errors.Is(err, os.ErrNotExist) {
return nil, nil
} else if err != nil {
return nil, fmt.Errorf("find files: %w", err)
}
var result []string
walkErr := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil { if err != nil {
return err return err
} }
if !info.IsDir() && strings.HasSuffix(path, ext) { if d.IsDir() {
return nil
}
if strings.HasSuffix(path, ext) {
result = append(result, path) result = append(result, path)
} }
return nil return nil
}) })
if walkErr != nil {
if err != nil { return nil, fmt.Errorf("walk %s: %w", root, walkErr)
return nil, fmt.Errorf("error walking directory: %w", err)
} }
return result, nil return result, nil
} }
func Generate(outDir string, title string, dryRun bool) { func trimInfoSuffix(path string) string {
showDir := filepath.Join(outDir, title) return strings.TrimSuffix(path, ".info.json")
log.Printf("Writing NFO's for %s\n", showDir)
if dryRun {
return
} }
infojsons, err := findFiles(showDir, ".info.json") func globSidecars(base string) ([]string, error) {
matches, err := filepath.Glob(base + ".*")
if err != nil { if err != nil {
panic(err) return nil, fmt.Errorf("glob %s.*: %w", base, err)
} }
show := regexp.MustCompile("s(NA)") var sidecars []string
season := regexp.MustCompile(`s\d\d\d\d`) for _, match := range matches {
if strings.HasSuffix(match, ".info.json") {
for index, path := range infojsons { continue
log.Println(index, path) }
switch { sidecars = append(sidecars, match)
case show.MatchString(path): }
show := model.LoadShow(path) return sidecars, nil
nfo.WriteShowInfo(show, filepath.Join(showDir, "tvshow.nfo"))
showBanner(show, showDir)
showFanart(show, showDir)
case season.MatchString(path):
ep := model.LoadEpisode(path)
nfo.WriteEpisodeNFO(ep, path)
default:
log.Printf("no match for '%s'\n", path)
} }
os.Remove(path) func directoryImages(dir string) ([]string, error) {
} entries, err := os.ReadDir(dir)
images, err := findFiles(showDir, ".jpg")
if err != nil { if err != nil {
panic(err) if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("readdir %s: %w", dir, err)
} }
for index, path := range images { var images []string
log.Println(index, path) for _, entry := range entries {
switch { if entry.IsDir() {
case show.MatchString(path): continue
showPoster(path, showDir) }
case season.MatchString(path): name := entry.Name()
episodeImage(path) switch strings.ToLower(filepath.Ext(name)) {
default: case ".jpg", ".jpeg", ".png", ".webp":
log.Printf("no match for '%s'\n", path) images = append(images, filepath.Join(dir, name))
}
}
return images, nil
}
func resolveMediaPath(base string, episode model.Episode, sidecars []string) (string, []string) {
if episode.Ext != "" {
candidate := base + "." + episode.Ext
if _, err := os.Stat(candidate); err == nil {
var rest []string
for _, path := range sidecars {
if path != candidate {
rest = append(rest, path)
}
}
return candidate, rest
} }
} }
del := filepath.Join(showDir, "sNA") for _, path := range sidecars {
log.Printf("removing '%s'\n", del) ext := strings.ToLower(filepath.Ext(path))
err = os.RemoveAll(del) switch ext {
case ".mp4", ".mkv", ".webm", ".m4v", ".mov", ".avi":
var rest []string
for _, other := range sidecars {
if other != path {
rest = append(rest, other)
}
}
return path, rest
}
}
return "", sidecars
}
func ScanShows(root string) ([]ShowAsset, error) {
infoFiles, err := findFiles(root, ".info.json")
if err != nil { if err != nil {
log.Println("failed to remove", err) return nil, err
} }
var assets []ShowAsset
for _, infoPath := range infoFiles {
show := model.LoadShow(infoPath)
dir := filepath.Dir(infoPath)
imgs, imgErr := directoryImages(dir)
if imgErr != nil {
log.Printf("scan shows: %v\n", imgErr)
}
assets = append(assets, ShowAsset{
Show: show,
InfoPath: infoPath,
Dir: dir,
Images: imgs,
})
}
return assets, nil
}
func ScanEpisodes(root string) ([]EpisodeAsset, error) {
infoFiles, err := findFiles(root, ".info.json")
if err != nil {
return nil, err
}
var assets []EpisodeAsset
for _, infoPath := range infoFiles {
episode := model.LoadEpisode(infoPath)
base := trimInfoSuffix(infoPath)
dir := filepath.Dir(infoPath)
sidecars, sidecarErr := globSidecars(base)
if sidecarErr != nil {
return nil, sidecarErr
}
mediaPath, remaining := resolveMediaPath(base, episode, sidecars)
assets = append(assets, EpisodeAsset{
Episode: episode,
InfoPath: infoPath,
Dir: dir,
MediaPath: mediaPath,
Sidecars: remaining,
})
}
return assets, nil
} }

View file

@ -0,0 +1,101 @@
package metadata
import (
"os"
"path/filepath"
"testing"
)
func TestScanEpisodes(t *testing.T) {
root := t.TempDir()
seasonDir := filepath.Join(root, "s2024")
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
base := filepath.Join(seasonDir, "TestChannel.s2024Se0210S.Sample.abc123")
infoPath := base + ".info.json"
videoPath := base + ".mp4"
thumbPath := base + ".jpg"
epJSON := `{"title":"Episode","channel":"Test Channel","id":"abc123","description":"Episode description","upload_date":"20240210","ext":"mp4"}`
if err := os.WriteFile(infoPath, []byte(epJSON), 0o644); err != nil {
t.Fatalf("write info: %v", err)
}
if err := os.WriteFile(videoPath, []byte("video"), 0o644); err != nil {
t.Fatalf("write video: %v", err)
}
if err := os.WriteFile(thumbPath, []byte("thumb"), 0o644); err != nil {
t.Fatalf("write thumbnail: %v", err)
}
assets, err := ScanEpisodes(root)
if err != nil {
t.Fatalf("ScanEpisodes error: %v", err)
}
if len(assets) != 1 {
t.Fatalf("expected 1 asset, got %d", len(assets))
}
asset := assets[0]
if asset.MediaPath != videoPath {
t.Fatalf("unexpected media path: %s", asset.MediaPath)
}
if len(asset.Sidecars) != 1 || asset.Sidecars[0] != thumbPath {
t.Fatalf("unexpected sidecars: %#v", asset.Sidecars)
}
}
func TestScanShows(t *testing.T) {
root := t.TempDir()
showDir := filepath.Join(root, "sNA")
if err := os.MkdirAll(showDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
base := filepath.Join(showDir, "test-channel")
infoPath := base + ".info.json"
posterPath := base + ".jpg"
showJSON := `{"channel":"Test Channel","channel_id":"chan123","description":"Show description","thumbnails":[]}`
if err := os.WriteFile(infoPath, []byte(showJSON), 0o644); err != nil {
t.Fatalf("write show info: %v", err)
}
if err := os.WriteFile(posterPath, []byte("poster"), 0o644); err != nil {
t.Fatalf("write poster: %v", err)
}
assets, err := ScanShows(root)
if err != nil {
t.Fatalf("ScanShows error: %v", err)
}
if len(assets) != 1 {
t.Fatalf("expected 1 show asset, got %d", len(assets))
}
asset := assets[0]
if asset.InfoPath != infoPath {
t.Fatalf("unexpected info path: %s", asset.InfoPath)
}
if len(asset.Images) != 1 || asset.Images[0] != posterPath {
t.Fatalf("unexpected images: %#v", asset.Images)
}
}
func TestNormalizeEpisodeThumbnail(t *testing.T) {
got := NormalizeEpisodeThumbnail("file.jpg")
expected := "file-thumb.jpg"
if got != expected {
t.Fatalf("expected %s, got %s", expected, got)
}
got = NormalizeEpisodeThumbnail("file-thumb.jpg")
if got != "file-thumb.jpg" {
t.Fatalf("normalization should keep thumb suffix, got %s", got)
}
got = NormalizeEpisodeThumbnail("file.jpeg")
if got != "file-thumb.jpeg" {
t.Fatalf("unexpected jpeg normalization: %s", got)
}
}

View file

@ -29,6 +29,7 @@ type Episode struct {
Id string `json:"id" xml:"-"` Id string `json:"id" xml:"-"`
UniqueId UniqueId `json:"-" xml:"uniqueid"` UniqueId UniqueId `json:"-" xml:"uniqueid"`
Plot string `json:"description" xml:"plot"` Plot string `json:"description" xml:"plot"`
Ext string `json:"ext" xml:"-"`
UploadDate string `json:"upload_date" xml:"-"` UploadDate string `json:"upload_date" xml:"-"`
Aired string `json:"-" xml:"aired"` Aired string `json:"-" xml:"aired"`
Season string `json:"-" xml:"season"` Season string `json:"-" xml:"season"`

View file

@ -4,15 +4,12 @@ import (
"encoding/xml" "encoding/xml"
"log" "log"
"os" "os"
"strings"
"git.meatbag.se/varl/subsyt/internal/model" "git.meatbag.se/varl/subsyt/internal/model"
) )
func WriteEpisodeNFO(ep model.Episode, info_path string) { func WriteEpisodeNFO(ep model.Episode, outPath string) {
out_path := strings.Replace(info_path, ".info.json", ".nfo", 1) log.Printf("writing episode nfo to '%s'\n", outPath)
log.Printf("writing info from '%s' to '%s'\n", info_path, out_path)
xmlData, err := xml.MarshalIndent(ep, "", " ") xmlData, err := xml.MarshalIndent(ep, "", " ")
if err != nil { if err != nil {
@ -21,7 +18,7 @@ func WriteEpisodeNFO(ep model.Episode, info_path string) {
complete := xml.Header + string(xmlData) complete := xml.Header + string(xmlData)
log.Printf("%s", complete) log.Printf("%s", complete)
os.WriteFile(out_path, xmlData, 0644) os.WriteFile(outPath, xmlData, 0644)
} }
func WriteShowInfo(show model.Show, out_path string) { func WriteShowInfo(show model.Show, out_path string) {

View file

@ -0,0 +1,166 @@
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))
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 ""
}

View file

@ -0,0 +1,120 @@
package organize
import (
"os"
"path/filepath"
"testing"
"git.meatbag.se/varl/subsyt/internal/config"
"git.meatbag.se/varl/subsyt/internal/fsops"
"git.meatbag.se/varl/subsyt/internal/metadata"
)
func TestOrganizerProcess(t *testing.T) {
root := t.TempDir()
paths := config.Paths{
DownloadRoot: filepath.Join(root, "downloads"),
ChannelsDir: filepath.Join(root, "downloads", "channels"),
EpisodesDir: filepath.Join(root, "downloads", "episodes"),
MediaDir: filepath.Join(root, "library"),
}
if err := os.MkdirAll(paths.ChannelsDir, 0o755); err != nil {
t.Fatalf("mkdir channels: %v", err)
}
if err := os.MkdirAll(paths.EpisodesDir, 0o755); err != nil {
t.Fatalf("mkdir episodes: %v", err)
}
showSub := filepath.Join(paths.ChannelsDir, "sNA")
if err := os.MkdirAll(showSub, 0o755); err != nil {
t.Fatalf("mkdir show subdir: %v", err)
}
showBase := filepath.Join(showSub, "test-channel")
showInfo := showBase + ".info.json"
showImage := showBase + ".jpg"
showJSON := `{"channel":"Test Channel","channel_id":"chan123","description":"Show description","thumbnails":[]}`
if err := os.WriteFile(showInfo, []byte(showJSON), 0o644); err != nil {
t.Fatalf("write show info: %v", err)
}
if err := os.WriteFile(showImage, []byte("poster"), 0o644); err != nil {
t.Fatalf("write show image: %v", err)
}
seasonDir := filepath.Join(paths.EpisodesDir, "s2024")
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
t.Fatalf("mkdir season: %v", err)
}
base := filepath.Join(seasonDir, "Test Channel.s2024Se0210S.Sample.abc123")
infoPath := base + ".info.json"
videoPath := base + ".mp4"
subsPath := base + ".en.vtt"
thumbPath := base + ".jpg"
epJSON := `{"title":"Episode","channel":"Test Channel","id":"abc123","description":"Episode description","upload_date":"20240210","ext":"mp4"}`
if err := os.WriteFile(infoPath, []byte(epJSON), 0o644); err != nil {
t.Fatalf("write episode info: %v", err)
}
if err := os.WriteFile(videoPath, []byte("video"), 0o644); err != nil {
t.Fatalf("write episode video: %v", err)
}
if err := os.WriteFile(subsPath, []byte("subtitles"), 0o644); err != nil {
t.Fatalf("write subtitles: %v", err)
}
if err := os.WriteFile(thumbPath, []byte("thumb"), 0o644); err != nil {
t.Fatalf("write thumb: %v", err)
}
shows, err := metadata.ScanShows(paths.ChannelsDir)
if err != nil {
t.Fatalf("scan shows: %v", err)
}
episodes, err := metadata.ScanEpisodes(paths.EpisodesDir)
if err != nil {
t.Fatalf("scan episodes: %v", err)
}
organizer := Organizer{
Paths: paths,
FS: fsops.Manager{DryRun: false},
}
if err := organizer.Prepare(); err != nil {
t.Fatalf("prepare: %v", err)
}
if err := organizer.ProcessShows(shows); err != nil {
t.Fatalf("process shows: %v", err)
}
if err := organizer.ProcessEpisodes(episodes); err != nil {
t.Fatalf("process episodes: %v", err)
}
showDir := filepath.Join(paths.MediaDir, "Test Channel")
seasonOut := filepath.Join(showDir, "s2024")
if _, err := os.Stat(filepath.Join(showDir, "tvshow.nfo")); err != nil {
t.Fatalf("expected tvshow.nfo: %v", err)
}
if _, err := os.Stat(filepath.Join(showDir, "poster.jpg")); err != nil {
t.Fatalf("expected poster.jpg: %v", err)
}
if _, err := os.Stat(filepath.Join(seasonOut, filepath.Base(videoPath))); err != nil {
t.Fatalf("expected moved video: %v", err)
}
if _, err := os.Stat(filepath.Join(seasonOut, "Test Channel.s2024Se0210S.Sample.abc123-thumb.jpg")); err != nil {
t.Fatalf("expected moved thumbnail: %v", err)
}
if _, err := os.Stat(filepath.Join(seasonOut, "Test Channel.s2024Se0210S.Sample.abc123.en.vtt")); err != nil {
t.Fatalf("expected moved subtitles: %v", err)
}
if _, err := os.Stat(filepath.Join(seasonOut, "Test Channel.s2024Se0210S.Sample.abc123.nfo")); err != nil {
t.Fatalf("expected episode nfo: %v", err)
}
if _, err := os.Stat(infoPath); !os.IsNotExist(err) {
t.Fatalf("expected episode info removed, got err=%v", err)
}
if _, err := os.Stat(showInfo); !os.IsNotExist(err) {
t.Fatalf("expected show info removed, got err=%v", err)
}
}

View file

@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"

65
main.go
View file

@ -7,18 +7,29 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"path/filepath"
"time" "time"
"git.meatbag.se/varl/subsyt/internal/config" "git.meatbag.se/varl/subsyt/internal/config"
"git.meatbag.se/varl/subsyt/internal/dl" "git.meatbag.se/varl/subsyt/internal/dl"
"git.meatbag.se/varl/subsyt/internal/format" "git.meatbag.se/varl/subsyt/internal/format"
"git.meatbag.se/varl/subsyt/internal/fsops"
"git.meatbag.se/varl/subsyt/internal/metadata" "git.meatbag.se/varl/subsyt/internal/metadata"
"git.meatbag.se/varl/subsyt/internal/organize"
"git.meatbag.se/varl/subsyt/internal/scheduler" "git.meatbag.se/varl/subsyt/internal/scheduler"
"git.meatbag.se/varl/subsyt/internal/server" "git.meatbag.se/varl/subsyt/internal/server"
) )
func run(cfg config.Config) { func run(cfg config.Config) {
paths := cfg.Paths()
organizer := organize.Organizer{
Paths: paths,
FS: fsops.Manager{DryRun: cfg.Dry_run},
}
if err := organizer.Prepare(); err != nil {
log.Fatalf("prepare directories: %v", err)
}
provider := cfg.Provider["youtube"] provider := cfg.Provider["youtube"]
if err := dl.UpgradeYtDlp(provider.Cmd); err != nil { if err := dl.UpgradeYtDlp(provider.Cmd); err != nil {
@ -48,7 +59,7 @@ func run(cfg config.Config) {
dl.Youtube(dl.Download{ dl.Youtube(dl.Download{
Url: feed.Author.Uri, Url: feed.Author.Uri,
OutDir: filepath.Join(cfg.Out_dir, outline.Title), OutDir: paths.ChannelsDir,
DryRun: cfg.Dry_run, DryRun: cfg.Dry_run,
Metadata: true, Metadata: true,
}, provider) }, provider)
@ -61,15 +72,39 @@ func run(cfg config.Config) {
log.Printf("Entry: %#v", entry) log.Printf("Entry: %#v", entry)
dl.Youtube(dl.Download{ dl.Youtube(dl.Download{
Url: url, Url: url,
OutDir: filepath.Join(cfg.Out_dir, feed.Title), OutDir: paths.EpisodesDir,
DryRun: cfg.Dry_run, DryRun: cfg.Dry_run,
Metadata: false, Metadata: false,
}, provider) }, provider)
} }
}
}
metadata.Generate(cfg.Out_dir, feed.Title, cfg.Dry_run) if err := syncLibrary(organizer); err != nil {
log.Fatalf("organize library: %v", err)
} }
} }
func syncLibrary(organizer organize.Organizer) error {
shows, err := metadata.ScanShows(organizer.Paths.ChannelsDir)
if err != nil {
return fmt.Errorf("scan shows: %w", err)
}
if err := organizer.ProcessShows(shows); err != nil {
return fmt.Errorf("process shows: %w", err)
}
episodes, err := metadata.ScanEpisodes(organizer.Paths.EpisodesDir)
if err != nil {
return fmt.Errorf("scan episodes: %w", err)
}
if err := organizer.ProcessEpisodes(episodes); err != nil {
return fmt.Errorf("process episodes: %w", err)
}
return nil
} }
func main() { func main() {
@ -119,6 +154,8 @@ func main() {
} }
func setupAPIServer(ctx context.Context, cfg config.Config, provider config.Provider) error { func setupAPIServer(ctx context.Context, cfg config.Config, provider config.Provider) error {
paths := cfg.Paths()
queue, err := server.NewQueue(cfg.Http_api.Queue_file) queue, err := server.NewQueue(cfg.Http_api.Queue_file)
if err != nil { if err != nil {
return fmt.Errorf("load queue: %w", err) return fmt.Errorf("load queue: %w", err)
@ -126,7 +163,7 @@ func setupAPIServer(ctx context.Context, cfg config.Config, provider config.Prov
go queueWorker(ctx, queue, cfg, provider) go queueWorker(ctx, queue, cfg, provider)
srv := server.NewServer(cfg.Http_api, cfg.Out_dir, queue) srv := server.NewServer(cfg.Http_api, paths.MediaDir, queue)
go func() { go func() {
if err := srv.Start(ctx); err != nil { if err := srv.Start(ctx); err != nil {
log.Printf("http api server stopped with error: %v", err) log.Printf("http api server stopped with error: %v", err)
@ -137,6 +174,16 @@ func setupAPIServer(ctx context.Context, cfg config.Config, provider config.Prov
} }
func queueWorker(ctx context.Context, q *server.Queue, cfg config.Config, provider config.Provider) { func queueWorker(ctx context.Context, q *server.Queue, cfg config.Config, provider config.Provider) {
organizer := organize.Organizer{
Paths: cfg.Paths(),
FS: fsops.Manager{DryRun: cfg.Dry_run},
}
if err := organizer.Prepare(); err != nil {
log.Printf("queue prepare error: %v", err)
return
}
for { for {
item, err := q.Next(ctx) item, err := q.Next(ctx)
if err != nil { if err != nil {
@ -152,16 +199,16 @@ func queueWorker(ctx context.Context, q *server.Queue, cfg config.Config, provid
continue continue
} }
outPath := filepath.Join(cfg.Out_dir, "_misc")
dl.Youtube(dl.Download{ dl.Youtube(dl.Download{
Url: item.Request.URL, Url: item.Request.URL,
OutDir: outPath, OutDir: organizer.Paths.EpisodesDir,
DryRun: cfg.Dry_run, DryRun: cfg.Dry_run,
Metadata: false, Metadata: false,
}, provider) }, provider)
metadata.Generate(cfg.Out_dir, "_misc", cfg.Dry_run) if err := syncLibrary(organizer); err != nil {
log.Printf("queue organize error: %v", err)
}
if err := q.MarkDone(item.ID); err != nil { if err := q.MarkDone(item.ID); err != nil {
log.Printf("queue mark done failed: %v", err) log.Printf("queue mark done failed: %v", err)