commit 8dccbf25b877172cb99aeab01bb468af4204f4a8 Author: Tordarus Date: Fri Jun 6 16:45:59 2025 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/anime_episode_cache.go b/anime_episode_cache.go new file mode 100644 index 0000000..d215951 --- /dev/null +++ b/anime_episode_cache.go @@ -0,0 +1,23 @@ +package main + +import ( + "git.tordarus.net/nyaanime/model" + "git.tordarus.net/tordarus/anilist" +) + +var AnimeEpisodeCache = map[anilist.MediaID]map[int]model.AnimeEpisode{} + +func GetAnimeEpisode(anime *anilist.Media, episode int) model.AnimeEpisode { + if _, ok := AnimeEpisodeCache[anime.ID]; !ok { + AnimeEpisodeCache[anime.ID] = map[int]model.AnimeEpisode{} + } + + if _, ok := AnimeEpisodeCache[anime.ID][episode]; !ok { + AnimeEpisodeCache[anime.ID][episode] = model.AnimeEpisode{ + Anime: anime, + Episode: episode, + } + } + + return AnimeEpisodeCache[anime.ID][episode] +} diff --git a/check_torrents.go b/check_torrents.go new file mode 100644 index 0000000..14c8fa8 --- /dev/null +++ b/check_torrents.go @@ -0,0 +1,93 @@ +package main + +import ( + "fmt" + "log" + "sort" + "time" + + "git.tordarus.net/nyaanime/logic" + "git.tordarus.net/nyaanime/model" + "git.tordarus.net/tordarus/adverr/v2" + "git.tordarus.net/tordarus/slices" +) + +func CheckTorrents() { + log.Println("checking torrents") + start := time.Now() + + torrents, err := GetTorrents() + if err != nil { + fmt.Println(adverr.Wrap("retrieving torrents failed", err)) + return + } + + animeList, err := logic.GetAnimeListByAnimeID(logic.AnimeStatuses) + if err != nil { + fmt.Println(adverr.Wrap("retrieving anime list failed", err)) + return + } + + // parse torrents + parsedTorrentsByAnimeEp := ParseTorrentsByAnimeEp(torrents) + + // filter not on anime list + animeListTorrents := FilterTorrentsByAnimeList(parsedTorrentsByAnimeEp, animeList) + + // filter essential properties + essentialTorrents := FilterEssentialTorrents(animeListTorrents) + + // filter preferred properties + preferredTorrents := GetTorrentsWithMaxPrioByAnimeEp(essentialTorrents) + + // information gathered for logging purposes + downloadingEpisodes := map[model.AnimeEpisode]struct{}{} + inCollectionEpisodes := map[model.AnimeEpisode]bool{} + collectionPreferredTorrents := map[*model.ParsedTorrent]bool{} + downloadedTorrents := map[model.AnimeEpisode]*TorrentPriority{} + + for animeEp, torrentPrio := range preferredTorrents { + if IsCurrentlyDownloading(animeEp) { + downloadingEpisodes[animeEp] = struct{}{} // debug output + continue + } + + props, inCollection := logic.GetAnimeEpProps(animeEp) + + inCollectionEpisodes[animeEp] = inCollection // debug output + if inCollection { + collectionPreferred := props.Priority >= torrentPrio.Priority + collectionPreferredTorrents[torrentPrio.ParsedTorrent] = collectionPreferred // debug output + if collectionPreferred { + continue + } + } + + if err := DownloadTorrent(animeEp, torrentPrio.ParsedTorrent); err != nil { + fmt.Println(fmt.Sprintf("could not download torrent %s", torrentPrio.ParsedTorrent.Torrent.ID), err) + } + + downloadedTorrents[animeEp] = torrentPrio // debug output + } + + duration := time.Since(start) + + ShowDebugInfo( + parsedTorrentsByAnimeEp, + animeListTorrents, + essentialTorrents, + preferredTorrents, + downloadingEpisodes, + inCollectionEpisodes, + collectionPreferredTorrents, + downloadedTorrents, + ) + + downloadedAnimeEpisodes := slices.OfMap(downloadedTorrents, func(animeEp model.AnimeEpisode, torrentPrio *TorrentPriority) model.AnimeEpisode { + return animeEp + }) + sort.Slice(downloadedAnimeEpisodes, GetAnimeEpisodesSortFunc(downloadedAnimeEpisodes)) + SendTelegramAnimeEpMessage(TelegramDownloadMessagePattern, downloadedAnimeEpisodes) + + log.Printf("check took %s. sleeping for %s\n", duration.Truncate(time.Millisecond), (PollRate - duration).Truncate(time.Millisecond)) +} diff --git a/download_torrent_file.go b/download_torrent_file.go new file mode 100644 index 0000000..31d75a8 --- /dev/null +++ b/download_torrent_file.go @@ -0,0 +1,40 @@ +package main + +import ( + "io" + "net/http" + "os" + "path" + "path/filepath" + + "git.tordarus.net/nyaanime/model" +) + +func DownloadTorrent(animeEp model.AnimeEpisode, torrent *model.ParsedTorrent) error { + if err := SetCurrentlyDownloading(animeEp); err != nil { + return ErrLockFileCreationFailed.Wrap(err, animeEp.Anime.Title.Romaji, animeEp.Episode) + } + + torrentFilePath := filepath.Join(TorrentPath, path.Base(torrent.Torrent.Link)) + + //fmt.Printf("download: %s -> %s\n", torrent.Torrent.Link, torrentFilePath) + + resp, err := http.Get(torrent.Torrent.Link) + if err != nil { + return ErrDownloadTorrentFileFailed.Wrap(err, torrent.Torrent.Link) + } + defer resp.Body.Close() + + file, err := os.Create(torrentFilePath) + if err != nil { + return ErrSaveTorrentFileFailed.Wrap(err, torrent.Torrent.Link) + } + defer file.Close() + + _, err = io.Copy(file, resp.Body) + if err != nil { + return ErrSaveTorrentFileFailed.Wrap(err, torrent.Torrent.Link) + } + + return nil +} diff --git a/envvars.go b/envvars.go new file mode 100644 index 0000000..d309f7d --- /dev/null +++ b/envvars.go @@ -0,0 +1,25 @@ +package main + +import ( + "text/template" + "time" + + "git.tordarus.net/nyaanime/logic" + "git.tordarus.net/tordarus/envvars" +) + +var ( + PollRate = envvars.Object("POLL_RATE", 30*time.Minute, time.ParseDuration) + + TorrentPath = envvars.String("TORRENT_PATH", "") + + DebugAnimeEpisodePatternStr = envvars.String("DEBUG_ANIME_EPISODE_PATTERN", `{{.Anime.Title.UserPreferred}} episode {{.Episode}}`) + DebugAnimeEpisodePattern = template.Must(template.New("DEBUG_ANIME_EPISODE_PATTERN").Parse(DebugAnimeEpisodePatternStr)) + + TelegramBotToken = envvars.String("TELEGRAM_API_TOKEN", "") + TelegramChatID = envvars.Int64("TELEGRAM_CHAT_ID", 0) + TelegramDownloadMessagePatternStr = logic.EscSeqReplacer.Replace(envvars.String("TELEGRAM_DOWNLOAD_MESSAGE_PATTERN", `Download started{{range .}}\n{{.Anime.Title.UserPreferred}} episode {{.Episode}}{{end}}`)) + TelegramDownloadMessagePattern = template.Must(template.New("TELEGRAM_DOWNLOAD_MESSAGE_PATTERN").Parse(TelegramDownloadMessagePatternStr)) + + DownloadAll = envvars.Bool("DOWNLOAD_ALL_ANIMES", false) +) diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..df2ab6e --- /dev/null +++ b/errors.go @@ -0,0 +1,12 @@ +package main + +import "git.tordarus.net/tordarus/adverr/v2" + +var ( + ErrNoSuitableParser = adverr.NewErrTmpl("ErrNoSuitableParser", "could not parse torrent with ID %s because no suitable parser found") + ErrTorrentParseFailed = adverr.NewErrTmpl("ErrTorrentParseFailed", "could not parse torrent with ID %s (parsed with '%s')") + ErrTorrentNotObtainable = adverr.NewErrTmpl("ErrTorrentNotObtainable", "torrents from nyaa.si not obtainable (reason: %s)") + ErrDownloadTorrentFileFailed = adverr.NewErrTmpl("ErrDownloadTorrentFileFailed", "torrent file download failed: %s") + ErrLockFileCreationFailed = adverr.NewErrTmpl("ErrLockFileCreationFailed", "creation of lock file for anime '%s' and episode %d failed") + ErrSaveTorrentFileFailed = adverr.NewErrTmpl("ErrSaveTorrentFileFailed", "torrent file could not be saved: %s") +) diff --git a/get_torrents.go b/get_torrents.go new file mode 100644 index 0000000..7cab2a1 --- /dev/null +++ b/get_torrents.go @@ -0,0 +1,78 @@ +package main + +import ( + "fmt" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "git.tordarus.net/nyaanime/model" + "github.com/PuerkitoBio/goquery" +) + +var torrentLinkRegex = regexp.MustCompile(`https:\/\/nyaa\.si\/download\/(\d+?)\.torrent`) + +func GetTorrents() ([]model.Torrent, error) { + resp, err := http.Get("https://nyaa.si/?page=rss&f=0&c=1_0") + if err != nil { + return nil, ErrTorrentNotObtainable.Wrap(err, "torrent data acqusition failed") + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil, ErrTorrentNotObtainable.New("invalid status code from nyaa.si: " + strconv.Itoa(resp.StatusCode)) + } + + nyaa, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + return nil, ErrTorrentNotObtainable.Wrap(err, "nyaa.si response parsing failed") + } + + torrents := make([]model.Torrent, 0, 75) + + nyaa.Find("item").Each(func(i int, s *goquery.Selection) { + time, err := time.Parse(time.RFC1123Z, s.Find("pubDate").Text()) + if err != nil { + fmt.Println("could not parse time:", s.Find("pubDate")) + return + } + + seeders, err := strconv.Atoi(s.Find("nyaa\\:seeders").Text()) + if err != nil { + fmt.Println("could not parse seeders:", s.Find("nyaa\\:seeders").Text()) + return + } + + leechers, err := strconv.Atoi(s.Find("nyaa\\:leechers").Text()) + if err != nil { + fmt.Println("could not parse leechers:", s.Find("nyaa\\:leechers").Text()) + return + } + + downloads, err := strconv.Atoi(s.Find("nyaa\\:downloads").Text()) + if err != nil { + fmt.Println("could not parse downloads:", s.Find("nyaa\\:downloads").Text()) + return + } + + // goquery can't parse the link tag for some reason + // therefore I have to get around this bug by exploiting regex + matches := torrentLinkRegex.FindStringSubmatch(s.Text()) + link := matches[0] + id := matches[1] + + torrents = append(torrents, model.Torrent{ + ID: model.TorrentID(id), + Title: s.Find("title").Text(), + Link: link, + Time: time, + Seeders: seeders, + Leechers: leechers, + Downloads: downloads, + Trusted: strings.Contains(strings.ToLower(s.Find("nyaa\\:trusted").Text()), "yes"), + }) + }) + + return torrents, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ad89df1 --- /dev/null +++ b/go.mod @@ -0,0 +1,33 @@ +module git.tordarus.net/nyaanime/downloader + +go 1.23.0 + +toolchain go1.24.3 + +require ( + git.tordarus.net/nyaanime/logic v0.0.1 + 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/envvars v0.0.0-20250114175450-d73e12b838a5 + git.tordarus.net/tordarus/slices v0.0.14 + git.tordarus.net/tordarus/tprint v0.0.1 + github.com/PuerkitoBio/goquery v1.10.3 + github.com/fatih/color v1.18.0 + github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible +) + +require ( + git.tordarus.net/tordarus/channel v0.1.19 // indirect + git.tordarus.net/tordarus/gmath v0.0.7 // indirect + github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/technoweenie/multipartstreamer v1.0.1 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sys v0.32.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..10dec3c --- /dev/null +++ b/go.sum @@ -0,0 +1,112 @@ +git.tordarus.net/nyaanime/logic v0.0.1 h1:fUa/O9/WgzJhmGFGSuy6gx+Rr2Y+/RLee3qaNO0l1lI= +git.tordarus.net/nyaanime/logic v0.0.1/go.mod h1:+4sKxSzwgCoeZv3lyWB3yFa5CVyi/frmwsTqDf8C3mE= +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/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= +github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaELYAEARXRkq2LrJ0yDDv1rW7+wrTEdVL3uaU= +github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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= +github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM= +github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +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/local_file_check.go b/local_file_check.go new file mode 100644 index 0000000..90d7fa1 --- /dev/null +++ b/local_file_check.go @@ -0,0 +1,31 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + + "git.tordarus.net/nyaanime/logic" + "git.tordarus.net/nyaanime/model" +) + +func IsCurrentlyDownloading(animeEp model.AnimeEpisode) bool { + animeEpPath := logic.GetAnimeEpFilepath(animeEp, "lock") + _, err := os.Stat(animeEpPath) + return !errors.Is(err, os.ErrNotExist) +} + +func SetCurrentlyDownloading(animeEp model.AnimeEpisode) error { + animeEpPath := logic.GetAnimeEpFilepath(animeEp, "lock") + + dir := filepath.Dir(animeEpPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + file, err := os.Create(animeEpPath) + if err != nil { + defer file.Close() + } + return err +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..3ffee08 --- /dev/null +++ b/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "os/exec" + "time" + + "git.tordarus.net/nyaanime/logic" +) + +func main() { + // check for ffmpeg in PATH + if _, err := exec.LookPath("ffmpeg"); err != nil { + panic(err) // TODO error handling + } + + // check for ffprobe in PATH + if _, err := exec.LookPath("ffprobe"); err != nil { + panic(err) // TODO error handling + } + + // get access token once at startup to be sure that an access token is obtainable at all + if _, err := logic.GetAnilistAccessToken(); err != nil { + panic(err) // TODO error handling + } + + if err := InitTelegramBot(); err != nil { + panic(err) // TODO error handling + } + + logic.PrintPriorityTables() + + ticker := time.NewTicker(PollRate) + defer ticker.Stop() + + CheckTorrents() + for range ticker.C { + CheckTorrents() + } +} diff --git a/show_debug_info.go b/show_debug_info.go new file mode 100644 index 0000000..7d20b3e --- /dev/null +++ b/show_debug_info.go @@ -0,0 +1,86 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/fatih/color" + + "git.tordarus.net/nyaanime/model" + "git.tordarus.net/tordarus/slices" + "git.tordarus.net/tordarus/tprint" +) + +var ( + BoldText = color.New(color.Bold) +) + +func ShowDebugInfo( + parsedTorrentsByAnimeEp, animeListTorrents, essentialTorrents map[model.AnimeEpisode][]*model.ParsedTorrent, + preferredTorrents map[model.AnimeEpisode]*TorrentPriority, + downloadingEpisodes map[model.AnimeEpisode]struct{}, + inCollectionEpisodes map[model.AnimeEpisode]bool, + collectionPreferredTorrents map[*model.ParsedTorrent]bool, + downloadedTorrents map[model.AnimeEpisode]*TorrentPriority) { + + for animeEp, parsedTorrents := range parsedTorrentsByAnimeEp { + table := tprint.NewTable("id", "resolution", "languages", "subtitles", "seeders", "leechers", "downloads", "trusted", "group", "evaluation") + + for _, torrent := range parsedTorrents { + eval := getEvaluation( + slices.Contains(essentialTorrents[animeEp], torrent), + preferredTorrents[animeEp] != nil && preferredTorrents[animeEp].ParsedTorrent == torrent, + collectionPreferredTorrents[torrent], + downloadedTorrents[animeEp] != nil && downloadedTorrents[animeEp].ParsedTorrent == torrent, + ) + + table.AddRow( + torrent.Torrent.ID, + torrent.Resolution, + strings.Join(torrent.Languages, ", "), + strings.Join(torrent.Subtitles, ", "), + torrent.Torrent.Seeders, + torrent.Torrent.Leechers, + torrent.Torrent.Downloads, + torrent.Torrent.Trusted, + torrent.Parser.Identity, + eval, + ) + } + + var epState string + + if _, onList := animeListTorrents[animeEp]; onList { + if _, downloading := downloadingEpisodes[animeEp]; downloading { + epState = color.BlueString("CURRENTLY DOWNLOADING") + } else if inCollectionEpisodes[animeEp] { + epState = color.GreenString("IN COLLECTION") + } else { + epState = color.YellowString("ON LIST") + } + } else { + epState = color.RedString("NOT ON LIST") + } + + b := new(strings.Builder) + if err := DebugAnimeEpisodePattern.Execute(b, animeEp); err != nil { + panic(err) + } + header := BoldText.Sprintf("%s (%s)", color.MagentaString(b.String()), epState) + fmt.Println(tprint.FormatHeaderTable(header, table)) + } +} + +func getEvaluation(essential, preferred, collectionPreferred, downloaded bool) string { + if downloaded { + return color.GreenString("DOWNLOAD STARTED") + } else if collectionPreferred { + return color.GreenString("collection preferred") + } else if preferred { + return color.BlueString("torrent preferred") + } else if essential { + return color.YellowString("torrent considered") + } else { + return color.RedString("torrent ignored") + } +} diff --git a/telegram.go b/telegram.go new file mode 100644 index 0000000..012f2e0 --- /dev/null +++ b/telegram.go @@ -0,0 +1,48 @@ +package main + +import ( + "strings" + "text/template" + + "git.tordarus.net/nyaanime/model" + "git.tordarus.net/tordarus/adverr/v2" + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api" +) + +var TelegramBot *tgbotapi.BotAPI + +func InitTelegramBot() error { + if TelegramBotToken != "" && TelegramChatID != 0 { + bot, err := tgbotapi.NewBotAPI(TelegramBotToken) + if err != nil { + return err + } + TelegramBot = bot + } + return nil +} + +func SendTelegramMessage(text string) { + if TelegramBot == nil || strings.TrimSpace(text) == "" { + return + } + + msg := tgbotapi.NewMessage(TelegramChatID, text) + _, err := TelegramBot.Send(msg) + if err != nil { + adverr.Println(adverr.Wrap("could not send telegram message", err)) + } +} + +func SendTelegramAnimeEpMessage(messagePattern *template.Template, animeEps []model.AnimeEpisode) { + if len(animeEps) == 0 { + return + } + + b := new(strings.Builder) + if err := messagePattern.Execute(b, animeEps); err != nil { + adverr.Println(adverr.Wrap("could not send telegram message", err)) + } + + SendTelegramMessage(b.String()) +} diff --git a/torrent_filter.go b/torrent_filter.go new file mode 100644 index 0000000..3c4627a --- /dev/null +++ b/torrent_filter.go @@ -0,0 +1,70 @@ +package main + +import ( + "git.tordarus.net/nyaanime/logic" + "git.tordarus.net/nyaanime/model" + "git.tordarus.net/tordarus/anilist" + "git.tordarus.net/tordarus/slices" +) + +func FilterTorrentsByAnimeList(allTorrents map[model.AnimeEpisode][]*model.ParsedTorrent, animeList map[anilist.MediaID]*anilist.MediaList) map[model.AnimeEpisode][]*model.ParsedTorrent { + if DownloadAll { + return allTorrents + } + + filtered := map[model.AnimeEpisode][]*model.ParsedTorrent{} + for animeEp, torrents := range allTorrents { + if _, ok := animeList[animeEp.Anime.ID]; ok { + filtered[animeEp] = torrents + } + } + return filtered +} + +func FilterEssentialTorrents(allTorrents map[model.AnimeEpisode][]*model.ParsedTorrent) map[model.AnimeEpisode][]*model.ParsedTorrent { + filtered := map[model.AnimeEpisode][]*model.ParsedTorrent{} + for animeEpisode, parsedTorrents := range allTorrents { + for _, parsedTorrent := range parsedTorrents { + if HasEssentialProperties(parsedTorrent) { + filtered[animeEpisode] = append(filtered[animeEpisode], parsedTorrent) + } + } + } + return filtered +} + +func HasEssentialProperties(torrent *model.ParsedTorrent) bool { + if torrent.Resolution < logic.MinResolution || torrent.Resolution > logic.MaxResolution { + return false + } + + if torrent.Torrent.Seeders < logic.MinSeeders || torrent.Torrent.Seeders > logic.MaxSeeders { + return false + } + + if torrent.Torrent.Leechers < logic.MinLeechers || torrent.Torrent.Leechers > logic.MaxLeechers { + return false + } + + if torrent.Torrent.Downloads < logic.MinDownloads || torrent.Torrent.Downloads > logic.MaxDownloads { + return false + } + + if logic.TrustedOnly && !torrent.Torrent.Trusted { + return false + } + + for _, essentialLanguage := range logic.EssentialLanguages { + if !slices.Contains(torrent.Languages, essentialLanguage) { + return false + } + } + + for _, essentialSubtitle := range logic.EssentialSubtitles { + if !slices.Contains(torrent.Subtitles, essentialSubtitle) { + return false + } + } + + return true +} diff --git a/torrent_parse.go b/torrent_parse.go new file mode 100644 index 0000000..ca4848c --- /dev/null +++ b/torrent_parse.go @@ -0,0 +1,52 @@ +package main + +import ( + "errors" + "fmt" + "os" + + "git.tordarus.net/nyaanime/logic" + "git.tordarus.net/nyaanime/model" + "git.tordarus.net/nyaanime/parsers" + "git.tordarus.net/tordarus/adverr/v2" + "github.com/fatih/color" +) + +func ParseTorrent(torrent *model.Torrent) (*model.ParsedTorrent, error) { + for _, parser := range parsers.Parsers { + parsedTorrent, ok := parser.TorrentParser(&parser, torrent) + if ok { + anime, err := logic.SearchAnimeByTitle(parsedTorrent.OriginalAnimeTitle) + if err != nil { + return parsedTorrent, ErrTorrentParseFailed.Wrap(err, torrent.ID, parser.String()) + } + + parsedTorrent.Anime = anime + return parsedTorrent, nil + } + } + + return nil, ErrNoSuitableParser.New(torrent.ID) +} + +func ParseTorrentsByAnimeEp(torrents []model.Torrent) map[model.AnimeEpisode][]*model.ParsedTorrent { + torrentsByAnimeEp := map[model.AnimeEpisode][]*model.ParsedTorrent{} + + for _, torrent := range torrents { + torrent := torrent + parsedTorrent, err := ParseTorrent(&torrent) + if err != nil { + if errors.Is(err, logic.ErrAnimeNotFound) { + fmt.Fprintln(os.Stderr, color.RedString("torrent with ID %s could not be parsed because anime was not found: \"%s\"", torrent.ID, parsedTorrent.OriginalAnimeTitle)) + } else if !errors.Is(err, ErrNoSuitableParser) { + adverr.Println(err) + } + continue + } + + animeEp := GetAnimeEpisode(parsedTorrent.Anime, parsedTorrent.Episode) + torrentsByAnimeEp[animeEp] = append(torrentsByAnimeEp[animeEp], parsedTorrent) + } + + return torrentsByAnimeEp +} diff --git a/torrent_priority.go b/torrent_priority.go new file mode 100644 index 0000000..dd98311 --- /dev/null +++ b/torrent_priority.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + + "git.tordarus.net/nyaanime/logic" + "git.tordarus.net/nyaanime/model" +) + +type TorrentPriority struct { + ParsedTorrent *model.ParsedTorrent + Priority int + PreferredProperties map[string]int +} + +func NewTorrentPriority(torrent *model.ParsedTorrent) *TorrentPriority { + priority, preferredProperties := logic.DeterminePriority(torrent) + + return &TorrentPriority{ + ParsedTorrent: torrent, + Priority: priority, + PreferredProperties: preferredProperties, + } +} + +func (tp TorrentPriority) String() string { + return fmt.Sprintf("%s | priority: %d", tp.ParsedTorrent.String(), tp.Priority) +} diff --git a/torrent_sort.go b/torrent_sort.go new file mode 100644 index 0000000..a471a7a --- /dev/null +++ b/torrent_sort.go @@ -0,0 +1,28 @@ +package main + +import ( + "git.tordarus.net/nyaanime/model" + "git.tordarus.net/tordarus/slices" +) + +func GetTorrentsWithMaxPrioByAnimeEp(torrents map[model.AnimeEpisode][]*model.ParsedTorrent) map[model.AnimeEpisode]*TorrentPriority { + torrentsWithPrio := map[model.AnimeEpisode]*TorrentPriority{} + + for animeEp, torrentList := range torrents { + torrentPrioList := slices.Map(torrentList, NewTorrentPriority) + + var maxPrio *TorrentPriority + + for _, torrentPrio := range torrentPrioList { + if maxPrio == nil || torrentPrio.Priority > maxPrio.Priority { + maxPrio = torrentPrio + } + } + + if maxPrio != nil { + torrentsWithPrio[animeEp] = maxPrio + } + } + + return torrentsWithPrio +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..4c3ae5a --- /dev/null +++ b/utils.go @@ -0,0 +1,36 @@ +package main + +import ( + "sort" + + "git.tordarus.net/nyaanime/model" + "git.tordarus.net/tordarus/tprint" +) + +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 GetAnimeEpisodesSortFunc(s []model.AnimeEpisode) func(i, j int) bool { + return func(i, j int) bool { + if s[i].Anime.ID < s[j].Anime.ID { + return true + } else if s[i].Anime.ID > s[j].Anime.ID { + return false + } else { + return s[i].Episode <= s[j].Episode + } + } +}