diff --git a/README.md b/README.md
index 4b477fa..c9564b4 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,8 @@ out_dir = "./vids" # path to archive vids
[provider]
[provider.youtube]
cmd = "./yt-dlp" # path to yt-dlp binary
+quality = "res:1080" # set the preferred quality
+output_path_template = "" # yt-dlp output template
url = "https://www.youtube.com" # full yt url
throttle = 1 # throttle yt request
range = "1:5:1" # [START][:STOP][:STEP]
@@ -42,6 +44,9 @@ opml_file = "./opml.xml" # the opml file to use
## Cookies
+> [!CRITICAL]
+> Your account **MAY** be banned when using cookies !
+
E.g. from Chromium:
```
diff --git a/internal/config/config.go b/internal/config/config.go
index 05b285d..7bc5ea9 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -8,14 +8,16 @@ import (
)
type Provider struct {
- Url string
- Throttle int
- Range string
- After_date string
- Cmd string
- Cookies bool
- Cookies_file string
- Opml_file string
+ Url string
+ Throttle int
+ Range string
+ After_date string
+ Cmd string
+ Cookies bool
+ Cookies_file string
+ Opml_file string
+ Quality string
+ Output_path_template string
}
type Config struct {
@@ -37,7 +39,7 @@ func Load(filepath string) (Config, error) {
panic(err)
}
- log.Printf("Loaded config:")
+ log.Println("Loaded config:")
log.Printf("%+v\n", cfg)
return cfg, err
diff --git a/internal/dl/dl.go b/internal/dl/dl.go
index b24e2ab..d4d83ae 100644
--- a/internal/dl/dl.go
+++ b/internal/dl/dl.go
@@ -2,8 +2,11 @@ package dl
import (
"bufio"
+ "io"
"log"
+ "net/http"
"net/url"
+ "os"
"os/exec"
"path/filepath"
"strconv"
@@ -20,10 +23,9 @@ type Download struct {
DryRun bool
}
-func Get(d Download, p config.Provider) {
- output := filepath.Join("%(channel)s-%(title)s-%(id)s.%(ext)s")
- archive := filepath.Join(d.OutDir, d.Name, "archive.txt")
- outdir := filepath.Join(d.OutDir, d.Name)
+func Youtube(d Download, p config.Provider) {
+ archive := filepath.Join(d.OutDir, "archive.txt")
+ outdir := d.OutDir
curl := strings.TrimPrefix(d.Url, "/feed/")
furl, err := url.JoinPath(p.Url, curl, "videos")
@@ -43,22 +45,26 @@ func Get(d Download, p config.Provider) {
"--sleep-interval", throttle,
"--sleep-subtitles", throttle,
"--sleep-requests", throttle,
+ "--format-sort", p.Quality,
"--prefer-free-formats",
"--write-subs",
"--no-write-automatic-subs",
"--sub-langs", "en",
"--paths", outdir,
- "--output", output,
+ "--output", p.Output_path_template,
"--download-archive", archive,
"--break-on-existing",
"--playlist-items", p.Range,
"--restrict-filenames",
"--embed-metadata",
+ "--write-thumbnail",
+ "--write-info-json",
+ "--convert-thumbnails", "jpg",
}
if d.DryRun == true {
args = append(args, "--simulate")
- log.Printf("/!\\ DRY RUN ENABLED /!\\")
+ log.Println("/!\\ DRY RUN ENABLED /!\\")
} else {
args = append(args, "--no-simulate")
}
@@ -83,7 +89,7 @@ func Get(d Download, p config.Provider) {
log.Fatal(err)
}
- log.Printf("[%s] running yt-dlp for: %s", d.Name, d.Url)
+ log.Printf("[%s] running yt-dlp for: %s\n", d.OutDir, d.Url)
var wg sync.WaitGroup
wg.Add(2)
@@ -92,7 +98,7 @@ func Get(d Download, p config.Provider) {
defer wg.Done()
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
- log.Printf("[%s] %s\n", d.Name, scanner.Text())
+ log.Printf("[%s] %s\n", d.OutDir, scanner.Text())
}
}()
@@ -100,7 +106,7 @@ func Get(d Download, p config.Provider) {
defer wg.Done()
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
- log.Printf("[%s] %s\n", d.Name, scanner.Text())
+ log.Printf("[%s] %s\n", d.OutDir, scanner.Text())
}
}()
@@ -116,3 +122,30 @@ func Get(d Download, p config.Provider) {
log.Printf("Error: %s\n", err)
}
}
+
+func Fetch(d Download) {
+ // Create output directory if it doesn't exist
+ if err := os.MkdirAll(d.OutDir, 0755); err != nil {
+ }
+
+ outputPath := filepath.Join(d.OutDir, d.Name)
+
+ out, err := os.Create(outputPath)
+ if err != nil {
+ log.Printf("failed to create '%s'\n", outputPath)
+ return
+ }
+ defer out.Close()
+
+ resp, err := http.Get(d.Url)
+ if err != nil {
+ log.Printf("failed to download '%s'\n", d.Url)
+ return
+ }
+ defer resp.Body.Close()
+
+ _, err = io.Copy(out, resp.Body)
+ if err != nil {
+ log.Printf("failed to write file")
+ }
+}
diff --git a/internal/metadata/metadata.go b/internal/metadata/metadata.go
new file mode 100644
index 0000000..d9415e6
--- /dev/null
+++ b/internal/metadata/metadata.go
@@ -0,0 +1,128 @@
+package metadata
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "git.meatbag.se/varl/subsyt/internal/dl"
+ "git.meatbag.se/varl/subsyt/internal/models"
+ "git.meatbag.se/varl/subsyt/internal/nfo"
+)
+
+func findFiles(scanPath string, ext string) ([]string, error) {
+ var result []string
+
+ err := filepath.Walk(scanPath, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if !info.IsDir() && strings.HasSuffix(path, ext) {
+ result = append(result, path)
+ }
+ return nil
+ })
+
+ if err != nil {
+ return nil, fmt.Errorf("error walking directory: %w", err)
+ }
+
+ return result, nil
+}
+
+func Generate(outDir string, title string, dryRun bool) {
+ showDir := filepath.Join(outDir, title)
+ log.Printf("Writing NFO's for %s\n", showDir)
+
+ if dryRun {
+ return
+ }
+
+ infojsons, err := findFiles(showDir, ".info.json")
+ if err != nil {
+ panic(err)
+ }
+
+ show := regexp.MustCompile("s(NA)")
+ season := regexp.MustCompile(`s\d\d\d\d`)
+
+ for index, path := range infojsons {
+ log.Println(index, path)
+ switch {
+ case show.MatchString(path):
+ show := models.LoadShow(path)
+ nfo.WriteShowInfo(show, filepath.Join(showDir, "tvshow.nfo"))
+ showBanner(show, showDir)
+ case season.MatchString(path):
+ ep := models.LoadEpisode(path)
+ nfo.WriteEpisodeNFO(ep, path)
+ default:
+ log.Printf("no match for '%s'\n", path)
+ }
+
+ os.Remove(path)
+ }
+
+ images, err := findFiles(showDir, ".jpg")
+ if err != nil {
+ panic(err)
+ }
+
+ for index, path := range images {
+ log.Println(index, path)
+ switch {
+ case show.MatchString(path):
+ showPoster(path, showDir)
+ case season.MatchString(path):
+ episodeImage(path)
+ default:
+ log.Printf("no match for '%s'\n", path)
+ }
+ }
+
+ del := filepath.Join(showDir, "sNA")
+ log.Printf("removing '%s'\n", del)
+ err = os.RemoveAll(del)
+ if err != nil {
+ log.Println("failed to remove", err)
+ }
+}
+
+func episodeImage(path string) {
+ if strings.Contains(path, "-thumb") {
+ log.Printf("thumbnail detected '%s'\n", path)
+ 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) {
+ poster := filepath.Join(show_dir, "poster.jpg")
+ 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 models.Show, showDir string) {
+ for index, thumb := range show.Thumbnails {
+ log.Println(index, thumb)
+ if thumb.Id == "banner_uncropped" {
+ log.Println("found banner candidate")
+ dl.Fetch(dl.Download{
+ Url: thumb.Url,
+ OutDir: showDir,
+ Name: "banner.jpg",
+ })
+ }
+ }
+}
diff --git a/internal/models/episode.go b/internal/models/episode.go
new file mode 100644
index 0000000..3c16760
--- /dev/null
+++ b/internal/models/episode.go
@@ -0,0 +1,73 @@
+package models
+
+import (
+ "encoding/json"
+ "encoding/xml"
+ "fmt"
+ "os"
+ "time"
+)
+
+// Target Episode XML structure:
+//
+//
+//
+// #{safe(metadata["title"])}
+// #{safe(metadata["uploader"])}
+// #{safe(metadata["id"])}
+// #{safe(metadata["description"])}
+// #{safe(upload_date)}
+// #{safe(season)}
+// #{episode}
+// YouTube
+//
+
+type Episode struct {
+ XMLName xml.Name `xml:"episodedetails"`
+ Title string `json:"title" xml:"title"`
+ ShowTitle string `json:"channel" xml:"showtitle"`
+ Id string `json:"id" xml:"-"`
+ UniqueId UniqueId `json:"-" xml:"uniqueid"`
+ Plot string `json:"description" xml:"plot"`
+ UploadDate string `json:"upload_date" xml:"-"`
+ Aired string `json:"-" xml:"aired"`
+ Season string `json:"-" xml:"season"`
+ Episode string `json:"-" xml:"episode"`
+ Genre string `json:"-" xml:"genre"`
+ InfoPath string `json:"-" xml:"-"`
+}
+
+func LoadEpisode(info_path string) Episode {
+ info, err := os.ReadFile(info_path)
+ if err != nil {
+ panic(err)
+ }
+
+ episode := Episode{}
+
+ err = json.Unmarshal(info, &episode)
+ if err != nil {
+ panic(err)
+ }
+
+ parsed, err := time.Parse("20060102", episode.UploadDate)
+ if err != nil {
+ panic(err)
+ }
+
+ episode.Aired = parsed.String()
+ episode.Season = fmt.Sprintf("s%d", parsed.Year())
+ episode.Episode = fmt.Sprintf("e%02d%02d", parsed.Month(), parsed.Day())
+
+ // these fields cannot be inferred
+ episode.Genre = "YouTube"
+ episode.UniqueId = UniqueId{
+ Default: "true",
+ Type: "youtube",
+ Text: episode.Id,
+ }
+
+ episode.InfoPath = info_path
+
+ return episode
+}
diff --git a/internal/models/show.go b/internal/models/show.go
new file mode 100644
index 0000000..88b9b4e
--- /dev/null
+++ b/internal/models/show.go
@@ -0,0 +1,58 @@
+package models
+
+import (
+ "encoding/json"
+ "encoding/xml"
+ "os"
+)
+
+// Target Show XML structure:
+//
+//
+// #{safe(metadata["title"]}/title>
+// #{safe(metadata["description"]}
+// #{safe(metadata["id"])}
+// YouTube
+//
+
+type Show struct {
+ XMLName xml.Name `xml:"tvshow"`
+ Title string `json:"channel" xml:"title"`
+ Id string `json:"channel_id" xml:"-"`
+ UniqueId UniqueId `json:"-" xml:"uniqueid"`
+ Plot string `json:"description" xml:"plot"`
+ Genre string `json:"-" xml:"genre"`
+ InfoPath string `json:"-" xml:"-"`
+ Thumbnails []Thumbnail `json:"thumbnails" xml:"-"`
+}
+
+type Thumbnail struct {
+ Url string `json:"url" xml:"-"`
+ Id string `json:"id" xml:"-"`
+}
+
+func LoadShow(info_path string) Show {
+ info, err := os.ReadFile(info_path)
+ if err != nil {
+ panic(err)
+ }
+
+ show := Show{}
+
+ err = json.Unmarshal(info, &show)
+ if err != nil {
+ panic(err)
+ }
+
+ // these fields cannot be inferred
+ show.Genre = "YouTube"
+ show.UniqueId = UniqueId{
+ Default: "true",
+ Type: "youtube",
+ Text: show.Id,
+ }
+
+ show.InfoPath = info_path
+
+ return show
+}
diff --git a/internal/models/uniqueid.go b/internal/models/uniqueid.go
new file mode 100644
index 0000000..82f62cd
--- /dev/null
+++ b/internal/models/uniqueid.go
@@ -0,0 +1,7 @@
+package models
+
+type UniqueId struct {
+ Text string `xml:",chardata"`
+ Type string `xml:"type,attr"`
+ Default string `xml:"default,attr"`
+}
diff --git a/internal/nfo/nfo.go b/internal/nfo/nfo.go
new file mode 100644
index 0000000..dbf0c26
--- /dev/null
+++ b/internal/nfo/nfo.go
@@ -0,0 +1,38 @@
+package nfo
+
+import (
+ "encoding/xml"
+ "log"
+ "os"
+ "strings"
+
+ "git.meatbag.se/varl/subsyt/internal/models"
+)
+
+func WriteEpisodeNFO(ep models.Episode, info_path string) {
+ out_path := strings.Replace(info_path, ".info.json", ".nfo", 1)
+
+ log.Printf("writing info from '%s' to '%s'\n", info_path, out_path)
+
+ xmlData, err := xml.MarshalIndent(ep, "", " ")
+ if err != nil {
+ panic(err)
+ }
+
+ complete := xml.Header + string(xmlData)
+ log.Printf("%s", complete)
+ os.WriteFile(out_path, xmlData, 0644)
+}
+
+func WriteShowInfo(show models.Show, out_path string) {
+ log.Printf("writing info from '%s' to '%s'\n", show, out_path)
+
+ xmlData, err := xml.MarshalIndent(show, "", " ")
+ if err != nil {
+ panic(err)
+ }
+
+ complete := xml.Header + string(xmlData)
+ log.Printf("%s", complete)
+ os.WriteFile(out_path, xmlData, 0644)
+}
diff --git a/main.go b/main.go
index 866acd4..18219e7 100644
--- a/main.go
+++ b/main.go
@@ -3,9 +3,11 @@ package main
import (
"log"
"os"
+ "path/filepath"
"git.meatbag.se/varl/subsyt/internal/config"
"git.meatbag.se/varl/subsyt/internal/dl"
+ "git.meatbag.se/varl/subsyt/internal/metadata"
"git.meatbag.se/varl/subsyt/internal/opml"
)
@@ -29,15 +31,16 @@ func main() {
}
for _, outlines := range opml.Body.Outline {
- log.Printf("Archiving videos from OPML: %s", outlines.Title)
+ log.Printf("Archiving videos from OPML: %s\n", outlines.Title)
for _, outline := range outlines.Outlines {
- dl.Get(dl.Download{
+ dl.Youtube(dl.Download{
Url: outline.XmlUrl,
- Name: outline.Title,
- OutDir: cfg.Out_dir,
+ OutDir: filepath.Join(cfg.Out_dir, outline.Title),
DryRun: cfg.Dry_run,
}, provider)
+
+ metadata.Generate(cfg.Out_dir, outline.Title, cfg.Dry_run)
}
}
}