using System.Net; using System.Text.RegularExpressions; using AnimeAnnouncer.Cache; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; namespace AnimeAnnouncer.Web.Pages; public class IndexModel : PageModel { private readonly ILogger _logger; private readonly TMDBCache? _tmdbCache; public List AiringShows { get; set; } = new(); public string? ErrorMessage { get; set; } public IndexModel(ILogger logger, IServiceProvider serviceProvider) { _logger = logger; _tmdbCache = serviceProvider.GetService(); } public async Task OnGetAsync() { if (_tmdbCache == null) { ErrorMessage = "Redis cache connection is not configured or unavailable."; return; } try { List? airingList = await _tmdbCache.GetAiringList(); if (airingList == null || airingList.Count == 0) { AiringShows = new List(); return; } var viewModels = new List(); 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); } // 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); } // With no details from either source there is nothing worth displaying. if (cacheItem == null && anilistItem == null) continue; viewModels.Add(new AiringShowViewModel { AiringSoon = airingItem, CachedDetails = cacheItem, AnilistDetails = anilistItem }); } // Exclude placeholder/sentinel last-air-dates (e.g. DateTime.MaxValue == 12/31/9999, // used by the announcer when an exact air date is unknown), then sort descending // by LastAirDate (nulls last). var displayable = viewModels.Where(x => { var date = x.EffectiveLastAirDate; // Keep items with a real, displayable date; drop the MaxValue sentinel. return !date.HasValue || date.Value < DateTime.MaxValue.AddMonths(-1); }).ToList(); AiringShows = displayable .Where(x => x.EffectiveLastAirDate.HasValue && x.EffectiveLastAirDate.Value.Date >= DateTime.Now.Date) .OrderBy(x => x.EffectiveLastAirDate) .ThenBy(x => x.Title) .ToList(); } catch (Exception ex) { _logger.LogError(ex, "Error loading airing soon list"); ErrorMessage = $"Unable to load airing soon list: {ex.Message}"; } } public async Task OnPostDownloadAsync(int showId) { if (_tmdbCache == null) return StatusCode(StatusCodes.Status503ServiceUnavailable); try { if (!await _tmdbCache.SetDownload(showId, true)) _logger.LogWarning("Download requested for show {ShowID}, which is not in the airing list", showId); } catch (Exception ex) { _logger.LogError(ex, "Failed to flag show {ShowID} for download", showId); } // Post/redirect/get, landing back on the row that was clicked. return RedirectToPage(null, null, null, $"show-{showId}"); } public class ShowImage { public required string Url { get; set; } public required string Label { get; set; } public required string Source { get; set; } } public class AiringShowViewModel { private static readonly Regex HtmlTagPattern = new("<[^>]+>", RegexOptions.Compiled); public required AiringSoonItem AiringSoon { get; set; } public TMDBCacheItem? CachedDetails { get; set; } public AnilistCacheItem? AnilistDetails { get; set; } public string Title => AiringSoon.Title; public int ShowID => AiringSoon.ShowID; public bool Download => AiringSoon.Download; public DateTime? EffectiveLastAirDate => AiringSoon.LastAirDate ?? CachedDetails?.LastAirDate; public int LatestEpisodeNumber => AiringSoon.LatestEpisodeNumber > 0 ? AiringSoon.LatestEpisodeNumber : (CachedDetails?.LatestEpisodeNumber ?? 0); public int? LastEpisodeNumber => CachedDetails?.LastEpisodeNumber; public int? LatestSeasonNumber => CachedDetails?.LatestSeasonNumber; public int? LatestOrdinalEpisodeNumber => CachedDetails?.LatestOrdinalEpisodeNumber; public double? VoteAverage => CachedDetails?.VoteAverage; // --- Anilist --------------------------------------------------------- public bool HasAnilist => AnilistDetails != null; public int? AnilistShowID => AnilistDetails?.ShowID; public string? AnilistUrl => AnilistShowID.HasValue ? $"https://anilist.co/anime/{AnilistShowID}" : null; /// 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 { var candidates = new[] { AnilistDetails?.RomajiTitle, AnilistDetails?.NativeTitle, AnilistDetails?.EnglishTitle }; return candidates .Where(t => !string.IsNullOrWhiteSpace(t)) .Select(t => t!.Trim()) .Where(t => !string.Equals(t, Title, StringComparison.OrdinalIgnoreCase)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); } } // --- Genres & tags --------------------------------------------------- /// How many tags are shown before the rest are tucked behind a "more" toggle. public const int VisibleTagCount = 10; /// /// Genres from both sources, AniList first. TMDB's "Animation" genre is dropped since every /// show on this page is anime, so it carries no information. /// public List Genres => MergeDistinct(AnilistDetails?.Genres, CachedDetails?.Genres) .Where(g => !string.Equals(g, "Animation", StringComparison.OrdinalIgnoreCase)) .ToList(); /// AniList tags followed by TMDB keywords, minus anything already listed as a genre. public List Tags { get { var genres = new HashSet(Genres, StringComparer.OrdinalIgnoreCase); return MergeDistinct(AnilistDetails?.Tags, CachedDetails?.Keywords) .Where(t => !genres.Contains(t)) .ToList(); } } public List VisibleTags => Tags.Take(VisibleTagCount).ToList(); public List OverflowTags => Tags.Skip(VisibleTagCount).ToList(); private static IEnumerable MergeDistinct(params IEnumerable?[] sources) => sources .Where(s => s != null) .SelectMany(s => s!) .Where(s => !string.IsNullOrWhiteSpace(s)) .Select(s => s.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase); /// 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; public bool IsFuture { get { var date = EffectiveLastAirDate; return date.HasValue && date.Value > AirReferenceNow; } } public int DaysUntil { get { var date = EffectiveLastAirDate; if (!date.HasValue) return 0; return (int)((date.Value - AirReferenceNow).Ticks / TimeSpan.FromDays(1).Ticks); } } public string AirDateFormatted { get { var date = EffectiveLastAirDate; return date.HasValue ? date.Value.ToString("MM/dd/yyyy") : "Unknown"; } } public string? AirDateLongFormatted => EffectiveLastAirDate?.ToString("dddd, MMMM d, yyyy"); /// 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(); } }