Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Emby/Emby.Plugins.Moonfin/Api/SettingsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@ public async Task<object> Get(GetResolvedProfileRequest request)
if (adminDefaults != null) return Json(adminDefaults);
return Json(404, new { Error = "No settings found" });
}

// A pull is the only place a client learns about hides made elsewhere, so this is
// where its baseline moves.
await Settings.RecordHiddenContentBaselineAsync(userId.Value, profile, resolved).ConfigureAwait(false);

return Json(resolved);
}

Expand Down
20 changes: 20 additions & 0 deletions Emby/Emby.Plugins.Moonfin/Models/MoonfinUserSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ public class MoonfinUserSettings
[JsonPropertyName("tv")]
public MoonfinSettingsProfile? Tv { get; set; }

/// <summary>
/// What each profile was last handed for the hidden content maps, keyed by profile name.
/// Clients push their whole map rather than a delta, so this is what tells an item a
/// profile unhid apart from one it never received.
/// </summary>
[JsonPropertyName("hiddenContentBaselines")]
public Dictionary<string, HiddenContentBaseline>? HiddenContentBaselines { get; set; }

[JsonPropertyName("seerrEnabled")] public bool? SeerrEnabled { get; set; }
[JsonPropertyName("seerrApiKey")] public string? SeerrApiKey { get; set; }
[JsonPropertyName("seerrRows")] public SeerrRowsConfig? SeerrRows { get; set; }
Expand Down Expand Up @@ -114,6 +122,18 @@ public void SetProfile(string profileName, MoonfinSettingsProfile? profile)
public static readonly string[] ValidProfiles = { "global", "desktop", "mobile", "tv" };
}

/// <summary>
/// The hidden content maps one profile is known to hold.
/// </summary>
public class HiddenContentBaseline
{
[JsonPropertyName("continueWatching")]
public string? ContinueWatching { get; set; }

[JsonPropertyName("nextUpSeries")]
public string? NextUpSeries { get; set; }
}

public class SeerrRowsConfig
{
[JsonPropertyName("trendingMovies")] public bool? TrendingMovies { get; set; }
Expand Down
186 changes: 164 additions & 22 deletions Emby/Emby.Plugins.Moonfin/Services/MoonfinSettingsService.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
Expand Down Expand Up @@ -92,6 +93,48 @@ private string GetUserSettingsPath(Guid userId) =>
}
}

/// <summary>
/// Notes what a profile was just handed for the hidden content maps, which is what the
/// next push from it gets merged against.
/// </summary>
public async Task RecordHiddenContentBaselineAsync(Guid userId, string profileName, MoonfinSettingsProfile resolved)
{
if (string.IsNullOrEmpty(profileName)) return;

var name = profileName.ToLowerInvariant();
var filePath = GetUserSettingsPath(userId);

await _lock.WaitAsync().ConfigureAwait(false);
try
{
var settings = await Task.Run(() => ReadSettingsWithRecovery(filePath)).ConfigureAwait(false);
if (settings == null) return;

if (settings.HiddenContentBaselines != null &&
settings.HiddenContentBaselines.TryGetValue(name, out var known) &&
known.ContinueWatching == resolved.HiddenContinueWatchingItems &&
known.NextUpSeries == resolved.HiddenNextUpSeries)
{
return;
}

var baseline = BaselineFor(settings, name);
baseline.ContinueWatching = resolved.HiddenContinueWatchingItems;
baseline.NextUpSeries = resolved.HiddenNextUpSeries;

var serialized = JsonSerializer.Serialize(settings, _jsonOptions);
await Task.Run(() => AtomicFile.WriteAllText(filePath, serialized)).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.ErrorException("Could not record the hidden content baseline for user " + userId, ex);
}
finally
{
_lock.Release();
}
}

