22 Commits
Author SHA1 Message Date
chris 8831d0ce2f Increased redis cache time to 7 days; tweaked finale announce event (checking anilist); minor visual tweaks to web 2026-09-18 16:21:31 -04:00
chris 943aef2f7b Initial commit of AnimeAnnouncer.Web 2026-09-17 14:10:13 -04:00
chris 2198f6ad84 Added support for Anilist lookups and date confirmations for ending series 2026-09-13 15:05:18 -04:00
chris e336b2ae08 Added Anilist datasource; Handled bug when two-posts were not enough for monthly airing post 2026-08-31 21:36:21 -04:00
chris 8dcc153806 - Added show-airing-list switch
- Handled shows announced with seasons > 1
- Handled cases where status posting failed
2026-03-17 13:09:52 -04:00
chris 41c75ee3f2 Handled case where showResult.NextEpisodeToAir was null due to non-dubbed series finishing airing before dubbed 2025-04-06 15:48:47 -04:00
chris 5cfcecc5c3 Numerous fixes to improve functionality 2025-03-25 00:12:03 -04:00
chris 6a50c94cf2 Minor tweaks to AiringList before trying to handle late-week airing 2025-02-17 01:03:06 -05:00
chris acac2a25e8 Added some guard rails around airing list to avoid announcing old shows 2025-02-05 00:24:32 -05:00
chris 47b44fb59a Handled airing multi-post 2024-12-31 20:59:03 -05:00
chris 965bd58799 Initial commit of Airing Soon list 2024-11-29 15:42:12 -05:00
chris 039e3189eb Changed ShowAnnounced to save last announced season number 2024-11-19 19:52:44 -05:00
chris 6a518b0c9b Added Levenshtein title matching; Added AiringSoonList 2024-11-19 19:36:35 -05:00
chris 7b830659bc Added TMDbLib library reference until nuget gets the required package for episode groups 2024-11-12 16:32:24 -05:00
chris c423d8f3ed Added implementation of episode groups with overrides for finale 2024-11-11 21:44:36 -05:00
chris 25b214c0d7 Fixed appSettings name to work on linux; Set newly added torrent's seed mode and ratio 2024-10-13 21:57:13 -04:00
chris 310978054d Added anidb search link for the mastodon post 2024-10-09 20:37:36 -04:00
chris c1103564bb Merge branch 'MassTorrentAdd' 2024-10-09 19:54:23 -04:00
chris 57c42bcc41 Changed season finale logic to only print when the episode was actually changed 2024-10-08 17:33:41 -04:00
chris 7bc2ef89a2 Handled case where a 2-cour season is a single TMDB season with 2 "finales" 2024-10-07 22:23:47 -04:00
chris 2e48daae3a Removed internal on setters for cache item so JsonSerializer could set them 2024-10-06 00:54:54 -04:00
chris 5937ddcbd8 Get season detail to look for episodes marked as finale; multiple tweaks to avoid announcing at the incorrect time 2024-10-02 16:49:53 -04:00
85 changed files with 75651 additions and 41 deletions
@@ -0,0 +1,13 @@
<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>
</Project>
+26
View File
@@ -0,0 +1,26 @@
@page
@model ErrorModel
@{
ViewData["Title"] = "Error";
}
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (Model.ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@Model.RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
+27
View File
@@ -0,0 +1,27 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace AnimeAnnouncer.Web.Pages;
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
[IgnoreAntiforgeryToken]
public class ErrorModel : PageModel
{
public string? RequestId { get; set; }
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
private readonly ILogger<ErrorModel> _logger;
public ErrorModel(ILogger<ErrorModel> logger)
{
_logger = logger;
}
public void OnGet()
{
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
}
}
+125
View File
@@ -0,0 +1,125 @@
@page
@model IndexModel
@{
ViewData["Title"] = "Airing Soon Anime";
}
<div class="container-fluid 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, fetched from the TMDB cache. Sorted by air date (<strong>newest 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="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-5 row-cols-xxl-6 g-4">
@foreach (var show in Model.AiringShows)
{
<div class="col">
<div class="card h-100 anime-card @(show.IsFuture ? "border-danger" : "")">
<div class="anime-poster-container">
@if (!string.IsNullOrWhiteSpace(show.PosterUrl))
{
<img src="@show.PosterUrl" class="anime-poster" alt="@show.Title" title="@show.Title" loading="lazy" />
}
else
{
<div class="anime-poster d-flex align-items-center justify-content-center text-secondary">
<span class="display-4">🗂️</span>
</div>
}
@if (show.VoteAverage.HasValue && show.VoteAverage > 0)
{
<span class="position-absolute top-0 start-0 badge rounded-pill bg-warning text-dark m-2 shadow-sm">⭐ @string.Format("{0:0.0}", show.VoteAverage)</span>
}
@if (show.EffectiveLastAirDate.HasValue)
{
@if (show.IsFuture)
{
<span class="position-absolute top-0 end-0 badge rounded-pill bg-danger text-white m-2 shadow-sm">Airs in @Math.Max(show.DaysUntil, 1)d</span>
}
else
{
<span class="position-absolute top-0 end-0 badge rounded-pill bg-secondary text-white m-2 shadow-sm">Aired @Math.Abs(show.DaysUntil)d ago</span>
}
}
</div>
<div class="card-body d-flex flex-column">
<h5 class="card-title text-truncate" title="@show.Title">@show.Title</h5>
<div class="show-meta mb-2 d-flex flex-wrap gap-1">
@if (show.LatestSeasonNumber.HasValue && show.LatestSeasonNumber > 0)
{
<span class="badge rounded-pill bg-light text-dark border">Season @show.LatestSeasonNumber</span>
}
@if (show.LatestEpisodeNumber > 0)
{
@if (show.LastEpisodeNumber.HasValue && show.LastEpisodeNumber > 0)
{
<span class="badge rounded-pill bg-light text-dark border">Ep @show.LatestEpisodeNumber / @show.LastEpisodeNumber</span>
}
else
{
<span class="badge rounded-pill bg-light text-dark border">Ep @show.LatestEpisodeNumber</span>
}
}
@if (show.LatestOrdinalEpisodeNumber.HasValue && show.LatestOrdinalEpisodeNumber > 0)
{
<span class="badge rounded-pill bg-light text-dark border">Total: @show.LatestOrdinalEpisodeNumber</span>
}
</div>
<div class="show-dates mb-2">
@if (show.EffectiveLastAirDate.HasValue)
{
<small class="text-primary fw-semibold">📅 Last air date: @show.AirDateFormatted</small>
}
else
{
<small class="text-secondary">📅 Last air date: @show.AirDateFormatted</small>
}
</div>
</div>
<div class="card-footer bg-transparent border-top-0 pt-0">
@if (!string.IsNullOrWhiteSpace(show.Overview))
{
<p class="show-overview text-muted" title="@show.Overview">@show.Overview</p>
}
else
{
<p class="show-overview text-secondary fst-italic">No overview available.</p>
}
<div class="d-grid mt-2">
<a href="https://www.themoviedb.org/tv/@show.ShowID" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-primary">View on TMDB</a>
</div>
</div>
</div>
</div>
}
</div>
}
</div>
+150
View File
@@ -0,0 +1,150 @@
using AnimeAnnouncer.Cache;
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($"ShowCache-{airingItem.Title}");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to retrieve cache item for title: {Title}", airingItem.Title);
}
viewModels.Add(new AiringShowViewModel
{
AiringSoon = airingItem,
CachedDetails = cacheItem
});
}
// 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 > DateTime.Now)
.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 class AiringShowViewModel
{
public required AiringSoonItem AiringSoon { get; set; }
public TMDBCacheItem? CachedDetails { get; set; }
public string Title => AiringSoon.Title;
public int ShowID => AiringSoon.ShowID;
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;
public string? Overview => !string.IsNullOrWhiteSpace(CachedDetails?.LatestSeasonOverview)
? CachedDetails.LatestSeasonOverview
: CachedDetails?.Overview;
public string? PosterUrl
{
get
{
if (!string.IsNullOrWhiteSpace(CachedDetails?.LatestSeasonPosterPath))
return CachedDetails.LatestSeasonPosterPath;
if (!string.IsNullOrWhiteSpace(CachedDetails?.PosterPath))
return CachedDetails.PosterPath;
if (!string.IsNullOrWhiteSpace(CachedDetails?.AnilistPosterPath))
return CachedDetails.AnilistPosterPath;
return null;
}
}
public string? BackdropUrl => CachedDetails?.BackdropPath;
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";
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
@page
@model PrivacyModel
@{
ViewData["Title"] = "Privacy Policy";
}
<h1>@ViewData["Title"]</h1>
<p>Use this page to detail your site's privacy policy.</p>
@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace AnimeAnnouncer.Web.Pages;
public class PrivacyModel : PageModel
{
private readonly ILogger<PrivacyModel> _logger;
public PrivacyModel(ILogger<PrivacyModel> logger)
{
_logger = logger;
}
public void OnGet()
{
}
}
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - AnimeAnnouncer.Web</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
<link rel="stylesheet" href="~/AnimeAnnouncer.Web.styles.css" asp-append-version="true" />
</head>
<body>
<div class="container pt-3">
<main role="main" class="pb-3">
@RenderBody()
</main>
</div>
<footer class="border-top footer text-muted">
<div class="container">
&copy; 2026 - AnimeAnnouncer.Web - <a asp-area="" asp-page="/Privacy">Privacy</a>
</div>
</footer>
<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>
@@ -0,0 +1,50 @@
/* Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
for details on configuring this project to bundle and minify static web assets. */
a.navbar-brand {
white-space: normal;
text-align: center;
word-break: break-all;
}
a {
color: #4dabf7;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.nav-pills .nav-link.active, .nav-pills .show > .nav-link {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.border-top {
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.border-bottom {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.box-shadow {
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .2);
}
button.accept-policy {
font-size: 1rem;
line-height: inherit;
}
.footer {
position: absolute;
bottom: 0;
width: 100%;
white-space: nowrap;
line-height: 60px;
background-color: #121212;
color: #adb5bd;
}
@@ -0,0 +1,2 @@
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>
@@ -0,0 +1,3 @@
@using AnimeAnnouncer.Web
@namespace AnimeAnnouncer.Web.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}
+34
View File
@@ -0,0 +1,34 @@
using AnimeAnnouncer.Cache;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorPages();
var redisConnectionString = builder.Configuration["redisConnectionString"] ?? builder.Configuration.GetConnectionString("Redis");
if (!string.IsNullOrEmpty(redisConnectionString))
{
builder.Services.AddSingleton(new TMDBCache(redisConnectionString));
}
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapRazorPages();
app.Run();
@@ -0,0 +1,38 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:25738",
"sslPort": 44352
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5062",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7253;http://localhost:5062",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,9 @@
{
"DetailedErrors": true,
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"redisConnectionString": "echidna.chrispr.lan, password=AidsAidsAidsTest"
}
+129
View File
@@ -0,0 +1,129 @@
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;
}
/* --- Anime airing-soon cards --- */
.anime-card {
border-radius: 0.75rem;
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease;
background-color: #1e1e1e;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.anime-card:hover {
transform: translateY(-4px);
box-shadow: 0 0.75rem 1.5rem rgba(0, 0, 0, 0.45);
}
.anime-poster-container {
position: relative;
height: 320px;
background-color: #2a2a2a;
overflow: hidden;
}
.anime-poster {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.3s ease;
}
.anime-card:hover .anime-poster {
transform: scale(1.03);
}
.show-overview {
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.45;
font-size: 0.875rem;
min-height: 5.1em;
margin-bottom: 0.5rem;
white-space: normal;
word-break: normal;
overflow-wrap: break-word;
}
/* --- Wide / ultra-wide / 4K display support ---
Bootstrap 5's ".container" tops out at max-width: 1320px starting at its
largest built-in breakpoint (1400px) and never grows beyond that, so on
large desktop and 4K monitors (1920px, 2560px, 3440px, 3840px+) the page
content sits in a narrow column with large empty side margins. These
extra breakpoints extend the same scaling pattern Bootstrap uses
(max-width ~= 94% of the viewport) up through 4K viewports. */
@media (min-width: 1600px) {
.container {
max-width: 1500px;
}
}
@media (min-width: 1900px) {
.container {
max-width: 1800px;
}
}
@media (min-width: 2560px) {
.container {
max-width: 2400px;
}
}
@media (min-width: 3200px) {
.container {
max-width: 3000px;
}
}
@media (min-width: 3840px) {
.container {
max-width: 3600px;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

+4
View File
@@ -0,0 +1,4 @@
// Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
// for details on configuring this project to bundle and minify static web assets.
// Write your JavaScript code.
@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2011-2021 Twitter, Inc.
Copyright (c) 2011-2021 The Bootstrap Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,427 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
background-color: currentColor;
border: 0;
opacity: 0.25;
}
hr:not([size]) {
height: 1px;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title],
abbr[data-bs-original-title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-left: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.2em;
background-color: #fcf8e3;
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: #0d6efd;
text-decoration: underline;
}
a:hover {
color: #0a58ca;
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 1em;
direction: ltr /* rtl:ignore */;
unicode-bidi: bidi-override;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: #d63384;
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.2rem 0.4rem;
font-size: 0.875em;
color: #fff;
background-color: #212529;
border-radius: 0.2rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
font-weight: 700;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: #6c757d;
text-align: left;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]::-webkit-calendar-picker-indicator {
display: none;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: left;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
outline-offset: -2px;
-webkit-appearance: textfield;
}
/* rtl:raw:
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::file-selector-button {
font: inherit;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){h1{font-size:2.5rem}}h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){h2{font-size:2rem}}h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){h3{font-size:1.75rem}}h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){h4{font-size:1.5rem}}h5{font-size:1.25rem}h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:.875em}mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}
/*# sourceMappingURL=bootstrap-reboot.min.css.map */
File diff suppressed because one or more lines are too long
@@ -0,0 +1,424 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
background-color: currentColor;
border: 0;
opacity: 0.25;
}
hr:not([size]) {
height: 1px;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title],
abbr[data-bs-original-title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-right: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-right: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.2em;
background-color: #fcf8e3;
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: #0d6efd;
text-decoration: underline;
}
a:hover {
color: #0a58ca;
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 1em;
direction: ltr ;
unicode-bidi: bidi-override;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: #d63384;
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.2rem 0.4rem;
font-size: 0.875em;
color: #fff;
background-color: #212529;
border-radius: 0.2rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
font-weight: 700;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: #6c757d;
text-align: right;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]::-webkit-calendar-picker-indicator {
display: none;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: right;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: right;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
outline-offset: -2px;
-webkit-appearance: textfield;
}
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::file-selector-button {
font: inherit;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){h1{font-size:2.5rem}}h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){h2{font-size:2rem}}h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){h3{font-size:1.75rem}}h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){h4{font-size:1.5rem}}h5{font-size:1.25rem}h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-right:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-right:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:.875em}mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:right}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:right;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:right}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}[type=email],[type=number],[type=tel],[type=url]{direction:ltr}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}
/*# sourceMappingURL=bootstrap-reboot.rtl.min.css.map */
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,23 @@
The MIT License (MIT)
Copyright (c) .NET Foundation and Contributors
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,435 @@
/**
* @license
* Unobtrusive validation support library for jQuery and jQuery Validate
* Copyright (c) .NET Foundation. All rights reserved.
* Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
* @version v4.0.0
*/
/*jslint white: true, browser: true, onevar: true, undef: true, nomen: true, eqeqeq: true, plusplus: true, bitwise: true, regexp: true, newcap: true, immed: true, strict: false */
/*global document: false, jQuery: false */
(function (factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define("jquery.validate.unobtrusive", ['jquery-validation'], factory);
} else if (typeof module === 'object' && module.exports) {
// CommonJS-like environments that support module.exports
module.exports = factory(require('jquery-validation'));
} else {
// Browser global
jQuery.validator.unobtrusive = factory(jQuery);
}
}(function ($) {
var $jQval = $.validator,
adapters,
data_validation = "unobtrusiveValidation";
function setValidationValues(options, ruleName, value) {
options.rules[ruleName] = value;
if (options.message) {
options.messages[ruleName] = options.message;
}
}
function splitAndTrim(value) {
return value.replace(/^\s+|\s+$/g, "").split(/\s*,\s*/g);
}
function escapeAttributeValue(value) {
// As mentioned on http://api.jquery.com/category/selectors/
return value.replace(/([!"#$%&'()*+,./:;<=>?@\[\\\]^`{|}~])/g, "\\$1");
}
function getModelPrefix(fieldName) {
return fieldName.substr(0, fieldName.lastIndexOf(".") + 1);
}
function appendModelPrefix(value, prefix) {
if (value.indexOf("*.") === 0) {
value = value.replace("*.", prefix);
}
return value;
}
function onError(error, inputElement) { // 'this' is the form element
var container = $(this).find("[data-valmsg-for='" + escapeAttributeValue(inputElement[0].name) + "']"),
replaceAttrValue = container.attr("data-valmsg-replace"),
replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) !== false : null;
container.removeClass("field-validation-valid").addClass("field-validation-error");
error.data("unobtrusiveContainer", container);
if (replace) {
container.empty();
error.removeClass("input-validation-error").appendTo(container);
}
else {
error.hide();
}
}
function onErrors(event, validator) { // 'this' is the form element
var container = $(this).find("[data-valmsg-summary=true]"),
list = container.find("ul");
if (list && list.length && validator.errorList.length) {
list.empty();
container.addClass("validation-summary-errors").removeClass("validation-summary-valid");
$.each(validator.errorList, function () {
$("<li />").html(this.message).appendTo(list);
});
}
}
function onSuccess(error) { // 'this' is the form element
var container = error.data("unobtrusiveContainer");
if (container) {
var replaceAttrValue = container.attr("data-valmsg-replace"),
replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) : null;
container.addClass("field-validation-valid").removeClass("field-validation-error");
error.removeData("unobtrusiveContainer");
if (replace) {
container.empty();
}
}
}
function onReset(event) { // 'this' is the form element
var $form = $(this),
key = '__jquery_unobtrusive_validation_form_reset';
if ($form.data(key)) {
return;
}
// Set a flag that indicates we're currently resetting the form.
$form.data(key, true);
try {
$form.data("validator").resetForm();
} finally {
$form.removeData(key);
}
$form.find(".validation-summary-errors")
.addClass("validation-summary-valid")
.removeClass("validation-summary-errors");
$form.find(".field-validation-error")
.addClass("field-validation-valid")
.removeClass("field-validation-error")
.removeData("unobtrusiveContainer")
.find(">*") // If we were using valmsg-replace, get the underlying error
.removeData("unobtrusiveContainer");
}
function validationInfo(form) {
var $form = $(form),
result = $form.data(data_validation),
onResetProxy = $.proxy(onReset, form),
defaultOptions = $jQval.unobtrusive.options || {},
execInContext = function (name, args) {
var func = defaultOptions[name];
func && $.isFunction(func) && func.apply(form, args);
};
if (!result) {
result = {
options: { // options structure passed to jQuery Validate's validate() method
errorClass: defaultOptions.errorClass || "input-validation-error",
errorElement: defaultOptions.errorElement || "span",
errorPlacement: function () {
onError.apply(form, arguments);
execInContext("errorPlacement", arguments);
},
invalidHandler: function () {
onErrors.apply(form, arguments);
execInContext("invalidHandler", arguments);
},
messages: {},
rules: {},
success: function () {
onSuccess.apply(form, arguments);
execInContext("success", arguments);
}
},
attachValidation: function () {
$form
.off("reset." + data_validation, onResetProxy)
.on("reset." + data_validation, onResetProxy)
.validate(this.options);
},
validate: function () { // a validation function that is called by unobtrusive Ajax
$form.validate();
return $form.valid();
}
};
$form.data(data_validation, result);
}
return result;
}
$jQval.unobtrusive = {
adapters: [],
parseElement: function (element, skipAttach) {
/// <summary>
/// Parses a single HTML element for unobtrusive validation attributes.
/// </summary>
/// <param name="element" domElement="true">The HTML element to be parsed.</param>
/// <param name="skipAttach" type="Boolean">[Optional] true to skip attaching the
/// validation to the form. If parsing just this single element, you should specify true.
/// If parsing several elements, you should specify false, and manually attach the validation
/// to the form when you are finished. The default is false.</param>
var $element = $(element),
form = $element.parents("form")[0],
valInfo, rules, messages;
if (!form) { // Cannot do client-side validation without a form
return;
}
valInfo = validationInfo(form);
valInfo.options.rules[element.name] = rules = {};
valInfo.options.messages[element.name] = messages = {};
$.each(this.adapters, function () {
var prefix = "data-val-" + this.name,
message = $element.attr(prefix),
paramValues = {};
if (message !== undefined) { // Compare against undefined, because an empty message is legal (and falsy)
prefix += "-";
$.each(this.params, function () {
paramValues[this] = $element.attr(prefix + this);
});
this.adapt({
element: element,
form: form,
message: message,
params: paramValues,
rules: rules,
messages: messages
});
}
});
$.extend(rules, { "__dummy__": true });
if (!skipAttach) {
valInfo.attachValidation();
}
},
parse: function (selector) {
/// <summary>
/// Parses all the HTML elements in the specified selector. It looks for input elements decorated
/// with the [data-val=true] attribute value and enables validation according to the data-val-*
/// attribute values.
/// </summary>
/// <param name="selector" type="String">Any valid jQuery selector.</param>
// $forms includes all forms in selector's DOM hierarchy (parent, children and self) that have at least one
// element with data-val=true
var $selector = $(selector),
$forms = $selector.parents()
.addBack()
.filter("form")
.add($selector.find("form"))
.has("[data-val=true]");
$selector.find("[data-val=true]").each(function () {
$jQval.unobtrusive.parseElement(this, true);
});
$forms.each(function () {
var info = validationInfo(this);
if (info) {
info.attachValidation();
}
});
}
};
adapters = $jQval.unobtrusive.adapters;
adapters.add = function (adapterName, params, fn) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
/// <param name="params" type="Array" optional="true">[Optional] An array of parameter names (strings) that will
/// be extracted from the data-val-nnnn-mmmm HTML attributes (where nnnn is the adapter name, and
/// mmmm is the parameter name).</param>
/// <param name="fn" type="Function">The function to call, which adapts the values from the HTML
/// attributes into jQuery Validate rules and/or messages.</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
if (!fn) { // Called with no params, just a function
fn = params;
params = [];
}
this.push({ name: adapterName, params: params, adapt: fn });
return this;
};
adapters.addBool = function (adapterName, ruleName) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
/// the jQuery Validate validation rule has no parameter values.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
/// <param name="ruleName" type="String" optional="true">[Optional] The name of the jQuery Validate rule. If not provided, the value
/// of adapterName will be used instead.</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
return this.add(adapterName, function (options) {
setValidationValues(options, ruleName || adapterName, true);
});
};
adapters.addMinMax = function (adapterName, minRuleName, maxRuleName, minMaxRuleName, minAttribute, maxAttribute) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
/// the jQuery Validate validation has three potential rules (one for min-only, one for max-only, and
/// one for min-and-max). The HTML parameters are expected to be named -min and -max.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
/// <param name="minRuleName" type="String">The name of the jQuery Validate rule to be used when you only
/// have a minimum value.</param>
/// <param name="maxRuleName" type="String">The name of the jQuery Validate rule to be used when you only
/// have a maximum value.</param>
/// <param name="minMaxRuleName" type="String">The name of the jQuery Validate rule to be used when you
/// have both a minimum and maximum value.</param>
/// <param name="minAttribute" type="String" optional="true">[Optional] The name of the HTML attribute that
/// contains the minimum value. The default is "min".</param>
/// <param name="maxAttribute" type="String" optional="true">[Optional] The name of the HTML attribute that
/// contains the maximum value. The default is "max".</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
return this.add(adapterName, [minAttribute || "min", maxAttribute || "max"], function (options) {
var min = options.params.min,
max = options.params.max;
if (min && max) {
setValidationValues(options, minMaxRuleName, [min, max]);
}
else if (min) {
setValidationValues(options, minRuleName, min);
}
else if (max) {
setValidationValues(options, maxRuleName, max);
}
});
};
adapters.addSingleVal = function (adapterName, attribute, ruleName) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
/// the jQuery Validate validation rule has a single value.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute(where nnnn is the adapter name).</param>
/// <param name="attribute" type="String">[Optional] The name of the HTML attribute that contains the value.
/// The default is "val".</param>
/// <param name="ruleName" type="String" optional="true">[Optional] The name of the jQuery Validate rule. If not provided, the value
/// of adapterName will be used instead.</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
return this.add(adapterName, [attribute || "val"], function (options) {
setValidationValues(options, ruleName || adapterName, options.params[attribute]);
});
};
$jQval.addMethod("__dummy__", function (value, element, params) {
return true;
});
$jQval.addMethod("regex", function (value, element, params) {
var match;
if (this.optional(element)) {
return true;
}
match = new RegExp(params).exec(value);
return (match && (match.index === 0) && (match[0].length === value.length));
});
$jQval.addMethod("nonalphamin", function (value, element, nonalphamin) {
var match;
if (nonalphamin) {
match = value.match(/\W/g);
match = match && match.length >= nonalphamin;
}
return match;
});
if ($jQval.methods.extension) {
adapters.addSingleVal("accept", "mimtype");
adapters.addSingleVal("extension", "extension");
} else {
// for backward compatibility, when the 'extension' validation method does not exist, such as with versions
// of JQuery Validation plugin prior to 1.10, we should use the 'accept' method for
// validating the extension, and ignore mime-type validations as they are not supported.
adapters.addSingleVal("extension", "extension", "accept");
}
adapters.addSingleVal("regex", "pattern");
adapters.addBool("creditcard").addBool("date").addBool("digits").addBool("email").addBool("number").addBool("url");
adapters.addMinMax("length", "minlength", "maxlength", "rangelength").addMinMax("range", "min", "max", "range");
adapters.addMinMax("minlength", "minlength").addMinMax("maxlength", "minlength", "maxlength");
adapters.add("equalto", ["other"], function (options) {
var prefix = getModelPrefix(options.element.name),
other = options.params.other,
fullOtherName = appendModelPrefix(other, prefix),
element = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(fullOtherName) + "']")[0];
setValidationValues(options, "equalTo", element);
});
adapters.add("required", function (options) {
// jQuery Validate equates "required" with "mandatory" for checkbox elements
if (options.element.tagName.toUpperCase() !== "INPUT" || options.element.type.toUpperCase() !== "CHECKBOX") {
setValidationValues(options, "required", true);
}
});
adapters.add("remote", ["url", "type", "additionalfields"], function (options) {
var value = {
url: options.params.url,
type: options.params.type || "GET",
data: {}
},
prefix = getModelPrefix(options.element.name);
$.each(splitAndTrim(options.params.additionalfields || options.element.name), function (i, fieldName) {
var paramName = appendModelPrefix(fieldName, prefix);
value.data[paramName] = function () {
var field = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(paramName) + "']");
// For checkboxes and radio buttons, only pick up values from checked fields.
if (field.is(":checkbox")) {
return field.filter(":checked").val() || field.filter(":hidden").val() || '';
}
else if (field.is(":radio")) {
return field.filter(":checked").val() || '';
}
return field.val();
};
});
setValidationValues(options, "remote", value);
});
adapters.add("password", ["min", "nonalphamin", "regex"], function (options) {
if (options.params.min) {
setValidationValues(options, "minlength", options.params.min);
}
if (options.params.nonalphamin) {
setValidationValues(options, "nonalphamin", options.params.nonalphamin);
}
if (options.params.regex) {
setValidationValues(options, "regex", options.params.regex);
}
});
adapters.add("fileextensions", ["extensions"], function (options) {
setValidationValues(options, "extension", options.params.extensions);
});
$(function () {
$jQval.unobtrusive.parse(document);
});
return $jQval.unobtrusive;
}));
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
The MIT License (MIT)
=====================
Copyright Jörn Zaefferer
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,21 @@
Copyright OpenJS Foundation and other contributors, https://openjsf.org/
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+34
View File
@@ -7,20 +7,54 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AnimeAnnouncer", "AnimeAnno
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MassTorrentAdd", "MassTorrentAdd\MassTorrentAdd.csproj", "{AE94FFAE-8D5B-46EF-BC7C-48510F0F7F04}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AnimeAnnouncer.Web", "AnimeAnnouncer.Web\AnimeAnnouncer.Web.csproj", "{DD4F134D-B455-4A8C-8DDE-AA0ED735DA42}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{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|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.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.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.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
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+48
View File
@@ -0,0 +1,48 @@
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;
}
private async Task Authenticate()
{
Boolean success = await client.TryAuthenticateAsync(Token);
if (!success)
throw new Exception("Unable to authenticate with AniList!");
}
}
+7 -1
View File
@@ -8,17 +8,23 @@
</PropertyGroup>
<ItemGroup>
<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="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.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="TMDbLib" Version="2.2.0" />
</ItemGroup>
<ItemGroup>
<None Update="appSettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<Reference Include="TMDbLib">
<HintPath>..\Libraries\TMDbLib.dll</HintPath>
</Reference>
</ItemGroup>
</Project>
+11
View File
@@ -0,0 +1,11 @@
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; }
}
}
+33 -1
View File
@@ -20,6 +20,7 @@ public class TMDBCache
public async Task SetCacheItem(String key, TMDBCacheItem item)
{
var db = redis.GetDatabase();
String jsonItem = JsonSerializer.Serialize(item);
await db.StringSetAsync(key, jsonItem, CacheExpiration);
}
@@ -28,11 +29,37 @@ public class TMDBCache
var db = redis.GetDatabase();
String jsonItem = await db.StringGetAsync(key);
if(!String.IsNullOrEmpty(jsonItem))
if (!String.IsNullOrEmpty(jsonItem))
return JsonSerializer.Deserialize<TMDBCacheItem>(jsonItem);
else
return null;
}
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
return null;
}
public async Task<bool> KeyExists(String key)
{
var db = redis.GetDatabase();
@@ -43,4 +70,9 @@ public class TMDBCache
var db = redis.GetDatabase();
await db.StringSetAsync(key, value);
}
public async Task<String?> GetStringValue(String key)
{
var db = redis.GetDatabase();
return await db.StringGetAsync(key);
}
}
+15 -7
View File
@@ -8,12 +8,20 @@ namespace AnimeAnnouncer.Cache
public int LatestSeasonNumber { get; set; }
public int LastEpisodeNumber { get; set; }
public string? PosterPath { get; internal set; }
public string? BackdropPath { get; internal set; }
public string? LatestSeasonPosterPath { get; internal set; }
public string? LatestSeasonOverview { get; internal set; }
public double VoteAverage { get; internal set; }
public string? Overview { get; internal set; }
public int? LatestOrdinalEpisodeNumber { get; internal set; }
public string? PosterPath { get; set; }
public string? BackdropPath { get; set; }
public string? LatestSeasonPosterPath { get; set; }
public string? LatestSeasonOverview { get; set; }
public double VoteAverage { get; set; }
public string? Overview { get; set; }
public int? LatestOrdinalEpisodeNumber { get; set; }
public DateTime? LastAirDate { get; set; }
//This is the latest episode number to air, NOT the last episode in the series
public int? LatestEpisodeNumber { get; set; }
public Boolean? AnilistPopulated { get; set; }
public String? AnilistPosterPath { get; set; }
}
}
+344 -27
View File
@@ -1,12 +1,15 @@
using System.ComponentModel;
using System.Runtime.ExceptionServices;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using AnimeAnnouncer.Cache;
using AnimeAnnouncer.RSS;
using Mastonet;
using Mastonet.Entities;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using TMDbLib.Client;
using TMDbLib.Objects.Authentication;
namespace AnimeAnnouncer
{
@@ -17,6 +20,9 @@ namespace AnimeAnnouncer
private static TMDbClient tmdbClient;
private static TMDBCache tmdbCache;
private static MastodonClient mastodonClient;
private static Anilist? anilistClient;
private static List<AiringSoonItem> airingSoonList;
static void Main(string[] args)
{
Console.WriteLine("Starting...");
@@ -43,6 +49,8 @@ namespace AnimeAnnouncer
Console.WriteLine("Using cache");
}
tmdbCache.CacheExpiration = TimeSpan.FromDays(7);
String mastodonInstance = config["MastodonInstance"];
if(!String.IsNullOrEmpty(mastodonInstance))
@@ -51,14 +59,42 @@ namespace AnimeAnnouncer
mastodonClient = new MastodonClient(mastodonInstance, mastodonToken);
}
String anilistToken = config["AnilistToken"];
if (!String.IsNullOrEmpty(anilistToken))
anilistClient = new Anilist(anilistToken);
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.RssReadFinished += OnRssReadFinished;
nyaaIndexer.Start(true);
nyaaIndexer.IterationSleepTime = 60;
nyaaIndexer.Start(false);
Console.WriteLine("Press enter to quit...");
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)
{
try
@@ -73,6 +109,14 @@ namespace AnimeAnnouncer
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,10 +136,14 @@ namespace AnimeAnnouncer
return;
}
int latestSeasonNumber = 0, latestEpisodeNumber = 0, supposedShowId = 0;
int latestSeasonNumber = 0, lastEpisodeNumber = 0, latestEpisodeNumber = 0, supposedShowId = 0;
int? latestOrdinalEpisodeNumber = null;
bool finaleConfirmed = false;
DateTime? lastAirDate = null;
TMDBCacheItem? cachedShow = null;
try
@@ -105,8 +153,9 @@ namespace AnimeAnnouncer
{
Console.WriteLine("Cache hit");
latestSeasonNumber = cachedShow.LatestSeasonNumber;
latestEpisodeNumber = cachedShow.LastEpisodeNumber;
lastEpisodeNumber = cachedShow.LastEpisodeNumber;
latestOrdinalEpisodeNumber = cachedShow.LatestOrdinalEpisodeNumber;
lastAirDate = cachedShow.LastAirDate;
supposedShowId = cachedShow.ShowID;
}
}
@@ -128,7 +177,8 @@ namespace AnimeAnnouncer
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);
@@ -142,11 +192,68 @@ namespace AnimeAnnouncer
latestSeasonNumber = latestSeason.SeasonNumber;
latestEpisodeNumber = latestSeason.EpisodeCount;
lastEpisodeNumber = latestSeason.EpisodeCount;
latestEpisodeNumber = int.Parse(episode);
//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
{
Console.WriteLine("Looking up Anilist title " + title);
var anilistMedia = await anilistClient.RetrieveAiringShowInformation(title);
if (anilistMedia != null)
{
if (anilistMedia.Episodes.HasValue && anilistMedia.Episodes != lastEpisodeNumber)
{
Console.WriteLine(
$"Overriding episode count of {lastEpisodeNumber} using Anilist's showID {anilistMedia.Id} titled {anilistMedia.Title.EnglishTitle} with {anilistMedia.Episodes} episodes.");
lastEpisodeNumber = anilistMedia.Episodes.Value;
}
anilistPosterPath = anilistMedia.BannerImageUrl?.ToString();
}
else
Console.WriteLine("0 anilist results found");
}
catch (Exception ex)
{
Console.WriteLine($"An error occurred during Anilist lookup: {ex}");
}
}
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)
@@ -164,11 +271,18 @@ namespace AnimeAnnouncer
Overview = showResult.Overview,
VoteAverage = showResult.VoteAverage,
ShowID = supposedShowId,
LatestSeasonNumber = latestSeason.SeasonNumber,
LastEpisodeNumber = latestSeason.EpisodeCount
LatestSeasonNumber = latestSeasonNumber,
LastEpisodeNumber = lastEpisodeNumber,
LastAirDate = lastAirDate,
LatestEpisodeNumber = latestEpisodeNumber,
AnilistPopulated = anilistPosterPath != null,
AnilistPosterPath = anilistPosterPath
};
_ = tmdbCache.SetCacheItem($"ShowCache-{title}", cachedShow);
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)
{
@@ -181,35 +295,94 @@ namespace AnimeAnnouncer
if(latestSeasonNumber != int.Parse(season))
{
Console.WriteLine($"Failing release due to TMDB's season number {latestSeasonNumber} not matching title season {season}");
return;
}
var titleSeason = int.Parse(season);
bool seasonOverride = false;
if(latestSeasonNumber ==1 && 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;
}
if(latestEpisodeNumber != int.Parse(episode))
{
if(!latestOrdinalEpisodeNumber.HasValue || (latestOrdinalEpisodeNumber.HasValue && latestOrdinalEpisodeNumber.Value != int.Parse(episode)))
seasonOverride = finaleConfirmed = true;
_ = tmdbCache.SetCacheItem($"ShowCache-{title}", cachedShow);
if(targetSeason != null && (targetSeason.Episodes.OrderBy(e => e.Order).First().AirDate + TimeSpan.FromDays(180)) > DateTime.Now &&
((showResult.NextEpisodeToAir ?? showResult.LastEpisodeToAir).AirDate + TimeSpan.FromDays(30)) > DateTime.Now)
UpdateAiringShowList(cachedShow);
}
else
Console.WriteLine("Could not find a finale episode.");
}
}
}
}
if(!seasonOverride)
{
Console.WriteLine($"Failing release due to TMDB's 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;
}
}
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
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.");
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)
{
try
{
_ = tmdbCache.SetPair($"ShowAnnounced-{supposedShowId}", latestSeasonNumber.ToString());
PostStatus(cachedShow);
}
catch(Exception ex)
{
@@ -220,6 +393,68 @@ namespace AnimeAnnouncer
Console.WriteLine($"Choosing {title} as a finished season!");
}
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 void PostStatus(TMDBCacheItem cachedShow)
{
//get poster
@@ -235,17 +470,99 @@ namespace AnimeAnnouncer
//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 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)
{
statusText = statusText[..497] + "..";
}
_ = mastodonClient.PublishStatus(statusText,
Visibility.Public, mediaIds: attachment != null ? new String[] {attachment.Id} : null);
String anidbLink = $"{Environment.NewLine}https://anilist.co/search/anime?search={cachedShow.Title.Replace(" ", "%20")}";
int linkLength = anidbLink.Length;
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)
{
Console.WriteLine($"Not updating {cachedShow.Title} airdate due to episode number not incrementing");
return;
}
airingSoonList.RemoveAll(s => s.ShowID == cachedShow.ShowID);
airingSoonList.Add(airingSoonItem);
}
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];
}
}
}
+10 -2
View File
@@ -22,7 +22,7 @@ namespace MassTorrentAdd
var builder = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
.AddJsonFile("appSettings.json", optional: false, reloadOnChange: true);
IConfigurationRoot configuration = builder.Build();
transmissionHost = configuration.GetValue<String>("transmissionURI");
@@ -70,7 +70,7 @@ namespace MassTorrentAdd
transmissionDirectoryName = System.Text.RegularExpressions.Regex.Replace(transmissionDirectoryName, @"E\d{2,3}.*?(\d{3,4}p)", " $1");
transmissionDirectoryName = System.Text.RegularExpressions.Regex.Replace(transmissionDirectoryName, @"\s\(.*?\)$", "");
transmissionDirectoryName = destinationFolder + (destinationFolder.EndsWith("/") ? String.Empty : "/") + transmissionDirectoryName.Replace(" ", ".");
Console.WriteLine("to " + transmissionDirectoryName);
Console.WriteLine($"{items.Count()} item(s) to {transmissionDirectoryName}");
Console.WriteLine("Continue?");
if(Console.ReadKey().KeyChar != 'y')
@@ -94,6 +94,14 @@ namespace MassTorrentAdd
Metainfo = Convert.ToBase64String(torrentFileData)
};
var torrentInfo = transmissionClient.TorrentAdd(newTorrent);
var seedSettings = new Transmission.API.RPC.Arguments.TorrentSettings()
{
SeedRatioLimit = 1.0,
SeedRatioMode = 1,
IDs = [torrentInfo.ID]
};
transmissionClient.TorrentSet(seedSettings);
//transmissionClient.TorrentVerify(new object[] { torrentInfo.ID });
}
+1 -1
View File
@@ -1,2 +1,2 @@
docker build --pull --rm -f "AnimeAnnouncer/Dockerfile" -t dtr.chrispr.org/animeannouncer:latest .
docker build -f "AnimeAnnouncer/Dockerfile" -t dtr.chrispr.org/animeannouncer:latest .
docker push dtr.chrispr.org/animeannouncer:latest