From 5c3d781822c511d77e7964c3c18c93f0b10dde83 Mon Sep 17 00:00:00 2001 From: Tordarus Date: Fri, 6 Jun 2025 16:39:45 +0200 Subject: [PATCH] initial commit --- access_token.go | 39 +++++++++++++++++++++ anilist.go | 71 +++++++++++++++++++++++++++++++++++++++ anime_episode_filepath.go | 31 +++++++++++++++++ determine_priority.go | 28 +++++++++++++++ envvars.go | 66 ++++++++++++++++++++++++++++++++++++ errors.go | 13 +++++++ esc_seq.go | 14 ++++++++ file_priority.go | 21 ++++++++++++ file_props.go | 58 ++++++++++++++++++++++++++++++++ go.mod | 23 +++++++++++++ go.sum | 24 +++++++++++++ preferred_props.go | 53 +++++++++++++++++++++++++++++ utils.go | 59 ++++++++++++++++++++++++++++++++ 13 files changed, 500 insertions(+) create mode 100644 access_token.go create mode 100644 anilist.go create mode 100644 anime_episode_filepath.go create mode 100644 determine_priority.go create mode 100644 envvars.go create mode 100644 errors.go create mode 100644 esc_seq.go create mode 100644 file_priority.go create mode 100644 file_props.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 preferred_props.go create mode 100644 utils.go diff --git a/access_token.go b/access_token.go new file mode 100644 index 0000000..3b419c7 --- /dev/null +++ b/access_token.go @@ -0,0 +1,39 @@ +package logic + +import ( + "io" + "net/http" + "strings" +) + +func GetAnilistAccessToken() (string, error) { + if strings.HasPrefix(AnilistAccessToken, "ey") { + return AnilistAccessToken, nil + } + + if StoragePath == "" { + return "", ErrAnilistTokenNotObtainable.New() + } else if StorageUser == "" || StoragePass == "" { + return "", ErrInvalidStorageParams.New() + } + + req, err := http.NewRequest("GET", StoragePath, nil) + if err != nil { + return "", ErrStorageRequestFailed.Wrap(err) + } + + req.SetBasicAuth(StorageUser, StoragePass) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", ErrStorageRequestFailed.Wrap(err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", ErrStorageRequestFailed.Wrap(err) + } + + return strings.TrimSpace(string(data)), nil +} diff --git a/anilist.go b/anilist.go new file mode 100644 index 0000000..657a7d3 --- /dev/null +++ b/anilist.go @@ -0,0 +1,71 @@ +package logic + +import ( + "context" + + "git.tordarus.net/tordarus/anilist" + "git.tordarus.net/tordarus/channel" +) + +func GetAnimeListByAnimeID(statuses []anilist.MediaListStatus) (map[anilist.MediaID]*anilist.MediaList, error) { + animeListChannel, err := GetCurrentlyWatchingAnimes(statuses...) + if err != nil { + return nil, err + } + + toMapFunc := func(entry *anilist.MediaList) (anilist.MediaID, *anilist.MediaList) { return entry.MediaID, entry } + return channel.ToMap(animeListChannel, toMapFunc), nil +} + +func GetCurrentlyWatchingAnimes(statuses ...anilist.MediaListStatus) (<-chan *anilist.MediaList, error) { + return GetCurrentlyWatchingAnimesContext(context.Background(), statuses...) +} + +func GetCurrentlyWatchingAnimesContext(ctx context.Context, statuses ...anilist.MediaListStatus) (<-chan *anilist.MediaList, error) { + token, err := GetAnilistAccessToken() + if err != nil { + return nil, ErrAnimeListNotObtainable.Wrap(err, "access token acquisition failed") + } + + media := channel.Map(channel.Of(statuses...), func(status anilist.MediaListStatus) <-chan *anilist.MediaList { + return anilist.NewApi(token).GetMediaList(ctx, anilist.MediaListQuery{ + UserName: AnilistUsername, + Type: anilist.MediaTypeAnime, + Status: status, + }, nil).Chan + }) + + return channel.FlatChan(media), nil +} + +var ( + animeByTitleCache = map[string]*anilist.Media{} +) + +func SearchAnimeByTitle(title string) (anime *anilist.Media, err error) { + // caching + if cacheEntry, ok := animeByTitleCache[title]; ok { + return cacheEntry, nil + } + defer func() { + if err != nil && anime != nil { + animeByTitleCache[title] = anime + } + }() + + token, err := GetAnilistAccessToken() + if err != nil { + return nil, err + } + + anime = anilist.NewApi(token).GetMedia(context.Background(), anilist.MediaQuery{ + Search: title, + Type: anilist.MediaTypeAnime, + }, nil).First() + + if anime == nil { + return nil, ErrAnimeNotFound.New(title) + } + + return anime, nil +} diff --git a/anime_episode_filepath.go b/anime_episode_filepath.go new file mode 100644 index 0000000..5164836 --- /dev/null +++ b/anime_episode_filepath.go @@ -0,0 +1,31 @@ +package logic + +import ( + "path/filepath" + "strings" + + "git.tordarus.net/nyaanime/model" + "git.tordarus.net/tordarus/anilist" +) + +type AnimePathPatternData struct { + Anime *anilist.Media + Episode int + Ext string +} + +func GetAnimeEpFilepath(animeEp model.AnimeEpisode, ext string) string { + ext = strings.TrimPrefix(ext, ".") + + tmplData := AnimePathPatternData{ + Anime: animeEp.Anime, + Episode: animeEp.Episode, + Ext: ext, + } + + b := new(strings.Builder) + if err := AnimeEpFilepathPattern.Execute(b, tmplData); err != nil { + panic(err) + } + return filepath.Join(AnimePath, b.String()) +} diff --git a/determine_priority.go b/determine_priority.go new file mode 100644 index 0000000..550d411 --- /dev/null +++ b/determine_priority.go @@ -0,0 +1,28 @@ +package logic + +import "git.tordarus.net/nyaanime/model" + +func DeterminePriority(props model.PropertyHolder) (priority int, preferredProperties map[string]int) { + preferredProperties = map[string]int{} + + for _, lang := range props.GetLanguages() { + if langPriority, ok := PreferredLanguages[lang]; ok { + priority += langPriority + preferredProperties["lang/"+lang] = langPriority + } + } + + for _, sub := range props.GetSubtitles() { + if subPriority, ok := PreferredSubtitles[sub]; ok { + priority += subPriority + preferredProperties["sub/"+sub] = subPriority + } + } + + if prefRes, ok := PreferredResolutions[props.GetResolution()]; ok { + priority += prefRes + preferredProperties["res/"+props.GetResolution().String()] = prefRes + } + + return +} diff --git a/envvars.go b/envvars.go new file mode 100644 index 0000000..b2f149d --- /dev/null +++ b/envvars.go @@ -0,0 +1,66 @@ +package logic + +import ( + "html/template" + "math" + + "git.tordarus.net/nyaanime/model" + "git.tordarus.net/tordarus/anilist" + "git.tordarus.net/tordarus/envvars" +) + +var ( + AnilistUsername = envvars.String("ANILIST_USERNAME", "username") + AnilistAccessToken = envvars.String("ANILIST_TOKEN", "") + + StoragePath = envvars.String("STORAGE_PATH", "") + StorageUser = envvars.String("STORAGE_USERNAME", "") + StoragePass = envvars.String("STORAGE_PASSWORD", "") + + AnimePath = envvars.String("ANIME_PATH", "") + + AnimeEpFilepathPatternStr = envvars.String("EPISODE_FILEPATH_PATTERN", `{{.Anime.Title.UserPreferred}}/{{.Anime.Title.UserPreferred}} Episode {{.Episode}}.{{.Ext}}`) + AnimeEpFilepathPattern = template.Must(template.New("EPISODE_FILEPATH_PATTERN").Parse(AnimeEpFilepathPatternStr)) + + // essential torrent properties + + MaxResolution = envvars.Object("MAX_RESOLUTION", model.Resolution4K, model.ParseResolution) + MinResolution = envvars.Object("MIN_RESOLUTION", model.ResolutionHD, model.ParseResolution) + + EssentialLanguages = envvars.StringSlice("ESSENTIAL_LANGUAGES", "|", []string{}) + EssentialSubtitles = envvars.StringSlice("ESSENTIAL_SUBTITLES", "|", []string{}) + + MaxSeeders = envvars.Int("MAX_SEEDERS", math.MaxInt) + MinSeeders = envvars.Int("MIN_SEEDERS", 0) + + MaxLeechers = envvars.Int("MAX_LEECHERS", math.MaxInt) + MinLeechers = envvars.Int("MIN_LEECHERS", 0) + + MaxDownloads = envvars.Int("MAX_DOWNLOADS", math.MaxInt) + MinDownloads = envvars.Int("MIN_DOWNLOADS", 0) + + TrustedOnly = envvars.Bool("TRUSTED_ONLY", false) + + // preferred torrent properties + PreferredLanguages = ParsePreferredStringProps(envvars.StringSlice("PREFERRED_LANGUAGES", "|", []string{})) + PreferredSubtitles = ParsePreferredStringProps(envvars.StringSlice("PREFERRED_SUBTITLES", "|", []string{})) + PreferredResolutions = ParsePreferredProps(envvars.StringSlice("PREFERRED_RESOLUTIONS", "|", []string{}), model.ParseResolution) + + AnimeStatuses = envvars.ObjectSlice("ANIME_STATUSES", ",", []anilist.MediaListStatus{ + anilist.MediaListStatusCurrent, + anilist.MediaListStatusPlanning, + }, ParseMediaListStatus) + + /* + TODO + + better idea? implementation in torrent_sort.go (sort.Slice) + PreferredTorrents = envvars.StringSlice("PREFERRED_TORRENTS", []string{"seeders", "subtitles", "languages", "downloads"}) + + old idea? + PreferMoreLanguages = envvars.Bool("PREFERER_MORE_LANGUAGES", false) + PreferMoreSubtitles = envvars.Bool("PREFERER_MORE_SUBTITLES", false) + PreferMoreSeeders = envvars.Bool("PREFERER_MORE_SEEDERS", false) + PreferMoreDownloads = envvars.Bool("PREFERER_MORE_DOWNLOADS", false) + */ +) diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..12095bb --- /dev/null +++ b/errors.go @@ -0,0 +1,13 @@ +package logic + +import "git.tordarus.net/tordarus/adverr/v2" + +var ( + ErrInvalidGlobSyntax = adverr.NewErrTmpl("ErrInvalidGlobSyntax", "invalid filepath.Glob syntax: '%s'") + ErrAnilistTokenNotObtainable = adverr.NewErrTmpl("ErrAnilistTokenNotObtainable", "neither ANILIST_TOKEN nor STORAGE_PATH provided") + ErrAnimeListNotObtainable = adverr.NewErrTmpl("ErrAnimeListNotObtainable", "anime list from anilist.co not obtainable (reason: %s)") + ErrAnimeNotFound = adverr.NewErrTmpl("ErrAnimeNotFound", "could not find anime with name '%s'") + ErrInvalidStorageParams = adverr.NewErrTmpl("ErrInvalidStorageParams", "STORAGE_USER or STORAGE_PASS not provided") + ErrStorageRequestFailed = adverr.NewErrTmpl("ErrStorageRequestFailed", "request to file storage could not be made") + ErrInvalidAnimeStatus = adverr.NewErrTmpl("ErrInvalidAnimeStatus", "invalid status '%s' in ANIME_STATUS (allowed: %s)") +) diff --git a/esc_seq.go b/esc_seq.go new file mode 100644 index 0000000..0217d2e --- /dev/null +++ b/esc_seq.go @@ -0,0 +1,14 @@ +package logic + +import "strings" + +var EscSeqReplacer = strings.NewReplacer( + `\\`, `\`, + `\n`, "\n", + `\t`, "\t", + `\f`, "\f", + `\r`, "\r", + `\v`, "\v", + `\b`, "\b", + `\a`, "\a", +) diff --git a/file_priority.go b/file_priority.go new file mode 100644 index 0000000..45c0afa --- /dev/null +++ b/file_priority.go @@ -0,0 +1,21 @@ +package logic + +import ( + "git.tordarus.net/nyaanime/model" +) + +type FilePriority struct { + Properties model.PropertyHolder + Priority int + PreferredProperties map[string]int +} + +func NewFilePriority(props model.PropertyHolder) *FilePriority { + priority, preferredProperties := DeterminePriority(props) + + return &FilePriority{ + Properties: props, + Priority: priority, + PreferredProperties: preferredProperties, + } +} diff --git a/file_props.go b/file_props.go new file mode 100644 index 0000000..4c17c35 --- /dev/null +++ b/file_props.go @@ -0,0 +1,58 @@ +package logic + +import ( + "path/filepath" + + "git.tordarus.net/nyaanime/model" + "git.tordarus.net/nyaanime/parsers" + "git.tordarus.net/tordarus/slices" +) + +func GetAnimeEpProps(animeEp model.AnimeEpisode) (*FilePriority, bool) { + animeEpPath := GetAnimeEpFilepath(animeEp, "*") + files, err := filepath.Glob(animeEpPath) + if err != nil { + panic(ErrInvalidGlobSyntax.Wrap(err, animeEpPath)) + } + + var mostPrio *FilePriority + + for _, file := range files { + props, err := parsers.AnalyzeFile(file) + if err != nil { + continue + } + + // if !HasFileEssentialProperties(props) { + // continue + // } + + fp := NewFilePriority(props) + + if mostPrio == nil || fp.Priority > mostPrio.Priority { + mostPrio = fp + } + } + + return mostPrio, mostPrio != nil +} + +func HasFileEssentialProperties(props model.PropertyHolder) bool { + if props.GetResolution() < MinResolution || props.GetResolution() > MaxResolution { + return false + } + + for _, essentialLanguage := range EssentialLanguages { + if !slices.Contains(props.GetLanguages(), essentialLanguage) { + return false + } + } + + for _, essentialSubtitle := range EssentialSubtitles { + if !slices.Contains(props.GetSubtitles(), essentialSubtitle) { + return false + } + } + + return true +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1e245e9 --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module git.tordarus.net/nyaanime/logic + +go 1.23 + +toolchain go1.24.3 + +require ( + git.tordarus.net/nyaanime/model v0.0.1 + git.tordarus.net/nyaanime/parsers v0.0.2 + git.tordarus.net/tordarus/adverr/v2 v2.0.2 + git.tordarus.net/tordarus/anilist v1.5.2 + git.tordarus.net/tordarus/channel v0.1.19 + git.tordarus.net/tordarus/envvars v0.0.0-20250114175450-d73e12b838a5 + git.tordarus.net/tordarus/gmath v0.0.7 + git.tordarus.net/tordarus/slices v0.0.14 + git.tordarus.net/tordarus/tprint v0.0.1 +) + +require ( + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + gopkg.in/vansante/go-ffprobe.v2 v2.2.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5e1f252 --- /dev/null +++ b/go.sum @@ -0,0 +1,24 @@ +git.tordarus.net/nyaanime/model v0.0.1 h1:/I+87Z6eEw/o2adltKnCk4FZai2mPekjYlzEjY1ppyQ= +git.tordarus.net/nyaanime/model v0.0.1/go.mod h1:oHV82UMNy4XgPHkI6tZiwabdi6myqHXgjMi9sNZ+rG4= +git.tordarus.net/nyaanime/parsers v0.0.2 h1:UxfDGxgS2guldLhRtlLNjst/UyeA8OC44oj7nUeRUB8= +git.tordarus.net/nyaanime/parsers v0.0.2/go.mod h1:sx8HyJCpG7zzwRAEE1ZlmVPirPc3fdArlCM5L1sxEaQ= +git.tordarus.net/tordarus/adverr/v2 v2.0.2 h1:7nvNjMMjtGPq0EY6duMiv+seJ7MacNvKSBmckHl6Erg= +git.tordarus.net/tordarus/adverr/v2 v2.0.2/go.mod h1:gCC46KsWosZJh7MVNDEU99hKQoxEWZgHITDHtmFwwiQ= +git.tordarus.net/tordarus/anilist v1.5.2 h1:SxlovS+e3lgL2SowQQwj8dQrIZzRFPomcGCw3V+My0Q= +git.tordarus.net/tordarus/anilist v1.5.2/go.mod h1:Mrhx/9+8HJVj5ebQ5fJuXqL220tEJhgQIqFK2WKPXgA= +git.tordarus.net/tordarus/channel v0.1.19 h1:d9xnSwFyvBh4B1/82mt0A7Gpm2nIZJTc+9ceJMIOu5Q= +git.tordarus.net/tordarus/channel v0.1.19/go.mod h1:8/dWFTdGO7g4AeSZ7cF6GerkGbe9c4dBVMVDBxOd9m4= +git.tordarus.net/tordarus/envvars v0.0.0-20250114175450-d73e12b838a5 h1:rKNDX/YGunqg8TEU6q1rgS2BcDKVmUW2cg61JOE/wws= +git.tordarus.net/tordarus/envvars v0.0.0-20250114175450-d73e12b838a5/go.mod h1:/qVGwrEmqtIrZyuuoIQl4vquSkPWUNJmlGNedDrdYfg= +git.tordarus.net/tordarus/gmath v0.0.7 h1:tR48idt9AUL0r556ww3ZxByTKJEr6NWCTlhl2ihzYxQ= +git.tordarus.net/tordarus/gmath v0.0.7/go.mod h1:mO7aPlvNrGVE9UFXEuuACjZgMDsM63l3OcQy6xSQnoE= +git.tordarus.net/tordarus/slices v0.0.14 h1:Jy1VRMs777WewJ7mxTgjyQIMm/Zr+co18/XoQ01YZ3A= +git.tordarus.net/tordarus/slices v0.0.14/go.mod h1:RgE7A1aSAezIvPUgcbUuMHu0q4xGKoRevT+DC0eJmwI= +git.tordarus.net/tordarus/tprint v0.0.1 h1:aM5c0nLwicUIoic/xguwE5fQdQ2bB3z0+FQEN/Yt0H4= +git.tordarus.net/tordarus/tprint v0.0.1/go.mod h1:2UdHVY/ue8vXeJU/IJY1xBikDaH35kaMzxjk9ryKB8Q= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +gopkg.in/vansante/go-ffprobe.v2 v2.2.1 h1:sFV08OT1eZ1yroLCZVClIVd9YySgCh9eGjBWO0oRayI= +gopkg.in/vansante/go-ffprobe.v2 v2.2.1/go.mod h1:qF0AlAjk7Nqzqf3y333Ly+KxN3cKF2JqA3JT5ZheUGE= diff --git a/preferred_props.go b/preferred_props.go new file mode 100644 index 0000000..d72f9ef --- /dev/null +++ b/preferred_props.go @@ -0,0 +1,53 @@ +package logic + +import ( + "strings" + + "git.tordarus.net/tordarus/gmath" + "git.tordarus.net/tordarus/slices" +) + +// ParsePreferredProps parses properties and its corresponding priority. +// priorities are distributed exponentially in reverse order. +// +// That means the last entry will have priority 1, the second last 2, then 4, 8 and so on. +// +// Properties with name "_" will be ignored and function as a placeholder to increase the priority +// of the properties which comes before them. +// +// Properties separated by comma will have the same priorities. +// +// str usually is the return value of a call to strings.Split(str, "|") +// +// Examples: +// str = "a|b|c" -> c:1 b:2 a:4 +// str = "a|b|_|c" -> c:1 b:4 a:8 +// str = "a,b|c" -> c:1 b:4 a:4 +// str = "d|_|a,b|c" -> c:1 b:4 a:4 d:16 +// +// Additionally, properties can be converted to a generic type with the converter function +func ParsePreferredProps[T comparable](str []string, converter func(string) (T, error)) map[T]int { + props := map[T]int{} + + for i, subProps := range slices.Reverse(str) { + if subProps == "_" { + continue + } + + propPriority := gmath.Pow(2, i) + for _, subProp := range strings.Split(subProps, ",") { + subPropT, err := converter(subProp) + if err != nil { + continue + } + + props[subPropT] = propPriority + } + } + + return props +} + +func ParsePreferredStringProps(str []string) map[string]int { + return ParsePreferredProps(str, func(s string) (string, error) { return s, nil }) +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..2cca7e0 --- /dev/null +++ b/utils.go @@ -0,0 +1,59 @@ +package logic + +import ( + "fmt" + "sort" + "strings" + + "git.tordarus.net/nyaanime/model" + "git.tordarus.net/tordarus/anilist" + "git.tordarus.net/tordarus/slices" + "git.tordarus.net/tordarus/tprint" +) + +var AllMediaListStatuses = []anilist.MediaListStatus{ + anilist.MediaListStatusCurrent, + anilist.MediaListStatusPlanning, + anilist.MediaListStatusCompleted, + anilist.MediaListStatusDropped, + anilist.MediaListStatusPaused, + anilist.MediaListStatusRepeating, +} + +func ParseMediaListStatus(str string) (anilist.MediaListStatus, error) { + s := anilist.MediaListStatus(strings.ToUpper(str)) + + allStatusesStr := slices.Map(AllMediaListStatuses, func(status anilist.MediaListStatus) string { + return string(status) + }) + + if !slices.Contains(AllMediaListStatuses, s) { + return s, ErrInvalidAnimeStatus.New(s, strings.Join(allStatusesStr, ",")) + } + + return s, nil +} + +func Map2Table[K comparable](title string, m map[K]int) string { + table := tprint.NewTable(title, "priority") + + entries := make([]model.Pair[K, int], 0, len(m)) + for name, priority := range m { + entries = append(entries, model.Pair[K, int]{First: name, Second: priority}) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Second > entries[j].Second }) + + for _, entry := range entries { + table.AddRow(entry.First, entry.Second) + } + + return table.String() +} + +func PrintPriorityTables() { + fmt.Println("generated priority values:") + fmt.Print(Map2Table("language", PreferredLanguages)) + fmt.Print(Map2Table("subtitle", PreferredSubtitles)) + fmt.Print(Map2Table("resolution", PreferredResolutions)) + fmt.Println() +}