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)
{