diff --git a/Jellyfin/backend/Api/MoonfinController.cs b/Jellyfin/backend/Api/MoonfinController.cs index 73d4dbb2..c944c522 100644 --- a/Jellyfin/backend/Api/MoonfinController.cs +++ b/Jellyfin/backend/Api/MoonfinController.cs @@ -358,6 +358,120 @@ public async Task> GetUserSettings([FromRoute] return Ok(settings); } + /// + /// Explains what a user gets on one device type, setting by setting, and which layer each + /// value comes from (admin only, read-only). + /// + /// The user ID. + /// global, desktop, mobile or tv. + [HttpGet("Admin/Settings/Explain/{userId}")] + [Authorize(Policy = "RequiresElevation")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task> ExplainUserSettings( + [FromRoute] Guid userId, + [FromQuery] string profile = "global") + { + if (MoonfinPlugin.Instance?.Configuration?.EnableSettingsSync != true) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, new { Error = "Settings sync is disabled" }); + } + + if (userId == Guid.Empty || !MoonfinUserSettings.ValidProfiles.Contains(profile.ToLowerInvariant())) + { + return BadRequest(new { Error = "A valid userId and a profile of global, desktop, mobile or tv are required" }); + } + + return Ok(await _settingsService.ExplainProfileAsync(userId, profile)); + } + + /// + /// For every user on the server, how many settings each profile stores and how many only + /// repeat what the layers below give (admin only, read-only). + /// + [HttpGet("Admin/Settings/Overview")] + [Authorize(Policy = "RequiresElevation")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task>> GetSettingsOverview() + { + if (MoonfinPlugin.Instance?.Configuration?.EnableSettingsSync != true) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, new { Error = "Settings sync is disabled" }); + } + + var userIds = GetAllServerUserIds(); + if (userIds == null) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, new { Error = "User manager unavailable" }); + } + + return Ok(await _settingsService.GetSettingsOverviewAsync(userIds)); + } + + /// + /// Removes, for the given users, the stored values that only repeat what they would inherit + /// anyway (admin only). What users get doesn't change. With dryRun, only counts. + /// + [HttpPost("Admin/Settings/RemoveRepeated")] + [Authorize(Policy = "RequiresElevation")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task RemoveRepeatedSettings([FromBody] MoonfinSettingsUsersRequest request, [FromQuery] bool dryRun = true) + { + if (MoonfinPlugin.Instance?.Configuration?.EnableSettingsSync != true) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, new { Error = "Settings sync is disabled" }); + } + + if (request.UserIds == null || request.UserIds.Count == 0) + { + return BadRequest(new { Error = "Pick at least one user" }); + } + + var (users, values) = await _settingsService.RemoveRepeatedValuesAsync(request.UserIds, dryRun); + return Ok(new { users, values, dryRun }); + } + + /// + /// Removes the named settings from one user's profiles, or every user's, so they fall back to + /// the admin defaults (admin only). + /// + [HttpPost("Admin/Settings/Clear")] + [Authorize(Policy = "RequiresElevation")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task ClearUserSettings([FromBody] MoonfinClearSettingsRequest request) + { + if (MoonfinPlugin.Instance?.Configuration?.EnableSettingsSync != true) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, new { Error = "Settings sync is disabled" }); + } + + if (request.Fields == null || request.Fields.Count == 0) + { + return BadRequest(new { Error = "Pick at least one setting to clear" }); + } + + if (request.UserIds == null || request.UserIds.Count == 0) + { + return BadRequest(new { Error = "Pick at least one user" }); + } + + try + { + var (users, values) = await _settingsService.ClearSettingsAsync(request.Fields, request.UserIds); + return Ok(new { users, values }); + } + catch (ArgumentException ex) + { + return BadRequest(new { Error = ex.Message }); + } + } + /// /// Saves settings for the current authenticated user. /// @@ -942,7 +1056,12 @@ public async Task> SaveMyProfile( var existed = _settingsService.UserSettingsExist(userId.Value); - await _settingsService.SaveProfileAsync(userId.Value, profile, request.Profile, request.ClientId); + await _settingsService.SaveProfileAsync( + userId.Value, + profile, + request.Profile, + request.ClientId, + dropInheritedValues: true); return Ok(new MoonfinSaveResponse { @@ -1646,6 +1765,22 @@ public class MoonfinProfileSaveRequest public string? ClientId { get; set; } } +/// +/// The users an admin settings action applies to. +/// +public class MoonfinSettingsUsersRequest +{ + public List? UserIds { get; set; } +} + +/// +/// Settings for an admin to clear, and the users to clear them for. +/// +public class MoonfinClearSettingsRequest : MoonfinSettingsUsersRequest +{ + public List? Fields { get; set; } +} + /// /// Response for saving settings. /// diff --git a/Jellyfin/backend/Models/SettingsExplanation.cs b/Jellyfin/backend/Models/SettingsExplanation.cs new file mode 100644 index 00000000..1f4517f0 --- /dev/null +++ b/Jellyfin/backend/Models/SettingsExplanation.cs @@ -0,0 +1,93 @@ +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Moonfin.Server.Models; + +/// +/// What one user gets on one device type, setting by setting, for the admin preview. +/// +public class SettingsExplanation +{ + [JsonPropertyName("profile")] + public string Profile { get; set; } = string.Empty; + + /// False when the user never synced, so everything comes from the defaults. + [JsonPropertyName("hasSettingsFile")] + public bool HasSettingsFile { get; set; } + + [JsonPropertyName("lastUpdated")] + public long? LastUpdated { get; set; } + + [JsonPropertyName("lastUpdatedBy")] + public string? LastUpdatedBy { get; set; } + + [JsonPropertyName("fields")] + public List Fields { get; set; } = new(); +} + +/// +/// One setting: the value the user gets, the layer it comes from, and what each layer holds. +/// +public class SettingExplanation +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// device, global, default (the admin defaults) or none (the app's own default). + [JsonPropertyName("source")] + public string Source { get; set; } = "none"; + + [JsonPropertyName("effective")] + public JsonNode? Effective { get; set; } + + [JsonPropertyName("device")] + public JsonNode? Device { get; set; } + + [JsonPropertyName("global")] + public JsonNode? Global { get; set; } + + [JsonPropertyName("default")] + public JsonNode? Default { get; set; } + + /// The device value only repeats what global or the defaults would give. + [JsonPropertyName("deviceRedundant")] + public bool DeviceRedundant { get; set; } + + /// The global value only repeats the admin default. + [JsonPropertyName("globalRedundant")] + public bool GlobalRedundant { get; set; } +} + +/// +/// How many settings one user has stored in each profile, for the admin overview. +/// +public class SettingsOverviewEntry +{ + [JsonPropertyName("userId")] + public Guid UserId { get; set; } + + [JsonPropertyName("hasSettingsFile")] + public bool HasSettingsFile { get; set; } + + [JsonPropertyName("lastUpdated")] + public long? LastUpdated { get; set; } + + [JsonPropertyName("lastUpdatedBy")] + public string? LastUpdatedBy { get; set; } + + [JsonPropertyName("desktop")] + public int Desktop { get; set; } + + [JsonPropertyName("mobile")] + public int Mobile { get; set; } + + [JsonPropertyName("tv")] + public int Tv { get; set; } + + [JsonPropertyName("global")] + public int Global { get; set; } + + /// How many stored values only repeat what the layers below give. + [JsonPropertyName("repeated")] + public int Repeated { get; set; } +} diff --git a/Jellyfin/backend/Pages/configPage.html b/Jellyfin/backend/Pages/configPage.html index f54a8f16..2a47c04c 100644 --- a/Jellyfin/backend/Pages/configPage.html +++ b/Jellyfin/backend/Pages/configPage.html @@ -62,7 +62,8 @@ max-width: none; } - #MoonfinConfigPage .defaultsSubnavBar { + #MoonfinConfigPage .defaultsSubnavBar, + #MoonfinConfigPage .previewProfileTabs { display: flex; gap: 4px; flex-wrap: wrap; @@ -74,7 +75,8 @@ background: rgba(0, 0, 0, 0.22); } - #MoonfinConfigPage .defaultsSubtabBtn { + #MoonfinConfigPage .defaultsSubtabBtn, + #MoonfinConfigPage .previewProfileTab { background: transparent; border: 0; color: rgba(255, 255, 255, 0.75); @@ -86,12 +88,14 @@ transition: all 0.2s ease; } - #MoonfinConfigPage .defaultsSubtabBtn:hover { + #MoonfinConfigPage .defaultsSubtabBtn:hover, + #MoonfinConfigPage .previewProfileTab:hover { background: rgba(255, 255, 255, 0.12); color: #fff; } - #MoonfinConfigPage .defaultsSubtabBtn.active { + #MoonfinConfigPage .defaultsSubtabBtn.active, + #MoonfinConfigPage .previewProfileTab.active { background: #00a4dc; color: #fff; font-weight: 600; @@ -694,6 +698,60 @@ text-decoration: underline; } + /* The settings preview tables. Long values are cut, a click on a cell shows it whole. */ + #MoonfinConfigPage .previewTable { + width: 100%; + border-collapse: collapse; + font-size: 0.85em; + table-layout: fixed; + } + + #MoonfinConfigPage .previewTable th { + padding: 4px 8px; + text-align: left; + color: rgba(255, 255, 255, 0.5); + } + + #MoonfinConfigPage .previewTable td { + padding: 4px 8px; + vertical-align: top; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + #MoonfinConfigPage .previewTable td.is-expanded { + white-space: normal; + word-break: break-all; + } + + #MoonfinConfigPage .previewTable tbody tr { + border-top: 1px solid rgba(255, 255, 255, 0.06); + } + + #MoonfinConfigPage .previewTable tbody tr.is-active { + background: rgba(0, 164, 220, 0.12); + } + + #MoonfinConfigPage .previewTable .previewRepeat { + color: #e0a030; + } + + #MoonfinConfigPage #PreviewSettingsSummary ul { + margin: 0.3em 0 0; + padding-left: 1.4em; + } + + /* User names in the settings overview: same link, sized to fit a table cell. */ + #MoonfinConfigPage #PreviewOverview .moonfinLinkButton { + padding: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; + } + #MoonfinConfigPage.moonfin-searching .moonfinPageHead, #MoonfinConfigPage.moonfin-searching .moonfinSyncDeps, #MoonfinConfigPage.moonfin-searching .moonfinLayersCard { @@ -829,6 +887,57 @@

