Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcadf31192 | ||
|
|
32aca8b197 | ||
|
|
57b2d8835d | ||
|
|
d9d19d175f | ||
|
|
cffecbb469 | ||
|
|
a1ec0ea7f5 | ||
|
|
8831d0ce2f | ||
|
|
943aef2f7b | ||
|
|
2198f6ad84 | ||
|
|
e336b2ae08 | ||
|
|
8dcc153806 | ||
|
|
41c75ee3f2 | ||
|
|
5cfcecc5c3 | ||
|
|
6a50c94cf2 | ||
|
|
acac2a25e8 | ||
|
|
47b44fb59a | ||
|
|
965bd58799 | ||
|
|
039e3189eb | ||
|
|
6a518b0c9b | ||
|
|
7b830659bc | ||
|
|
c423d8f3ed | ||
|
|
25b214c0d7 | ||
|
|
310978054d | ||
|
|
c1103564bb | ||
|
|
57c42bcc41 | ||
|
|
7bc2ef89a2 | ||
|
|
2e48daae3a | ||
|
|
5937ddcbd8 |
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
© 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";
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"redisConnectionString": "echidna.chrispr.lan, password=AidsAidsAidsTest"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 |
@@ -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
+1
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 */
|
||||||
+1
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 */
|
||||||
+1
File diff suppressed because one or more lines are too long
+8
@@ -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 */
|
||||||
+1
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
+1
File diff suppressed because one or more lines are too long
+4857
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+7
File diff suppressed because one or more lines are too long
+1
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.
|
||||||
Vendored
+435
@@ -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;
|
||||||
|
}));
|
||||||
Vendored
+8
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.
|
||||||
+1512
File diff suppressed because it is too large
Load Diff
+4
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.
|
||||||
+10881
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
@@ -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
|
||||||
|
|||||||
@@ -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!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,24 @@ namespace AnimeAnnouncer.Cache
|
|||||||
|
|
||||||
public int LatestSeasonNumber { get; set; }
|
public int LatestSeasonNumber { get; set; }
|
||||||
public int LastEpisodeNumber { get; set; }
|
public int LastEpisodeNumber { get; set; }
|
||||||
public string? PosterPath { get; internal set; }
|
public string? PosterPath { get; set; }
|
||||||
public string? BackdropPath { get; internal set; }
|
public string? BackdropPath { get; set; }
|
||||||
public string? LatestSeasonPosterPath { get; internal set; }
|
public string? LatestSeasonPosterPath { get; set; }
|
||||||
public string? LatestSeasonOverview { get; internal set; }
|
public string? LatestSeasonOverview { get; set; }
|
||||||
public double VoteAverage { get; internal set; }
|
public double VoteAverage { get; set; }
|
||||||
public string? Overview { get; internal set; }
|
public string? Overview { get; set; }
|
||||||
public int? LatestOrdinalEpisodeNumber { get; internal 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; }
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+428
-33
@@ -1,12 +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.TvShows;
|
||||||
|
|
||||||
namespace AnimeAnnouncer
|
namespace AnimeAnnouncer
|
||||||
{
|
{
|
||||||
@@ -17,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...");
|
||||||
@@ -43,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))
|
||||||
@@ -51,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
|
||||||
@@ -73,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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,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();
|
||||||
|
|
||||||
@@ -142,11 +213,80 @@ 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(latestEpisodeNumber >= (lastEpisodeNumber - 2) || !HasAiringEndDate(supposedShowId))
|
||||||
|
{
|
||||||
|
Console.WriteLine("Querying episode information");
|
||||||
|
var latestSeasonDetail = await tmdbClient.GetTvSeasonAsync(supposedShowId, latestSeasonNumber);
|
||||||
|
|
||||||
|
var seasonFinale = latestSeasonDetail.Episodes.LastOrDefault(e => e.EpisodeType.Equals("finale", StringComparison.InvariantCultureIgnoreCase));
|
||||||
|
|
||||||
|
if(seasonFinale != null)
|
||||||
|
{
|
||||||
|
if(seasonFinale.EpisodeNumber != lastEpisodeNumber)
|
||||||
|
{
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if(showResult.Seasons.Count > 1)
|
if(showResult.Seasons.Count > 1)
|
||||||
{
|
{
|
||||||
latestOrdinalEpisodeNumber = showResult.Seasons.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)
|
||||||
@@ -164,11 +304,20 @@ namespace AnimeAnnouncer
|
|||||||
Overview = showResult.Overview,
|
Overview = showResult.Overview,
|
||||||
VoteAverage = showResult.VoteAverage,
|
VoteAverage = showResult.VoteAverage,
|
||||||
ShowID = supposedShowId,
|
ShowID = supposedShowId,
|
||||||
LatestSeasonNumber = latestSeason.SeasonNumber,
|
LatestSeasonNumber = latestSeasonNumber,
|
||||||
LastEpisodeNumber = latestSeason.EpisodeCount
|
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)
|
||||||
{
|
{
|
||||||
@@ -181,35 +330,101 @@ namespace AnimeAnnouncer
|
|||||||
|
|
||||||
if(latestSeasonNumber != int.Parse(season))
|
if(latestSeasonNumber != int.Parse(season))
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Failing release due to TMDB's season number {latestSeasonNumber} not matching title season {season}");
|
var titleSeason = int.Parse(season);
|
||||||
return;
|
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(latestEpisodeNumber != int.Parse(episode))
|
}
|
||||||
{
|
}
|
||||||
if(!latestOrdinalEpisodeNumber.HasValue || (latestOrdinalEpisodeNumber.HasValue && latestOrdinalEpisodeNumber.Value != int.Parse(episode)))
|
if(!seasonOverride)
|
||||||
{
|
{
|
||||||
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 season number {latestSeasonNumber} not matching title season {season}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(lastEpisodeNumber != 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 {lastEpisodeNumber}|{latestOrdinalEpisodeNumber ?? 0} not matching title episode number {episode}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//Sometimes TMDB metadata hasn't been filled in yet
|
||||||
|
if(lastEpisodeNumber < 3)
|
||||||
|
{
|
||||||
|
Console.WriteLine("Skipping announcement due to a low episode number");
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
@@ -220,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;
|
||||||
@@ -235,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];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+33
-56
@@ -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")
|
Console.WriteLine("No items found in the feed");
|
||||||
select new
|
return;
|
||||||
{
|
}
|
||||||
Title = item.Element("title").Value,
|
|
||||||
Published = item.Element("pubDate").Value,
|
|
||||||
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,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
|
||||||
|
|||||||
Reference in New Issue
Block a user