From afb3e34e717a10b41020d19727397831bdca2f62 Mon Sep 17 00:00:00 2001 From: ivan tkachenko Date: Sun, 11 Jan 2026 02:52:26 +0200 Subject: [PATCH] Implement seasonal content framework to ensure that New Year's songs won't play in summer. --- CHANGELOG.md | 1 + MuzikaGromche/Exporter.cs | 1 + MuzikaGromche/Plugin.cs | 23 +++++++-- MuzikaGromche/SeasonalContent.cs | 82 ++++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 MuzikaGromche/SeasonalContent.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c3aeaa2..93eef01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Added a new track Paarden. - Added a new track DiscoKapot. - Added an accessibility option to reduce the intensity of overly distracting visual effects. +- Seasonal content like New Year's songs (IkWilJe, Paarden, DiscoKapot) will only be available for selection during their respective seasons. ## MuzikaGromche 1337.9001.3 - v73 Happy New Year Edition diff --git a/MuzikaGromche/Exporter.cs b/MuzikaGromche/Exporter.cs index 5ba4214..ed28a4c 100644 --- a/MuzikaGromche/Exporter.cs +++ b/MuzikaGromche/Exporter.cs @@ -56,6 +56,7 @@ namespace MuzikaGromche { ["Name"] = audioTrack.Name, // may be different from selectableTrack.Name, if selectable track is a group ["IsExplicit"] = selectableTrack.IsExplicit, + ["Season"] = selectableTrack.Season?.Name, ["Language"] = selectableTrack.Language.Full, ["WindUpTimer"] = audioTrack.WindUpTimer, ["Bpm"] = audioTrack.Bpm, diff --git a/MuzikaGromche/Plugin.cs b/MuzikaGromche/Plugin.cs index 1724587..2a749f9 100644 --- a/MuzikaGromche/Plugin.cs +++ b/MuzikaGromche/Plugin.cs @@ -884,6 +884,7 @@ namespace MuzikaGromche Name = "IkWilJe", AudioType = AudioType.OGGVORBIS, Language = Language.ENGLISH, + Season = Season.NewYear, WindUpTimer = 43.03f, Beats = 13 * 4 + 2, // = 54 BeatsOffset = 0f, @@ -909,6 +910,7 @@ namespace MuzikaGromche Name = "Paarden", AudioType = AudioType.OGGVORBIS, Language = Language.RUSSIAN, + Season = Season.NewYear, WindUpTimer = 36.12f, Bars = 8, BeatsOffset = 0f, @@ -934,6 +936,7 @@ namespace MuzikaGromche Name = "DiscoKapot", AudioType = AudioType.OGGVORBIS, Language = Language.RUSSIAN, + Season = Season.NewYear, WindUpTimer = 30.3f, Bars = 8, BeatsOffset = 0f, @@ -970,12 +973,19 @@ namespace MuzikaGromche public static ISelectableTrack ChooseTrack() { var seed = GetCurrentSeed(); - var tracks = Config.SkipExplicitTracks.Value ? [.. Tracks.Where(track => !track.IsExplicit)] : Tracks; - int[] weights = [.. tracks.Select(track => track.Weight.Value)]; + var today = DateTime.Today; + var season = SeasonalContentManager.CurrentSeason(today); + var tracksEnumerable = SeasonalContentManager.Filter(Tracks, season); + if (Config.SkipExplicitTracks.Value) + { + tracksEnumerable = tracksEnumerable.Where(track => !track.IsExplicit); + } + var tracks = tracksEnumerable.ToArray(); + int[] weights = tracks.Select(track => track.Weight.Value).ToArray(); var rwi = new RandomWeightedIndex(weights); var trackId = rwi.GetRandomWeightedIndex(seed); var track = tracks[trackId]; - Log.LogInfo($"Seed is {seed}, chosen track is \"{track.Name}\", #{trackId} of {rwi}"); + Log.LogInfo($"Seed is {seed}, season is {season?.Name ?? ""}, chosen track is \"{track.Name}\", #{trackId} of {rwi}"); return tracks[trackId]; } @@ -1212,7 +1222,7 @@ namespace MuzikaGromche // An instance of a track which appears as a configuration entry and // can be selected using weighted random from a list of selectable tracks. - public interface ISelectableTrack + public interface ISelectableTrack : ISeasonalContent { // Name of the track, as shown in config entry UI; also used for default file names. public string Name { get; init; } @@ -1423,6 +1433,7 @@ namespace MuzikaGromche { public /* required */ Language Language { get; init; } public bool IsExplicit { get; init; } = false; + public Season? Season { get; init; } = null; ConfigEntry ISelectableTrack.Weight { get; set; } = null!; IAudioTrack[] ISelectableTrack.GetTracks() => [this]; @@ -1440,6 +1451,7 @@ namespace MuzikaGromche public /* required */ string Name { get; init; } = ""; public /* required */ Language Language { get; init; } public bool IsExplicit { get; init; } = false; + public Season? Season { get; init; } = null; ConfigEntry ISelectableTrack.Weight { get; set; } = null!; public /* required */ IAudioTrack[] Tracks = []; @@ -2480,8 +2492,9 @@ namespace MuzikaGromche } // Create slider entry for track + var seasonal = track.Season is Season season ? $"This is seasonal content for {season.Name}.\n\n" : ""; string warning = track.IsExplicit ? "Explicit Content/Lyrics!\n\n" : ""; - string description = $"Language: {language.Full}\n\n{warning}Random (relative) chance of selecting this track.\n\nSet to zero to effectively disable the track."; + string description = $"Language: {language.Full}\n\n{seasonal}{warning}Random (relative) chance of selecting this track.\n\nSet to zero to effectively disable the track."; track.Weight = configFile.Bind( new ConfigDefinition(section, track.Name), 50, diff --git a/MuzikaGromche/SeasonalContent.cs b/MuzikaGromche/SeasonalContent.cs new file mode 100644 index 0000000..8bcc2d9 --- /dev/null +++ b/MuzikaGromche/SeasonalContent.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MuzikaGromche; + +public delegate bool SeasonalContentPredicate(DateTime dateTime); + +// I'm not really sure what to do with seasonal content yet. +// +// There could be two approaches: +// - Force seasonal content tracks to be the only available tracks during their season. +// Then seasons must be short, so they don't cut off too much content for too long. +// - Exclude seasonal content tracks from the pool when their season is not active. +// Considering how many tracks are there in the playlist permanently already, +// this might not give the seasonal content enough visibility. +// +// Either way, seasonal content tracks would be listed in the config UI at all times, +// which makes it confusing if you try to select only seasonal tracks outside of their season. + +// Seasons may NOT overlap. There is at most ONE active season at any given date. +public readonly record struct Season(string Name, string Description, SeasonalContentPredicate IsActive) +{ + public override string ToString() => Name; + + public static readonly Season NewYear = new("New Year", "New Year and Christmas holiday season", dateTime => + { + // December 10 - February 29 + var month = dateTime.Month; + var day = dateTime.Day; + return (month == 12 && day >= 10) || (month == 1) || (month == 2 && day <= 29); + }); + + // Note: it is important that this property goes last + public static readonly Season[] All = [NewYear]; +} + +public interface ISeasonalContent +{ + public Season? Season { get; init; } +} + +public static class SeasonalContentManager +{ + public static Season? CurrentSeason(DateTime dateTime) + { + foreach (var season in Season.All) + { + if (season.IsActive(dateTime)) + { + return season; + } + } + return null; + } + + public static Season? CurrentSeason() => CurrentSeason(DateTime.Today); + + // Take second approach: filter out seasonal content that is not in the current season. + public static IEnumerable Filter(this IEnumerable items, Season? season) where T : ISeasonalContent + { + return items.Where(item => + { + if (item.Season == null) + { + return true; // always available + } + return item.Season == season; + }); + } + + public static IEnumerable Filter(this IEnumerable items, DateTime dateTime) where T : ISeasonalContent + { + var season = CurrentSeason(dateTime); + return Filter(items, season); + } + + public static IEnumerable Filter(this IEnumerable items) where T : ISeasonalContent + { + return Filter(items, DateTime.Today); + } +}