From d9d19d175f824bb8046320809e5487e4431f387a Mon Sep 17 00:00:00 2001 From: chrispr Date: Tue, 22 Sep 2026 19:42:10 -0400 Subject: [PATCH] Opus changes --- AnimeAnnouncer.Web/AnimeAnnouncer.Web.csproj | 17 + AnimeAnnouncer.Web/Pages/Index.cshtml | 194 ++++++---- AnimeAnnouncer.Web/Pages/Index.cshtml.cs | 162 +++++++- AnimeAnnouncer.Web/wwwroot/css/site.css | 374 +++++++++++++++++-- 4 files changed, 638 insertions(+), 109 deletions(-) diff --git a/AnimeAnnouncer.Web/AnimeAnnouncer.Web.csproj b/AnimeAnnouncer.Web/AnimeAnnouncer.Web.csproj index 75bb852..88493eb 100644 --- a/AnimeAnnouncer.Web/AnimeAnnouncer.Web.csproj +++ b/AnimeAnnouncer.Web/AnimeAnnouncer.Web.csproj @@ -10,4 +10,21 @@ enable + + + animeannouncer-web + + latest + + app + + + + + + + diff --git a/AnimeAnnouncer.Web/Pages/Index.cshtml b/AnimeAnnouncer.Web/Pages/Index.cshtml index 2a3211e..9d842c1 100644 --- a/AnimeAnnouncer.Web/Pages/Index.cshtml +++ b/AnimeAnnouncer.Web/Pages/Index.cshtml @@ -1,17 +1,17 @@ -๏ปฟ@page +@page @model IndexModel @{ ViewData["Title"] = "Airing Soon Anime"; } -
+

๐Ÿ—“๏ธ Airing Soon Anime @(Model.AiringShows == null ? "No data" : (Model.AiringShows.Count + (Model.AiringShows.Count == 1 ? " show" : " shows")))

-

Anime with an upcoming or recent latest episode, fetched from the TMDB cache. Sorted by air date (newest first).

+

Anime with an upcoming or recent latest episode, drawn from the TMDB and AniList caches. Sorted by air date (soonest first).