public async Task<MoonfinSettingsProfile?> GetResolvedProfileAsync(Guid userId, string profileName)
{
var settings = await GetUserSettingsAsync(userId).ConfigureAwait(false);
Expand Down Expand Up @@ -226,12 +269,16 @@ public async Task SaveUserSettingsAsync(Guid userId, MoonfinUserSettings setting
{
MoonfinUserSettings finalSettings;
string? beforeComparisonJson = null;
string? storedHiddenContinueWatching = null;
string? storedHiddenNextUp = null;
if (mergeMode == "merge")
{
var existingSettings = await Task.Run(() => ReadSettingsWithRecovery(filePath)).ConfigureAwait(false);
if (existingSettings != null && existingSettings.NeedsMigration)
existingSettings = MigrateV1ToV2(existingSettings);
beforeComparisonJson = SerializeForComparison(existingSettings ?? new MoonfinUserSettings());
storedHiddenContinueWatching = existingSettings?.Global?.HiddenContinueWatchingItems;
storedHiddenNextUp = existingSettings?.Global?.HiddenNextUpSeries;
finalSettings = MergeSettings(existingSettings, settings);
}
else
Expand All @@ -244,7 +291,7 @@ public async Task SaveUserSettingsAsync(Guid userId, MoonfinUserSettings setting
finalSettings.LastUpdatedBy = clientId ?? "unknown";
finalSettings.SchemaVersion = 2;

MoveContentHidingToGlobal(finalSettings);
MoveContentHidingToGlobal(finalSettings, storedHiddenContinueWatching, storedHiddenNextUp);

settingsChanged = SettingsDiffer(beforeComparisonJson, finalSettings);

Expand Down Expand Up @@ -274,6 +321,8 @@ public async Task SaveProfileAsync(Guid userId, string profileName, MoonfinSetti
if (settings.NeedsMigration) settings = MigrateV1ToV2(settings);

var beforeComparisonJson = SerializeForComparison(settings);
var storedHiddenContinueWatching = settings.Global?.HiddenContinueWatchingItems;
var storedHiddenNextUp = settings.Global?.HiddenNextUpSeries;

var existingProfile = string.Equals(profileName, "global", StringComparison.OrdinalIgnoreCase)
? settings.Global
Expand All @@ -289,7 +338,7 @@ public async Task SaveProfileAsync(Guid userId, string profileName, MoonfinSetti
settings.LastUpdatedBy = clientId ?? "unknown";
settings.SchemaVersion = 2;

MoveContentHidingToGlobal(settings);
MoveContentHidingToGlobal(settings, storedHiddenContinueWatching, storedHiddenNextUp);

settingsChanged = SettingsDiffer(beforeComparisonJson, settings);

Expand Down Expand Up @@ -329,6 +378,7 @@ private bool SettingsDiffer(string? before, MoonfinUserSettings after)
node.Remove("schemaVersion");
node.Remove("lastUpdated");
node.Remove("lastUpdatedBy");
node.Remove("hiddenContentBaselines");
return node.ToJsonString();
}

Expand Down Expand Up @@ -708,30 +758,56 @@ private MoonfinUserSettings MigrateV1ToV2(MoonfinUserSettings v1)
};
}

private void MoveContentHidingToGlobal(MoonfinUserSettings settings)
private static readonly (string Name, Func<MoonfinUserSettings, MoonfinSettingsProfile?> Get)[] HidingProfiles =
{
("global", s => s.Global),
("desktop", s => s.Desktop),
("mobile", s => s.Mobile),
("tv", s => s.Tv),
};

/// <summary>
/// Content hiding is a global preference, so each profile's hidden lists get lifted into
/// the global one. Everything a profile pushed is merged in, anything its baseline still
/// lists that the push leaves out counts as unhidden and is dropped, and hides it hasn't
/// pulled yet are left alone. The stored global maps are passed in because a push to the
/// global profile overwrites them in place before this runs.
/// </summary>
private static void MoveContentHidingToGlobal(
MoonfinUserSettings settings,
string? storedContinueWatching,
string? storedNextUp)
{
if (settings.Global == null)
{
settings.Global = new MoonfinSettingsProfile();
}

// Content hiding is a global preference, so lift each device's hidden lists into the
// global profile. Union across devices so a file that hid items on more than one device
// keeps them all instead of the last device winning.
var devices = new[] { settings.Desktop, settings.Mobile, settings.Tv };
var hiddenContinueWatching = MergeHiddenEntries(
settings,
storedContinueWatching,
p => p.HiddenContinueWatchingItems,
b => b.ContinueWatching,
(b, v) => b.ContinueWatching = v);

var hiddenContinueWatching = UnionHiddenEntries(devices, p => p.HiddenContinueWatchingItems);
if (hiddenContinueWatching != null)
{
settings.Global.HiddenContinueWatchingItems = hiddenContinueWatching;
}

var hiddenNextUp = UnionHiddenEntries(devices, p => p.HiddenNextUpSeries);
var hiddenNextUp = MergeHiddenEntries(
settings,
storedNextUp,
p => p.HiddenNextUpSeries,
b => b.NextUpSeries,
(b, v) => b.NextUpSeries = v);

if (hiddenNextUp != null)
{
settings.Global.HiddenNextUpSeries = hiddenNextUp;
}

var devices = new[] { settings.Desktop, settings.Mobile, settings.Tv };
foreach (var device in devices)
{
if (device == null) continue;
Expand All @@ -740,31 +816,97 @@ private void MoveContentHidingToGlobal(MoonfinUserSettings settings)
}
}

private static string? UnionHiddenEntries(
MoonfinSettingsProfile?[] devices,
Func<MoonfinSettingsProfile, string?> selector)
/// <summary>
/// Folds every profile's push of one hidden map into the stored global value. Null when no
/// profile pushed the field, so a partial save leaves what is on file alone.
/// </summary>
private static string? MergeHiddenEntries(
MoonfinUserSettings settings,
string? stored,
Func<MoonfinSettingsProfile, string?> selector,
Func<HiddenContentBaseline, string?> readBaseline,
Action<HiddenContentBaseline, string?> writeBaseline)
{
Dictionary<string, string>? merged = null;
foreach (var device in devices)
var merged = ParseHiddenEntries(stored);
var pushed = false;

foreach (var (name, get) in HidingProfiles)
{
if (device == null) continue;
var value = selector(device);
var profile = get(settings);
if (profile == null) continue;

var value = selector(profile);
if (value == null) continue;

if (merged == null)
// The global profile is where the stored map lives, so it only counts as a push
// when a client sent something other than what was already on file.
if (name == "global" && value == stored) continue;

pushed = true;
var entries = ParseHiddenEntries(value);

string? baseline = null;
if (settings.HiddenContentBaselines != null &&
settings.HiddenContentBaselines.TryGetValue(name, out var known))
{
merged = new Dictionary<string, string>();
baseline = readBaseline(known);
}
foreach (var pair in ParseHiddenEntries(value))

foreach (var key in ParseHiddenEntries(baseline).Keys)
{
merged[pair.Key] = pair.Value;
if (!entries.ContainsKey(key))
{
merged.Remove(key);
}
}

foreach (var pair in entries)
{
if (!merged.TryGetValue(pair.Key, out var existingDate) ||
IsLaterHide(pair.Value, existingDate))
{
merged[pair.Key] = pair.Value;
}
}

writeBaseline(BaselineFor(settings, name), value);
}

return pushed ? JsonSerializer.Serialize(merged) : null;
}

private static HiddenContentBaseline BaselineFor(MoonfinUserSettings settings, string profileName)
{
if (settings.HiddenContentBaselines == null)
{
settings.HiddenContentBaselines = new Dictionary<string, HiddenContentBaseline>();
}

if (!settings.HiddenContentBaselines.TryGetValue(profileName, out var baseline))
{
baseline = new HiddenContentBaseline();
settings.HiddenContentBaselines[profileName] = baseline;
}

return baseline;
}

/// <summary>
/// Whether one hide timestamp is later than another. Clients write ISO-8601, but the field
/// is free-form, so anything that won't parse falls back to comparing the text.
/// </summary>
private static bool IsLaterHide(string candidate, string existing)
{
if (DateTimeOffset.TryParse(candidate, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var candidateAt) &&
DateTimeOffset.TryParse(existing, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var existingAt))
{
return candidateAt > existingAt;
}

return merged == null ? null : JsonSerializer.Serialize(merged);
return string.Compare(candidate, existing, StringComparison.Ordinal) > 0;
}

private static Dictionary<string, string> ParseHiddenEntries(string value)
private static Dictionary<string, string> ParseHiddenEntries(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
Expand Down
4 changes: 4 additions & 0 deletions Jellyfin/backend/Api/MoonfinController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,10 @@ public async Task<ActionResult<MoonfinSettingsProfile>> GetResolvedProfile([From
return adminDefaults != null ? Ok(adminDefaults) : NotFound(new { Error = "No settings found" });
}

// A pull is the only place a client learns about hides made elsewhere, so this is where
// its baseline moves.
await _settingsService.RecordHiddenContentBaselineAsync(userId.Value, profile, resolved);

return Ok(resolved);
}

Expand Down
20 changes: 20 additions & 0 deletions Jellyfin/backend/Models/MoonfinUserSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ public class MoonfinUserSettings
[JsonPropertyName("tv")]
public MoonfinSettingsProfile? Tv { get; set; }

/// <summary>
/// What each profile was last handed for the hidden content maps, keyed by profile name.
/// Clients push their whole map rather than a delta, so this is what tells an item a profile
/// unhid apart from one it never received.
/// </summary>
[JsonPropertyName("hiddenContentBaselines")]
public Dictionary<string, HiddenContentBaseline>? HiddenContentBaselines { get; set; }

// ─── Legacy v1 flat fields (for migration) ─────────────────────────
// These are populated when reading a v1 file, then migrated to profiles.

Expand Down Expand Up @@ -186,6 +194,18 @@ public void SetProfile(string profileName, MoonfinSettingsProfile? profile)
public static readonly string[] ValidProfiles = { "global", "desktop", "mobile", "tv" };
}

/// <summary>
/// The hidden content maps one profile is known to hold.
/// </summary>
public class HiddenContentBaseline
{
[JsonPropertyName("continueWatching")]
public string? ContinueWatching { get; set; }

[JsonPropertyName("nextUpSeries")]
public string? NextUpSeries { get; set; }
}

public class SeerrRowsConfig
{
[JsonPropertyName("trendingMovies")]
Expand Down
Loading
Loading