using System.Globalization; namespace Jellyfin.Plugin.Vanguard.Seasons; /// Where an item sits in the rolling season scheme. public sealed record SeasonSlot(int SeasonNumber, string SeasonName, int EpisodeNumber, DateOnly BroadcastDay, SeasonKind Kind); public enum SeasonKind { Day, Week, Month, Year } /// /// Pure function: (item timestamp, now, settings) -> season/episode numbers and season name. /// Rolling consolidation: items in the current ISO week are bucketed by broadcast day; in the /// current month by ISO week; in the current year by month; older by year. Episode numbers are /// MMDDHHMMSS of the local timestamp, so they never change when the season rolls. /// public static class SeasonPolicy { /// Week seasons are 1YYYYWW so they never collide with YYYYMM month seasons. Lengths: year 4, month 6, week 7, day 8. public const int WeekBase = 1_000_000; /// Recover kind and display name from a season number alone (used by the Season provider). public static (SeasonKind Kind, string Name)? Describe(int seasonNumber, IReadOnlyDictionary? nameOverrides = null) { (SeasonKind, string)? r = seasonNumber switch { >= 10_000_000 and <= 99_999_999 when TryDay(seasonNumber, out var d) => (SeasonKind.Day, d.ToString("dddd, MMM d", CultureInfo.InvariantCulture)), >= WeekBase and < 10_000_000 => (SeasonKind.Week, $"Week {seasonNumber % 100}"), >= 100_000 and <= 999_999 when seasonNumber % 100 is >= 1 and <= 12 => (SeasonKind.Month, new DateOnly(seasonNumber / 100, seasonNumber % 100, 1).ToString("MMMM yyyy", CultureInfo.InvariantCulture)), >= 1000 and <= 9999 => (SeasonKind.Year, seasonNumber.ToString(CultureInfo.InvariantCulture)), _ => null, }; if (r is null) return null; if (nameOverrides is not null && nameOverrides.TryGetValue(seasonNumber, out var over) && !string.IsNullOrWhiteSpace(over)) { return (r.Value.Item1, over.Trim()); } return r; } private static bool TryDay(int n, out DateOnly d) { try { d = new DateOnly(n / 10000, n / 100 % 100, n % 100); return true; } catch (ArgumentOutOfRangeException) { d = default; return false; } } public static SeasonSlot Resolve(DateTimeOffset itemUtc, DateTimeOffset nowUtc, TimeZoneInfo tz, int rolloverHour, IReadOnlyDictionary? nameOverrides = null) { var item = TimeZoneInfo.ConvertTime(itemUtc, tz); var now = TimeZoneInfo.ConvertTime(nowUtc, tz); var itemDay = BroadcastDay(item, rolloverHour); var today = BroadcastDay(now, rolloverHour); var (itemIsoYear, itemIsoWeek) = IsoWeek(itemDay); var (todayIsoYear, todayIsoWeek) = IsoWeek(today); int season; string name; SeasonKind kind; if (itemIsoYear == todayIsoYear && itemIsoWeek == todayIsoWeek) { kind = SeasonKind.Day; season = itemDay.Year * 10000 + itemDay.Month * 100 + itemDay.Day; // 20261030 name = itemDay.ToString("dddd, MMM d", CultureInfo.InvariantCulture); // Thursday, Oct 30 } else if (itemDay.Year == today.Year && itemDay.Month == today.Month) { kind = SeasonKind.Week; season = WeekBase + itemIsoYear * 100 + itemIsoWeek; // 1202644 (leading 1 keeps weeks distinct from months) name = $"Week {itemIsoWeek}"; } else if (itemDay.Year == today.Year) { kind = SeasonKind.Month; season = itemDay.Year * 100 + itemDay.Month; // 202610 name = itemDay.ToString("MMMM yyyy", CultureInfo.InvariantCulture); // October 2026 } else { kind = SeasonKind.Year; season = itemDay.Year; // 2026 name = itemDay.Year.ToString(CultureInfo.InvariantCulture); } if (nameOverrides is not null && nameOverrides.TryGetValue(season, out var over) && !string.IsNullOrWhiteSpace(over)) { name = over.Trim(); } // MMDDHHMMSS of the local timestamp: 1030210311 -> fits int32 (max 1231235959 < 2147483647) var episode = item.Month * 100000000 + item.Day * 1000000 + item.Hour * 10000 + item.Minute * 100 + item.Second; return new SeasonSlot(season, name, episode, itemDay, kind); } /// Calendar day the item belongs to after shifting the day boundary to . public static DateOnly BroadcastDay(DateTimeOffset local, int rolloverHour) => DateOnly.FromDateTime(local.DateTime.AddHours(-rolloverHour)); public static (int IsoYear, int IsoWeek) IsoWeek(DateOnly d) { var dt = d.ToDateTime(TimeOnly.MinValue); return (ISOWeek.GetYear(dt), ISOWeek.GetWeekOfYear(dt)); } /// Parse "202611=Winter Scrims" lines into a map. public static IReadOnlyDictionary ParseOverrides(string? text) { var map = new Dictionary(); foreach (var raw in (text ?? string.Empty).Split('\n')) { var line = raw.Trim(); if (line.Length == 0 || line.StartsWith('#')) continue; var eq = line.IndexOf('='); if (eq <= 0) continue; if (int.TryParse(line[..eq].Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var key)) { map[key] = line[(eq + 1)..].Trim(); } } return map; } }