feat: generate metadata for shows and episodes
All checks were successful
build / build (push) Successful in 2m2s

This commit is contained in:
Viktor Varland 2025-04-04 13:24:06 +02:00
parent 7af5900b04
commit dda892750d
Signed by: varl
GPG key ID: 7459F0B410115EE8
9 changed files with 369 additions and 22 deletions

View file

@ -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:
```

View file

@ -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

View file

@ -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")
}
}

View file

@ -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",
})
}
}
}

View file

@ -0,0 +1,73 @@
package models
import (
"encoding/json"
"encoding/xml"
"fmt"
"os"
"time"
)
// Target Episode XML structure:
//
// <?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
// <episodedetails>
// <title>#{safe(metadata["title"])}</title>
// <showtitle>#{safe(metadata["uploader"])}</showtitle>
// <uniqueid type="youtube" default="true">#{safe(metadata["id"])}</uniqueid>
// <plot>#{safe(metadata["description"])}</plot>
// <aired>#{safe(upload_date)}</aired>
// <season>#{safe(season)}</season>
// <episode>#{episode}</episode>
// <genre>YouTube</genre>
// </episodedetails>
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
}

58
internal/models/show.go Normal file
View file

@ -0,0 +1,58 @@
package models
import (
"encoding/json"
"encoding/xml"
"os"
)
// Target Show XML structure:
// <?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
// <tvshow>
// <title>#{safe(metadata["title"]}/title>
// <plot>#{safe(metadata["description"]}</plot>
// <uniqueid type="youtube" default="true">#{safe(metadata["id"])}</uniqueid>
// <genre>YouTube</genre>
// </tvshow>
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
}

View file

@ -0,0 +1,7 @@
package models
type UniqueId struct {
Text string `xml:",chardata"`
Type string `xml:"type,attr"`
Default string `xml:"default,attr"`
}

38
internal/nfo/nfo.go Normal file
View file

@ -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)
}

11
main.go
View file

@ -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)
}
}
}