diff --git a/AnimeAnnouncer.Torrents/NyaaRssReader.cs b/AnimeAnnouncer.Torrents/NyaaRssReader.cs new file mode 100644 index 0000000..f217624 --- /dev/null +++ b/AnimeAnnouncer.Torrents/NyaaRssReader.cs @@ -0,0 +1,63 @@ +using System.Xml.Linq; + +namespace AnimeAnnouncer.Torrents +{ + public record NyaaRssItem( + String Title, + String Published, + String Category, + String CategoryID, + String Size, + String Link, + String Description, + String PostID); + + /// + /// Reads Nyaa RSS feeds and downloads the .torrent files they link to. + /// + public class NyaaRssReader + { + private readonly HttpClient httpClient; + + public NyaaRssReader(HttpClient? httpClient = null) + { + this.httpClient = httpClient ?? new HttpClient(); + } + + /// + /// Converts a Nyaa search URL into its RSS equivalent, if it isn't one already. + /// + public static String ToRssUrl(String url) + { + return url.Contains("rss") ? url : url + "&page=rss"; + } + + public async Task> ReadAsync(String url) + { + var response = await httpClient.GetStringAsync(ToRssUrl(url)); + return Parse(response); + } + + public static List Parse(String rssXml) + { + XDocument doc = XDocument.Parse(rssXml); + XNamespace nyaaNS = doc.Root!.GetNamespaceOfPrefix("nyaa") ?? XNamespace.None; + return (from item in doc.Descendants("channel").Elements("item") + select new NyaaRssItem( + Title: item.Element("title")?.Value ?? String.Empty, + Published: item.Element("pubDate")?.Value ?? String.Empty, + Category: item.Element(nyaaNS + "category")?.Value ?? String.Empty, + CategoryID: item.Element(nyaaNS + "categoryId")?.Value ?? String.Empty, + Size: item.Element(nyaaNS + "size")?.Value ?? String.Empty, + Link: item.Element("link")?.Value ?? String.Empty, + Description: item.Element("description")?.Value ?? String.Empty, + PostID: item.Element("guid")?.Value ?? String.Empty + )).ToList(); + } + + public Task DownloadTorrentAsync(NyaaRssItem item) + { + return httpClient.GetByteArrayAsync(item.Link); + } + } +} diff --git a/AnimeAnnouncer.Torrents/TorrentDirectoryNamer.cs b/AnimeAnnouncer.Torrents/TorrentDirectoryNamer.cs new file mode 100644 index 0000000..f71c853 --- /dev/null +++ b/AnimeAnnouncer.Torrents/TorrentDirectoryNamer.cs @@ -0,0 +1,35 @@ +using System.Text.RegularExpressions; + +namespace AnimeAnnouncer.Torrents +{ + public static class TorrentDirectoryNamer + { + /// + /// Turns a release title into a dotted directory name, stripping the episode number + /// (keeping the resolution) and any trailing parenthesised suffix. + /// e.g. "Show Name S01E05 1080p WEB (Group)" -> "Show.Name.S01.1080p.WEB" + /// + public static String FromTitle(String title) + { + String name = title.Trim(); + name = Regex.Replace(name, @"E\d{2,3}.*?(\d{3,4}p)", " $1"); + name = Regex.Replace(name, @"\s\(.*?\)$", ""); + return name.Replace(" ", "."); + } + + public static String PrepareForURI(String title) + { + var titlePart = Regex.Replace(title, @"[^\w\s]+", String.Empty); + titlePart = Regex.Replace(titlePart, @"\s+", "+"); + return titlePart; + } + + /// + /// Builds the full save path for a release title under . + /// + public static String FromTitle(String title, String destinationFolder) + { + return destinationFolder + (destinationFolder.EndsWith("/") ? String.Empty : "/") + FromTitle(title); + } + } +} diff --git a/AnimeAnnouncer.Torrents/TransmissionService.cs b/AnimeAnnouncer.Torrents/TransmissionService.cs new file mode 100644 index 0000000..a5b0dcc --- /dev/null +++ b/AnimeAnnouncer.Torrents/TransmissionService.cs @@ -0,0 +1,57 @@ +using Transmission.API.RPC; +using Transmission.API.RPC.Arguments; +using Transmission.API.RPC.Entity; + +namespace AnimeAnnouncer.Torrents +{ + /// + /// Thin wrapper around the Transmission RPC client for adding torrents to the server. + /// + public class TransmissionService + { + public Client Client { get; } + + private TransmissionService(Client client) + { + Client = client; + } + + /// + /// Creates a client and verifies the server is reachable by requesting session information. + /// Throws if the server doesn't respond with a valid session. + /// + public static async Task ConnectAsync(String host, String? username, String? password) + { + var client = new Client(host, null, username, password); + var sessionInformation = await client.GetSessionInformationAsync(); + if (sessionInformation == null || String.IsNullOrEmpty(sessionInformation.Version)) + throw new InvalidOperationException($"An error occurred while receiving session information from the rpc server at {host}"); + + return new TransmissionService(client); + } + + /// + /// Adds a .torrent file to the server, saving to , and applies a seed ratio limit. + /// + public async Task AddNewTorrentFileToServerAsync(byte[] torrentFileData, String saveLocation, bool paused = true, double seedRatioLimit = 1.0) + { + NewTorrent newTorrent = new() + { + DownloadDirectory = saveLocation, + Paused = paused, + Metainfo = Convert.ToBase64String(torrentFileData) + }; + var torrentInfo = await Client.TorrentAddAsync(newTorrent); + var seedSettings = new TorrentSettings() + { + SeedRatioLimit = seedRatioLimit, + SeedRatioMode = 1, + IDs = [torrentInfo.ID] + }; + + // TorrentSetAsync returns void in Transmission.API.RPC 2.1.2, so it can't be awaited + Client.TorrentSet(seedSettings); + return torrentInfo; + } + } +} diff --git a/AnimeAnnouncer.Web/Pages/Index.cshtml.cs b/AnimeAnnouncer.Web/Pages/Index.cshtml.cs index 1df3064..b547ec0 100644 --- a/AnimeAnnouncer.Web/Pages/Index.cshtml.cs +++ b/AnimeAnnouncer.Web/Pages/Index.cshtml.cs @@ -65,6 +65,10 @@ public class IndexModel : PageModel _logger.LogWarning(ex, "Failed to retrieve Anilist cache item for title: {Title}", airingItem.Title); } + // With no details from either source there is nothing worth displaying. + if (cacheItem == null && anilistItem == null) + continue; + viewModels.Add(new AiringShowViewModel { AiringSoon = airingItem, diff --git a/AnimeAnnouncer/Program.cs b/AnimeAnnouncer/Program.cs index d23be7d..46c8275 100644 --- a/AnimeAnnouncer/Program.cs +++ b/AnimeAnnouncer/Program.cs @@ -54,7 +54,7 @@ namespace AnimeAnnouncer Console.WriteLine("Using cache"); } - tmdbCache.CacheExpiration = TimeSpan.FromDays(7); + tmdbCache.CacheExpiration = TimeSpan.FromDays(8); String mastodonInstance = config["MastodonInstance"]; @@ -332,7 +332,7 @@ namespace AnimeAnnouncer { var titleSeason = int.Parse(season); bool seasonOverride = false; - if(latestSeasonNumber ==1 && titleSeason > 1 && lastEpisodeNumber > 3) + if(latestSeasonNumber < titleSeason && titleSeason > 1 && lastEpisodeNumber > 3) { //Check episode groups to see if this is a single-season default show var showResult = await tmdbClient.GetTvShowAsync(supposedShowId, TMDbLib.Objects.TvShows.TvShowMethods.EpisodeGroups); var seasonEpisodeGroup = showResult.EpisodeGroups.Results.FirstOrDefault(eg => eg.Name == "Seasons"); @@ -419,10 +419,10 @@ namespace AnimeAnnouncer _ = tmdbCache.SetPair($"ShowAnnounced-{supposedShowId}", latestSeasonNumber.ToString()); _ = PostStatus(cachedShow); var existingAiringItem = airingSoonList.FirstOrDefault(s => s.ShowID == cachedShow.ShowID); - if (existingAiringItem != null && existingAiringItem.Download) + if (existingAiringItem is { Download: true }) { Console.WriteLine($"Attempting to queue {existingAiringItem.Title} in the torrent client"); - _ = AddToTorrentClient(cachedShow); + await AddToTorrentClient(cachedShow); } } @@ -449,6 +449,7 @@ namespace AnimeAnnouncer } var folderName = TorrentDirectoryNamer.FromTitle(items.First().Title, destinationFolder); + Console.WriteLine($"Adding {folderName} to transmission"); foreach (var item in items) {