Files
Media-Server-Setup/jellyfin-plugin-vanguard/Jellyfin.Plugin.Vanguard.Core/Seasons/SeasonPolicy.cs

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;
}
}