- Send a live message to all currently connected Moonfin clients.
- This message is not saved and only appears for users with an active connection.
+ Send a message to all connected Moonfin clients right now. It is also saved to
+ the Messages tab, so users who were offline still see it later.
+ For anything you want to write properly, use the Messages tab instead.
diff --git a/Emby/Emby.Plugins.Moonfin/Pages/moonfin.js b/Emby/Emby.Plugins.Moonfin/Pages/moonfin.js
index 7da78e9..ca464c4 100644
--- a/Emby/Emby.Plugins.Moonfin/Pages/moonfin.js
+++ b/Emby/Emby.Plugins.Moonfin/Pages/moonfin.js
@@ -1702,6 +1702,7 @@ define(['baseView', 'loading', 'emby-input', 'emby-button', 'emby-checkbox', 'em
setNullableBoolSelect(view, '#DefaultNavbarAlwaysExpanded', defaults.navbarAlwaysExpanded);
setNullableBoolSelect(view, '#DefaultEnableFolderView', defaults.enableFolderView);
setNullableBoolSelect(view, '#DefaultShowSeerrButton', defaults.showSeerrButton);
+ setNullableBoolSelect(view, '#DefaultShowServerMessagesButton', defaults.showServerMessagesButton);
setSelectValue(view, '#DefaultMediaBarSourceType', defaults.mediaBarSourceType, 'Configured source');
loadAdminGenrePicker(view, defaults.mediaBarExcludedGenres || []);
@@ -1868,6 +1869,7 @@ define(['baseView', 'loading', 'emby-input', 'emby-button', 'emby-checkbox', 'em
d.navbarAlwaysExpanded = getNullableBoolSelect(view, '#DefaultNavbarAlwaysExpanded');
d.enableFolderView = getNullableBoolSelect(view, '#DefaultEnableFolderView');
d.showSeerrButton = getNullableBoolSelect(view, '#DefaultShowSeerrButton');
+ d.showServerMessagesButton = getNullableBoolSelect(view, '#DefaultShowServerMessagesButton');
d.mediaBarSourceType = view.querySelector('#DefaultMediaBarSourceType').value || null;
var genreIds = Array.prototype.slice.call(view.querySelectorAll('.adminGenreCb:checked')).map(function (cb) { return cb.dataset.id; });
@@ -2068,6 +2070,361 @@ define(['baseView', 'loading', 'emby-input', 'emby-button', 'emby-checkbox', 'em
});
}
+ var moonfinMessageColors = {
+ white: '#e8eaed',
+ green: '#4caf6d',
+ blue: '#4a9ee0',
+ yellow: '#e0b040',
+ red: '#e05260'
+ };
+
+ var moonfinDeliveryLabels = {
+ inbox: 'Notify',
+ popup: 'Opens in app'
+ };
+
+ function setMessageResult(view, text, color) {
+ var result = view.querySelector('#MessageSaveResult');
+ if (!result) return;
+
+ if (!text) {
+ result.style.display = 'none';
+ result.textContent = '';
+ return;
+ }
+
+ result.style.display = '';
+ result.style.color = color || '';
+ result.textContent = text;
+ }
+
+ // The date inputs work in the admin's own time zone, but the server stores UTC.
+ function messageDateToInput(utcValue) {
+ if (!utcValue) return '';
+
+ var date = new Date(utcValue);
+ if (isNaN(date.getTime())) return '';
+
+ var pad = function (n) { return (n < 10 ? '0' : '') + n; };
+ return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) +
+ 'T' + pad(date.getHours()) + ':' + pad(date.getMinutes());
+ }
+
+ function messageDateFromInput(value) {
+ if (!value) return null;
+ var date = new Date(value);
+ return isNaN(date.getTime()) ? null : date.toISOString();
+ }
+
+ function formatMessageDate(utcValue) {
+ if (!utcValue) return '';
+ var date = new Date(utcValue);
+ return isNaN(date.getTime()) ? '' : date.toLocaleString();
+ }
+
+ function toggleMessageTargets(view) {
+ var audience = view.querySelector('#MessageAudience');
+ var row = view.querySelector('#MessageTargetsRow');
+ if (!audience || !row) return;
+ row.style.display = audience.value === 'users' ? '' : 'none';
+ }
+
+ function loadMessageTargetUsers(view) {
+ var select = view.querySelector('#MessageTargets');
+ if (!select || !ApiClient.getUsers) return Promise.resolve();
+
+ return ApiClient.getUsers().then(function (users) {
+ select.innerHTML = '';
+ (users || []).forEach(function (user) {
+ var option = document.createElement('option');
+ option.value = user.Id;
+ option.textContent = user.Name;
+ select.appendChild(option);
+ });
+ }).catch(function () {});
+ }
+
+ function resetMessageForm(view) {
+ view.__moonfinEditingMessageId = null;
+
+ view.querySelector('#MessageTitle').value = '';
+ view.querySelector('#MessageBody').value = '';
+ view.querySelector('#MessageColor').value = 'white';
+ view.querySelector('#MessageDelivery').value = 'inbox';
+ view.querySelector('#MessageAudience').value = 'all';
+ view.querySelector('#MessageStart').value = '';
+ view.querySelector('#MessageEnd').value = '';
+ view.querySelector('#MessageActionLabel').value = '';
+ view.querySelector('#MessageActionUrl').value = '';
+
+ var targets = view.querySelector('#MessageTargets');
+ if (targets) {
+ Array.prototype.forEach.call(targets.options, function (option) { option.selected = false; });
+ }
+
+ var saveBtn = view.querySelector('#MessageSaveBtn');
+ if (saveBtn) saveBtn.querySelector('span').textContent = 'Save Message';
+
+ var resetBtn = view.querySelector('#MessageResetBtn');
+ if (resetBtn) resetBtn.style.display = 'none';
+
+ toggleMessageTargets(view);
+ setMessageResult(view, '', '');
+ }
+
+ function editMessage(view, item) {
+ view.__moonfinEditingMessageId = item.Id || item.id || null;
+
+ view.querySelector('#MessageTitle').value = item.Title || item.title || '';
+ view.querySelector('#MessageBody').value = item.Body || item.body || '';
+ view.querySelector('#MessageColor').value = item.Color || item.color || 'white';
+ view.querySelector('#MessageDelivery').value = item.Delivery || item.delivery || 'inbox';
+ view.querySelector('#MessageAudience').value = item.Audience || item.audience || 'all';
+ view.querySelector('#MessageStart').value = messageDateToInput(item.StartUtc || item.startUtc);
+ view.querySelector('#MessageEnd').value = messageDateToInput(item.EndUtc || item.endUtc);
+ view.querySelector('#MessageActionLabel').value = item.ActionLabel || item.actionLabel || '';
+ view.querySelector('#MessageActionUrl').value = item.ActionUrl || item.actionUrl || '';
+
+ var selected = item.TargetUserIds || item.targetUserIds || [];
+ var targets = view.querySelector('#MessageTargets');
+ if (targets) {
+ Array.prototype.forEach.call(targets.options, function (option) {
+ option.selected = selected.some(function (id) {
+ return String(id).toLowerCase() === String(option.value).toLowerCase();
+ });
+ });
+ }
+
+ var saveBtn = view.querySelector('#MessageSaveBtn');
+ if (saveBtn) saveBtn.querySelector('span').textContent = 'Update Message';
+
+ var resetBtn = view.querySelector('#MessageResetBtn');
+ if (resetBtn) resetBtn.style.display = '';
+
+ toggleMessageTargets(view);
+ setMessageResult(view, '', '');
+
+ var titleInput = view.querySelector('#MessageTitle');
+ if (titleInput) titleInput.focus();
+ }
+
+ function renderMessagesList(view, items) {
+ var container = view.querySelector('#MessagesList');
+ if (!container) return;
+
+ if (!items || items.length === 0) {
+ container.innerHTML = '
No messages yet.
';
+ return;
+ }
+
+ var now = new Date();
+ var html = '';
+
+ items.forEach(function (item, index) {
+ var id = item.Id || item.id || '';
+ var title = item.Title || item.title || '';
+ var body = item.Body || item.body || '';
+ var pick = item.Color || item.color || 'white';
+ var delivery = item.Delivery || item.delivery || 'inbox';
+ var audience = item.Audience || item.audience || 'all';
+ var start = item.StartUtc || item.startUtc;
+ var end = item.EndUtc || item.endUtc;
+ var targets = item.TargetUserIds || item.targetUserIds || [];
+ var color = moonfinMessageColors[pick] || moonfinMessageColors.white;
+
+ var state = 'Showing now';
+ if (start && new Date(start) > now) {
+ state = 'Starts ' + formatMessageDate(start);
+ } else if (end && new Date(end) <= now) {
+ state = 'Expired';
+ } else if (end) {
+ state = 'Until ' + formatMessageDate(end);
+ }
+
+ var who = audience === 'admins'
+ ? 'Admins only'
+ : audience === 'users'
+ ? targets.length + (targets.length === 1 ? ' user' : ' users')
+ : 'Everyone';
+
+ html += '
' +
+ '
' +
+ '
' +
+ '' + esc(title || '(no title)') + '' +
+ '
' +
+ '
' + esc(body.length > 160 ? body.slice(0, 160) + '…' : body) + '
' +
+ '
' +
+ esc(state) + ' • ' + esc(who) + ' • ' + esc(moonfinDeliveryLabels[delivery] || delivery) +
+ '
' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
' +
+ '
' +
+ '
';
+ });
+
+ container.innerHTML = html;
+ }
+
+ function loadMessagesList(view) {
+ var container = view.querySelector('#MessagesList');
+ if (!container) return;
+
+ var serverUrl = ApiClient.serverAddress ? ApiClient.serverAddress() : '';
+ container.innerHTML = '
Loading messages...
';
+
+ fetch(serverUrl + '/Moonfin/Admin/Messages', { method: 'GET', headers: moonfinAuthHeaders() })
+ .then(parseJsonResponse)
+ .then(function (payload) {
+ var items = payload.items || payload.Items || [];
+ view.__moonfinMessagesCache = items;
+ renderMessagesList(view, items);
+ })
+ .catch(function (error) {
+ container.innerHTML = '
' +
+ esc((error && error.message) ? error.message : 'Failed to load messages.') + '
';
+ });
+ }
+
+ // Moves one message up or down and saves the whole order, so the app shows
+ // them in the same sequence as this list.
+ function moveMessage(view, messageId, delta) {
+ var items = (view.__moonfinMessagesCache || []).slice();
+ var from = -1;
+ for (var i = 0; i < items.length; i++) {
+ if ((items[i].Id || items[i].id) === messageId) { from = i; break; }
+ }
+
+ var to = from + delta;
+ if (from < 0 || to < 0 || to >= items.length) return;
+
+ var moved = items.splice(from, 1)[0];
+ items.splice(to, 0, moved);
+
+ // Redrawn straight away so the arrows feel instant, then saved.
+ view.__moonfinMessagesCache = items;
+ renderMessagesList(view, items);
+
+ var serverUrl = ApiClient.serverAddress ? ApiClient.serverAddress() : '';
+ fetch(serverUrl + '/Moonfin/Admin/Messages/Order', {
+ method: 'POST',
+ headers: moonfinAuthHeaders(),
+ body: JSON.stringify({
+ Ids: items.map(function (item) { return item.Id || item.id; })
+ })
+ })
+ .then(parseJsonResponse)
+ .catch(function (error) {
+ setMessageResult(
+ view,
+ (error && error.message) ? error.message : 'Could not save the new order.',
+ '#d9534f'
+ );
+ loadMessagesList(view);
+ });
+ }
+
+ function saveMessage(view) {
+ var saveBtn = view.querySelector('#MessageSaveBtn');
+ var title = (view.querySelector('#MessageTitle').value || '').trim();
+ var body = (view.querySelector('#MessageBody').value || '').trim();
+
+ if (!title && !body) {
+ setMessageResult(view, 'Enter a title or a message.', '#d9534f');
+ return;
+ }
+
+ var audience = view.querySelector('#MessageAudience').value;
+ var targetSelect = view.querySelector('#MessageTargets');
+ var targetUserIds = [];
+ if (audience === 'users' && targetSelect) {
+ targetUserIds = Array.prototype.filter.call(targetSelect.options, function (option) {
+ return option.selected;
+ }).map(function (option) { return option.value; });
+
+ if (targetUserIds.length === 0) {
+ setMessageResult(view, 'Pick at least one user, or change who sees it.', '#d9534f');
+ return;
+ }
+ }
+
+ var payload = {
+ Id: view.__moonfinEditingMessageId || '',
+ Title: title,
+ Body: body,
+ Color: view.querySelector('#MessageColor').value,
+ Delivery: view.querySelector('#MessageDelivery').value,
+ Audience: audience,
+ TargetUserIds: targetUserIds,
+ ActionLabel: (view.querySelector('#MessageActionLabel').value || '').trim(),
+ ActionUrl: (view.querySelector('#MessageActionUrl').value || '').trim(),
+ StartUtc: messageDateFromInput(view.querySelector('#MessageStart').value),
+ EndUtc: messageDateFromInput(view.querySelector('#MessageEnd').value)
+ };
+
+ var serverUrl = ApiClient.serverAddress ? ApiClient.serverAddress() : '';
+ var wasEditing = !!view.__moonfinEditingMessageId;
+
+ if (saveBtn) saveBtn.disabled = true;
+ setMessageResult(view, '', '');
+
+ fetch(serverUrl + '/Moonfin/Admin/Messages', {
+ method: 'POST',
+ headers: moonfinAuthHeaders(),
+ body: JSON.stringify(payload)
+ })
+ .then(parseJsonResponse)
+ .then(function (response) {
+ var saved = response.item || response.Item || {};
+ var warnings = [];
+
+ if (payload.ActionUrl && !(saved.ActionUrl || saved.actionUrl)) {
+ warnings.push('the link was dropped, it must start with http:// or https://');
+ }
+ if (payload.EndUtc && !(saved.EndUtc || saved.endUtc)) {
+ warnings.push('the end date was dropped, it was before the start date');
+ }
+
+ var message = wasEditing ? 'Message updated.' : 'Message saved.';
+ if (warnings.length) message += ' Note: ' + warnings.join(', ') + '.';
+
+ // resetMessageForm clears the result line, so set it afterwards.
+ resetMessageForm(view);
+ setMessageResult(view, message, warnings.length ? '#f0ad4e' : '#52b54b');
+ loadMessagesList(view);
+ })
+ .catch(function (error) {
+ setMessageResult(view, (error && error.message) ? error.message : 'Save failed.', '#d9534f');
+ })
+ .finally(function () {
+ if (saveBtn) saveBtn.disabled = false;
+ });
+ }
+
+ function deleteMessage(view, messageId) {
+ if (!messageId) return;
+ if (!window.confirm('Delete this message?')) return;
+
+ var serverUrl = ApiClient.serverAddress ? ApiClient.serverAddress() : '';
+
+ fetch(serverUrl + '/Moonfin/Admin/Messages/' + encodeURIComponent(messageId), {
+ method: 'DELETE',
+ headers: moonfinAuthHeaders()
+ })
+ .then(parseJsonResponse)
+ .then(function () {
+ if (view.__moonfinEditingMessageId === messageId) resetMessageForm(view);
+ setMessageResult(view, 'Message deleted.', '#52b54b');
+ loadMessagesList(view);
+ })
+ .catch(function (error) {
+ setMessageResult(view, (error && error.message) ? error.message : 'Delete failed.', '#d9534f');
+ });
+ }
+
function initializeDefaultsSubtabs(view) {
var subnav = view.querySelector('.defaultsSubnavBar');
if (!subnav || subnav.dataset.bound) return;
@@ -2144,6 +2501,48 @@ define(['baseView', 'loading', 'emby-input', 'emby-button', 'emby-checkbox', 'em
var broadcastBtn = view.querySelector('#BroadcastMessageBtn');
if (broadcastBtn) broadcastBtn.addEventListener('click', function () { broadcast(view); });
+ var messageAudience = view.querySelector('#MessageAudience');
+ if (messageAudience) {
+ messageAudience.addEventListener('change', function () { toggleMessageTargets(view); });
+ }
+
+ var messageSaveBtn = view.querySelector('#MessageSaveBtn');
+ if (messageSaveBtn) messageSaveBtn.addEventListener('click', function () { saveMessage(view); });
+
+ var messageResetBtn = view.querySelector('#MessageResetBtn');
+ if (messageResetBtn) messageResetBtn.addEventListener('click', function () { resetMessageForm(view); });
+
+ var messagesListContainer = view.querySelector('#MessagesList');
+ if (messagesListContainer) {
+ messagesListContainer.addEventListener('click', function (event) {
+ var upButton = event.target.closest('.moonfinMessageUpBtn');
+ if (upButton) {
+ moveMessage(view, upButton.getAttribute('data-message-id') || '', -1);
+ return;
+ }
+
+ var downButton = event.target.closest('.moonfinMessageDownBtn');
+ if (downButton) {
+ moveMessage(view, downButton.getAttribute('data-message-id') || '', 1);
+ return;
+ }
+
+ var editButton = event.target.closest('.moonfinMessageEditBtn');
+ if (editButton) {
+ var editId = editButton.getAttribute('data-message-id') || '';
+ var cached = view.__moonfinMessagesCache || [];
+ var found = cached.filter(function (item) {
+ return (item.Id || item.id) === editId;
+ })[0];
+ if (found) editMessage(view, found);
+ return;
+ }
+
+ var deleteButton = event.target.closest('.moonfinMessageDeleteBtn');
+ if (deleteButton) deleteMessage(view, deleteButton.getAttribute('data-message-id') || '');
+ });
+ }
+
var mdblistTestBtn = view.querySelector('#MdblistTestKeyBtn');
if (mdblistTestBtn) mdblistTestBtn.addEventListener('click', function () { testMdblistKey(view); });
@@ -2164,6 +2563,11 @@ define(['baseView', 'loading', 'emby-input', 'emby-button', 'emby-checkbox', 'em
clearSelectedThemeFile(view);
setThemeUploadResult(view, '', '');
loadAdminThemesList(view);
+ // The user list must be there before the form can preselect targets on edit.
+ loadMessageTargetUsers(view).then(function () {
+ resetMessageForm(view);
+ loadMessagesList(view);
+ });
loadConfig(view);
};
diff --git a/Emby/Emby.Plugins.Moonfin/PluginConfiguration.cs b/Emby/Emby.Plugins.Moonfin/PluginConfiguration.cs
index 0a9efe7..e24c60e 100644
--- a/Emby/Emby.Plugins.Moonfin/PluginConfiguration.cs
+++ b/Emby/Emby.Plugins.Moonfin/PluginConfiguration.cs
@@ -130,6 +130,12 @@ public bool EnsureWebhookSecret()
///
Metadata index for uploaded custom themes stored in the plugin data folder.
public List
UploadedThemes { get; set; } = new List();
+ ///
+ /// Admin messages shown to users in the app. They are only a few KB of text, so they
+ /// live in the config instead of the data folder.
+ ///
+ public List Messages { get; set; } = new List();
+
// Retro games (EmulatorJS) configuration.
public bool GamesEnabled { get; set; } = false;
public List GameLibraryIds { get; set; } = new List();
@@ -224,4 +230,177 @@ public class UploadedThemeEntry
public string? UploadedByUserId { get; set; }
public string ChecksumSha256 { get; set; } = string.Empty;
}
+
+ ///
+ /// One message the admin wrote for users to read in the app.
+ ///
+ public class ServerMessage
+ {
+ /// Highest number of messages kept. Older ones are dropped on save.
+ public const int MaxStored = 50;
+
+ /// Body length cap, so a huge paste cannot break the app layout.
+ public const int MaxBodyLength = 2000;
+
+ /// Title length cap, the same limit the config page puts on its field.
+ public const int MaxTitleLength = 120;
+
+ public const string ColorGreen = "green";
+ public const string ColorRed = "red";
+ public const string ColorYellow = "yellow";
+ public const string ColorBlue = "blue";
+ public const string ColorWhite = "white";
+
+ /// Only marks the menu button as unread.
+ public const string DeliveryInbox = "inbox";
+
+ /// Opens the message window once, until the user reads it.
+ public const string DeliveryPopup = "popup";
+
+ public const string AudienceAll = "all";
+ public const string AudienceUsers = "users";
+ public const string AudienceAdmins = "admins";
+
+ public string Id { get; set; } = string.Empty;
+ public string Title { get; set; } = string.Empty;
+ public string Body { get; set; } = string.Empty;
+ public string Color { get; set; } = ColorWhite;
+ public string Delivery { get; set; } = DeliveryInbox;
+ public string? ActionLabel { get; set; }
+ public string? ActionUrl { get; set; }
+
+ /// When the message starts showing. Null means right away.
+ public DateTime? StartUtc { get; set; }
+
+ /// When the message stops showing. Null means never.
+ public DateTime? EndUtc { get; set; }
+
+ public string Audience { get; set; } = AudienceAll;
+
+ /// User IDs to show this to. Only used when Audience is "users".
+ public List TargetUserIds { get; set; } = new List();
+
+ public DateTime CreatedUtc { get; set; }
+ public string? CreatedByUserId { get; set; }
+
+ ///
+ /// True when this message should show right now, for this user.
+ ///
+ public bool IsVisibleTo(Guid userId, bool isAdmin, DateTime nowUtc)
+ {
+ if (StartUtc.HasValue && nowUtc < StartUtc.Value)
+ return false;
+
+ if (EndUtc.HasValue && nowUtc >= EndUtc.Value)
+ return false;
+
+ return Audience switch
+ {
+ AudienceAdmins => isAdmin,
+ AudienceUsers => TargetUserIds.Any(id =>
+ Guid.TryParse(id, out var target) && target == userId),
+ _ => true
+ };
+ }
+
+ ///
+ /// Cleans admin input before it is saved. Bad values fall back to the default instead
+ /// of being rejected, except the action URL which is dropped when it is not http or
+ /// https.
+ ///
+ public void Sanitize()
+ {
+ Title = (Title ?? string.Empty).Trim();
+ Body = (Body ?? string.Empty).Trim();
+
+ if (Body.Length > MaxBodyLength)
+ Body = Body.Substring(0, MaxBodyLength);
+
+ if (Title.Length > MaxTitleLength)
+ Title = Title.Substring(0, MaxTitleLength);
+
+ Color = Color switch
+ {
+ ColorGreen => ColorGreen,
+ ColorRed => ColorRed,
+ ColorYellow => ColorYellow,
+ ColorBlue => ColorBlue,
+ _ => ColorWhite
+ };
+
+ Delivery = Delivery == DeliveryPopup ? DeliveryPopup : DeliveryInbox;
+
+ Audience = Audience switch
+ {
+ AudienceUsers => AudienceUsers,
+ AudienceAdmins => AudienceAdmins,
+ _ => AudienceAll
+ };
+
+ if (Audience != AudienceUsers)
+ {
+ TargetUserIds = new List();
+ }
+ else
+ {
+ TargetUserIds = TargetUserIds
+ .Where(id => Guid.TryParse(id, out _))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ ActionLabel = string.IsNullOrWhiteSpace(ActionLabel) ? null : ActionLabel.Trim();
+ ActionUrl = SanitizeActionUrl(ActionUrl);
+
+ // A link with no label is useless to the user, and a label with no link does nothing.
+ if (ActionUrl == null)
+ ActionLabel = null;
+ else if (ActionLabel == null)
+ ActionUrl = null;
+
+ // An end date before the start date would hide the message forever.
+ if (StartUtc.HasValue && EndUtc.HasValue && EndUtc.Value <= StartUtc.Value)
+ EndUtc = null;
+ }
+
+ ///
+ /// Keeps only real http and https links. Anything else is dropped, since this URL gets
+ /// opened on the user's device.
+ ///
+ private static string? SanitizeActionUrl(string? rawUrl)
+ {
+ if (string.IsNullOrWhiteSpace(rawUrl))
+ return null;
+
+ var value = rawUrl.Trim().Trim('"', '\'').Trim();
+
+ if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) ||
+ (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
+ return null;
+
+ // AbsoluteUri keeps the escaping the admin typed, where ToString would decode it.
+ return uri.AbsoluteUri;
+ }
+
+ ///
+ /// Drops expired messages, then the oldest ones if the list is still too long. Keeps
+ /// the config file from growing forever without needing a scheduled task.
+ ///
+ public static void Prune(List messages)
+ {
+ var now = DateTime.UtcNow;
+ messages.RemoveAll(m => m.EndUtc.HasValue && m.EndUtc.Value < now);
+
+ if (messages.Count <= MaxStored)
+ return;
+
+ var extra = messages
+ .OrderBy(m => m.CreatedUtc)
+ .Take(messages.Count - MaxStored)
+ .ToList();
+
+ foreach (var message in extra)
+ messages.Remove(message);
+ }
+ }
}
diff --git a/Jellyfin/backend/Api/ControllerExtensions.cs b/Jellyfin/backend/Api/ControllerExtensions.cs
index 8aeb043..e5305b8 100644
--- a/Jellyfin/backend/Api/ControllerExtensions.cs
+++ b/Jellyfin/backend/Api/ControllerExtensions.cs
@@ -13,6 +13,13 @@ public static class ControllerExtensions
return Guid.TryParse(userIdClaim, out var userId) ? userId : null;
}
+ ///
+ /// True when the caller is a server admin. This is the same role the "RequiresElevation"
+ /// policy checks, so it matches what Jellyfin itself allows.
+ ///
+ public static bool IsAdminFromClaims(this ControllerBase controller) =>
+ controller.User.IsInRole("Administrator");
+
public static string? GetDeviceIdFromClaims(this ControllerBase controller)
{
var deviceId = controller.User.FindFirst("Jellyfin-DeviceId")?.Value;
diff --git a/Jellyfin/backend/Api/MoonfinController.cs b/Jellyfin/backend/Api/MoonfinController.cs
index 3f6ce7b..8980378 100644
--- a/Jellyfin/backend/Api/MoonfinController.cs
+++ b/Jellyfin/backend/Api/MoonfinController.cs
@@ -83,6 +83,7 @@ public ActionResult Ping()
: null,
MdblistAvailable = !string.IsNullOrWhiteSpace(config?.MdblistApiKey),
TmdbAvailable = !string.IsNullOrWhiteSpace(config?.TmdbApiKey),
+ MessagesSupported = true,
DefaultSettings = config?.DefaultUserSettings
});
}
@@ -506,13 +507,30 @@ public ActionResult BroadcastMessage([FromBody] MoonfinBroadcastRequest request)
var deliveries = _settingsService.BroadcastMessage(message);
+ // Also save it, so users who were not connected still find it in the app. Older
+ // clients keep reading the "adminMessage" event above and ignore this.
+ var stored = new ServerMessage
+ {
+ Id = Guid.NewGuid().ToString("N"),
+ Body = message,
+ Color = ServerMessage.ColorWhite,
+ Delivery = ServerMessage.DeliveryPopup,
+ Audience = ServerMessage.AudienceAll,
+ CreatedUtc = DateTime.UtcNow,
+ CreatedByUserId = this.GetUserIdFromClaims()?.ToString("N")
+ };
+
+ config.Messages.Add(stored);
+ ServerMessage.Prune(config.Messages);
+ MoonfinPlugin.Instance?.SaveConfiguration();
+ _settingsService.BroadcastSystemEvent("messagesChanged");
+
// SSE only reaches clients that are open right now, so push covers
- // backgrounded and killed apps. The route is left blank because the
- // client ignores blank routes and just opens the app on a tap.
+ // backgrounded and killed apps. The route opens the messages window on tap.
var pushTargets = 0;
foreach (var userId in _notificationStore.GetUsersWithDevices())
{
- _pushDelivery.QueueToUser(userId, "Message from your server", message, route: "");
+ _pushDelivery.QueueToUser(userId, "Message from your server", message, route: "messages");
pushTargets++;
}
@@ -1525,6 +1543,13 @@ public class MoonfinPingResponse
[JsonPropertyName("tmdbAvailable")]
public bool? TmdbAvailable { get; set; }
+ ///
+ /// True when this plugin has the admin messages endpoints. Older plugins leave it out, so
+ /// the app reads a missing value as false and hides the button.
+ ///
+ [JsonPropertyName("messagesSupported")]
+ public bool? MessagesSupported { get; set; }
+
[JsonPropertyName("defaultSettings")]
public MoonfinSettingsProfile? DefaultSettings { get; set; }
diff --git a/Jellyfin/backend/Api/MoonfinMessagesController.cs b/Jellyfin/backend/Api/MoonfinMessagesController.cs
new file mode 100644
index 0000000..a218aa9
--- /dev/null
+++ b/Jellyfin/backend/Api/MoonfinMessagesController.cs
@@ -0,0 +1,255 @@
+using System.Net.Mime;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Moonfin.Server.Services;
+
+namespace Moonfin.Server.Api;
+
+///
+/// API controller for admin messages shown to users in the Moonfin app.
+///
+[ApiController]
+[Route("Moonfin")]
+[Produces(MediaTypeNames.Application.Json)]
+public class MoonfinMessagesController : ControllerBase
+{
+ private readonly MoonfinSettingsService _settingsService;
+ private readonly NotificationStore _notificationStore;
+ private readonly PushDeliveryService _pushDelivery;
+
+ public MoonfinMessagesController(
+ MoonfinSettingsService settingsService,
+ NotificationStore notificationStore,
+ PushDeliveryService pushDelivery)
+ {
+ _settingsService = settingsService;
+ _notificationStore = notificationStore;
+ _pushDelivery = pushDelivery;
+ }
+
+ ///
+ /// Returns the messages the calling user should see right now.
+ ///
+ [HttpGet("Messages")]
+ [Authorize]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+ public ActionResult GetMessages()
+ {
+ var config = MoonfinPlugin.Instance?.Configuration;
+ if (config?.EnableSettingsSync != true)
+ {
+ return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "Settings sync is disabled" });
+ }
+
+ var userId = this.GetUserIdFromClaims();
+ if (userId == null)
+ {
+ return Unauthorized(new { error = "A valid user token is required" });
+ }
+
+ // Filtering happens here, not on the client. Sending everything and hiding it in the
+ // app would let any user read messages meant for someone else.
+ var isAdmin = this.IsAdminFromClaims();
+ var now = DateTime.UtcNow;
+ var items = config.Messages
+ .Where(m => m.IsVisibleTo(userId.Value, isAdmin, now))
+ .ToList();
+
+ return Ok(new { items });
+ }
+
+ ///
+ /// Returns every message, including scheduled and expired ones, for the admin panel.
+ ///
+ [HttpGet("Admin/Messages")]
+ [Authorize(Policy = "RequiresElevation")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult GetAdminMessages()
+ {
+ var messages = MoonfinPlugin.Instance?.Configuration.Messages ?? new List();
+
+ return Ok(new
+ {
+ items = messages
+ .ToList()
+ });
+ }
+
+ ///
+ /// Creates a message, or replaces one when the ID already exists.
+ ///
+ [HttpPost("Admin/Messages")]
+ [Authorize(Policy = "RequiresElevation")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public ActionResult SaveMessage([FromBody] ServerMessage message)
+ {
+ var plugin = MoonfinPlugin.Instance;
+ if (plugin == null)
+ {
+ return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "Plugin is not ready" });
+ }
+
+ if (message == null)
+ {
+ return BadRequest(new { error = "A message body is required" });
+ }
+
+ message.Sanitize();
+
+ if (message.Title.Length == 0 && message.Body.Length == 0)
+ {
+ return BadRequest(new { error = "A title or a body is required" });
+ }
+
+ var messages = plugin.Configuration.Messages;
+ var existing = messages.FirstOrDefault(m =>
+ string.Equals(m.Id, message.Id, StringComparison.OrdinalIgnoreCase));
+
+ if (existing != null)
+ {
+ message.CreatedUtc = existing.CreatedUtc;
+ message.CreatedByUserId = existing.CreatedByUserId;
+ // Replaced in place, so editing a message does not move it in the list.
+ messages[messages.IndexOf(existing)] = message;
+ }
+ else
+ {
+ message.Id = Guid.NewGuid().ToString("N");
+ message.CreatedUtc = DateTime.UtcNow;
+ message.CreatedByUserId = this.GetUserIdFromClaims()?.ToString("N");
+ // Newest first by default. The admin can move it with the arrows.
+ messages.Insert(0, message);
+ }
+
+ ServerMessage.Prune(messages);
+ plugin.SaveConfiguration();
+
+ _settingsService.BroadcastSystemEvent("messagesChanged");
+
+ // Only push for messages meant to interrupt. An inbox message can wait until the user
+ // opens the app.
+ if (existing == null && message.Delivery != ServerMessage.DeliveryInbox)
+ {
+ SendPush(message);
+ }
+
+ return Ok(new { success = true, item = message });
+ }
+
+ ///
+ /// Reorders the stored messages to match the IDs given. The app shows them in this
+ /// order, so this is what the up and down arrows in the config page drive.
+ ///
+ [HttpPost("Admin/Messages/Order")]
+ [Authorize(Policy = "RequiresElevation")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public ActionResult ReorderMessages([FromBody] MoonfinMessageOrderRequest request)
+ {
+ var plugin = MoonfinPlugin.Instance;
+ if (plugin == null)
+ {
+ return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "Plugin is not ready" });
+ }
+
+ var ids = request?.Ids;
+ if (ids == null || ids.Count == 0)
+ {
+ return BadRequest(new { error = "ids is required" });
+ }
+
+ var messages = plugin.Configuration.Messages;
+ var reordered = new List(messages.Count);
+
+ foreach (var id in ids)
+ {
+ var match = messages.FirstOrDefault(m =>
+ string.Equals(m.Id, id, StringComparison.OrdinalIgnoreCase));
+ if (match != null && !reordered.Contains(match))
+ {
+ reordered.Add(match);
+ }
+ }
+
+ // Anything the caller left out keeps its place at the end, so a stale list from
+ // the config page cannot delete messages.
+ reordered.AddRange(messages.Where(m => !reordered.Contains(m)));
+
+ plugin.Configuration.Messages = reordered;
+ plugin.SaveConfiguration();
+ _settingsService.BroadcastSystemEvent("messagesChanged");
+
+ return Ok(new { success = true });
+ }
+
+ ///
+ /// Deletes one message by ID.
+ ///
+ [HttpDelete("Admin/Messages/{messageId}")]
+ [Authorize(Policy = "RequiresElevation")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public ActionResult DeleteMessage([FromRoute] string messageId)
+ {
+ var plugin = MoonfinPlugin.Instance;
+ if (plugin == null)
+ {
+ return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "Plugin is not ready" });
+ }
+
+ var removed = plugin.Configuration.Messages
+ .RemoveAll(m => string.Equals(m.Id, messageId, StringComparison.OrdinalIgnoreCase));
+
+ if (removed == 0)
+ {
+ return NotFound(new { error = "Message not found" });
+ }
+
+ plugin.SaveConfiguration();
+ _settingsService.BroadcastSystemEvent("messagesChanged");
+
+ return Ok(new { success = true });
+ }
+
+ ///
+ /// Queues a push so the message also reaches users who do not have the app open.
+ /// The route tells the app to open the messages window on tap.
+ ///
+ private void SendPush(ServerMessage message)
+ {
+ // Admin-only messages get no push: we cannot tell who is an admin without the user
+ // manager, and admins still see them next time they open the app.
+ if (message.Audience == ServerMessage.AudienceAdmins)
+ {
+ return;
+ }
+
+ var title = message.Title.Length > 0 ? message.Title : "Message from your server";
+ var body = message.Body;
+
+ foreach (var userId in _notificationStore.GetUsersWithDevices())
+ {
+ if (message.Audience == ServerMessage.AudienceUsers &&
+ !message.TargetUserIds.Any(id =>
+ Guid.TryParse(id, out var target) && target == userId))
+ {
+ continue;
+ }
+
+ _pushDelivery.QueueToUser(userId, title, body, route: "messages");
+ }
+ }
+}
+
+///
+/// Body for the message reorder endpoint.
+///
+public class MoonfinMessageOrderRequest
+{
+ /// Message IDs in the order they should be shown.
+ public List Ids { get; set; } = new();
+}
diff --git a/Jellyfin/backend/Models/MoonfinSettingsProfile.cs b/Jellyfin/backend/Models/MoonfinSettingsProfile.cs
index b78ee09..10c3949 100644
--- a/Jellyfin/backend/Models/MoonfinSettingsProfile.cs
+++ b/Jellyfin/backend/Models/MoonfinSettingsProfile.cs
@@ -848,6 +848,9 @@ public class MoonfinSettingsProfile
[JsonPropertyName("crashReportsEnabled")]
public bool? CrashReportsEnabled { get; set; }
+ [JsonPropertyName("showServerMessagesButton")]
+ public bool? ShowServerMessagesButton { get; set; }
+
[JsonPropertyName("diagnosticLoggingEnabled")]
public bool? DiagnosticLoggingEnabled { get; set; }
diff --git a/Jellyfin/backend/Pages/configPage.html b/Jellyfin/backend/Pages/configPage.html
index af1c50c..bc04f76 100644
--- a/Jellyfin/backend/Pages/configPage.html
+++ b/Jellyfin/backend/Pages/configPage.html
@@ -374,6 +374,10 @@ Moonfin Settings
palette
ThemesUpload & catalog
+