23 Commits
Author SHA1 Message Date
chris dcadf31192 More changes to add Torrents automatically; Modified cache to 8 days; Modified season finale lookups for edge case 2026-10-24 14:37:51 -04:00
chris 32aca8b197 Initial commit of auto-adding torrent system 2026-10-11 12:25:32 -04:00
chris 57b2d8835d Added Genre and Tags 2026-09-25 14:21:54 -04:00
chris d9d19d175f Opus changes 2026-09-22 19:42:10 -04:00
chris cffecbb469 Added missing AnilistCacheItem.cs 2026-09-21 14:20:50 -04:00
chris a1ec0ea7f5 Various minor changes to announcer to support Web version and include Anilist 2026-09-21 14:19:39 -04:00
chris 8831d0ce2f Increased redis cache time to 7 days; tweaked finale announce event (checking anilist); minor visual tweaks to web 2026-09-18 16:21:31 -04:00
chris 943aef2f7b Initial commit of AnimeAnnouncer.Web 2026-09-17 14:10:13 -04:00
chris 2198f6ad84 Added support for Anilist lookups and date confirmations for ending series 2026-09-13 15:05:18 -04:00
chris e336b2ae08 Added Anilist datasource; Handled bug when two-posts were not enough for monthly airing post 2026-08-31 21:36:21 -04:00
chris 8dcc153806 - Added show-airing-list switch
- Handled shows announced with seasons > 1
- Handled cases where status posting failed
2026-03-17 13:09:52 -04:00
chris 41c75ee3f2 Handled case where showResult.NextEpisodeToAir was null due to non-dubbed series finishing airing before dubbed 2025-04-06 15:48:47 -04:00
chris 5cfcecc5c3 Numerous fixes to improve functionality 2025-03-25 00:12:03 -04:00
chris 6a50c94cf2 Minor tweaks to AiringList before trying to handle late-week airing 2025-02-17 01:03:06 -05:00
chris acac2a25e8 Added some guard rails around airing list to avoid announcing old shows 2025-02-05 00:24:32 -05:00
chris 47b44fb59a Handled airing multi-post 2024-12-31 20:59:03 -05:00
chris 965bd58799 Initial commit of Airing Soon list 2024-11-29 15:42:12 -05:00
chris 039e3189eb Changed ShowAnnounced to save last announced season number 2024-11-19 19:52:44 -05:00
chris 6a518b0c9b Added Levenshtein title matching; Added AiringSoonList 2024-11-19 19:36:35 -05:00
chris 7b830659bc Added TMDbLib library reference until nuget gets the required package for episode groups 2024-11-12 16:32:24 -05:00
chris c423d8f3ed Added implementation of episode groups with overrides for finale 2024-11-11 21:44:36 -05:00
chris 25b214c0d7 Fixed appSettings name to work on linux; Set newly added torrent's seed mode and ratio 2024-10-13 21:57:13 -04:00
chris 310978054d Added anidb search link for the mastodon post 2024-10-09 20:37:36 -04:00
90 changed files with 76653 additions and 98 deletions
+63
View File
@@ -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);
/// <summary>
/// Reads Nyaa RSS feeds and downloads the .torrent files they link to.
/// </summary>
public class NyaaRssReader
{
private readonly HttpClient httpClient;
public NyaaRssReader(HttpClient? httpClient = null)
{
this.httpClient = httpClient ?? new HttpClient();
}
/// <summary>
/// Converts a Nyaa search URL into its RSS equivalent, if it isn't one already.
/// </summary>
public static String ToRssUrl(String url)
{
return url.Contains("rss") ? url : url + "&page=rss";
}
public async Task<List<NyaaRssItem>> ReadAsync(String url)
{
var response = await httpClient.GetStringAsync(ToRssUrl(url));
return Parse(response);
}
public static List<NyaaRssItem> 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<byte[]> DownloadTorrentAsync(NyaaRssItem item)
{
return httpClient.GetByteArrayAsync(item.Link);
}
}
}
@@ -0,0 +1,35 @@
using System.Text.RegularExpressions;
namespace AnimeAnnouncer.Torrents
{
public static class TorrentDirectoryNamer
{
/// <summary>
/// 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"
/// </summary>
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;
}
/// <summary>
/// Builds the full save path for a release title under <paramref name="destinationFolder"/>.
/// </summary>
public static String FromTitle(String title, String destinationFolder)
{
return destinationFolder + (destinationFolder.EndsWith("/") ? String.Empty : "/") + FromTitle(title);
}
}
}
@@ -0,0 +1,57 @@
using Transmission.API.RPC;
using Transmission.API.RPC.Arguments;
using Transmission.API.RPC.Entity;
namespace AnimeAnnouncer.Torrents
{
/// <summary>
/// Thin wrapper around the Transmission RPC client for adding torrents to the server.
/// </summary>
public class TransmissionService
{
public Client Client { get; }
private TransmissionService(Client client)
{
Client = client;
}
/// <summary>
/// Creates a client and verifies the server is reachable by requesting session information.
/// Throws <see cref="InvalidOperationException"/> if the server doesn't respond with a valid session.
/// </summary>
public static async Task<TransmissionService> 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);
}
/// <summary>
/// Adds a .torrent file to the server, saving to <paramref name="saveLocation"/>, and applies a seed ratio limit.
/// </summary>
public async Task<NewTorrentInfo> 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;
}
}
}
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\AnimeAnnouncer\AnimeAnnouncer.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<!-- SDK container publishing (dotnet publish -t:PublishContainer). The Web SDK
includes Microsoft.NET.Build.Containers, so no Dockerfile is needed; the
base image is inferred from TargetFramework (aspnet:8.0). -->
<PropertyGroup Label="Container">
<ContainerRepository>animeannouncer-web</ContainerRepository>
<!-- Add a version alongside it at publish time:
dotnet publish ... -p:ContainerImageTags='"1.1.0;latest"' -->
<ContainerImageTags>latest</ContainerImageTags>
<!-- The aspnet base image ships a non-root 'app' user; 8080 is unprivileged. -->
<ContainerUser>app</ContainerUser>
</PropertyGroup>
<ItemGroup>
<!-- Matches ASPNETCORE_HTTP_PORTS, which the aspnet:8.0 image defaults to 8080. -->
<ContainerPort Include="8080" Type="tcp" />
</ItemGroup>
</Project>
+26
View File
@@ -0,0 +1,26 @@
@page
@model ErrorModel
@{
ViewData["Title"] = "Error";
}
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (Model.ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@Model.RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
+27
View File
@@ -0,0 +1,27 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace AnimeAnnouncer.Web.Pages;
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
[IgnoreAntiforgeryToken]
public class ErrorModel : PageModel
{
public string? RequestId { get; set; }
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
private readonly ILogger<ErrorModel> _logger;
public ErrorModel(ILogger<ErrorModel> logger)
{
_logger = logger;
}
public void OnGet()
{
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
}
}
+224
View File
@@ -0,0 +1,224 @@
@page
@model IndexModel
@{
ViewData["Title"] = "Airing Soon Anime";
}
<div class="airing-container">
<div class="d-flex flex-wrap align-items-center justify-content-between mb-2">
<h1 class="display-5 mb-0 d-flex align-items-center flex-wrap">
<span>🗓️ Airing Soon Anime</span>
<span class="badge rounded-pill bg-dark text-white ms-2 fs-6">@(Model.AiringShows == null ? "No data" : (Model.AiringShows.Count + (Model.AiringShows.Count == 1 ? " show" : " shows")))</span>
</h1>
</div>
<p class="lead text-secondary mb-4">Anime with an upcoming or recent latest episode, drawn from the TMDB and AniList caches. Sorted by air date (<strong>soonest first</strong>).</p>
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert alert-danger shadow-sm" role="alert">
<strong>Error:</strong> @Model.ErrorMessage
</div>
}
@if (Model.AiringShows == null || Model.AiringShows.Count == 0)
{
<div class="text-center py-5">
<div class="mb-3">
<span class="display-1">📺</span>
</div>
<h3>No airing soon shows found</h3>
<p class="text-secondary">The Redis cache either returned no entries or the airing soon list is empty.</p>
</div>
}
else
{
<div class="airing-list">
@foreach (var show in Model.AiringShows)
{
<article id="show-@show.ShowID" class="airing-row @(show.IsFuture ? "is-upcoming" : "is-aired")">
@if (!string.IsNullOrWhiteSpace(show.HeroImageUrl))
{
<div class="airing-backdrop" style="background-image: url('@show.HeroImageUrl');" aria-hidden="true"></div>
}
<div class="airing-scrim" aria-hidden="true"></div>
<div class="airing-content">
<div class="airing-poster">
@if (!string.IsNullOrWhiteSpace(show.PosterUrl))
{
<img src="@show.PosterUrl" alt="@show.Title poster" title="@show.Title" loading="lazy" />
}
else
{
<div class="airing-poster-placeholder"><span>🗂️</span></div>
}
@if (show.LatestSeasonNumber.HasValue && show.LatestSeasonNumber > 0)
{
<span class="airing-poster-tag">Season @show.LatestSeasonNumber</span>
}
</div>
<div class="airing-body">
<header class="airing-header">
<div class="airing-titles">
<h2 class="airing-title">@show.Title</h2>
@if (show.AlternateTitles.Count > 0)
{
<p class="airing-alt-titles">@string.Join(" · ", show.AlternateTitles)</p>
}
</div>
<div class="airing-scores">
@if (show.VoteAverage.HasValue && show.VoteAverage > 0)
{
<span class="score-chip score-tmdb" title="TMDB user score">
<span class="score-value">@string.Format("{0:0.0}", show.VoteAverage)</span>
<span class="score-source">TMDB</span>
</span>
}
@if (show.AnilistScore.HasValue && show.AnilistScore > 0)
{
<span class="score-chip score-anilist" title="AniList average score">
<span class="score-value">@string.Format("{0:0.0}", show.AnilistScore)</span>
<span class="score-source">AniList</span>
</span>
}
</div>
</header>
<div class="airing-meta">
@if (show.LatestEpisodeNumber > 0)
{
<span class="meta-chip">
@if (show.TotalEpisodes.HasValue && show.TotalEpisodes > 0)
{
<text>Episode @show.LatestEpisodeNumber of @show.TotalEpisodes</text>
}
else
{
<text>Episode @show.LatestEpisodeNumber</text>
}
</span>
}
@if (show.LatestOrdinalEpisodeNumber.HasValue && show.LatestOrdinalEpisodeNumber > 0)
{
<span class="meta-chip">@show.LatestOrdinalEpisodeNumber episodes overall</span>
}
@if (!string.IsNullOrWhiteSpace(show.AnilistStatus))
{
<span class="meta-chip">@show.AnilistStatus</span>
}
@if (show.SeasonYear.HasValue)
{
<span class="meta-chip">@show.SeasonYear</span>
}
@if (show.IsFinaleNext && show.IsFuture)
{
<span class="meta-chip meta-chip-finale">🏁 Season finale</span>
}
</div>
@if (show.Genres.Count > 0 || show.Tags.Count > 0)
{
<div class="airing-taxonomy">
@foreach (var genre in show.Genres)
{
<span class="genre-chip">@genre</span>
}
@foreach (var tag in show.VisibleTags)
{
<span class="tag-chip">#@tag</span>
}
@if (show.OverflowTags.Count > 0)
{
<details class="tag-overflow">
<summary class="tag-chip tag-more">+@show.OverflowTags.Count more</summary>
@foreach (var tag in show.OverflowTags)
{
<span class="tag-chip">#@tag</span>
}
</details>
}
</div>
}
@if (show.EpisodeProgressPercent.HasValue)
{
<div class="airing-progress" title="@show.LatestEpisodeNumber of @show.TotalEpisodes episodes aired">
<div class="airing-progress-bar" style="width: @(show.EpisodeProgressPercent)%;"></div>
</div>
}
@if (!string.IsNullOrWhiteSpace(show.Overview))
{
<p class="airing-overview">
@if (show.OverviewIsSeasonSpecific && show.LatestSeasonNumber > 0)
{
<span class="overview-label">Season @show.LatestSeasonNumber —</span>
}
@show.Overview
</p>
}
else
{
<p class="airing-overview text-secondary fst-italic">No overview available.</p>
}
<footer class="airing-footer">
<a href="https://www.themoviedb.org/tv/@show.ShowID" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-light">TMDB</a>
@if (!string.IsNullOrWhiteSpace(show.AnilistUrl))
{
<a href="@show.AnilistUrl" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-info">AniList</a>
}
@if (show.Download)
{
<span class="btn btn-sm btn-success disabled" aria-disabled="true">✓ Marked for download</span>
}
else
{
<form method="post" asp-page-handler="Download" class="d-inline">
<input type="hidden" name="showId" value="@show.ShowID" />
<button type="submit" class="btn btn-sm btn-outline-success">⬇ Download</button>
</form>
}
@if (show.SecondaryImages.Count > 0)
{
<div class="airing-gallery">
@foreach (var image in show.SecondaryImages)
{
<a href="@image.Url" target="_blank" rel="noopener noreferrer"
class="gallery-thumb" title="@image.Label (@image.Source)">
<img src="@image.Url" alt="@show.Title — @image.Label" loading="lazy" />
</a>
}
</div>
}
</footer>
</div>
<aside class="airing-countdown">
@if (show.EffectiveLastAirDate.HasValue)
{
@if (show.IsFuture)
{
<span class="countdown-number">@Math.Max(show.DaysUntil, 1)</span>
<span class="countdown-unit">@(Math.Max(show.DaysUntil, 1) == 1 ? "day" : "days") away</span>
}
else
{
<span class="countdown-number">@Math.Abs(show.DaysUntil)</span>
<span class="countdown-unit">@(Math.Abs(show.DaysUntil) == 1 ? "day" : "days") ago</span>
}
<span class="countdown-date" title="@show.AirDateLongFormatted">@show.AirDateFormatted</span>
}
else
{
<span class="countdown-unit">Air date unknown</span>
}
</aside>
</div>
</article>
}
</div>
}
</div>
+345
View File
@@ -0,0 +1,345 @@
using System.Net;
using System.Text.RegularExpressions;
using AnimeAnnouncer.Cache;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace AnimeAnnouncer.Web.Pages;
public class IndexModel : PageModel
{
private readonly ILogger<IndexModel> _logger;
private readonly TMDBCache? _tmdbCache;
public List<AiringShowViewModel> AiringShows { get; set; } = new();
public string? ErrorMessage { get; set; }
public IndexModel(ILogger<IndexModel> logger, IServiceProvider serviceProvider)
{
_logger = logger;
_tmdbCache = serviceProvider.GetService<TMDBCache>();
}
public async Task OnGetAsync()
{
if (_tmdbCache == null)
{
ErrorMessage = "Redis cache connection is not configured or unavailable.";
return;
}
try
{
List<AiringSoonItem>? airingList = await _tmdbCache.GetAiringList();
if (airingList == null || airingList.Count == 0)
{
AiringShows = new List<AiringShowViewModel>();
return;
}
var viewModels = new List<AiringShowViewModel>();
foreach (var airingItem in airingList)
{
TMDBCacheItem? cacheItem = null;
try
{
cacheItem = await _tmdbCache.GetCacheItem<TMDBCacheItem>($"ShowCache-{airingItem.Title}");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to retrieve cache item for title: {Title}", airingItem.Title);
}
// The announcer stores Anilist details under a parallel key using the same
// title, so the two caches line up one-to-one when Anilist was consulted.
AnilistCacheItem? anilistItem = null;
try
{
anilistItem = await _tmdbCache.GetCacheItem<AnilistCacheItem>($"AnilistCache-{airingItem.Title}");
}
catch (Exception ex)
{
_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,
CachedDetails = cacheItem,
AnilistDetails = anilistItem
});
}
// Exclude placeholder/sentinel last-air-dates (e.g. DateTime.MaxValue == 12/31/9999,
// used by the announcer when an exact air date is unknown), then sort descending
// by LastAirDate (nulls last).
var displayable = viewModels.Where(x =>
{
var date = x.EffectiveLastAirDate;
// Keep items with a real, displayable date; drop the MaxValue sentinel.
return !date.HasValue || date.Value < DateTime.MaxValue.AddMonths(-1);
}).ToList();
AiringShows = displayable
.Where(x => x.EffectiveLastAirDate.HasValue && x.EffectiveLastAirDate.Value.Date >= DateTime.Now.Date)
.OrderBy(x => x.EffectiveLastAirDate)
.ThenBy(x => x.Title)
.ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading airing soon list");
ErrorMessage = $"Unable to load airing soon list: {ex.Message}";
}
}
public async Task<IActionResult> OnPostDownloadAsync(int showId)
{
if (_tmdbCache == null)
return StatusCode(StatusCodes.Status503ServiceUnavailable);
try
{
if (!await _tmdbCache.SetDownload(showId, true))
_logger.LogWarning("Download requested for show {ShowID}, which is not in the airing list", showId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to flag show {ShowID} for download", showId);
}
// Post/redirect/get, landing back on the row that was clicked.
return RedirectToPage(null, null, null, $"show-{showId}");
}
public class ShowImage
{
public required string Url { get; set; }
public required string Label { get; set; }
public required string Source { get; set; }
}
public class AiringShowViewModel
{
private static readonly Regex HtmlTagPattern = new("<[^>]+>", RegexOptions.Compiled);
public required AiringSoonItem AiringSoon { get; set; }
public TMDBCacheItem? CachedDetails { get; set; }
public AnilistCacheItem? AnilistDetails { get; set; }
public string Title => AiringSoon.Title;
public int ShowID => AiringSoon.ShowID;
public bool Download => AiringSoon.Download;
public DateTime? EffectiveLastAirDate => AiringSoon.LastAirDate ?? CachedDetails?.LastAirDate;
public int LatestEpisodeNumber => AiringSoon.LatestEpisodeNumber > 0
? AiringSoon.LatestEpisodeNumber
: (CachedDetails?.LatestEpisodeNumber ?? 0);
public int? LastEpisodeNumber => CachedDetails?.LastEpisodeNumber;
public int? LatestSeasonNumber => CachedDetails?.LatestSeasonNumber;
public int? LatestOrdinalEpisodeNumber => CachedDetails?.LatestOrdinalEpisodeNumber;
public double? VoteAverage => CachedDetails?.VoteAverage;
// --- Anilist ---------------------------------------------------------
public bool HasAnilist => AnilistDetails != null;
public int? AnilistShowID => AnilistDetails?.ShowID;
public string? AnilistUrl => AnilistShowID.HasValue ? $"https://anilist.co/anime/{AnilistShowID}" : null;
/// <summary>Anilist's 0-100 score, rescaled to the 0-10 range TMDB uses.</summary>
public double? AnilistScore => AnilistDetails?.AverageScore.HasValue == true
? AnilistDetails.AverageScore!.Value / 10.0
: null;
public string? AnilistStatus => string.IsNullOrWhiteSpace(AnilistDetails?.Status)
? null
// Anilist statuses arrive as enum names like RELEASING / NOT_YET_RELEASED.
: string.Join(' ', AnilistDetails.Status.Split('_', StringSplitOptions.RemoveEmptyEntries)
.Select(word => char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant()));
public int? SeasonYear => AnilistDetails?.SeasonYear;
/// <summary>Total episode count, preferring Anilist which the announcer treats as more reliable.</summary>
public int? TotalEpisodes => AnilistDetails?.Episodes ?? (LastEpisodeNumber > 0 ? LastEpisodeNumber : null);
/// <summary>Alternate titles worth showing, i.e. anything that is not just the display title again.</summary>
public List<string> AlternateTitles
{
get
{
var candidates = new[] { AnilistDetails?.RomajiTitle, AnilistDetails?.NativeTitle, AnilistDetails?.EnglishTitle };
return candidates
.Where(t => !string.IsNullOrWhiteSpace(t))
.Select(t => t!.Trim())
.Where(t => !string.Equals(t, Title, StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
}
// --- Genres & tags ---------------------------------------------------
/// <summary>How many tags are shown before the rest are tucked behind a "more" toggle.</summary>
public const int VisibleTagCount = 10;
/// <summary>
/// Genres from both sources, AniList first. TMDB's "Animation" genre is dropped since every
/// show on this page is anime, so it carries no information.
/// </summary>
public List<string> Genres => MergeDistinct(AnilistDetails?.Genres, CachedDetails?.Genres)
.Where(g => !string.Equals(g, "Animation", StringComparison.OrdinalIgnoreCase))
.ToList();
/// <summary>AniList tags followed by TMDB keywords, minus anything already listed as a genre.</summary>
public List<string> Tags
{
get
{
var genres = new HashSet<string>(Genres, StringComparer.OrdinalIgnoreCase);
return MergeDistinct(AnilistDetails?.Tags, CachedDetails?.Keywords)
.Where(t => !genres.Contains(t))
.ToList();
}
}
public List<string> VisibleTags => Tags.Take(VisibleTagCount).ToList();
public List<string> OverflowTags => Tags.Skip(VisibleTagCount).ToList();
private static IEnumerable<string> MergeDistinct(params IEnumerable<string>?[] sources) =>
sources
.Where(s => s != null)
.SelectMany(s => s!)
.Where(s => !string.IsNullOrWhiteSpace(s))
.Select(s => s.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase);
/// <summary>Anilist descriptions are HTML fragments; flatten them to plain text for display.</summary>
public string? AnilistDescription
{
get
{
if (string.IsNullOrWhiteSpace(AnilistDetails?.Description))
return null;
var text = AnilistDetails.Description
.Replace("<br>", " ", StringComparison.OrdinalIgnoreCase)
.Replace("<br/>", " ", StringComparison.OrdinalIgnoreCase)
.Replace("<br />", " ", StringComparison.OrdinalIgnoreCase);
text = WebUtility.HtmlDecode(HtmlTagPattern.Replace(text, string.Empty));
text = Regex.Replace(text, @"\s{2,}", " ").Trim();
return string.IsNullOrWhiteSpace(text) ? null : text;
}
}
public string? Overview => !string.IsNullOrWhiteSpace(CachedDetails?.LatestSeasonOverview)
? CachedDetails.LatestSeasonOverview
: (!string.IsNullOrWhiteSpace(CachedDetails?.Overview) ? CachedDetails.Overview : AnilistDescription);
/// <summary>True when <see cref="Overview"/> describes the current season rather than the series.</summary>
public bool OverviewIsSeasonSpecific => !string.IsNullOrWhiteSpace(CachedDetails?.LatestSeasonOverview);
// --- Artwork ---------------------------------------------------------
public string? SeasonPosterUrl => Clean(CachedDetails?.LatestSeasonPosterPath);
public string? ShowPosterUrl => Clean(CachedDetails?.PosterPath);
public string? BackdropUrl => Clean(CachedDetails?.BackdropPath);
public string? AnilistCoverUrl => Clean(AnilistDetails?.CoverImageUrl);
public string? AnilistBannerUrl => Clean(AnilistDetails?.BannerImageUrl);
/// <summary>The tall artwork shown alongside the entry; the season's own poster wins when present.</summary>
public string? PosterUrl => SeasonPosterUrl ?? ShowPosterUrl ?? AnilistCoverUrl;
/// <summary>The wide artwork used as the row background.</summary>
public string? HeroImageUrl => BackdropUrl ?? AnilistBannerUrl;
/// <summary>Every distinct piece of artwork, for the secondary thumbnail strip.</summary>
public List<ShowImage> Gallery
{
get
{
var images = new List<ShowImage>();
void Add(string? url, string label, string source)
{
if (string.IsNullOrWhiteSpace(url))
return;
if (images.Any(i => string.Equals(i.Url, url, StringComparison.OrdinalIgnoreCase)))
return;
images.Add(new ShowImage { Url = url, Label = label, Source = source });
}
// The primary poster is already displayed beside the row, so start after it.
Add(SeasonPosterUrl, $"Season {LatestSeasonNumber} poster", "TMDB");
Add(ShowPosterUrl, "Series poster", "TMDB");
Add(AnilistCoverUrl, "Cover art", "AniList");
Add(BackdropUrl, "Backdrop", "TMDB");
Add(AnilistBannerUrl, "Banner", "AniList");
return images;
}
}
/// <summary>Gallery entries other than the one already shown as the main poster.</summary>
public List<ShowImage> SecondaryImages =>
Gallery.Where(i => !string.Equals(i.Url, PosterUrl, StringComparison.OrdinalIgnoreCase)).ToList();
// --- Air dates -------------------------------------------------------
public DateTime AirReferenceNow => DateTime.Now;
public bool IsFuture
{
get
{
var date = EffectiveLastAirDate;
return date.HasValue && date.Value > AirReferenceNow;
}
}
public int DaysUntil
{
get
{
var date = EffectiveLastAirDate;
if (!date.HasValue)
return 0;
return (int)((date.Value - AirReferenceNow).Ticks / TimeSpan.FromDays(1).Ticks);
}
}
public string AirDateFormatted
{
get
{
var date = EffectiveLastAirDate;
return date.HasValue ? date.Value.ToString("MM/dd/yyyy") : "Unknown";
}
}
public string? AirDateLongFormatted => EffectiveLastAirDate?.ToString("dddd, MMMM d, yyyy");
/// <summary>How far into the season the latest aired episode sits, as a percentage.</summary>
public int? EpisodeProgressPercent
{
get
{
var total = TotalEpisodes;
if (!total.HasValue || total.Value <= 0 || LatestEpisodeNumber <= 0)
return null;
return (int)Math.Round(Math.Clamp(LatestEpisodeNumber / (double)total.Value, 0, 1) * 100);
}
}
/// <summary>True when the upcoming episode is the last one of the season.</summary>
public bool IsFinaleNext => TotalEpisodes.HasValue && LatestEpisodeNumber > 0 && LatestEpisodeNumber >= TotalEpisodes.Value;
private static string? Clean(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}
+8
View File
@@ -0,0 +1,8 @@
@page
@model PrivacyModel
@{
ViewData["Title"] = "Privacy Policy";
}
<h1>@ViewData["Title"]</h1>
<p>Use this page to detail your site's privacy policy.</p>
@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace AnimeAnnouncer.Web.Pages;
public class PrivacyModel : PageModel
{
private readonly ILogger<PrivacyModel> _logger;
public PrivacyModel(ILogger<PrivacyModel> logger)
{
_logger = logger;
}
public void OnGet()
{
}
}
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - AnimeAnnouncer.Web</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
<link rel="stylesheet" href="~/AnimeAnnouncer.Web.styles.css" asp-append-version="true" />
</head>
<body>
<div class="container pt-3">
<main role="main" class="pb-3">
@RenderBody()
</main>
</div>
<footer class="border-top footer text-muted">
<div class="container">
&copy; 2026 - AnimeAnnouncer.Web - <a asp-area="" asp-page="/Privacy">Privacy</a>
</div>
</footer>
<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>
@@ -0,0 +1,50 @@
/* Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
for details on configuring this project to bundle and minify static web assets. */
a.navbar-brand {
white-space: normal;
text-align: center;
word-break: break-all;
}
a {
color: #4dabf7;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.nav-pills .nav-link.active, .nav-pills .show > .nav-link {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.border-top {
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.border-bottom {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.box-shadow {
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .2);
}
button.accept-policy {
font-size: 1rem;
line-height: inherit;
}
.footer {
position: absolute;
bottom: 0;
width: 100%;
white-space: nowrap;
line-height: 60px;
background-color: #121212;
color: #adb5bd;
}
@@ -0,0 +1,2 @@
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>
@@ -0,0 +1,3 @@
@using AnimeAnnouncer.Web
@namespace AnimeAnnouncer.Web.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}
+34
View File
@@ -0,0 +1,34 @@
using AnimeAnnouncer.Cache;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorPages();
var redisConnectionString = builder.Configuration["redisConnectionString"] ?? builder.Configuration.GetConnectionString("Redis");
if (!string.IsNullOrEmpty(redisConnectionString))
{
builder.Services.AddSingleton(new TMDBCache(redisConnectionString));
}
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapRazorPages();
app.Run();
@@ -0,0 +1,38 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:25738",
"sslPort": 44352
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5062",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7253;http://localhost:5062",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,9 @@
{
"DetailedErrors": true,
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"redisConnectionString": "echidna.chrispr.lan, password=AidsAidsAidsTest"
}
+494
View File
@@ -0,0 +1,494 @@
html {
font-size: 14px;
}
@media (min-width: 768px) {
html {
font-size: 16px;
}
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
html {
position: relative;
min-height: 100%;
}
body {
margin-bottom: 60px;
background-color: #121212;
color: #e9ecef;
}
a {
color: #4dabf7;
}
a:hover {
color: #74c0fc;
}
.text-secondary,
.text-muted {
color: #adb5bd !important;
}
.card {
background-color: #1e1e1e;
color: #e9ecef;
}
/* --- Airing-soon rows -------------------------------------------------
Each show gets one full-width horizontal row: the TMDB backdrop (or the
AniList banner as a fallback) fills the row behind a scrim, the season
poster anchors the left edge, and the remaining artwork appears as a
thumbnail strip in the footer. */
.airing-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.airing-row {
position: relative;
overflow: hidden;
border-radius: 1rem;
background-color: #1a1a1a;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45);
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.airing-row:hover {
transform: translateY(-3px);
box-shadow: 0 0.9rem 1.8rem rgba(0, 0, 0, 0.55);
border-color: rgba(255, 255, 255, 0.16);
}
.airing-row.is-upcoming {
border-left: 4px solid #ff6b6b;
}
.airing-row.is-aired {
border-left: 4px solid rgba(255, 255, 255, 0.18);
}
.airing-backdrop {
position: absolute;
inset: 0;
background-size: cover;
background-position: center 25%;
opacity: 0.6;
transform: scale(1.02);
transition: opacity 0.3s ease, transform 0.4s ease;
}
.airing-row:hover .airing-backdrop {
opacity: 0.8;
transform: scale(1.05);
}
/* Keeps the text readable regardless of how bright the backdrop is. */
.airing-scrim {
position: absolute;
inset: 0;
background:
linear-gradient(90deg, rgba(18, 18, 18, 0.96) 0%, rgba(18, 18, 18, 0.86) 40%, rgba(18, 18, 18, 0.35) 100%),
linear-gradient(0deg, rgba(18, 18, 18, 0.7) 0%, rgba(18, 18, 18, 0.2) 65%);
}
.airing-content {
position: relative;
display: flex;
gap: 1.5rem;
padding: 1.5rem;
}
/* --- Poster --- */
.airing-poster {
position: relative;
flex: 0 0 170px;
aspect-ratio: 2 / 3;
border-radius: 0.75rem;
overflow: hidden;
background-color: #2a2a2a;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.6);
}
.airing-poster img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.airing-poster-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 3rem;
color: #6c757d;
}
.airing-poster-tag {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 0.3rem 0.5rem;
text-align: center;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.02em;
color: #f8f9fa;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(2px);
}
/* --- Body --- */
.airing-body {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.airing-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.airing-titles {
min-width: 0;
}
.airing-title {
margin: 0;
font-size: clamp(1.35rem, 1.1rem + 0.9vw, 2.1rem);
font-weight: 700;
line-height: 1.15;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8);
}
.airing-alt-titles {
margin: 0.25rem 0 0;
font-size: 0.85rem;
color: #adb5bd;
}
.airing-scores {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.score-chip {
display: flex;
flex-direction: column;
align-items: center;
min-width: 3.6rem;
padding: 0.3rem 0.5rem;
border-radius: 0.6rem;
background: rgba(0, 0, 0, 0.55);
border: 1px solid rgba(255, 255, 255, 0.18);
backdrop-filter: blur(4px);
}
.score-value {
font-size: 1.15rem;
font-weight: 700;
line-height: 1.1;
}
.score-source {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #adb5bd;
}
.score-tmdb .score-value {
color: #ffd43b;
}
.score-anilist .score-value {
color: #02a9ff;
}
/* --- Metadata --- */
.airing-meta {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.meta-chip {
padding: 0.2rem 0.65rem;
border-radius: 999px;
font-size: 0.8rem;
color: #e9ecef;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.16);
backdrop-filter: blur(4px);
}
.meta-chip-finale {
color: #ffd43b;
border-color: rgba(255, 212, 59, 0.5);
background: rgba(255, 212, 59, 0.12);
}
/* --- Genres & tags --- */
.airing-taxonomy {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
}
.genre-chip {
padding: 0.15rem 0.6rem;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 600;
color: #d0bfff;
background: rgba(151, 117, 250, 0.18);
border: 1px solid rgba(151, 117, 250, 0.45);
backdrop-filter: blur(4px);
}
.tag-chip {
padding: 0.1rem 0.5rem;
border-radius: 4px;
font-size: 0.72rem;
color: #ced4da;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* The overflow list flows inline with the other chips once opened. */
.tag-overflow {
display: contents;
}
.tag-overflow > summary {
list-style: none;
cursor: pointer;
}
.tag-overflow > summary::-webkit-details-marker {
display: none;
}
.tag-more {
color: #74c0fc;
border-style: dashed;
}
.tag-overflow[open] > .tag-more {
display: none;
}
.airing-progress {
height: 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
overflow: hidden;
}
.airing-progress-bar {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #4dabf7, #02a9ff);
}
.airing-overview {
margin: 0;
font-size: 0.95rem;
line-height: 1.55;
color: #dee2e6;
/* Full-width rows fit most synopses; only the longest ones get clipped. */
display: -webkit-box;
-webkit-line-clamp: 6;
-webkit-box-orient: vertical;
overflow: hidden;
overflow-wrap: break-word;
}
.overview-label {
font-weight: 600;
color: #adb5bd;
}
.airing-footer {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: auto;
padding-top: 0.5rem;
}
.airing-gallery {
display: flex;
gap: 0.4rem;
margin-left: auto;
flex-wrap: wrap;
}
.gallery-thumb {
display: block;
height: 46px;
border-radius: 0.35rem;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.18);
opacity: 0.75;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.gallery-thumb:hover {
opacity: 1;
transform: translateY(-2px);
}
.gallery-thumb img {
height: 46px;
width: auto;
display: block;
}
/* --- Countdown --- */
.airing-countdown {
flex: 0 0 8rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding-left: 1rem;
border-left: 1px solid rgba(255, 255, 255, 0.1);
}
.countdown-number {
font-size: 3rem;
font-weight: 800;
line-height: 1;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.8);
}
.is-upcoming .countdown-number {
color: #ff8787;
}
.is-aired .countdown-number {
color: #adb5bd;
}
.countdown-unit {
font-size: 0.85rem;
color: #ced4da;
}
.countdown-date {
margin-top: 0.35rem;
font-size: 0.78rem;
color: #adb5bd;
}
/* --- Narrower viewports --- */
@media (max-width: 991.98px) {
.airing-poster {
flex-basis: 140px;
}
.countdown-number {
font-size: 2.25rem;
}
}
@media (max-width: 767.98px) {
.airing-content {
flex-wrap: wrap;
padding: 1rem;
gap: 1rem;
}
.airing-poster {
flex-basis: 110px;
align-self: flex-start;
}
.airing-body {
flex-basis: 0;
}
/* The countdown drops below the row rather than squeezing the synopsis. */
.airing-countdown {
order: 3;
flex: 0 0 100%;
flex-direction: row;
justify-content: flex-start;
gap: 0.5rem;
padding-left: 0;
padding-top: 0.75rem;
border-left: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.countdown-number {
font-size: 1.5rem;
}
.airing-header {
flex-direction: column;
}
.airing-overview {
-webkit-line-clamp: 10;
}
}
/* --- Wide / ultra-wide / 4K display support ---
Bootstrap 5's ".container" tops out at max-width: 1320px starting at its
largest built-in breakpoint (1400px) and never grows beyond that, so on
large desktop and 4K monitors (1920px, 2560px, 3440px, 3840px+) the page
content sits in a narrow column with large empty side margins. These
extra breakpoints extend the same scaling pattern Bootstrap uses
(max-width ~= 94% of the viewport) up through 4K viewports. */
@media (min-width: 1600px) {
.container {
max-width: 1500px;
}
}
@media (min-width: 1900px) {
.container {
max-width: 1800px;
}
}
@media (min-width: 2560px) {
.container {
max-width: 2400px;
}
}
@media (min-width: 3200px) {
.container {
max-width: 3000px;
}
}
@media (min-width: 3840px) {
.container {
max-width: 3600px;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

+4
View File
@@ -0,0 +1,4 @@
// Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
// for details on configuring this project to bundle and minify static web assets.
// Write your JavaScript code.
@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2011-2021 Twitter, Inc.
Copyright (c) 2011-2021 The Bootstrap Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,427 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
background-color: currentColor;
border: 0;
opacity: 0.25;
}
hr:not([size]) {
height: 1px;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title],
abbr[data-bs-original-title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-left: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.2em;
background-color: #fcf8e3;
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: #0d6efd;
text-decoration: underline;
}
a:hover {
color: #0a58ca;
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 1em;
direction: ltr /* rtl:ignore */;
unicode-bidi: bidi-override;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: #d63384;
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.2rem 0.4rem;
font-size: 0.875em;
color: #fff;
background-color: #212529;
border-radius: 0.2rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
font-weight: 700;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: #6c757d;
text-align: left;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]::-webkit-calendar-picker-indicator {
display: none;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: left;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
outline-offset: -2px;
-webkit-appearance: textfield;
}
/* rtl:raw:
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::file-selector-button {
font: inherit;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){h1{font-size:2.5rem}}h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){h2{font-size:2rem}}h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){h3{font-size:1.75rem}}h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){h4{font-size:1.5rem}}h5{font-size:1.25rem}h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:.875em}mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}
/*# sourceMappingURL=bootstrap-reboot.min.css.map */
File diff suppressed because one or more lines are too long
@@ -0,0 +1,424 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
background-color: currentColor;
border: 0;
opacity: 0.25;
}
hr:not([size]) {
height: 1px;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title],
abbr[data-bs-original-title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-right: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-right: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.2em;
background-color: #fcf8e3;
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: #0d6efd;
text-decoration: underline;
}
a:hover {
color: #0a58ca;
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 1em;
direction: ltr ;
unicode-bidi: bidi-override;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: #d63384;
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.2rem 0.4rem;
font-size: 0.875em;
color: #fff;
background-color: #212529;
border-radius: 0.2rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
font-weight: 700;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: #6c757d;
text-align: right;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]::-webkit-calendar-picker-indicator {
display: none;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: right;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: right;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
outline-offset: -2px;
-webkit-appearance: textfield;
}
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::file-selector-button {
font: inherit;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){h1{font-size:2.5rem}}h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){h2{font-size:2rem}}h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){h3{font-size:1.75rem}}h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){h4{font-size:1.5rem}}h5{font-size:1.25rem}h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-right:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-right:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:.875em}mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:right}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:right;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:right}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}[type=email],[type=number],[type=tel],[type=url]{direction:ltr}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}
/*# sourceMappingURL=bootstrap-reboot.rtl.min.css.map */
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,23 @@
The MIT License (MIT)
Copyright (c) .NET Foundation and Contributors
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,435 @@
/**
* @license
* Unobtrusive validation support library for jQuery and jQuery Validate
* Copyright (c) .NET Foundation. All rights reserved.
* Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
* @version v4.0.0
*/
/*jslint white: true, browser: true, onevar: true, undef: true, nomen: true, eqeqeq: true, plusplus: true, bitwise: true, regexp: true, newcap: true, immed: true, strict: false */
/*global document: false, jQuery: false */
(function (factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define("jquery.validate.unobtrusive", ['jquery-validation'], factory);
} else if (typeof module === 'object' && module.exports) {
// CommonJS-like environments that support module.exports
module.exports = factory(require('jquery-validation'));
} else {
// Browser global
jQuery.validator.unobtrusive = factory(jQuery);
}
}(function ($) {
var $jQval = $.validator,
adapters,
data_validation = "unobtrusiveValidation";
function setValidationValues(options, ruleName, value) {
options.rules[ruleName] = value;
if (options.message) {
options.messages[ruleName] = options.message;
}
}
function splitAndTrim(value) {
return value.replace(/^\s+|\s+$/g, "").split(/\s*,\s*/g);
}
function escapeAttributeValue(value) {
// As mentioned on http://api.jquery.com/category/selectors/
return value.replace(/([!"#$%&'()*+,./:;<=>?@\[\\\]^`{|}~])/g, "\\$1");
}
function getModelPrefix(fieldName) {
return fieldName.substr(0, fieldName.lastIndexOf(".") + 1);
}
function appendModelPrefix(value, prefix) {
if (value.indexOf("*.") === 0) {
value = value.replace("*.", prefix);
}
return value;
}
function onError(error, inputElement) { // 'this' is the form element
var container = $(this).find("[data-valmsg-for='" + escapeAttributeValue(inputElement[0].name) + "']"),
replaceAttrValue = container.attr("data-valmsg-replace"),
replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) !== false : null;
container.removeClass("field-validation-valid").addClass("field-validation-error");
error.data("unobtrusiveContainer", container);
if (replace) {
container.empty();
error.removeClass("input-validation-error").appendTo(container);
}
else {
error.hide();
}
}
function onErrors(event, validator) { // 'this' is the form element
var container = $(this).find("[data-valmsg-summary=true]"),
list = container.find("ul");
if (list && list.length && validator.errorList.length) {
list.empty();
container.addClass("validation-summary-errors").removeClass("validation-summary-valid");
$.each(validator.errorList, function () {
$("<li />").html(this.message).appendTo(list);
});
}
}
function onSuccess(error) { // 'this' is the form element
var container = error.data("unobtrusiveContainer");
if (container) {
var replaceAttrValue = container.attr("data-valmsg-replace"),
replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) : null;
container.addClass("field-validation-valid").removeClass("field-validation-error");
error.removeData("unobtrusiveContainer");
if (replace) {
container.empty();
}
}
}
function onReset(event) { // 'this' is the form element
var $form = $(this),
key = '__jquery_unobtrusive_validation_form_reset';
if ($form.data(key)) {
return;
}
// Set a flag that indicates we're currently resetting the form.
$form.data(key, true);
try {
$form.data("validator").resetForm();
} finally {
$form.removeData(key);
}
$form.find(".validation-summary-errors")
.addClass("validation-summary-valid")
.removeClass("validation-summary-errors");
$form.find(".field-validation-error")
.addClass("field-validation-valid")
.removeClass("field-validation-error")
.removeData("unobtrusiveContainer")
.find(">*") // If we were using valmsg-replace, get the underlying error
.removeData("unobtrusiveContainer");
}
function validationInfo(form) {
var $form = $(form),
result = $form.data(data_validation),
onResetProxy = $.proxy(onReset, form),
defaultOptions = $jQval.unobtrusive.options || {},
execInContext = function (name, args) {
var func = defaultOptions[name];
func && $.isFunction(func) && func.apply(form, args);
};
if (!result) {
result = {
options: { // options structure passed to jQuery Validate's validate() method
errorClass: defaultOptions.errorClass || "input-validation-error",
errorElement: defaultOptions.errorElement || "span",
errorPlacement: function () {
onError.apply(form, arguments);
execInContext("errorPlacement", arguments);
},
invalidHandler: function () {
onErrors.apply(form, arguments);
execInContext("invalidHandler", arguments);
},
messages: {},
rules: {},
success: function () {
onSuccess.apply(form, arguments);
execInContext("success", arguments);
}
},
attachValidation: function () {
$form
.off("reset." + data_validation, onResetProxy)
.on("reset." + data_validation, onResetProxy)
.validate(this.options);
},
validate: function () { // a validation function that is called by unobtrusive Ajax
$form.validate();
return $form.valid();
}
};
$form.data(data_validation, result);
}
return result;
}
$jQval.unobtrusive = {
adapters: [],
parseElement: function (element, skipAttach) {
/// <summary>
/// Parses a single HTML element for unobtrusive validation attributes.
/// </summary>
/// <param name="element" domElement="true">The HTML element to be parsed.</param>
/// <param name="skipAttach" type="Boolean">[Optional] true to skip attaching the
/// validation to the form. If parsing just this single element, you should specify true.
/// If parsing several elements, you should specify false, and manually attach the validation
/// to the form when you are finished. The default is false.</param>
var $element = $(element),
form = $element.parents("form")[0],
valInfo, rules, messages;
if (!form) { // Cannot do client-side validation without a form
return;
}
valInfo = validationInfo(form);
valInfo.options.rules[element.name] = rules = {};
valInfo.options.messages[element.name] = messages = {};
$.each(this.adapters, function () {
var prefix = "data-val-" + this.name,
message = $element.attr(prefix),
paramValues = {};
if (message !== undefined) { // Compare against undefined, because an empty message is legal (and falsy)
prefix += "-";
$.each(this.params, function () {
paramValues[this] = $element.attr(prefix + this);
});
this.adapt({
element: element,
form: form,
message: message,
params: paramValues,
rules: rules,
messages: messages
});
}
});
$.extend(rules, { "__dummy__": true });
if (!skipAttach) {
valInfo.attachValidation();
}
},
parse: function (selector) {
/// <summary>
/// Parses all the HTML elements in the specified selector. It looks for input elements decorated
/// with the [data-val=true] attribute value and enables validation according to the data-val-*
/// attribute values.
/// </summary>
/// <param name="selector" type="String">Any valid jQuery selector.</param>
// $forms includes all forms in selector's DOM hierarchy (parent, children and self) that have at least one
// element with data-val=true
var $selector = $(selector),
$forms = $selector.parents()
.addBack()
.filter("form")
.add($selector.find("form"))
.has("[data-val=true]");
$selector.find("[data-val=true]").each(function () {
$jQval.unobtrusive.parseElement(this, true);
});
$forms.each(function () {
var info = validationInfo(this);
if (info) {
info.attachValidation();
}
});
}
};
adapters = $jQval.unobtrusive.adapters;
adapters.add = function (adapterName, params, fn) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
/// <param name="params" type="Array" optional="true">[Optional] An array of parameter names (strings) that will
/// be extracted from the data-val-nnnn-mmmm HTML attributes (where nnnn is the adapter name, and
/// mmmm is the parameter name).</param>
/// <param name="fn" type="Function">The function to call, which adapts the values from the HTML
/// attributes into jQuery Validate rules and/or messages.</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
if (!fn) { // Called with no params, just a function
fn = params;
params = [];
}
this.push({ name: adapterName, params: params, adapt: fn });
return this;
};
adapters.addBool = function (adapterName, ruleName) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
/// the jQuery Validate validation rule has no parameter values.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
/// <param name="ruleName" type="String" optional="true">[Optional] The name of the jQuery Validate rule. If not provided, the value
/// of adapterName will be used instead.</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
return this.add(adapterName, function (options) {
setValidationValues(options, ruleName || adapterName, true);
});
};
adapters.addMinMax = function (adapterName, minRuleName, maxRuleName, minMaxRuleName, minAttribute, maxAttribute) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
/// the jQuery Validate validation has three potential rules (one for min-only, one for max-only, and
/// one for min-and-max). The HTML parameters are expected to be named -min and -max.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
/// <param name="minRuleName" type="String">The name of the jQuery Validate rule to be used when you only
/// have a minimum value.</param>
/// <param name="maxRuleName" type="String">The name of the jQuery Validate rule to be used when you only
/// have a maximum value.</param>
/// <param name="minMaxRuleName" type="String">The name of the jQuery Validate rule to be used when you
/// have both a minimum and maximum value.</param>
/// <param name="minAttribute" type="String" optional="true">[Optional] The name of the HTML attribute that
/// contains the minimum value. The default is "min".</param>
/// <param name="maxAttribute" type="String" optional="true">[Optional] The name of the HTML attribute that
/// contains the maximum value. The default is "max".</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
return this.add(adapterName, [minAttribute || "min", maxAttribute || "max"], function (options) {
var min = options.params.min,
max = options.params.max;
if (min && max) {
setValidationValues(options, minMaxRuleName, [min, max]);
}
else if (min) {
setValidationValues(options, minRuleName, min);
}
else if (max) {
setValidationValues(options, maxRuleName, max);
}
});
};
adapters.addSingleVal = function (adapterName, attribute, ruleName) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
/// the jQuery Validate validation rule has a single value.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute(where nnnn is the adapter name).</param>
/// <param name="attribute" type="String">[Optional] The name of the HTML attribute that contains the value.
/// The default is "val".</param>
/// <param name="ruleName" type="String" optional="true">[Optional] The name of the jQuery Validate rule. If not provided, the value
/// of adapterName will be used instead.</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
return this.add(adapterName, [attribute || "val"], function (options) {
setValidationValues(options, ruleName || adapterName, options.params[attribute]);
});
};
$jQval.addMethod("__dummy__", function (value, element, params) {
return true;
});
$jQval.addMethod("regex", function (value, element, params) {
var match;
if (this.optional(element)) {
return true;
}
match = new RegExp(params).exec(value);
return (match && (match.index === 0) && (match[0].length === value.length));
});
$jQval.addMethod("nonalphamin", function (value, element, nonalphamin) {
var match;
if (nonalphamin) {
match = value.match(/\W/g);
match = match && match.length >= nonalphamin;
}
return match;
});
if ($jQval.methods.extension) {
adapters.addSingleVal("accept", "mimtype");
adapters.addSingleVal("extension", "extension");
} else {
// for backward compatibility, when the 'extension' validation method does not exist, such as with versions
// of JQuery Validation plugin prior to 1.10, we should use the 'accept' method for
// validating the extension, and ignore mime-type validations as they are not supported.
adapters.addSingleVal("extension", "extension", "accept");
}
adapters.addSingleVal("regex", "pattern");
adapters.addBool("creditcard").addBool("date").addBool("digits").addBool("email").addBool("number").addBool("url");
adapters.addMinMax("length", "minlength", "maxlength", "rangelength").addMinMax("range", "min", "max", "range");
adapters.addMinMax("minlength", "minlength").addMinMax("maxlength", "minlength", "maxlength");
adapters.add("equalto", ["other"], function (options) {
var prefix = getModelPrefix(options.element.name),
other = options.params.other,
fullOtherName = appendModelPrefix(other, prefix),
element = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(fullOtherName) + "']")[0];
setValidationValues(options, "equalTo", element);
});
adapters.add("required", function (options) {
// jQuery Validate equates "required" with "mandatory" for checkbox elements
if (options.element.tagName.toUpperCase() !== "INPUT" || options.element.type.toUpperCase() !== "CHECKBOX") {
setValidationValues(options, "required", true);
}
});
adapters.add("remote", ["url", "type", "additionalfields"], function (options) {
var value = {
url: options.params.url,
type: options.params.type || "GET",
data: {}
},
prefix = getModelPrefix(options.element.name);
$.each(splitAndTrim(options.params.additionalfields || options.element.name), function (i, fieldName) {
var paramName = appendModelPrefix(fieldName, prefix);
value.data[paramName] = function () {
var field = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(paramName) + "']");
// For checkboxes and radio buttons, only pick up values from checked fields.
if (field.is(":checkbox")) {
return field.filter(":checked").val() || field.filter(":hidden").val() || '';
}
else if (field.is(":radio")) {
return field.filter(":checked").val() || '';
}
return field.val();
};
});
setValidationValues(options, "remote", value);
});
adapters.add("password", ["min", "nonalphamin", "regex"], function (options) {
if (options.params.min) {
setValidationValues(options, "minlength", options.params.min);
}
if (options.params.nonalphamin) {
setValidationValues(options, "nonalphamin", options.params.nonalphamin);
}
if (options.params.regex) {
setValidationValues(options, "regex", options.params.regex);
}
});
adapters.add("fileextensions", ["extensions"], function (options) {
setValidationValues(options, "extension", options.params.extensions);
});
$(function () {
$jQval.unobtrusive.parse(document);
});
return $jQval.unobtrusive;
}));
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
The MIT License (MIT)
=====================
Copyright Jörn Zaefferer
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,21 @@
Copyright OpenJS Foundation and other contributors, https://openjsf.org/
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+48
View File
@@ -7,20 +7,68 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AnimeAnnouncer", "AnimeAnno
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MassTorrentAdd", "MassTorrentAdd\MassTorrentAdd.csproj", "{AE94FFAE-8D5B-46EF-BC7C-48510F0F7F04}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MassTorrentAdd", "MassTorrentAdd\MassTorrentAdd.csproj", "{AE94FFAE-8D5B-46EF-BC7C-48510F0F7F04}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AnimeAnnouncer.Web", "AnimeAnnouncer.Web\AnimeAnnouncer.Web.csproj", "{DD4F134D-B455-4A8C-8DDE-AA0ED735DA42}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AnimeAnnouncer.Torrents", "AnimeAnnouncer.Torrents\AnimeAnnouncer.Torrents.csproj", "{901424DF-F8CD-449B-9517-73CA42D61B54}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{B6D808BF-954E-48C8-A37E-62A8AA4E06C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B6D808BF-954E-48C8-A37E-62A8AA4E06C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B6D808BF-954E-48C8-A37E-62A8AA4E06C0}.Debug|Any CPU.Build.0 = Debug|Any CPU {B6D808BF-954E-48C8-A37E-62A8AA4E06C0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B6D808BF-954E-48C8-A37E-62A8AA4E06C0}.Debug|x64.ActiveCfg = Debug|Any CPU
{B6D808BF-954E-48C8-A37E-62A8AA4E06C0}.Debug|x64.Build.0 = Debug|Any CPU
{B6D808BF-954E-48C8-A37E-62A8AA4E06C0}.Debug|x86.ActiveCfg = Debug|Any CPU
{B6D808BF-954E-48C8-A37E-62A8AA4E06C0}.Debug|x86.Build.0 = Debug|Any CPU
{B6D808BF-954E-48C8-A37E-62A8AA4E06C0}.Release|Any CPU.ActiveCfg = Release|Any CPU {B6D808BF-954E-48C8-A37E-62A8AA4E06C0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B6D808BF-954E-48C8-A37E-62A8AA4E06C0}.Release|Any CPU.Build.0 = Release|Any CPU {B6D808BF-954E-48C8-A37E-62A8AA4E06C0}.Release|Any CPU.Build.0 = Release|Any CPU
{B6D808BF-954E-48C8-A37E-62A8AA4E06C0}.Release|x64.ActiveCfg = Release|Any CPU
{B6D808BF-954E-48C8-A37E-62A8AA4E06C0}.Release|x64.Build.0 = Release|Any CPU
{B6D808BF-954E-48C8-A37E-62A8AA4E06C0}.Release|x86.ActiveCfg = Release|Any CPU
{B6D808BF-954E-48C8-A37E-62A8AA4E06C0}.Release|x86.Build.0 = Release|Any CPU
{AE94FFAE-8D5B-46EF-BC7C-48510F0F7F04}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AE94FFAE-8D5B-46EF-BC7C-48510F0F7F04}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AE94FFAE-8D5B-46EF-BC7C-48510F0F7F04}.Debug|Any CPU.Build.0 = Debug|Any CPU {AE94FFAE-8D5B-46EF-BC7C-48510F0F7F04}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AE94FFAE-8D5B-46EF-BC7C-48510F0F7F04}.Debug|x64.ActiveCfg = Debug|Any CPU
{AE94FFAE-8D5B-46EF-BC7C-48510F0F7F04}.Debug|x64.Build.0 = Debug|Any CPU
{AE94FFAE-8D5B-46EF-BC7C-48510F0F7F04}.Debug|x86.ActiveCfg = Debug|Any CPU
{AE94FFAE-8D5B-46EF-BC7C-48510F0F7F04}.Debug|x86.Build.0 = Debug|Any CPU
{AE94FFAE-8D5B-46EF-BC7C-48510F0F7F04}.Release|Any CPU.ActiveCfg = Release|Any CPU {AE94FFAE-8D5B-46EF-BC7C-48510F0F7F04}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AE94FFAE-8D5B-46EF-BC7C-48510F0F7F04}.Release|Any CPU.Build.0 = Release|Any CPU {AE94FFAE-8D5B-46EF-BC7C-48510F0F7F04}.Release|Any CPU.Build.0 = Release|Any CPU
{AE94FFAE-8D5B-46EF-BC7C-48510F0F7F04}.Release|x64.ActiveCfg = Release|Any CPU
{AE94FFAE-8D5B-46EF-BC7C-48510F0F7F04}.Release|x64.Build.0 = Release|Any CPU
{AE94FFAE-8D5B-46EF-BC7C-48510F0F7F04}.Release|x86.ActiveCfg = Release|Any CPU
{AE94FFAE-8D5B-46EF-BC7C-48510F0F7F04}.Release|x86.Build.0 = Release|Any CPU
{DD4F134D-B455-4A8C-8DDE-AA0ED735DA42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DD4F134D-B455-4A8C-8DDE-AA0ED735DA42}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DD4F134D-B455-4A8C-8DDE-AA0ED735DA42}.Debug|x64.ActiveCfg = Debug|Any CPU
{DD4F134D-B455-4A8C-8DDE-AA0ED735DA42}.Debug|x64.Build.0 = Debug|Any CPU
{DD4F134D-B455-4A8C-8DDE-AA0ED735DA42}.Debug|x86.ActiveCfg = Debug|Any CPU
{DD4F134D-B455-4A8C-8DDE-AA0ED735DA42}.Debug|x86.Build.0 = Debug|Any CPU
{DD4F134D-B455-4A8C-8DDE-AA0ED735DA42}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DD4F134D-B455-4A8C-8DDE-AA0ED735DA42}.Release|Any CPU.Build.0 = Release|Any CPU
{DD4F134D-B455-4A8C-8DDE-AA0ED735DA42}.Release|x64.ActiveCfg = Release|Any CPU
{DD4F134D-B455-4A8C-8DDE-AA0ED735DA42}.Release|x64.Build.0 = Release|Any CPU
{DD4F134D-B455-4A8C-8DDE-AA0ED735DA42}.Release|x86.ActiveCfg = Release|Any CPU
{DD4F134D-B455-4A8C-8DDE-AA0ED735DA42}.Release|x86.Build.0 = Release|Any CPU
{901424DF-F8CD-449B-9517-73CA42D61B54}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{901424DF-F8CD-449B-9517-73CA42D61B54}.Debug|Any CPU.Build.0 = Debug|Any CPU
{901424DF-F8CD-449B-9517-73CA42D61B54}.Debug|x64.ActiveCfg = Debug|Any CPU
{901424DF-F8CD-449B-9517-73CA42D61B54}.Debug|x64.Build.0 = Debug|Any CPU
{901424DF-F8CD-449B-9517-73CA42D61B54}.Debug|x86.ActiveCfg = Debug|Any CPU
{901424DF-F8CD-449B-9517-73CA42D61B54}.Debug|x86.Build.0 = Debug|Any CPU
{901424DF-F8CD-449B-9517-73CA42D61B54}.Release|Any CPU.ActiveCfg = Release|Any CPU
{901424DF-F8CD-449B-9517-73CA42D61B54}.Release|Any CPU.Build.0 = Release|Any CPU
{901424DF-F8CD-449B-9517-73CA42D61B54}.Release|x64.ActiveCfg = Release|Any CPU
{901424DF-F8CD-449B-9517-73CA42D61B54}.Release|x64.Build.0 = Release|Any CPU
{901424DF-F8CD-449B-9517-73CA42D61B54}.Release|x86.ActiveCfg = Release|Any CPU
{901424DF-F8CD-449B-9517-73CA42D61B54}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
+54
View File
@@ -0,0 +1,54 @@
using Mastonet.Entities;
namespace AnimeAnnouncer;
using Kavita.AniListNet; // Contains base classes for interacting with the API
using Kavita.AniListNet.Parameters; // Contains classes for mutation and filtering
using Kavita.AniListNet.Objects; // Contains model classes for handling data
public class Anilist
{
public String Token { get; set; }
private AniClient client;
public Anilist(String token)
{
Token = token;
client = new AniClient();
Task.WaitAll(Authenticate());
}
public async Task<Media?> RetrieveAiringShowInformation(String showName)
{
var results = await client.SearchMediaAsync(new SearchMediaFilter
{
Query = showName, // The term to search for
Type = MediaType.Anime, // Filters search results to anime only
Sort = MediaSort.Popularity, // Sorts them by popularity
Format = new Dictionary<MediaFormat, bool>
{
{ MediaFormat.TV, true }, // Set to only search for TV shows and movies
{ MediaFormat.Movie, true },
{ MediaFormat.TVShort, false } // Set to not show TV shorts
}
});
int currentYear = DateTime.Now.Year;
var result = results.Data.Where(p => p.SeasonYear >= currentYear).OrderByDescending(p => p.StartDate.ToDateTime()).ThenByDescending(p => p.Id).FirstOrDefault();
return result;
}
public async Task<List<String>> GetMediaTags(int mediaId)
{
var results = await client.GetMediaTagsAsync(mediaId);
return results.Select(p => p.Name).ToList();
}
private async Task Authenticate()
{
Boolean success = await client.TryAuthenticateAsync(Token);
if (!success)
throw new Exception("Unable to authenticate with AniList!");
}
}
+10 -1
View File
@@ -8,17 +8,26 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AnitomySharp.NET6" Version="0.5.1" /> <PackageReference Include="AnitomySharp.NET6" Version="0.5.1" />
<PackageReference Include="Kavita.AniListNet" Version="0.1.3" />
<PackageReference Include="Mastonet" Version="3.1.2" /> <PackageReference Include="Mastonet" Version="3.1.2" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.21.0" /> <PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.21.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="StackExchange.Redis" Version="2.8.16" /> <PackageReference Include="StackExchange.Redis" Version="2.8.16" />
<PackageReference Include="TMDbLib" Version="2.2.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Update="appSettings.json"> <None Update="appSettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None> </None>
</ItemGroup> </ItemGroup>
<ItemGroup>
<Reference Include="TMDbLib">
<HintPath>..\Libraries\TMDbLib.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AnimeAnnouncer.Torrents\AnimeAnnouncer.Torrents.csproj" />
</ItemGroup>
</Project> </Project>
+12
View File
@@ -0,0 +1,12 @@
namespace AnimeAnnouncer.Cache
{
public class AiringSoonItem
{
public required String Title { get; set; }
public int ShowID { get; set; }
public DateTime? LastAirDate { get; set; }
public int LatestEpisodeNumber { get; set; }
public bool Download { get; set; }
}
}
+45
View File
@@ -0,0 +1,45 @@
using Kavita.AniListNet.Objects;
namespace AnimeAnnouncer.Cache
{
public class AnilistCacheItem
{
public required String Title { get; set; }
public int ShowID { get; set; }
public string? EnglishTitle { get; set; }
public string? RomajiTitle { get; set; }
public string? NativeTitle { get; set; }
public int? Episodes { get; set; }
public string? Status { get; set; }
public int? SeasonYear { get; set; }
public string? Description { get; set; }
public string? BannerImageUrl { get; set; }
public string? CoverImageUrl { get; set; }
public int? AverageScore { get; set; }
public List<String> Genres { get; set; }
public List<String> Tags { get; set; }
public DateTime? Updated { get; set; }
public static AnilistCacheItem FromMedia(Media media, List<String> tags)
{
return new AnilistCacheItem
{
Title = media.Title.EnglishTitle ?? media.Title.RomajiTitle,
ShowID = media.Id,
EnglishTitle = media.Title.EnglishTitle,
RomajiTitle = media.Title.RomajiTitle,
NativeTitle = media.Title.NativeTitle,
Episodes = media.Episodes,
Status = media.Status?.ToString(),
SeasonYear = media.SeasonYear,
Description = media.Description,
BannerImageUrl = media.BannerImageUrl?.ToString(),
CoverImageUrl = media.Cover?.ExtraLargeImageUrl?.ToString(),
AverageScore = media.AverageScore,
Genres = media.Genres.ToList(),
Tags = tags
};
}
}
}
+50 -5
View File
@@ -17,22 +17,62 @@ public class TMDBCache
CacheExpiration = TimeSpan.FromDays(3); CacheExpiration = TimeSpan.FromDays(3);
redis = ConnectionMultiplexer.Connect(redisConnectionString); redis = ConnectionMultiplexer.Connect(redisConnectionString);
} }
public async Task SetCacheItem(String key, TMDBCacheItem item) public async Task SetCacheItem<T>(String key, T item)
{ {
var db = redis.GetDatabase(); var db = redis.GetDatabase();
String jsonItem = JsonSerializer.Serialize(item); String jsonItem = JsonSerializer.Serialize(item);
await db.StringSetAsync(key, jsonItem, CacheExpiration); await db.StringSetAsync(key, jsonItem, CacheExpiration);
} }
public async Task<TMDBCacheItem> GetCacheItem(String key) public async Task<T?> GetCacheItem<T>(String key)
{ {
var db = redis.GetDatabase(); var db = redis.GetDatabase();
String jsonItem = await db.StringGetAsync(key); String? jsonItem = await db.StringGetAsync(key);
if(!String.IsNullOrEmpty(jsonItem)) if (!String.IsNullOrEmpty(jsonItem))
return JsonSerializer.Deserialize<TMDBCacheItem>(jsonItem); return JsonSerializer.Deserialize<T>(jsonItem);
else
return default;
}
public async Task RemoveCacheItem(String key)
{
var db = redis.GetDatabase();
_ = await db.KeyDeleteAsync(key);
}
public async Task SetAiringSoon(List<AiringSoonItem> list)
{
var db = redis.GetDatabase();
String jsonItem = JsonSerializer.Serialize(list);
await db.StringSetAsync("AiringSoon", jsonItem, TimeSpan.FromDays(60));
}
public async Task<List<AiringSoonItem>> GetAiringList()
{
var db = redis.GetDatabase();
String jsonItem = await db.StringGetAsync("AiringSoon");
if (!String.IsNullOrEmpty(jsonItem))
return JsonSerializer.Deserialize<List<AiringSoonItem>>(jsonItem);
else else
return null; return null;
} }
/// <summary>Sets the Download flag on one airing list entry. Returns false when the show is not in the list.</summary>
public async Task<bool> SetDownload(int showID, bool download)
{
var list = await GetAiringList();
var item = list?.FirstOrDefault(s => s.ShowID == showID);
if (item == null)
return false;
item.Download = download;
await SetAiringSoon(list!);
return true;
}
public async Task<bool> KeyExists(String key) public async Task<bool> KeyExists(String key)
{ {
var db = redis.GetDatabase(); var db = redis.GetDatabase();
@@ -43,4 +83,9 @@ public class TMDBCache
var db = redis.GetDatabase(); var db = redis.GetDatabase();
await db.StringSetAsync(key, value); await db.StringSetAsync(key, value);
} }
public async Task<String?> GetStringValue(String key)
{
var db = redis.GetDatabase();
return await db.StringGetAsync(key);
}
} }
+12
View File
@@ -15,5 +15,17 @@ namespace AnimeAnnouncer.Cache
public double VoteAverage { get; set; } public double VoteAverage { get; set; }
public string? Overview { get; set; } public string? Overview { get; set; }
public int? LatestOrdinalEpisodeNumber { get; set; } public int? LatestOrdinalEpisodeNumber { get; set; }
public DateTime? LastAirDate { get; set; }
//This is the latest episode number to air, NOT the last episode in the series
public int? LatestEpisodeNumber { get; set; }
public Boolean? AnilistPopulated { get; set; }
public DateTime? Updated { get; set; }
public List<String> Genres { get; set; }
public List<String> Keywords { get; set; }
} }
} }
+402 -29
View File
@@ -1,13 +1,17 @@
using System.ComponentModel; using System.ComponentModel;
using System.Runtime.ExceptionServices;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using AnimeAnnouncer.Cache; using AnimeAnnouncer.Cache;
using AnimeAnnouncer.RSS; using AnimeAnnouncer.RSS;
using AnimeAnnouncer.Torrents;
using Mastonet; using Mastonet;
using Mastonet.Entities;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using TMDbLib.Client; using TMDbLib.Client;
using TMDbLib.Objects.Authentication; using TMDbLib.Objects.Authentication;
using TMDbLib.Objects.TvShows;
namespace AnimeAnnouncer namespace AnimeAnnouncer
{ {
@@ -18,6 +22,12 @@ namespace AnimeAnnouncer
private static TMDbClient tmdbClient; private static TMDbClient tmdbClient;
private static TMDBCache tmdbCache; private static TMDBCache tmdbCache;
private static MastodonClient mastodonClient; private static MastodonClient mastodonClient;
private static Anilist? anilistClient;
private static List<AiringSoonItem> airingSoonList;
private static TransmissionService transmissionService;
private static String destinationFolder;
static void Main(string[] args) static void Main(string[] args)
{ {
Console.WriteLine("Starting..."); Console.WriteLine("Starting...");
@@ -44,6 +54,8 @@ namespace AnimeAnnouncer
Console.WriteLine("Using cache"); Console.WriteLine("Using cache");
} }
tmdbCache.CacheExpiration = TimeSpan.FromDays(8);
String mastodonInstance = config["MastodonInstance"]; String mastodonInstance = config["MastodonInstance"];
if(!String.IsNullOrEmpty(mastodonInstance)) if(!String.IsNullOrEmpty(mastodonInstance))
@@ -52,14 +64,55 @@ namespace AnimeAnnouncer
mastodonClient = new MastodonClient(mastodonInstance, mastodonToken); mastodonClient = new MastodonClient(mastodonInstance, mastodonToken);
} }
String anilistToken = config["AnilistToken"];
if (!String.IsNullOrEmpty(anilistToken))
anilistClient = new Anilist(anilistToken);
String? transmissionHost = config["transmissionURI"];
String? transmissionUsername = config["transmissionUsername"];
String? transmissionPassword = config["transmissionPassword"];
destinationFolder = config["destinationFolder"];
if (transmissionHost != null && transmissionUsername != null && transmissionPassword != null &&
destinationFolder != null)
{
Console.WriteLine("Connecting to transmission");
transmissionService = TransmissionService.ConnectAsync(transmissionHost, transmissionUsername, transmissionPassword).Result;
}
airingSoonList = tmdbCache.GetAiringList().Result;
if(airingSoonList == null)
airingSoonList = new List<AiringSoonItem>();
if (args.Any(p => p == "--show-airing-list"))
{
PrintAiringList();
return;
}
nyaaIndexer.NewPost += OnNewPost; nyaaIndexer.NewPost += OnNewPost;
nyaaIndexer.RssReadFinished += OnRssReadFinished; nyaaIndexer.RssReadFinished += OnRssReadFinished;
nyaaIndexer.Start(true); nyaaIndexer.IterationSleepTime = 60;
nyaaIndexer.Start(false);
Console.WriteLine("Press enter to quit..."); Console.WriteLine("Press enter to quit...");
Console.ReadLine(); Console.ReadLine();
} }
private static void PrintAiringList()
{
var airingThisHalf = airingSoonList.Where(a => a.LastAirDate > DateTime.Now && a.LastAirDate.Value.Subtract(DateTime.Now) < TimeSpan.FromDays(180)).ToList();
int cnt = 1;
for(int i = 0;i < airingThisHalf.Count;i++)
{
var airing = airingThisHalf[i];
Console.WriteLine($"{cnt++}. {airing.Title} on {airing.LastAirDate:MM/dd/yyyy}{Environment.NewLine}");
}
}
static async void OnNewPost(RSS.NyaaItem item) static async void OnNewPost(RSS.NyaaItem item)
{ {
try try
@@ -74,6 +127,14 @@ namespace AnimeAnnouncer
static void OnRssReadFinished() static void OnRssReadFinished()
{ {
if(DateTime.Today.Day == 1)
{
if(!tmdbCache.KeyExists($"UpcomingAnnounced-{DateTime.Today.Year}-{DateTime.Today.Month}").Result)
{
CreateAiringMonthPost().Wait();
tmdbCache.SetPair($"UpcomingAnnounced-{DateTime.Today.Year}-{DateTime.Today.Month}", "1").Wait();
}
}
} }
@@ -93,21 +154,29 @@ namespace AnimeAnnouncer
return; return;
} }
int latestSeasonNumber = 0, latestEpisodeNumber = 0, supposedShowId = 0; int latestSeasonNumber = 0, lastEpisodeNumber = 0, latestEpisodeNumber = 0, supposedShowId = 0;
int? latestOrdinalEpisodeNumber = null; int? latestOrdinalEpisodeNumber = null;
bool finaleConfirmed = false;
DateTime? lastAirDate = null;
TMDBCacheItem? cachedShow = null; TMDBCacheItem? cachedShow = null;
latestEpisodeNumber = int.Parse(episode);
try try
{ {
cachedShow = await tmdbCache.GetCacheItem($"ShowCache-{title}"); cachedShow = await tmdbCache.GetCacheItem<TMDBCacheItem>($"ShowCache-{title}");
if(cachedShow != null)
if(cachedShow != null && (cachedShow.Updated.HasValue && DateTime.Now.Subtract(cachedShow.Updated.Value).Days < 6))
{ {
Console.WriteLine("Cache hit"); Console.WriteLine("Cache hit");
latestSeasonNumber = cachedShow.LatestSeasonNumber; latestSeasonNumber = cachedShow.LatestSeasonNumber;
latestEpisodeNumber = cachedShow.LastEpisodeNumber; lastEpisodeNumber = cachedShow.LastEpisodeNumber;
latestOrdinalEpisodeNumber = cachedShow.LatestOrdinalEpisodeNumber; latestOrdinalEpisodeNumber = cachedShow.LatestOrdinalEpisodeNumber;
lastAirDate = cachedShow.LastAirDate;
supposedShowId = cachedShow.ShowID; supposedShowId = cachedShow.ShowID;
} }
} }
@@ -129,9 +198,10 @@ namespace AnimeAnnouncer
return; return;
} }
supposedShowId = searchResults.Results.First().Id; //supposedShowId = searchResults.Results.First().Id;
supposedShowId = searchResults.Results.OrderBy(r => ComputeLevenshteinDistance(title, r.Name)).First().Id;
var showResult = await tmdbClient.GetTvShowAsync(supposedShowId); var showResult = await tmdbClient.GetTvShowAsync(supposedShowId, TvShowMethods.Keywords);
var latestSeason = showResult.Seasons.Where(s => s.AirDate.HasValue && s.AirDate.Value < DateTime.Now).OrderByDescending(s => s.SeasonNumber).FirstOrDefault(); var latestSeason = showResult.Seasons.Where(s => s.AirDate.HasValue && s.AirDate.Value < DateTime.Now).OrderByDescending(s => s.SeasonNumber).FirstOrDefault();
@@ -143,20 +213,69 @@ namespace AnimeAnnouncer
latestSeasonNumber = latestSeason.SeasonNumber; latestSeasonNumber = latestSeason.SeasonNumber;
latestEpisodeNumber = latestSeason.EpisodeCount; lastEpisodeNumber = latestSeason.EpisodeCount;
//if nearing the end of a season, confirm it's marked with season finale //if nearing the end of a season, confirm it's marked with season finale
if(int.Parse(episode) >= (latestEpisodeNumber - 2)) if(latestEpisodeNumber >= (lastEpisodeNumber - 2) || !HasAiringEndDate(supposedShowId))
{ {
Console.WriteLine("Querying episode information");
var latestSeasonDetail = await tmdbClient.GetTvSeasonAsync(supposedShowId, latestSeasonNumber); var latestSeasonDetail = await tmdbClient.GetTvSeasonAsync(supposedShowId, latestSeasonNumber);
var seasonFinale = latestSeasonDetail.Episodes.LastOrDefault(e => e.EpisodeType.Equals("finale", StringComparison.InvariantCultureIgnoreCase)); var seasonFinale = latestSeasonDetail.Episodes.LastOrDefault(e => e.EpisodeType.Equals("finale", StringComparison.InvariantCultureIgnoreCase));
if(seasonFinale != null && seasonFinale.EpisodeNumber != latestEpisodeNumber) if(seasonFinale != null)
{ {
Console.WriteLine($"Overriding previous finale choice of {latestEpisodeNumber} due to season detail response where it's {seasonFinale.EpisodeNumber}"); if(seasonFinale.EpisodeNumber != lastEpisodeNumber)
latestEpisodeNumber = seasonFinale.EpisodeNumber; {
Console.WriteLine($"Overriding previous finale choice of {lastEpisodeNumber} due to season detail response where it's {seasonFinale.EpisodeNumber}");
lastEpisodeNumber = seasonFinale.EpisodeNumber;
}
finaleConfirmed = true;
if (!lastAirDate.HasValue)
lastAirDate = seasonFinale.AirDate ?? DateTime.Now;
}
}
//potentially override TMDB episode count with more reliable anilist data
string? anilistPosterPath = null;
if (latestEpisodeNumber > 3 && anilistClient != null)
{
try
{
AnilistCacheItem? anilistCacheItem = await tmdbCache.GetCacheItem<AnilistCacheItem>($"AnilistCache-{title}");
if (anilistCacheItem == null)
{
Console.WriteLine("Looking up Anilist title " + title);
var anilistMedia = await anilistClient.RetrieveAiringShowInformation(title);
if (anilistMedia != null)
{
var tags = await anilistClient.GetMediaTags(anilistMedia.Id);
anilistCacheItem = AnilistCacheItem.FromMedia(anilistMedia, tags);
anilistCacheItem.Updated = DateTime.Now;
_ = tmdbCache.SetCacheItem($"AnilistCache-{title}", anilistCacheItem);
}
else
Console.WriteLine("0 anilist results found");
}
if (anilistCacheItem != null)
{
if (anilistCacheItem.Episodes.HasValue && anilistCacheItem.Episodes != lastEpisodeNumber)
{
Console.WriteLine(
$"Overriding episode count of {lastEpisodeNumber} using Anilist's showID {anilistCacheItem.ShowID} titled {anilistCacheItem.EnglishTitle} with {anilistCacheItem.Episodes} episodes.");
lastEpisodeNumber = anilistCacheItem.Episodes.Value;
}
anilistPosterPath = anilistCacheItem.BannerImageUrl;
}
}
catch (Exception ex)
{
Console.WriteLine($"An error occurred during Anilist lookup: {ex}");
} }
} }
@@ -164,6 +283,11 @@ namespace AnimeAnnouncer
{ {
latestOrdinalEpisodeNumber = showResult.Seasons.Where(s => s.SeasonNumber != 0).Sum(s => s.EpisodeCount); latestOrdinalEpisodeNumber = showResult.Seasons.Where(s => s.SeasonNumber != 0).Sum(s => s.EpisodeCount);
} }
//try to figure out Last Air Date based on this new airing episode
if(lastEpisodeNumber > latestEpisodeNumber)
{
lastAirDate = DateTime.Today.AddDays(7 * (lastEpisodeNumber - latestEpisodeNumber));
}
if(tmdbCache != null && searchResults != null) if(tmdbCache != null && searchResults != null)
{ {
@@ -181,10 +305,19 @@ namespace AnimeAnnouncer
VoteAverage = showResult.VoteAverage, VoteAverage = showResult.VoteAverage,
ShowID = supposedShowId, ShowID = supposedShowId,
LatestSeasonNumber = latestSeasonNumber, LatestSeasonNumber = latestSeasonNumber,
LastEpisodeNumber = latestEpisodeNumber LastEpisodeNumber = lastEpisodeNumber,
LastAirDate = lastAirDate,
LatestEpisodeNumber = latestEpisodeNumber,
AnilistPopulated = anilistPosterPath != null,
Updated = DateTime.Now,
Keywords = showResult.Keywords != null ? showResult.Keywords.Results.Select(p => p.Name).ToList() : new List<string>(),
Genres = showResult.Genres != null ? showResult.Genres.Select(p => p.Name).ToList() : new List<string>(),
}; };
_ = tmdbCache.SetCacheItem($"ShowCache-{title}", cachedShow); _ = tmdbCache.SetCacheItem($"ShowCache-{title}", cachedShow);
Console.WriteLine($"{title} Added to cache"); Console.WriteLine($"{title} Added to cache");
if(latestSeason.AirDate < DateTime.Now && (latestSeason.AirDate + TimeSpan.FromDays(180)) > DateTime.Now &&
((showResult.Status == "Ended" || showResult.NextEpisodeToAir == null ? showResult.LastAirDate : showResult.NextEpisodeToAir.AirDate) + TimeSpan.FromDays(30)) > DateTime.Now)
UpdateAiringShowList(cachedShow);
} }
catch(Exception ex) catch(Exception ex)
{ {
@@ -196,41 +329,101 @@ namespace AnimeAnnouncer
if(latestSeasonNumber != int.Parse(season)) if(latestSeasonNumber != int.Parse(season))
{
var titleSeason = int.Parse(season);
bool seasonOverride = false;
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");
if(seasonEpisodeGroup != null)
{ //Evaluate using new episode group
var targetEpisodeGroup = await tmdbClient.GetTvEpisodeGroupsAsync(seasonEpisodeGroup.Id);
if(targetEpisodeGroup != null)
{
var targetSeason = targetEpisodeGroup.Groups.FirstOrDefault(g => g.Order == titleSeason);
if(targetSeason != null)
{ //Episode_Type isn't making it with the current client, guess based on ordering
var targetFinale = targetSeason.Episodes.OrderByDescending(g => g.Order).FirstOrDefault(e => e.EpisodeType.Equals("finale", StringComparison.InvariantCultureIgnoreCase));
//Confirm it's aired within the last week
if(targetFinale != null && targetEpisodeGroup.Groups.Where(g => g.Episodes.Count != 0).OrderByDescending(g => g.Order).FirstOrDefault() == targetSeason)
{ //confirm the episode is marked as finale and that this season is the latest one in the episode group
Console.WriteLine($"!!Choosing S{titleSeason}E{targetFinale.Order + 1} airing on {targetFinale.AirDate} for finale using episode groups");
cachedShow.LatestSeasonNumber = latestSeasonNumber = titleSeason;
cachedShow.LastEpisodeNumber = lastEpisodeNumber = targetFinale.Order + 1;
if(cachedShow.LastEpisodeNumber > cachedShow.LatestEpisodeNumber)
{
cachedShow.LastAirDate = DateTime.Today.AddDays(7 * (lastEpisodeNumber - latestEpisodeNumber));
}
else
{
cachedShow.LastAirDate = lastAirDate = targetFinale.AirDate;
}
seasonOverride = finaleConfirmed = true;
_ = tmdbCache.SetCacheItem($"ShowCache-{title}", cachedShow);
if(targetSeason != null && (targetSeason.Episodes.OrderBy(e => e.Order).First().AirDate + TimeSpan.FromDays(180)) > DateTime.Now &&
((showResult.NextEpisodeToAir ?? showResult.LastEpisodeToAir).AirDate + TimeSpan.FromDays(30)) > DateTime.Now)
UpdateAiringShowList(cachedShow);
}
else
Console.WriteLine("Could not find a finale episode.");
}
}
}
}
if(!seasonOverride)
{ {
Console.WriteLine($"Failing release due to TMDB's season number {latestSeasonNumber} not matching title season {season}"); Console.WriteLine($"Failing release due to TMDB's season number {latestSeasonNumber} not matching title season {season}");
return; return;
} }
}
if(latestEpisodeNumber != int.Parse(episode)) if(lastEpisodeNumber != int.Parse(episode))
{ {
if(!latestOrdinalEpisodeNumber.HasValue || (latestOrdinalEpisodeNumber.HasValue && latestOrdinalEpisodeNumber.Value != int.Parse(episode))) if(!latestOrdinalEpisodeNumber.HasValue || (latestOrdinalEpisodeNumber.HasValue && latestOrdinalEpisodeNumber.Value != int.Parse(episode)))
{ {
Console.WriteLine($"Failing release due to TMDB's last episode number of {latestEpisodeNumber}|{latestOrdinalEpisodeNumber ?? 0} not matching title episode number {episode}"); Console.WriteLine($"Failing release due to TMDB's last episode number of {lastEpisodeNumber}|{latestOrdinalEpisodeNumber ?? 0} not matching title episode number {episode}");
return; return;
} }
} }
//Sometimes TMDB metadata hasn't been filled in yet //Sometimes TMDB metadata hasn't been filled in yet
if(latestEpisodeNumber < 3) if(lastEpisodeNumber < 3)
{ {
Console.WriteLine("Skipping announcement due to a low episode number"); Console.WriteLine("Skipping announcement due to a low episode number");
return; return;
} }
// Announce if unique // Announce if unique
if(await tmdbCache.KeyExists($"ShowAnnounced-{supposedShowId}")) var lastAnnouncedSeason = await tmdbCache.GetStringValue($"ShowAnnounced-{supposedShowId}");
Int32 lastSeason = 0;
if (lastAnnouncedSeason != null && Int32.TryParse(lastAnnouncedSeason, out lastSeason) &&
latestSeasonNumber <= lastSeason)
{ {
Console.WriteLine($"{title} has been previously announced, so avoiding announcing it again."); Console.WriteLine($"{title} has been previously announced, so avoiding announcing it again.");
return; return;
} }
else else if (!finaleConfirmed && (!cachedShow.AnilistPopulated.HasValue || !cachedShow.AnilistPopulated.Value))
{ {
_ = tmdbCache.SetPair($"ShowAnnounced-{supposedShowId}", "1"); Console.WriteLine($"Would have announced finale for {title}, on S{cachedShow.LatestSeasonNumber}E{cachedShow.LastEpisodeNumber} but the finale episode type was not found.");
//Remove from cache so subsequent releases cause a fresh lookup
await tmdbCache.RemoveCacheItem($"ShowCache-{title}");
return;
} }
if(mastodonClient != null && cachedShow != null) if(mastodonClient != null && cachedShow != null)
{ {
try try
{ {
PostStatus(cachedShow); _ = tmdbCache.SetPair($"ShowAnnounced-{supposedShowId}", latestSeasonNumber.ToString());
_ = PostStatus(cachedShow);
var existingAiringItem = airingSoonList.FirstOrDefault(s => s.ShowID == cachedShow.ShowID);
if (existingAiringItem is { Download: true })
{
Console.WriteLine($"Attempting to queue {existingAiringItem.Title} in the torrent client");
await AddToTorrentClient(cachedShow);
}
} }
catch(Exception ex) catch(Exception ex)
@@ -242,7 +435,92 @@ namespace AnimeAnnouncer
Console.WriteLine($"Choosing {title} as a finished season!"); Console.WriteLine($"Choosing {title} as a finished season!");
} }
private static async void PostStatus(TMDBCacheItem cachedShow) private static async Task AddToTorrentClient(TMDBCacheItem cacheItem)
{
var reader = new NyaaRssReader();
var titleParts = TorrentDirectoryNamer.PrepareForURI(cacheItem.Title);
var seasonNumber = $"S{cacheItem.LatestSeasonNumber:D2}";
var url = $"https://nyaa.si/?f=0&c=0_0&q={titleParts}+{seasonNumber}+CR+VARYG+DUAL";
var items = await reader.ReadAsync(url);
if (items.Count != cacheItem.LastEpisodeNumber)
{
Console.WriteLine($"{cacheItem.Title} had {items.Count} results but only {cacheItem.LastEpisodeNumber} episodes. Aborting.");
return;
}
var folderName = TorrentDirectoryNamer.FromTitle(items.First().Title, destinationFolder);
Console.WriteLine($"Adding {folderName} to transmission");
foreach (var item in items)
{
var torrentData = await reader.DownloadTorrentAsync(item);
await transmissionService.AddNewTorrentFileToServerAsync(torrentData, folderName);
}
}
private static async Task CreateAiringMonthPost()
{
var airingSoon = await tmdbCache.GetAiringList();
DateTime beginningOfMonth = DateTime.Today;
DateTime endOfMonth = DateTime.Today.AddMonths(1);
var airingThisMonth = airingSoon.Where(p => p.LastAirDate >= beginningOfMonth && p.LastAirDate <= endOfMonth).ToList();
String statusText = $"DUBBED airing anime ending this month (probably){Environment.NewLine + Environment.NewLine}";
int cnt = 1;
for(int i = 0;i < airingThisMonth.Count;i++)
{
var airing = airingThisMonth[i];
if(!await tmdbCache.KeyExists($"ShowAnnounced-{airing.ShowID}"))
{ //TODO: fix for seasons
statusText += $"{cnt++}. {airing.Title} on {airing.LastAirDate:MM/dd/yyyy}{Environment.NewLine}";
}
}
int neededPostCount = (int)Math.Ceiling((decimal)statusText.Length / 500);
if(neededPostCount >= 2)
{
//statusText = statusText[..497] + "...";
int lineBreakIdx = 500 - new string(statusText.Take(500).Reverse().ToArray()).IndexOf(Environment.NewLine);
String postOne = statusText.Substring(0, lineBreakIdx);
String postTwo = statusText.Substring(lineBreakIdx);
var statusOne = await mastodonClient.PublishStatus(postOne,
Visibility.Public, null);
if (postTwo.Length > 500)
{
lineBreakIdx = 500 - new string(postTwo.Take(500).Reverse().ToArray()).IndexOf(Environment.NewLine);
String postThree = postTwo.Substring(lineBreakIdx);
postTwo = postTwo.Substring(0, lineBreakIdx);
var statusTwo = await mastodonClient.PublishStatus(postTwo, Visibility.Public, statusOne.Id);
Thread.Sleep(1000);
var statusThree = await mastodonClient.PublishStatus(postThree, Visibility.Public, statusTwo.Id);
}
else
{
var statusTwo = await mastodonClient.PublishStatus(postTwo, Visibility.Public, statusOne.Id);
}
}else
{
_ = mastodonClient.PublishStatus(statusText,
Visibility.Public, null);
}
/*
else
{
for(cnt = 1; cnt < neededPostCount; cnt++)
{
//go back and find a line break
String postPart = "";
//_ = mastodonClient.PublishStatus(${statusText},
// Visibility.Public, null);
System.Threading.Thread.Sleep(1000);
}
}
*/
}
private static async Task PostStatus(TMDBCacheItem cachedShow)
{ {
//get poster //get poster
String? posterUrl = cachedShow.LatestSeasonPosterPath ?? cachedShow.PosterPath; String? posterUrl = cachedShow.LatestSeasonPosterPath ?? cachedShow.PosterPath;
@@ -257,17 +535,112 @@ namespace AnimeAnnouncer
//Prepare Status //Prepare Status
String statusText = $"{cachedShow.Title} ({cachedShow.VoteAverage}/10) DUBBED has finished airing season {cachedShow.LatestSeasonNumber}! Episode {cachedShow.LastEpisodeNumber} is now available for download. {Environment.NewLine + Environment.NewLine}"; String statusText = $"{cachedShow.Title} ({cachedShow.VoteAverage}/10) DUBBED has finished airing season {cachedShow.LatestSeasonNumber}! Episode {cachedShow.LastEpisodeNumber} is now available for download. {Environment.NewLine + Environment.NewLine}";
String overview = !String.IsNullOrWhiteSpace(cachedShow.LatestSeasonOverview) ? cachedShow.LatestSeasonOverview : cachedShow.Overview ?? String.Empty; String overview = !String.IsNullOrWhiteSpace(cachedShow.LatestSeasonOverview) ? cachedShow.LatestSeasonOverview : cachedShow.Overview ?? String.Empty;
if(!String.IsNullOrWhiteSpace(overview)) if (!String.IsNullOrWhiteSpace(overview))
{ {
statusText += $"Overview: {overview}"; statusText += $"{overview}";
} }
if(statusText.Length >= 500) String anidbLink = $"{Environment.NewLine}https://anilist.co/search/anime?search={cachedShow.Title.Replace(" ", "%20")}";
{ int linkLength = anidbLink.Length;
statusText = statusText[..497] + "..";
}
_ = mastodonClient.PublishStatus(statusText,
Visibility.Public, mediaIds: attachment != null ? new String[] {attachment.Id} : null);
if (statusText.Length + linkLength + 3 >= 500)
{
statusText = statusText[..(500 - (linkLength + 3))];
}
statusText += $"..{anidbLink}";
var status = await mastodonClient.PublishStatus(statusText,
Visibility.Public, mediaIds: attachment != null ? new String[] { attachment.Id } : null);
if(String.IsNullOrEmpty(status.Id))
{
Console.WriteLine("An error has occurred posting the status update");
}
}
private static async void UpdateAiringShowList(TMDBCacheItem cachedShow)
{
var airingSoonItem = new AiringSoonItem()
{
Title = cachedShow.Title,
ShowID = cachedShow.ShowID,
LastAirDate = cachedShow.LastAirDate ?? DateTime.MaxValue,
LatestEpisodeNumber = cachedShow.LatestEpisodeNumber ?? 0
};
var existingAiringItem = airingSoonList.FirstOrDefault(s => s.ShowID == cachedShow.ShowID);
if(existingAiringItem == null)
{
airingSoonList.Add(airingSoonItem);
}
else
{
if(existingAiringItem.LatestEpisodeNumber >= cachedShow.LatestEpisodeNumber && existingAiringItem.LastAirDate == airingSoonItem.LastAirDate)
{
Console.WriteLine($"Not updating {cachedShow.Title} airdate due to episode number not incrementing");
return;
}
airingSoonItem.Download = existingAiringItem.Download;
airingSoonList.RemoveAll(s => s.ShowID == cachedShow.ShowID);
airingSoonList.Add(airingSoonItem);
}
// The web page can flag shows for download directly in the cache, so carry those
// flags over rather than overwriting them with this process's in-memory copy.
var cachedList = await tmdbCache.GetAiringList();
if (cachedList != null)
{
foreach (var cached in cachedList.Where(c => c.Download))
{
var local = airingSoonList.FirstOrDefault(s => s.ShowID == cached.ShowID);
if (local != null)
local.Download = true;
}
}
airingSoonList.Sort((showOne, showTwo) => showOne.LastAirDate.Value.CompareTo(showTwo.LastAirDate.Value));
_ = tmdbCache.SetAiringSoon(airingSoonList);
Console.WriteLine($"Updated airing soon list, setting {cachedShow.Title} Season {cachedShow.LatestSeasonNumber} to end on {cachedShow.LastAirDate ?? DateTime.MaxValue}");
}
private static bool HasAiringEndDate(int showID)
{
return airingSoonList.Any(s => s.ShowID == showID && s.LastAirDate.HasValue);
}
private static int ComputeLevenshteinDistance(string s, string t)
{
int n = s.Length;
int m = t.Length;
int[,] d = new int[n + 1, m + 1];
// Verify arguments.
if (n == 0)
{
return m;
}
if (m == 0)
{
return n;
}
// Initialize arrays.
for (int i = 0; i <= n; d[i, 0] = i++)
{
}
for (int j = 0; j <= m; d[0, j] = j++)
{
}
// Begin looping.
for (int i = 1; i <= n; i++)
{
for (int j = 1; j <= m; j++)
{
// Compute cost.
int cost = (t[j - 1] == s[i - 1]) ? 0 : 1;
d[i, j] = Math.Min(
Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1),
d[i - 1, j - 1] + cost);
}
}
// Return cost.
return d[n, m];
} }
} }
} }
+4 -1
View File
@@ -16,7 +16,6 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.21.0" /> <PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.21.0" />
<PackageReference Include="Transmission.API.RPC" Version="2.1.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -25,4 +24,8 @@
</None> </None>
</ItemGroup> </ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AnimeAnnouncer.Torrents\AnimeAnnouncer.Torrents.csproj" />
</ItemGroup>
</Project> </Project>
+32 -55
View File
@@ -1,15 +1,11 @@
using Microsoft.Extensions.Configuration; using AnimeAnnouncer.Torrents;
using System.Xml.Linq; using Microsoft.Extensions.Configuration;
using Transmission.API.RPC;
namespace MassTorrentAdd namespace MassTorrentAdd
{ {
internal class Program internal class Program
{ {
static String watchFolder, transmissionHost, transmissionUsername, transmissionPassword, transmissionDownloadFolder, destinationFolder; static async Task Main(string[] args)
static Transmission.API.RPC.Client transmissionClient;
static void Main(string[] args)
{ {
if(args.Length == 0) if(args.Length == 0)
{ {
@@ -17,60 +13,53 @@ namespace MassTorrentAdd
return; return;
} }
String url = args[0]; String url = args[0];
if (!url.Contains("rss"))
url = url + "&page=rss";
var builder = new ConfigurationBuilder() var builder = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory()) .SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); .AddJsonFile("appSettings.json", optional: false, reloadOnChange: true);
IConfigurationRoot configuration = builder.Build(); IConfigurationRoot configuration = builder.Build();
transmissionHost = configuration.GetValue<String>("transmissionURI"); String transmissionHost = configuration.GetValue<String>("transmissionURI")!;
transmissionUsername = configuration.GetValue<String>("transmissionUsername"); String? transmissionUsername = configuration.GetValue<String>("transmissionUsername");
transmissionPassword = configuration.GetValue<String>("transmissionPassword"); String? transmissionPassword = configuration.GetValue<String>("transmissionPassword");
destinationFolder = configuration.GetValue<String>("destinationFolder"); String destinationFolder = configuration.GetValue<String>("destinationFolder")!;
Console.WriteLine($"Using transmission server {transmissionHost}"); Console.WriteLine($"Using transmission server {transmissionHost}");
transmissionClient = new Transmission.API.RPC.Client(transmissionHost, null, transmissionUsername, transmissionPassword); TransmissionService transmission;
var sessionInformation = transmissionClient.GetSessionInformation(); try
if (sessionInformation == null || String.IsNullOrEmpty(sessionInformation.Version))
{ {
Console.WriteLine("An error occurred while receiving session information from the rpc server"); transmission = await TransmissionService.ConnectAsync(transmissionHost, transmissionUsername, transmissionPassword);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
return; return;
} }
var nyaa = new NyaaRssReader();
HttpClient httpClient = new HttpClient(); List<NyaaRssItem> items;
var response = httpClient.GetStringAsync(url).Result; try
if(response == null)
{ {
Console.WriteLine("An error occurred while contacting Nyaa for a response"); items = await nyaa.ReadAsync(url);
}
catch (Exception ex)
{
Console.WriteLine($"An error occurred while contacting Nyaa for a response: {ex.Message}");
return; return;
} }
XDocument doc = XDocument.Parse(response); if (items.Count == 0)
XNamespace nyaaNS = doc.Root.GetNamespaceOfPrefix("nyaa");
var items = from item in doc.Descendants("channel").Elements("item")
select new
{ {
Title = item.Element("title").Value, Console.WriteLine("No items found in the feed");
Published = item.Element("pubDate").Value, return;
Category = item.Element(nyaaNS + "category").Value, }
CategoryID = item.Element(nyaaNS + "categoryId").Value,
Size = item.Element(nyaaNS + "size").Value,
Link = item.Element("link").Value,
Description = item.Element("description").Value,
PostID = item.Element("guid").Value,
};
Console.WriteLine("Downloading .."); Console.WriteLine("Downloading ..");
foreach ( var item in items ) foreach ( var item in items )
{ {
Console.WriteLine($"{item.Title}"); Console.WriteLine($"{item.Title}");
} }
String transmissionDirectoryName = items.First().Title.Trim(); String transmissionDirectoryName = TorrentDirectoryNamer.FromTitle(items.First().Title, destinationFolder);
transmissionDirectoryName = System.Text.RegularExpressions.Regex.Replace(transmissionDirectoryName, @"E\d{2,3}.*?(\d{3,4}p)", " $1"); Console.WriteLine($"{items.Count} item(s) to {transmissionDirectoryName}");
transmissionDirectoryName = System.Text.RegularExpressions.Regex.Replace(transmissionDirectoryName, @"\s\(.*?\)$", "");
transmissionDirectoryName = destinationFolder + (destinationFolder.EndsWith("/") ? String.Empty : "/") + transmissionDirectoryName.Replace(" ", ".");
Console.WriteLine("to " + transmissionDirectoryName);
Console.WriteLine("Continue?"); Console.WriteLine("Continue?");
if(Console.ReadKey().KeyChar != 'y') if(Console.ReadKey().KeyChar != 'y')
@@ -80,22 +69,10 @@ namespace MassTorrentAdd
} }
foreach (var item in items) foreach (var item in items)
{ {
var torrentData = httpClient.GetByteArrayAsync(item.Link).Result; var torrentData = await nyaa.DownloadTorrentAsync(item);
AddNewTorrentFileToServer(torrentData, transmissionDirectoryName); await transmission.AddNewTorrentFileToServerAsync(torrentData, transmissionDirectoryName);
} }
Console.WriteLine("Completed"); Console.WriteLine("Completed");
} }
private static void AddNewTorrentFileToServer(byte[] torrentFileData, String saveLocation)
{
Transmission.API.RPC.Entity.NewTorrent newTorrent = new()
{
DownloadDirectory = saveLocation,
Paused = true,
Metainfo = Convert.ToBase64String(torrentFileData)
};
var torrentInfo = transmissionClient.TorrentAdd(newTorrent);
//transmissionClient.TorrentVerify(new object[] { torrentInfo.ID });
}
} }
} }
+1 -1
View File
@@ -1,2 +1,2 @@
docker build --pull --rm -f "AnimeAnnouncer/Dockerfile" -t dtr.chrispr.org/animeannouncer:latest . docker build -f "AnimeAnnouncer/Dockerfile" -t dtr.chrispr.org/animeannouncer:latest .
docker push dtr.chrispr.org/animeannouncer:latest docker push dtr.chrispr.org/animeannouncer:latest