120 lines
5.7 KiB
C#
120 lines
5.7 KiB
C#
using System.Globalization;
|
|
|
|
namespace Jellyfin.Plugin.Vanguard.Seasons;
|
|
|
|
/// <summary>Where an item sits in the rolling season scheme.</summary>
|
|
public sealed record SeasonSlot(int SeasonNumber, string SeasonName, int EpisodeNumber, DateOnly BroadcastDay, SeasonKind Kind);
|
|
|
|
public enum SeasonKind { Day, Week, Month, Year }
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public static class SeasonPolicy
|
|
{
|
|
/// <summary>Week seasons are 1YYYYWW so they never collide with YYYYMM month seasons. Lengths: year 4, month 6, week 7, day 8.</summary>
|
|
public const int WeekBase = 1_000_000;
|
|
|
|
/// <summary>Recover kind and display name from a season number alone (used by the Season provider).</summary>
|
|
public static (SeasonKind Kind, string Name)? Describe(int seasonNumber, IReadOnlyDictionary<int, string>? 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<int, string>? 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);
|
|
}
|
|
|
|
/// <summary>Calendar day the item belongs to after shifting the day boundary to <paramref name="rolloverHour"/>.</summary>
|
|
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));
|
|
}
|
|
|
|
/// <summary>Parse "202611=Winter Scrims" lines into a map.</summary>
|
|
public static IReadOnlyDictionary<int, string> ParseOverrides(string? text)
|
|
{
|
|
var map = new Dictionary<int, string>();
|
|
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;
|
|
}
|
|
}
|