@if (!string.IsNullOrEmpty(Model.ErrorMessage)) { @@ -32,94 +32,158 @@ } else { -
+
@foreach (var show in Model.AiringShows) { -
-
-
+
+ @if (!string.IsNullOrWhiteSpace(show.HeroImageUrl)) + { + + } + + +
+
@if (!string.IsNullOrWhiteSpace(show.PosterUrl)) { - @show.Title + @show.Title poster } else { -
- ๐Ÿ—‚๏ธ +
๐Ÿ—‚๏ธ
+ } + @if (show.LatestSeasonNumber.HasValue && show.LatestSeasonNumber > 0) + { + Season @show.LatestSeasonNumber + } +
+ +
+
+
+

@show.Title

+ @if (show.AlternateTitles.Count > 0) + { +

@string.Join(" ยท ", show.AlternateTitles)

+ } +
+
+ @if (show.VoteAverage.HasValue && show.VoteAverage > 0) + { + + @string.Format("{0:0.0}", show.VoteAverage) + TMDB + + } + @if (show.AnilistScore.HasValue && show.AnilistScore > 0) + { + + @string.Format("{0:0.0}", show.AnilistScore) + AniList + + } +
+
+ +
+ @if (show.LatestEpisodeNumber > 0) + { + + @if (show.TotalEpisodes.HasValue && show.TotalEpisodes > 0) + { + Episode @show.LatestEpisodeNumber of @show.TotalEpisodes + } + else + { + Episode @show.LatestEpisodeNumber + } + + } + @if (show.LatestOrdinalEpisodeNumber.HasValue && show.LatestOrdinalEpisodeNumber > 0) + { + @show.LatestOrdinalEpisodeNumber episodes overall + } + @if (!string.IsNullOrWhiteSpace(show.AnilistStatus)) + { + @show.AnilistStatus + } + @if (show.SeasonYear.HasValue) + { + @show.SeasonYear + } + @if (show.IsFinaleNext && show.IsFuture) + { + ๐Ÿ Season finale + } +
+ + @if (show.EpisodeProgressPercent.HasValue) + { +
+
} - @if (show.VoteAverage.HasValue && show.VoteAverage > 0) + @if (!string.IsNullOrWhiteSpace(show.Overview)) { - โญ @string.Format("{0:0.0}", show.VoteAverage) +

+ @if (show.OverviewIsSeasonSpecific && show.LatestSeasonNumber > 0) + { + Season @show.LatestSeasonNumber โ€” + } + @show.Overview +

+ } + else + { +

No overview available.

} +
+ TMDB + @if (!string.IsNullOrWhiteSpace(show.AnilistUrl)) + { + AniList + } + + @if (show.SecondaryImages.Count > 0) + { + + } +
+
+ +
- -
-
@show.Title
- -
- @if (show.LatestSeasonNumber.HasValue && show.LatestSeasonNumber > 0) - { - Season @show.LatestSeasonNumber - } - @if (show.LatestEpisodeNumber > 0) - { - @if (show.LastEpisodeNumber.HasValue && show.LastEpisodeNumber > 0) - { - Ep @show.LatestEpisodeNumber / @show.LastEpisodeNumber - } - else - { - Ep @show.LatestEpisodeNumber - } - } - @if (show.LatestOrdinalEpisodeNumber.HasValue && show.LatestOrdinalEpisodeNumber > 0) - { - Total: @show.LatestOrdinalEpisodeNumber - } -
- -
- @if (show.EffectiveLastAirDate.HasValue) - { - ๐Ÿ“… Last air date: @show.AirDateFormatted - } - else - { - ๐Ÿ“… Last air date: @show.AirDateFormatted - } -
-
- - +
-
+ }
} -
\ No newline at end of file +
diff --git a/AnimeAnnouncer.Web/Pages/Index.cshtml.cs b/AnimeAnnouncer.Web/Pages/Index.cshtml.cs index 571443e..c28de69 100644 --- a/AnimeAnnouncer.Web/Pages/Index.cshtml.cs +++ b/AnimeAnnouncer.Web/Pages/Index.cshtml.cs @@ -1,3 +1,5 @@ +using System.Net; +using System.Text.RegularExpressions; using AnimeAnnouncer.Cache; using Microsoft.AspNetCore.Mvc.RazorPages; @@ -50,10 +52,23 @@ public class IndexModel : PageModel _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($"AnilistCache-{airingItem.Title}"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to retrieve Anilist cache item for title: {Title}", airingItem.Title); + } + viewModels.Add(new AiringShowViewModel { AiringSoon = airingItem, - CachedDetails = cacheItem + CachedDetails = cacheItem, + AnilistDetails = anilistItem }); } @@ -80,10 +95,20 @@ public class IndexModel : PageModel } } + 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; @@ -95,25 +120,116 @@ public class IndexModel : PageModel 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 + // --- Anilist --------------------------------------------------------- + + public bool HasAnilist => AnilistDetails != null; + public int? AnilistShowID => AnilistDetails?.ShowID; + public string? AnilistUrl => AnilistShowID.HasValue ? $"https://anilist.co/anime/{AnilistShowID}" : null; + + /// Anilist's 0-100 score, rescaled to the 0-10 range TMDB uses. + 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; + + /// Total episode count, preferring Anilist which the announcer treats as more reliable. + public int? TotalEpisodes => AnilistDetails?.Episodes ?? (LastEpisodeNumber > 0 ? LastEpisodeNumber : null); + + /// Alternate titles worth showing, i.e. anything that is not just the display title again. + public List AlternateTitles { 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; + 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(); } } - public string? BackdropUrl => CachedDetails?.BackdropPath; + /// Anilist descriptions are HTML fragments; flatten them to plain text for display. + public string? AnilistDescription + { + get + { + if (string.IsNullOrWhiteSpace(AnilistDetails?.Description)) + return null; + + var text = AnilistDetails.Description + .Replace("
", " ", StringComparison.OrdinalIgnoreCase) + .Replace("
", " ", StringComparison.OrdinalIgnoreCase) + .Replace("
", " ", 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); + + /// True when describes the current season rather than the series. + 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); + + /// The tall artwork shown alongside the entry; the season's own poster wins when present. + public string? PosterUrl => SeasonPosterUrl ?? ShowPosterUrl ?? AnilistCoverUrl; + + /// The wide artwork used as the row background. + public string? HeroImageUrl => BackdropUrl ?? AnilistBannerUrl; + + /// Every distinct piece of artwork, for the secondary thumbnail strip. + public List Gallery + { + get + { + var images = new List(); + + 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; + } + } + + /// Gallery entries other than the one already shown as the main poster. + public List SecondaryImages => + Gallery.Where(i => !string.Equals(i.Url, PosterUrl, StringComparison.OrdinalIgnoreCase)).ToList(); + + // --- Air dates ------------------------------------------------------- public DateTime AirReferenceNow => DateTime.Now; @@ -145,6 +261,24 @@ public class IndexModel : PageModel return date.HasValue ? date.Value.ToString("MM/dd/yyyy") : "Unknown"; } } + + public string? AirDateLongFormatted => EffectiveLastAirDate?.ToString("dddd, MMMM d, yyyy"); + + /// How far into the season the latest aired episode sits, as a percentage. + 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); + } + } + + /// True when the upcoming episode is the last one of the season. + public bool IsFinaleNext => TotalEpisodes.HasValue && LatestEpisodeNumber > 0 && LatestEpisodeNumber >= TotalEpisodes.Value; + + private static string? Clean(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } } - diff --git a/AnimeAnnouncer.Web/wwwroot/css/site.css b/AnimeAnnouncer.Web/wwwroot/css/site.css index dcc1e0c..f58973a 100644 --- a/AnimeAnnouncer.Web/wwwroot/css/site.css +++ b/AnimeAnnouncer.Web/wwwroot/css/site.css @@ -41,55 +41,369 @@ a:hover { color: #e9ecef; } -/* --- Anime airing-soon cards --- */ -.anime-card { +/* --- 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; - 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; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.6); } -.anime-poster { +.airing-poster img { width: 100%; height: 100%; object-fit: cover; display: block; - transition: transform 0.3s ease; } -.anime-card:hover .anime-poster { - transform: scale(1.03); +.airing-poster-placeholder { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + font-size: 3rem; + color: #6c757d; } -.show-overview { +.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); +} + +.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: 4; + -webkit-line-clamp: 6; -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; } +.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