Where a setting comes from

+ +
+

See where users' settings come from

+
+ How many settings each user stores in each profile, and how many only repeat what the layers below already give. + Tick users to act on them, click a name to see that user's settings one by one. Looking changes nothing. +
+ +
+ +
+
+ Remove Repeated Values deletes the stored values that only repeat what the layers below give, for the ticked users. + Nobody's settings change, but the Admin defaults reach those users again when you change them later.

+ Clear Selected Settings deletes the settings ticked in a user's table below from the ticked users' Device profiles and Global, + so the Admin defaults apply instead. Set and save an Admin default for those settings first: where there is none, + apps keep the value they already have. +
+ + + +
+ + +
@@ -8631,6 +8740,404 @@

Active Downloads

}); } + var refreshOverviewBtn = document.querySelector('#RefreshOverviewBtn'); + if (refreshOverviewBtn && !refreshOverviewBtn.dataset.bound) { + refreshOverviewBtn.dataset.bound = 'true'; + + var overviewData = []; + var checkedUsers = {}; + var previewUserId = ''; + var previewData = null; + var previewSelected = {}; + var detailRequest = 0; + // The server refuses to clear these, they hold what the user hid themselves. + var previewProtected = { hiddenContinueWatchingItems: true, hiddenNextUpSeries: true }; + + var serverUrl = function() { + return ApiClient.serverAddress ? ApiClient.serverAddress() : ''; + }; + + var postAdminSettings = function(path, body) { + return fetch(serverUrl() + path, { + method: 'POST', + headers: getMoonfinAuthHeaders(), + body: JSON.stringify(body || {}) + }).then(parseJsonResponse); + }; + + var previewActionResult = function(text, isError) { + var result = document.querySelector('#PreviewActionResult'); + if (!result) return; + result.style.display = ''; + result.style.color = isError ? '#d9534f' : '#52b54b'; + result.textContent = text; + }; + + var userName = function(userId) { + var entry = overviewData.filter(function(e) { return e.userId === userId; })[0]; + return entry ? entry.name : 'this user'; + }; + + var previewSourceLabel = function(source) { + if (source === 'device') return 'Device profile'; + if (source === 'global') return 'Global'; + if (source === 'default') return 'Admin defaults'; + return 'App default'; + }; + + var previewValueText = function(value) { + if (value === null || value === undefined) return ''; + return typeof value === 'string' ? value : JSON.stringify(value); + }; + + // Values come from what users stored, so cells are filled with textContent, never as HTML. + // Long values are cut to keep every column on screen. A click shows the whole value, + // another one cuts it again. + var previewCell = function(row, text, title, className) { + var cell = document.createElement('td'); + cell.addEventListener('click', function() { + cell.classList.toggle('is-expanded'); + }); + cell.textContent = text; + if (title) cell.title = title; + if (className) cell.className = className; + row.appendChild(cell); + return cell; + }; + + var previewTable = function(widths, labels, selectAll) { + var table = document.createElement('table'); + table.className = 'previewTable'; + + var columns = document.createElement('colgroup'); + widths.forEach(function(width) { + var col = document.createElement('col'); + col.style.width = width; + columns.appendChild(col); + }); + table.appendChild(columns); + + var head = document.createElement('tr'); + var boxCell = document.createElement('th'); + if (selectAll) boxCell.appendChild(selectAll); + head.appendChild(boxCell); + labels.forEach(function(label) { + var th = document.createElement('th'); + th.textContent = label; + head.appendChild(th); + }); + var thead = document.createElement('thead'); + thead.appendChild(head); + table.appendChild(thead); + return table; + }; + + var checkbox = function(checked, onChange, title) { + var box = document.createElement('input'); + box.type = 'checkbox'; + box.checked = checked; + if (title) box.title = title; + box.addEventListener('change', function() { onChange(box.checked); }); + return box; + }; + + var renderOverview = function() { + var container = document.querySelector('#PreviewOverview'); + if (!container) return; + container.innerHTML = ''; + + var selectAll = checkbox( + overviewData.length > 0 && overviewData.every(function(e) { return checkedUsers[e.userId]; }), + function(checked) { + overviewData.forEach(function(e) { + if (checked) checkedUsers[e.userId] = true; else delete checkedUsers[e.userId]; + }); + renderOverview(); + }, + 'Select every user'); + var table = previewTable(['2.2em', '22%', '9%', '9%', '9%', '9%', '10%', '30%'], + ['User', 'Desktop', 'Mobile', 'TV', 'Global', 'Repeated', 'Last synced'], selectAll); + + var body = document.createElement('tbody'); + overviewData.forEach(function(e) { + var row = document.createElement('tr'); + if (e.userId === previewUserId) row.className = 'is-active'; + + var pick = previewCell(row, ''); + pick.appendChild(checkbox(!!checkedUsers[e.userId], function(checked) { + if (checked) checkedUsers[e.userId] = true; else delete checkedUsers[e.userId]; + renderOverview(); + })); + + // A button, so the name opens with Enter or Space and reads as a control. + var open = document.createElement('button'); + open.type = 'button'; + open.textContent = e.name; + open.title = 'Show ' + e.name + '\'s settings'; + open.className = 'moonfinLinkButton'; + open.addEventListener('click', function(event) { + // The cell's own click expands long values, which a name doesn't need. + event.stopPropagation(); + previewUserId = e.userId; + previewSelected = {}; + renderOverview(); + loadDetail(); + }); + previewCell(row, '').appendChild(open); + + var synced = e.hasSettingsFile; + [e.desktop, e.mobile, e.tv, e.global].forEach(function(count) { + previewCell(row, synced ? String(count) : '-'); + }); + previewCell(row, synced ? String(e.repeated) : '-', '', e.repeated > 0 ? 'previewRepeat' : ''); + previewCell(row, synced + ? (e.lastUpdated ? new Date(e.lastUpdated).toLocaleString() : 'unknown') + ', ' + (e.lastUpdatedBy || 'unknown client') + : 'Never'); + body.appendChild(row); + }); + table.appendChild(body); + container.appendChild(table); + }; + + var loadOverview = function() { + var getUsers = ApiClient.getUsers ? ApiClient.getUsers() : Promise.resolve([]); + var getOverview = fetch(serverUrl() + '/Moonfin/Admin/Settings/Overview', { headers: getMoonfinAuthHeaders() }) + .then(parseJsonResponse); + return Promise.all([getUsers, getOverview]) + .then(function(results) { + var names = {}; + (results[0] || []).forEach(function(user) { names[user.Id] = user.Name; }); + overviewData = (results[1] || []).map(function(e) { + e.name = names[e.userId] || names[(e.userId || '').replace(/-/g, '')] || e.userId; + return e; + }).sort(function(a, b) { return a.name.localeCompare(b.name); }); + renderOverview(); + }) + .catch(function(error) { + previewActionResult('Could not load the users: ' + error.message, true); + }); + }; + + var renderDetail = function() { + var detail = document.querySelector('#PreviewDetail'); + var summary = document.querySelector('#PreviewSettingsSummary'); + var container = document.querySelector('#PreviewSettingsTable'); + if (!previewData || !detail || !summary || !container) return; + detail.style.display = ''; + document.querySelector('#PreviewDetailTitle').textContent = userName(previewUserId) + '\'s settings'; + + var fields = previewData.fields || []; + var onDevice = fields.filter(function(f) { return f.device !== null && f.device !== undefined; }).length; + var onGlobal = fields.filter(function(f) { return f.global !== null && f.global !== undefined; }).length; + var repeats = fields.filter(function(f) { return f.deviceRedundant || f.globalRedundant; }).length; + + summary.innerHTML = ''; + if (!previewData.hasSettingsFile) { + summary.textContent = 'This user never synced, so every setting comes from the Admin defaults or the app\'s own defaults.'; + } else { + summary.appendChild(document.createTextNode('Last synced ' + + (previewData.lastUpdated ? new Date(previewData.lastUpdated).toLocaleString() : 'at an unknown time') + + ' by ' + (previewData.lastUpdatedBy || 'an unknown client') + ':')); + var counts = document.createElement('ul'); + var profileName = previewData.profile === 'tv' ? 'TV' : previewData.profile.charAt(0).toUpperCase() + previewData.profile.slice(1); + var lines = previewData.profile === 'global' ? [] : [profileName + ' Device profile: ' + onDevice]; + lines.push('Global: ' + onGlobal, 'Repeated stored values: ' + repeats); + lines.forEach(function(line) { + var item = document.createElement('li'); + item.textContent = line; + counts.appendChild(item); + }); + summary.appendChild(counts); + } + + var storedOnly = !!(document.querySelector('#PreviewStoredOnly') || {}).checked; + var filter = ((document.querySelector('#PreviewFilter') || {}).value || '').trim().toLowerCase(); + var shown = fields.filter(function(f) { + if (storedOnly && f.source !== 'device' && f.source !== 'global') return false; + return !filter || f.name.toLowerCase().indexOf(filter) !== -1; + }); + var clearable = shown.filter(function(f) { return !previewProtected[f.name]; }); + + container.innerHTML = ''; + var selectAll = checkbox( + clearable.length > 0 && clearable.every(function(f) { return previewSelected[f.name]; }), + function(checked) { + clearable.forEach(function(f) { + if (checked) previewSelected[f.name] = true; else delete previewSelected[f.name]; + }); + renderDetail(); + }, + 'Select every setting shown'); + var table = previewTable(['2.2em', '22%', '17%', '11%', '15%', '15%', '15%', '5em'], + ['Setting', 'User gets', 'Comes from', 'Device profile', 'Global', 'Admin defaults', ''], selectAll); + + var body = document.createElement('tbody'); + shown.forEach(function(f) { + var row = document.createElement('tr'); + var pick = previewCell(row, ''); + if (previewProtected[f.name]) { + pick.title = 'Hidden items can\'t be cleared here'; + } else { + pick.appendChild(checkbox(!!previewSelected[f.name], function(checked) { + if (checked) previewSelected[f.name] = true; else delete previewSelected[f.name]; + })); + } + previewCell(row, f.name); + previewCell(row, previewValueText(f.effective), JSON.stringify(f.effective)); + previewCell(row, previewSourceLabel(f.source)); + previewCell(row, previewValueText(f.device), JSON.stringify(f.device)); + previewCell(row, previewValueText(f.global), JSON.stringify(f.global)); + previewCell(row, previewValueText(f['default']), JSON.stringify(f['default'])); + previewCell(row, f.deviceRedundant || f.globalRedundant ? 'repeat' : '', + f.deviceRedundant || f.globalRedundant ? 'Removing this stored value would change nothing for the user' : '', + 'previewRepeat'); + body.appendChild(row); + }); + table.appendChild(body); + container.appendChild(table); + + if (shown.length === 0 && storedOnly) { + var empty = document.createElement('div'); + empty.className = 'fieldDescription'; + empty.style.marginTop = '0.6em'; + empty.textContent = (filter ? 'None of the matching settings is' : 'No setting is') + + ' stored on this user, so they all come from Global, the Admin defaults or the app. ' + + 'Untick "Only show settings stored on the user" to see where each one comes from.'; + container.appendChild(empty); + } + }; + + var loadDetail = function() { + if (!previewUserId) return Promise.resolve(); + var activeTab = document.querySelector('#PreviewProfileTabs .previewProfileTab.active'); + var profile = activeTab ? activeTab.getAttribute('data-profile') : 'mobile'; + // Clicking another user or tab before this answer lands makes it stale, and + // showing it would put one user's settings under another's name. + var request = ++detailRequest; + Dashboard.showLoadingMsg(); + return fetch(serverUrl() + '/Moonfin/Admin/Settings/Explain/' + encodeURIComponent(previewUserId) + '?profile=' + encodeURIComponent(profile), { + headers: getMoonfinAuthHeaders() + }) + .then(parseJsonResponse) + .then(function(data) { + if (request !== detailRequest) return; + previewData = data; + renderDetail(); + }) + .catch(function(error) { + if (request !== detailRequest) return; + previewActionResult('Could not load ' + userName(previewUserId) + '\'s settings: ' + error.message, true); + }) + .finally(function() { + Dashboard.hideLoadingMsg(); + }); + }; + + var refreshAll = function() { + return loadOverview().then(loadDetail); + }; + + var checkedUserIds = function() { + return Object.keys(checkedUsers); + }; + + var checkedUsersLabel = function(ids) { + if (ids.length === 1) return userName(ids[0]); + return ids.length + ' users'; + }; + + refreshOverviewBtn.addEventListener('click', refreshAll); + + Array.prototype.forEach.call(document.querySelectorAll('#PreviewProfileTabs .previewProfileTab'), function(tab) { + tab.addEventListener('click', function() { + Array.prototype.forEach.call(document.querySelectorAll('#PreviewProfileTabs .previewProfileTab'), function(other) { + other.classList.toggle('active', other === tab); + }); + loadDetail(); + }); + }); + + ['#PreviewFilter', '#PreviewStoredOnly'].forEach(function(selector) { + var input = document.querySelector(selector); + if (input) { + input.addEventListener(selector === '#PreviewFilter' ? 'input' : 'change', renderDetail); + } + }); + + var removeRepeatedBtn = document.querySelector('#RemoveRepeatedBtn'); + if (removeRepeatedBtn) { + removeRepeatedBtn.addEventListener('click', function() { + var ids = checkedUserIds(); + if (ids.length === 0) { + previewActionResult('Tick at least one user in the list above.', true); + return; + } + removeRepeatedBtn.disabled = true; + postAdminSettings('/Moonfin/Admin/Settings/RemoveRepeated?dryRun=true', { userIds: ids }) + .then(function(count) { + if (!count.values) { + previewActionResult('Nothing to remove for ' + checkedUsersLabel(ids) + '.'); + return null; + } + if (!window.confirm('Remove ' + count.values + ' repeated values for ' + count.users + ' user(s)?\n\nNobody\'s settings change.')) { + return null; + } + return postAdminSettings('/Moonfin/Admin/Settings/RemoveRepeated?dryRun=false', { userIds: ids }) + .then(function(done) { + previewActionResult('Removed ' + done.values + ' repeated values for ' + done.users + ' user(s).'); + return refreshAll(); + }); + }) + .catch(function(error) { + previewActionResult('Could not remove the repeated values: ' + error.message, true); + }) + .finally(function() { + removeRepeatedBtn.disabled = false; + }); + }); + } + + var clearSelectedBtn = document.querySelector('#ClearSelectedBtn'); + if (clearSelectedBtn) { + clearSelectedBtn.addEventListener('click', function() { + var ids = checkedUserIds(); + var fields = Object.keys(previewSelected); + if (ids.length === 0) { + previewActionResult('Tick at least one user in the list above.', true); + return; + } + if (fields.length === 0) { + previewActionResult('Open a user and tick at least one setting in their table first.', true); + return; + } + var listed = fields.slice(0, 10).join(', ') + (fields.length > 10 ? ' and ' + (fields.length - 10) + ' more' : ''); + if (!window.confirm('Clear ' + fields.length + ' setting(s) for ' + checkedUsersLabel(ids) + '?\n\n' + listed + + '\n\nThe Admin defaults apply instead. Where there is none, apps keep the value they already have.')) { + return; + } + clearSelectedBtn.disabled = true; + postAdminSettings('/Moonfin/Admin/Settings/Clear', { fields: fields, userIds: ids }) + .then(function(done) { + previewSelected = {}; + // The cleared settings aren't stored on the user any more, so show + // every setting, or they'd vanish instead of showing where they now come from. + var storedOnly = document.querySelector('#PreviewStoredOnly'); + if (storedOnly) storedOnly.checked = false; + previewActionResult('Cleared ' + done.values + ' stored values for ' + done.users + ' user(s).'); + return refreshAll(); + }) + .catch(function(error) { + previewActionResult('Could not clear the settings: ' + error.message, true); + }) + .finally(function() { + clearSelectedBtn.disabled = false; + }); + }); + } + + loadOverview(); + } + var applySingleUserBtn = document.querySelector('#ApplySingleUserBtn'); if (applySingleUserBtn && !applySingleUserBtn.dataset.bound) { applySingleUserBtn.dataset.bound = 'true'; diff --git a/Jellyfin/backend/Services/MoonfinSettingsService.cs b/Jellyfin/backend/Services/MoonfinSettingsService.cs index cca7e40c..723a3546 100644 --- a/Jellyfin/backend/Services/MoonfinSettingsService.cs +++ b/Jellyfin/backend/Services/MoonfinSettingsService.cs @@ -36,6 +36,13 @@ public class MoonfinSettingsService nameof(MoonfinSettingsProfile.MediaBarMdblistRatingSources), ]; + /// The admin default for a setting, as resolution reads it. + private static object? AdminDefault(System.Reflection.PropertyInfo prop, MoonfinSettingsProfile? adminDefaults) + { + var value = adminDefaults == null ? null : prop.GetValue(adminDefaults); + return value is List { Count: 0 } && RatingSourceLists.Contains(prop.Name) ? null : value; + } + public MoonfinSettingsService(ILogger logger) : this(logger, MoonfinPlugin.ResolveDataFolderPath()) { @@ -188,14 +195,7 @@ public MoonfinSettingsProfile ResolveProfile(MoonfinUserSettings settings, strin // Resolution chain: device → global → admin defaults var value = deviceProfile != null ? prop.GetValue(deviceProfile) : null; value ??= global != null ? prop.GetValue(global) : null; - if (value == null && adminDefaults != null) - { - value = prop.GetValue(adminDefaults); - if (value is List { Count: 0 } && RatingSourceLists.Contains(prop.Name)) - { - value = null; - } - } + value ??= AdminDefault(prop, adminDefaults); if (value != null) { @@ -279,6 +279,357 @@ private static (List? HomeSections, List? Home return homeRowOrder.Count > 0 ? homeRowOrder : null; } + private static readonly JsonSerializerOptions ExplainJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + /// + /// Every setting the user gets on one device type, the layer it comes from, and whether a + /// stored value only repeats what the layers below would give. Read-only, for the admin preview. + /// + public async Task ExplainProfileAsync(Guid userId, string profileName) + { + var settings = await GetUserSettingsAsync(userId); + return ExplainProfile(settings, profileName, MoonfinPlugin.Instance?.Configuration?.DefaultUserSettings); + } + + internal static SettingsExplanation ExplainProfile( + MoonfinUserSettings? settings, + string profileName, + MoonfinSettingsProfile? adminDefaults) + { + var global = settings?.Global; + var device = settings != null && !string.Equals(profileName, "global", StringComparison.OrdinalIgnoreCase) + ? settings.GetProfile(profileName) + : null; + + var explanation = new SettingsExplanation + { + Profile = profileName.ToLowerInvariant(), + HasSettingsFile = settings != null, + LastUpdated = settings?.LastUpdated, + LastUpdatedBy = settings?.LastUpdatedBy + }; + + foreach (var prop in ProfileProps) + { + if (prop.Name is nameof(MoonfinSettingsProfile.HomeSections) or nameof(MoonfinSettingsProfile.HomeRowOrder)) + { + continue; + } + + explanation.Fields.Add(ExplainValue( + WireName(prop), + prop.Name.EndsWith("ApiKey", StringComparison.Ordinal), + device == null ? null : prop.GetValue(device), + global == null ? null : prop.GetValue(global), + AdminDefault(prop, adminDefaults))); + } + + // The home rows resolve as one block: the first layer that has a layout wins whole. + // They're never flagged as a repeat, since the cleanup leaves them alone. + var layout = ExplainValue( + "homeLayout", + false, + HomeLayoutOf(device), + HomeLayoutOf(global), + HomeLayoutOf(adminDefaults)); + layout.DeviceRedundant = false; + layout.GlobalRedundant = false; + explanation.Fields.Add(layout); + + return explanation; + } + + private static object? HomeLayoutOf(MoonfinSettingsProfile? profile) + { + if (profile == null || (profile.HomeSections == null && profile.HomeRowOrder == null)) + { + return null; + } + + return new { homeSections = profile.HomeSections, homeRowOrder = ResolveHomeRowOrder(profile) }; + } + + private static SettingExplanation ExplainValue(string name, bool secret, object? device, object? global, object? admin) + { + var deviceJson = device == null ? null : JsonSerializer.Serialize(device, ExplainJsonOptions); + var globalJson = global == null ? null : JsonSerializer.Serialize(global, ExplainJsonOptions); + var adminJson = admin == null ? null : JsonSerializer.Serialize(admin, ExplainJsonOptions); + + return new SettingExplanation + { + Name = name, + Source = deviceJson != null ? "device" : globalJson != null ? "global" : adminJson != null ? "default" : "none", + Effective = Show(deviceJson ?? globalJson ?? adminJson, secret), + Device = Show(deviceJson, secret), + Global = Show(globalJson, secret), + Default = Show(adminJson, secret), + // A stored value equal to what the layers below give was not really chosen, most + // likely a client writing every field back. Removing it would change nothing today. + DeviceRedundant = deviceJson != null && deviceJson == (globalJson ?? adminJson), + GlobalRedundant = globalJson != null && globalJson == adminJson + }; + } + + private static string WireName(System.Reflection.PropertyInfo prop) => + (prop.GetCustomAttributes(typeof(System.Text.Json.Serialization.JsonPropertyNameAttribute), false) + .FirstOrDefault() as System.Text.Json.Serialization.JsonPropertyNameAttribute)?.Name + ?? JsonNamingPolicy.CamelCase.ConvertName(prop.Name); + + // Left out of the inherited value cleanup: the home layout, because custom rows are + // mirrored across profiles on every save, and the hidden items, which have their own + // merge rules. + private static readonly HashSet NotDeduplicated = new(StringComparer.Ordinal) + { + nameof(MoonfinSettingsProfile.HomeSections), + nameof(MoonfinSettingsProfile.HomeRowOrder), + nameof(MoonfinSettingsProfile.HiddenContinueWatchingItems), + nameof(MoonfinSettingsProfile.HiddenNextUpSeries) + }; + + /// + /// Clears the values in that only repeat what + /// or the admin defaults give. With , only the settings set there + /// are looked at. Returns how many values were cleared. + /// + internal static int DropInheritedValues( + MoonfinSettingsProfile profile, + MoonfinSettingsProfile? below, + MoonfinSettingsProfile? adminDefaults, + MoonfinSettingsProfile? onlySetIn = null) + { + var dropped = 0; + foreach (var prop in ProfileProps) + { + if (NotDeduplicated.Contains(prop.Name) || (onlySetIn != null && prop.GetValue(onlySetIn) == null)) + { + continue; + } + + var value = prop.GetValue(profile); + var inherited = (below == null ? null : prop.GetValue(below)) ?? AdminDefault(prop, adminDefaults); + if (value == null || inherited == null) + { + continue; + } + + if (JsonSerializer.Serialize(value, ExplainJsonOptions) == JsonSerializer.Serialize(inherited, ExplainJsonOptions)) + { + prop.SetValue(profile, null); + dropped++; + } + } + + return dropped; + } + + /// + /// Clears, in every profile of the user, the values that only repeat what the layers below + /// give. What the user gets doesn't change. Returns how many values were cleared. + /// + internal static int RemoveRepeatedValues(MoonfinUserSettings settings, MoonfinSettingsProfile? adminDefaults) + { + // Devices first, against the global profile as it stands, then global against the defaults. + var removed = 0; + foreach (var name in new[] { "desktop", "mobile", "tv" }) + { + var device = settings.GetProfile(name); + if (device != null) + { + removed += DropInheritedValues(device, settings.Global, adminDefaults); + } + } + + if (settings.Global != null) + { + removed += DropInheritedValues(settings.Global, null, adminDefaults); + } + + return removed; + } + + /// + /// For each user, how many settings each profile stores and how many of them only repeat + /// what the layers below give. Read-only, for the admin overview. + /// + public async Task> GetSettingsOverviewAsync(IEnumerable userIds) + { + var adminDefaults = MoonfinPlugin.Instance?.Configuration?.DefaultUserSettings; + var overview = new List(); + foreach (var userId in userIds.Distinct()) + { + // A fresh read per user, so counting the repeats on it changes nothing stored. + var settings = await GetUserSettingsAsync(userId); + overview.Add(SummarizeSettings(userId, settings, adminDefaults)); + } + + return overview; + } + + internal static SettingsOverviewEntry SummarizeSettings(Guid userId, MoonfinUserSettings? settings, MoonfinSettingsProfile? adminDefaults) + { + var entry = new SettingsOverviewEntry + { + UserId = userId, + HasSettingsFile = settings != null, + LastUpdated = settings?.LastUpdated, + LastUpdatedBy = settings?.LastUpdatedBy, + Desktop = CountStored(settings?.Desktop), + Mobile = CountStored(settings?.Mobile), + Tv = CountStored(settings?.Tv), + Global = CountStored(settings?.Global) + }; + + if (settings != null) + { + entry.Repeated = RemoveRepeatedValues(settings, adminDefaults); + } + + return entry; + } + + // Counted the way the preview lists them: the home rows and their order are one setting. + private static int CountStored(MoonfinSettingsProfile? profile) + { + if (profile == null) + { + return 0; + } + + var count = ProfileProps.Count(p => p.Name is not (nameof(MoonfinSettingsProfile.HomeSections) or nameof(MoonfinSettingsProfile.HomeRowOrder)) + && p.GetValue(profile) != null); + return HomeLayoutOf(profile) == null ? count : count + 1; + } + + /// + /// Runs over the given users' files. With + /// , only counts. Returns the users and values affected. + /// + public async Task<(int Users, int Values)> RemoveRepeatedValuesAsync(IReadOnlyCollection userIds, bool dryRun) + { + var adminDefaults = MoonfinPlugin.Instance?.Configuration?.DefaultUserSettings; + return await RewriteUserFilesAsync(userIds, dryRun, "admin-cleanup", notify: false, + settings => RemoveRepeatedValues(settings, adminDefaults)); + } + + /// + /// Removes the named settings from every profile of the given users, so they fall back to + /// the admin defaults. "homeLayout" stands for the home rows and their order. + /// + /// A name isn't a setting that can be cleared. + public async Task<(int Users, int Values)> ClearSettingsAsync(IReadOnlyCollection names, IReadOnlyCollection userIds) + { + var props = new List(); + foreach (var name in names.Distinct(StringComparer.Ordinal)) + { + if (name == "homeLayout") + { + props.Add(typeof(MoonfinSettingsProfile).GetProperty(nameof(MoonfinSettingsProfile.HomeSections))!); + props.Add(typeof(MoonfinSettingsProfile).GetProperty(nameof(MoonfinSettingsProfile.HomeRowOrder))!); + continue; + } + + var prop = ProfileProps.FirstOrDefault(p => WireName(p) == name); + if (prop == null + || prop.Name is nameof(MoonfinSettingsProfile.HiddenContinueWatchingItems) or nameof(MoonfinSettingsProfile.HiddenNextUpSeries)) + { + throw new ArgumentException($"Unknown or protected setting: {name}"); + } + + props.Add(prop); + } + + return await RewriteUserFilesAsync(userIds, dryRun: false, "admin-clear", notify: true, settings => + { + var cleared = 0; + foreach (var profile in new[] { settings.Global, settings.Desktop, settings.Mobile, settings.Tv }) + { + if (profile == null) + { + continue; + } + + foreach (var prop in props.Where(p => p.GetValue(profile) != null)) + { + prop.SetValue(profile, null); + cleared++; + } + } + + return cleared; + }); + } + + private async Task<(int Users, int Values)> RewriteUserFilesAsync( + IReadOnlyCollection userIds, + bool dryRun, + string updatedBy, + bool notify, + Func rewrite) + { + EnsureDataDirectory(); + + var users = 0; + var values = 0; + foreach (var userId in userIds.Distinct()) + { + var filePath = GetUserSettingsPath(userId); + if (!File.Exists(filePath)) + { + continue; + } + + var changed = 0; + await _lock.WaitAsync(); + try + { + var settings = ReadSettingsWithRecovery(filePath); + if (settings != null && settings.NeedsMigration) + { + settings = MigrateV1ToV2(settings); + } + + changed = settings == null ? 0 : rewrite(settings); + if (changed > 0 && !dryRun) + { + settings!.LastUpdated = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + settings.LastUpdatedBy = updatedBy; + AtomicFile.WriteAllText(filePath, JsonSerializer.Serialize(settings, _jsonOptions)); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to rewrite the settings of user {UserId}", userId); + changed = 0; + } + finally + { + _lock.Release(); + } + + if (changed == 0) + { + continue; + } + + users++; + values += changed; + if (notify && !dryRun) + { + NotifySettingsChanged(userId); + } + } + + return (users, values); + } + + // API keys are shown as set or not, never in clear. + private static JsonNode? Show(string? json, bool secret) => + json == null ? null : secret ? JsonValue.Create("(set)") : JsonNode.Parse(json); + public async Task SaveUserSettingsAsync(Guid userId, MoonfinUserSettings settings, string? clientId = null, string mergeMode = "merge") { var filePath = GetUserSettingsPath(userId); @@ -353,7 +704,8 @@ public async Task SaveProfileAsync( string profileName, MoonfinSettingsProfile profile, string? clientId = null, - bool notifySettingsChanged = true) + bool notifySettingsChanged = true, + bool dropInheritedValues = false) { var filePath = GetUserSettingsPath(userId); bool settingsChanged; @@ -409,6 +761,19 @@ public async Task SaveProfileAsync( customSectionsBefore, CustomHomeSectionIds(savedProfile))); + if (dropInheritedValues) + { + // A pushed value equal to what the user would inherit isn't a choice, so it + // is cleared and a later change to the defaults still reaches this user. It + // also covers switching a setting back to the default, which a merge alone + // can't express. + DropInheritedValues( + savedProfile, + profileName.ToLowerInvariant() == "global" ? null : settings.Global, + MoonfinPlugin.Instance?.Configuration?.DefaultUserSettings, + onlySetIn: profile); + } + settingsChanged = SettingsDiffer(beforeComparisonJson, settings); var serialized = JsonSerializer.Serialize(settings, _jsonOptions); diff --git a/Jellyfin/tests/Moonfin.Server.Tests/InheritedSettingsTests.cs b/Jellyfin/tests/Moonfin.Server.Tests/InheritedSettingsTests.cs new file mode 100644 index 00000000..60f61726 --- /dev/null +++ b/Jellyfin/tests/Moonfin.Server.Tests/InheritedSettingsTests.cs @@ -0,0 +1,221 @@ +using Moonfin.Server.Models; +using Moonfin.Server.Services; +using Xunit; + +namespace Moonfin.Server.Tests; + +/// +/// A user's profile should only hold what the user chose. Values that repeat what the user +/// would inherit are dropped on push and by the admin cleanup, and an admin can clear chosen +/// settings so they fall back to the defaults. +/// +public sealed class InheritedSettingsTests : IDisposable +{ + private readonly string _dataPath; + private readonly MoonfinSettingsService _service; + + public InheritedSettingsTests() + { + _dataPath = Path.Combine(Path.GetTempPath(), "moonfin-inherited-tests-" + Guid.NewGuid().ToString("N")); + _service = new MoonfinSettingsService(new NoOpLogger(), _dataPath); + } + + public void Dispose() + { + if (Directory.Exists(_dataPath)) + { + Directory.Delete(_dataPath, recursive: true); + } + } + + private async Task UserWithGlobal(MoonfinSettingsProfile global) + { + var userId = Guid.NewGuid(); + await _service.SaveProfileAsync(userId, "global", global, "test", notifySettingsChanged: false); + return userId; + } + + private async Task Push(Guid userId, string profile, MoonfinSettingsProfile values) => + await _service.SaveProfileAsync(userId, profile, values, "test", notifySettingsChanged: false, dropInheritedValues: true); + + [Fact] + public async Task APushedValueEqualToWhatTheUserInheritsIsNotStored() + { + var userId = await UserWithGlobal(new MoonfinSettingsProfile { KeyboardStyle = "docked" }); + + await Push(userId, "mobile", new MoonfinSettingsProfile { KeyboardStyle = "docked", RadarrCalendarShowDate = false }); + + var settings = await _service.GetUserSettingsAsync(userId); + Assert.Null(settings!.Mobile!.KeyboardStyle); + Assert.False(settings.Mobile.RadarrCalendarShowDate); + } + + [Fact] + public async Task SwitchingBackToTheInheritedValueClearsTheChoice() + { + var userId = await UserWithGlobal(new MoonfinSettingsProfile { KeyboardStyle = "docked" }); + await Push(userId, "mobile", new MoonfinSettingsProfile { KeyboardStyle = "floating" }); + + await Push(userId, "mobile", new MoonfinSettingsProfile { KeyboardStyle = "docked" }); + + var settings = await _service.GetUserSettingsAsync(userId); + Assert.Null(settings!.Mobile!.KeyboardStyle); + Assert.Equal("docked", (await _service.GetResolvedProfileAsync(userId, "mobile"))!.KeyboardStyle); + } + + [Fact] + public async Task OnlyThePushedSettingsAreLookedAt() + { + // Older repeats stay until the admin cleanup, so a push never changes more than it sent. + var userId = await UserWithGlobal(new MoonfinSettingsProfile { KeyboardStyle = "docked", RadarrCalendarShowDate = true }); + await _service.SaveProfileAsync(userId, "mobile", new MoonfinSettingsProfile { KeyboardStyle = "docked" }, "old-client", notifySettingsChanged: false); + + await Push(userId, "mobile", new MoonfinSettingsProfile { RadarrCalendarShowDate = true }); + + Assert.Equal("docked", (await _service.GetUserSettingsAsync(userId))!.Mobile!.KeyboardStyle); + } + + [Fact] + public async Task AValueWithNothingBelowItIsStored() + { + var userId = Guid.NewGuid(); + + await Push(userId, "mobile", new MoonfinSettingsProfile { RadarrCalendarShowCinema = true }); + + Assert.True((await _service.GetUserSettingsAsync(userId))!.Mobile!.RadarrCalendarShowCinema); + } + + [Fact] + public async Task TheHomeLayoutIsNeverDropped() + { + var rows = new List { new() { Kind = "builtin", Type = "nextup", Enabled = true, Order = 0 } }; + var userId = await UserWithGlobal(new MoonfinSettingsProfile { HomeSections = rows }); + + await Push(userId, "mobile", new MoonfinSettingsProfile { HomeSections = rows }); + + Assert.NotNull((await _service.GetUserSettingsAsync(userId))!.Mobile!.HomeSections); + } + + [Fact] + public void TheCleanupComparesDevicesWithGlobalAndGlobalWithTheDefaults() + { + var settings = new MoonfinUserSettings + { + Global = new MoonfinSettingsProfile { KeyboardStyle = "docked", RadarrCalendarShowDate = true }, + Mobile = new MoonfinSettingsProfile { KeyboardStyle = "docked", RadarrCalendarShowCinema = true }, + Tv = new MoonfinSettingsProfile { KeyboardStyle = "floating" } + }; + var defaults = new MoonfinSettingsProfile { RadarrCalendarShowDate = true }; + + var removed = MoonfinSettingsService.RemoveRepeatedValues(settings, defaults); + + Assert.Equal(2, removed); + Assert.Null(settings.Mobile.KeyboardStyle); + Assert.True(settings.Mobile.RadarrCalendarShowCinema); + Assert.Equal("floating", settings.Tv.KeyboardStyle); + Assert.Null(settings.Global.RadarrCalendarShowDate); + Assert.Equal("docked", settings.Global.KeyboardStyle); + } + + [Fact] + public async Task TheCleanupChangesNothingTheUserGets() + { + var userId = await UserWithGlobal(new MoonfinSettingsProfile { KeyboardStyle = "docked" }); + await _service.SaveProfileAsync(userId, "mobile", + new MoonfinSettingsProfile { KeyboardStyle = "docked", RadarrCalendarShowCinema = true }, "old-client", notifySettingsChanged: false); + var before = await _service.GetResolvedProfileAsync(userId, "mobile"); + + var dryRun = await _service.RemoveRepeatedValuesAsync(new[] { userId }, dryRun: true); + Assert.Equal((1, 1), dryRun); + Assert.Equal("docked", (await _service.GetUserSettingsAsync(userId))!.Mobile!.KeyboardStyle); + + var run = await _service.RemoveRepeatedValuesAsync(new[] { userId }, dryRun: false); + Assert.Equal((1, 1), run); + + var after = await _service.GetResolvedProfileAsync(userId, "mobile"); + Assert.Null((await _service.GetUserSettingsAsync(userId))!.Mobile!.KeyboardStyle); + Assert.Equal(before!.KeyboardStyle, after!.KeyboardStyle); + Assert.Equal(before.RadarrCalendarShowCinema, after.RadarrCalendarShowCinema); + } + + [Fact] + public async Task ClearingASettingRemovesItFromEveryProfile() + { + var userId = await UserWithGlobal(new MoonfinSettingsProfile { RadarrCalendarShowCinema = true }); + await Push(userId, "tv", new MoonfinSettingsProfile { RadarrCalendarShowCinema = false, KeyboardStyle = "docked" }); + var other = await UserWithGlobal(new MoonfinSettingsProfile { RadarrCalendarShowCinema = true }); + + var result = await _service.ClearSettingsAsync(new[] { "radarrCalendarShowCinema" }, new[] { userId }); + + Assert.Equal((1, 2), result); + var settings = await _service.GetUserSettingsAsync(userId); + Assert.Null(settings!.Global!.RadarrCalendarShowCinema); + Assert.Null(settings.Tv!.RadarrCalendarShowCinema); + Assert.Equal("docked", settings.Tv.KeyboardStyle); + Assert.True((await _service.GetUserSettingsAsync(other))!.Global!.RadarrCalendarShowCinema); + } + + [Fact] + public async Task ClearingTheHomeLayoutRemovesTheRowsAndTheirOrder() + { + var userId = await UserWithGlobal(new MoonfinSettingsProfile + { + HomeSections = new List { new() { Kind = "builtin", Type = "nextup", Enabled = true, Order = 0 } }, + HomeRowOrder = new List { "nextup" } + }); + + await _service.ClearSettingsAsync(new[] { "homeLayout" }, new[] { userId }); + + var global = (await _service.GetUserSettingsAsync(userId))!.Global!; + Assert.Null(global.HomeSections); + Assert.Null(global.HomeRowOrder); + } + + [Fact] + public async Task UnknownOrProtectedSettingsAreRefused() + { + await Assert.ThrowsAsync(() => _service.ClearSettingsAsync(new[] { "notASetting" }, new[] { Guid.NewGuid() })); + await Assert.ThrowsAsync(() => _service.ClearSettingsAsync(new[] { "hiddenContinueWatchingItems" }, new[] { Guid.NewGuid() })); + } + + [Fact] + public void AnEmptyAdminRatingListIsUnsetSoTheUsersEmptyListStays() + { + // XmlSerializer loads an unset admin list as [], which resolution reads as unset. A user + // who turned every source off must keep that, not fall back to the app's sources. + var settings = new MoonfinUserSettings { Mobile = new MoonfinSettingsProfile { MdblistRatingSources = new List() } }; + var defaults = new MoonfinSettingsProfile { MdblistRatingSources = new List() }; + + Assert.Equal(0, MoonfinSettingsService.RemoveRepeatedValues(settings, defaults)); + Assert.Empty(settings.Mobile.MdblistRatingSources!); + + var explained = MoonfinSettingsService.ExplainProfile(settings, "mobile", defaults) + .Fields.Single(f => f.Name == "mdblistRatingSources"); + Assert.Null(explained.Default); + Assert.False(explained.DeviceRedundant); + } + + [Fact] + public async Task TheOverviewCountsWhatEachProfileStoresAndWhatRepeats() + { + var rows = new List { new() { Kind = "builtin", Type = "nextup", Enabled = true, Order = 0 } }; + var userId = await UserWithGlobal(new MoonfinSettingsProfile { KeyboardStyle = "docked" }); + await _service.SaveProfileAsync(userId, "tv", + new MoonfinSettingsProfile { KeyboardStyle = "docked", RadarrCalendarShowCinema = true, HomeSections = rows, HomeRowOrder = new List { "nextup" } }, + "old-client", notifySettingsChanged: false); + var neverSynced = Guid.NewGuid(); + + var overview = await _service.GetSettingsOverviewAsync(new[] { userId, neverSynced }); + + var user = overview.Single(e => e.UserId == userId); + Assert.Equal(3, user.Tv); + Assert.Equal(1, user.Global); + Assert.Equal(0, user.Mobile); + Assert.Equal(1, user.Repeated); + Assert.Equal("docked", (await _service.GetUserSettingsAsync(userId))!.Tv!.KeyboardStyle); + + var other = overview.Single(e => e.UserId == neverSynced); + Assert.False(other.HasSettingsFile); + Assert.Equal(0, other.Repeated); + } +} diff --git a/Jellyfin/tests/Moonfin.Server.Tests/SettingsExplanationTests.cs b/Jellyfin/tests/Moonfin.Server.Tests/SettingsExplanationTests.cs new file mode 100644 index 00000000..3da2070c --- /dev/null +++ b/Jellyfin/tests/Moonfin.Server.Tests/SettingsExplanationTests.cs @@ -0,0 +1,122 @@ +using Moonfin.Server.Models; +using Moonfin.Server.Services; +using Xunit; + +namespace Moonfin.Server.Tests; + +/// +/// The admin preview says, for each setting, which layer the user's value comes from and +/// whether a stored value only repeats what the layers below would give. +/// +public sealed class SettingsExplanationTests +{ + private static SettingExplanation Field(SettingsExplanation explanation, string name) => + explanation.Fields.Single(f => f.Name == name); + + [Fact] + public void TheDeviceValueWinsAndRepeatingTheDefaultIsRedundant() + { + var settings = new MoonfinUserSettings + { + Mobile = new MoonfinSettingsProfile { RadarrCalendarShowDate = true, RadarrCalendarShowDigital = false } + }; + var defaults = new MoonfinSettingsProfile { RadarrCalendarShowDate = true, RadarrCalendarShowDigital = true }; + + var explanation = MoonfinSettingsService.ExplainProfile(settings, "mobile", defaults); + + var date = Field(explanation, "radarrCalendarShowDate"); + Assert.Equal("device", date.Source); + Assert.True(date.DeviceRedundant); + + var digital = Field(explanation, "radarrCalendarShowDigital"); + Assert.Equal("device", digital.Source); + Assert.False(digital.Effective!.GetValue()); + Assert.False(digital.DeviceRedundant); + } + + [Fact] + public void AValueWithNothingBelowItIsNeverRedundant() + { + // The server can't know the app's own default, so it can't call this a repeat. + var settings = new MoonfinUserSettings { Mobile = new MoonfinSettingsProfile { RadarrCalendarShowCinema = true } }; + + var cinema = Field(MoonfinSettingsService.ExplainProfile(settings, "mobile", null), "radarrCalendarShowCinema"); + + Assert.Equal("device", cinema.Source); + Assert.False(cinema.DeviceRedundant); + } + + [Fact] + public void GlobalAndDefaultsFillWhatTheDeviceLeavesUnset() + { + var settings = new MoonfinUserSettings + { + Global = new MoonfinSettingsProfile { SeasonalRowEnabled = false }, + Mobile = new MoonfinSettingsProfile() + }; + var defaults = new MoonfinSettingsProfile { SeasonalRowEnabled = false, RadarrCalendarShowCinema = false }; + + var explanation = MoonfinSettingsService.ExplainProfile(settings, "mobile", defaults); + + var seasonal = Field(explanation, "seasonalRowEnabled"); + Assert.Equal("global", seasonal.Source); + Assert.True(seasonal.GlobalRedundant); + Assert.Equal("default", Field(explanation, "radarrCalendarShowCinema").Source); + Assert.Equal("none", Field(explanation, "radarrCalendarShowPhysical").Source); + } + + [Fact] + public void TheGlobalProfileSkipsTheDeviceLayer() + { + var settings = new MoonfinUserSettings { Tv = new MoonfinSettingsProfile { RadarrCalendarShowDate = false } }; + + var date = Field(MoonfinSettingsService.ExplainProfile(settings, "global", null), "radarrCalendarShowDate"); + + Assert.Equal("none", date.Source); + Assert.Null(date.Device); + } + + [Fact] + public void TheHomeLayoutComesFromOneLayerWhole() + { + var rows = new List + { + new() { Kind = "builtin", Type = "nextup", Enabled = true, Order = 0 } + }; + var settings = new MoonfinUserSettings + { + Tv = new MoonfinSettingsProfile { HomeSections = rows }, + Global = new MoonfinSettingsProfile { HomeSections = rows } + }; + + var explanation = MoonfinSettingsService.ExplainProfile(settings, "tv", null); + + var layout = Field(explanation, "homeLayout"); + Assert.Equal("device", layout.Source); + // Same rows as global, but never flagged: the cleanup leaves the layout alone. + Assert.False(layout.DeviceRedundant); + Assert.DoesNotContain(explanation.Fields, f => f.Name is "homeSections" or "homeRowOrder"); + } + + [Fact] + public void ApiKeysAreNeverShownInClear() + { + var settings = new MoonfinUserSettings { Global = new MoonfinSettingsProfile { MdblistApiKey = "secret-key" } }; + + var key = Field(MoonfinSettingsService.ExplainProfile(settings, "global", null), "mdblistApiKey"); + + Assert.Equal("(set)", key.Effective!.GetValue()); + Assert.DoesNotContain("secret-key", key.Global!.ToJsonString()); + } + + [Fact] + public void AUserWhoNeverSyncedGetsEverythingFromTheDefaults() + { + var defaults = new MoonfinSettingsProfile { RadarrCalendarShowDate = false }; + + var explanation = MoonfinSettingsService.ExplainProfile(null, "mobile", defaults); + + Assert.False(explanation.HasSettingsFile); + Assert.Equal("default", Field(explanation, "radarrCalendarShowDate").Source); + } +}