diff --git a/ImmichFrame.Core/Interfaces/IServerSettings.cs b/ImmichFrame.Core/Interfaces/IServerSettings.cs index fea6c442..03160c2e 100644 --- a/ImmichFrame.Core/Interfaces/IServerSettings.cs +++ b/ImmichFrame.Core/Interfaces/IServerSettings.cs @@ -66,6 +66,7 @@ public interface IGeneralSettings public bool PlayAudio { get; } public string Layout { get; } public string Language { get; } + public bool ClientPersistAssets { get; } public void Validate(); } diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV1.json b/ImmichFrame.WebApi.Tests/Resources/TestV1.json index e6c49102..ddc3e8a3 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV1.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV1.json @@ -9,6 +9,7 @@ "ImageFill": true, "PlayAudio": true, "Layout": "Layout_TEST", + "ClientPersistAssets": true, "DownloadImages": true, "ShowMemories": true, "ShowFavorites": true, diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV2.json b/ImmichFrame.WebApi.Tests/Resources/TestV2.json index 4d603dc9..4b3b5842 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2.json @@ -35,7 +35,8 @@ "ImagePan": true, "ImageFill": true, "PlayAudio": true, - "Layout": "Layout_TEST" + "Layout": "Layout_TEST", + "ClientPersistAssets": true }, "Accounts": [ { diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV2.yml b/ImmichFrame.WebApi.Tests/Resources/TestV2.yml index 47f45947..3c03d32e 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2.yml +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2.yml @@ -35,6 +35,7 @@ General: ImageFill: true PlayAudio: true Layout: Layout_TEST + ClientPersistAssets: true Accounts: - ImmichServerUrl: Account1.ImmichServerUrl_TEST ApiKey: Account1.ApiKey_TEST diff --git a/ImmichFrame.WebApi/Controllers/ConfigController.cs b/ImmichFrame.WebApi/Controllers/ConfigController.cs index 3eca9070..00098ce8 100644 --- a/ImmichFrame.WebApi/Controllers/ConfigController.cs +++ b/ImmichFrame.WebApi/Controllers/ConfigController.cs @@ -1,4 +1,5 @@ using ImmichFrame.Core.Interfaces; +using ImmichFrame.WebApi.Helpers; using ImmichFrame.WebApi.Models; using Microsoft.AspNetCore.Mvc; @@ -10,11 +11,13 @@ public class ConfigController : ControllerBase { private readonly ILogger _logger; private readonly IGeneralSettings _settings; + private readonly ServerSession _serverSession; - public ConfigController(ILogger logger, IGeneralSettings settings) + public ConfigController(ILogger logger, IGeneralSettings settings, ServerSession serverSession) { _logger = logger; _settings = settings; + _serverSession = serverSession; } [HttpGet(Name = "GetConfig")] @@ -22,7 +25,7 @@ public ClientSettingsDto GetConfig(string clientIdentifier = "") { var sanitizedClientIdentifier = clientIdentifier.SanitizeString(); _logger.LogDebug("Config requested by '{sanitizedClientIdentifier}'", sanitizedClientIdentifier); - return ClientSettingsDto.FromGeneralSettings(_settings); + return ClientSettingsDto.FromGeneralSettings(_settings, _serverSession.SessionId); } [HttpGet("Version", Name = "GetVersion")] diff --git a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs index 076f36da..e0ec548e 100644 --- a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs +++ b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs @@ -56,6 +56,7 @@ public class ServerSettingsV1 : IConfigSettable public bool ImageFill { get; set; } = false; public bool PlayAudio { get; set; } = false; public string Layout { get; set; } = "splitview"; + public bool ClientPersistAssets { get; set; } = false; } /// @@ -135,6 +136,7 @@ class GeneralSettingsV1Adapter(ServerSettingsV1 _delegate) : IGeneralSettings public bool PlayAudio => _delegate.PlayAudio; public string Layout => _delegate.Layout; public string Language => _delegate.Language; + public bool ClientPersistAssets => _delegate.ClientPersistAssets; public void Validate() { } } diff --git a/ImmichFrame.WebApi/Helpers/ServerSession.cs b/ImmichFrame.WebApi/Helpers/ServerSession.cs new file mode 100644 index 00000000..a2e68678 --- /dev/null +++ b/ImmichFrame.WebApi/Helpers/ServerSession.cs @@ -0,0 +1,11 @@ +namespace ImmichFrame.WebApi.Helpers; + +/// +/// Holds a unique session ID generated at server startup. +/// Clients compare it against their persisted value to detect a server restart +/// and clear stale persisted assets (which the restarted server can no longer route). +/// +public class ServerSession +{ + public string SessionId { get; } = Guid.NewGuid().ToString(); +} diff --git a/ImmichFrame.WebApi/Models/ClientSettingsDto.cs b/ImmichFrame.WebApi/Models/ClientSettingsDto.cs index ff0f9e75..e1f62b06 100644 --- a/ImmichFrame.WebApi/Models/ClientSettingsDto.cs +++ b/ImmichFrame.WebApi/Models/ClientSettingsDto.cs @@ -32,8 +32,10 @@ public class ClientSettingsDto public bool PlayAudio { get; set; } public string Layout { get; set; } public string Language { get; set; } + public bool ClientPersistAssets { get; set; } + public string ServerSessionId { get; set; } = string.Empty; - public static ClientSettingsDto FromGeneralSettings(IGeneralSettings generalSettings) + public static ClientSettingsDto FromGeneralSettings(IGeneralSettings generalSettings, string serverSessionId) { ClientSettingsDto dto = new ClientSettingsDto(); dto.Interval = generalSettings.Interval; @@ -64,6 +66,8 @@ public static ClientSettingsDto FromGeneralSettings(IGeneralSettings generalSett dto.PlayAudio = generalSettings.PlayAudio; dto.Layout = generalSettings.Layout; dto.Language = generalSettings.Language; + dto.ClientPersistAssets = generalSettings.ClientPersistAssets; + dto.ServerSessionId = serverSessionId; return dto; } } \ No newline at end of file diff --git a/ImmichFrame.WebApi/Models/ServerSettings.cs b/ImmichFrame.WebApi/Models/ServerSettings.cs index 74d0fb8e..3dade3cd 100644 --- a/ImmichFrame.WebApi/Models/ServerSettings.cs +++ b/ImmichFrame.WebApi/Models/ServerSettings.cs @@ -72,6 +72,7 @@ public class GeneralSettings : IGeneralSettings, IConfigSettable public string? WeatherLatLong { get; set; } = "40.7128,74.0060"; public string? Webhook { get; set; } public string? AuthenticationSecret { get; set; } + public bool ClientPersistAssets { get; set; } = false; public void Validate() { } } diff --git a/ImmichFrame.WebApi/Program.cs b/ImmichFrame.WebApi/Program.cs index f2d68244..864284d2 100644 --- a/ImmichFrame.WebApi/Program.cs +++ b/ImmichFrame.WebApi/Program.cs @@ -57,6 +57,7 @@ _ _ __ ___ _ __ ___ _ ___| |__ | |_ _ __ __ _ _ __ ___ ___ builder.Services.AddSingleton(srv => srv.GetRequiredService().GeneralSettings); // Register services +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/docker/Settings.example.json b/docker/Settings.example.json index a86a4d00..90f86318 100644 --- a/docker/Settings.example.json +++ b/docker/Settings.example.json @@ -35,7 +35,8 @@ "ImagePan": false, "ImageFill": false, "PlayAudio": false, - "Layout": "splitview" + "Layout": "splitview", + "ClientPersistAssets": false }, "Accounts": [ { diff --git a/docker/Settings.example.yml b/docker/Settings.example.yml index 173b31a5..585fc1fd 100644 --- a/docker/Settings.example.yml +++ b/docker/Settings.example.yml @@ -34,6 +34,7 @@ General: ImageFill: false PlayAudio: false Layout: splitview + ClientPersistAssets: false Accounts: - ImmichServerUrl: REQUIRED # Exactly one of ApiKey or ApiKeyFile must be set. diff --git a/docs/docs/getting-started/configuration.md b/docs/docs/getting-started/configuration.md index 7378c7d1..355725ed 100644 --- a/docs/docs/getting-started/configuration.md +++ b/docs/docs/getting-started/configuration.md @@ -104,6 +104,9 @@ General: PlayAudio: false # boolean # Allow two portrait images to be displayed next to each other Layout: 'splitview' # single | splitview + # Persist the asset queue, current assets, and history (back button) in client localStorage so a refresh/reload resumes in place instead of re-fetching. + # Note: a server restart clears the persisted assets on all clients (they can no longer be resolved after a restart). + ClientPersistAssets: false # boolean # multiple accounts permitted Accounts: diff --git a/immichFrame.Web/src/lib/components/home-page/home-page.svelte b/immichFrame.Web/src/lib/components/home-page/home-page.svelte index fa91b97e..b54cd4b8 100644 --- a/immichFrame.Web/src/lib/components/home-page/home-page.svelte +++ b/immichFrame.Web/src/lib/components/home-page/home-page.svelte @@ -2,7 +2,15 @@ import * as api from '$lib/index'; import ProgressBar from '$lib/components/elements/progress-bar.svelte'; import { slideshowStore } from '$lib/stores/slideshow.store'; - import { clientIdentifierStore, authSecretStore } from '$lib/stores/persist.store'; + import { + clientIdentifierStore, + authSecretStore, + serverSessionIdStore, + assetBacklogStore, + assetHistoryStore, + displayingAssetsStore, + clearPersistedStore + } from '$lib/stores/persist.store'; import { onDestroy, onMount, setContext, tick } from 'svelte'; import OverlayControls from '../elements/overlay-controls.svelte'; import AssetComponent from '../elements/asset-component.svelte'; @@ -135,6 +143,33 @@ }); } + // persist helpers - save to localStorage when the corresponding option is enabled + function persistBacklog() { + if ($configStore.clientPersistAssets) { + assetBacklogStore.set(assetBacklog); + } + } + + function persistHistory() { + if ($configStore.clientPersistAssets) { + assetHistoryStore.set(assetHistory); + } + } + + function persistDisplaying() { + if ($configStore.clientPersistAssets) { + displayingAssetsStore.set(displayingAssets); + } + } + + // Set the currently-displayed assets, persist them, and load their media. + async function showAssets(next: api.AssetResponseDto[]) { + displayingAssets = next; + persistDisplaying(); + await updateAssetPromises(); + assetsState = await pickAssets(next); + } + async function loadAssets() { try { let assetRequest = await api.getAssets(); @@ -151,6 +186,7 @@ assetBacklog = assetRequest.data.filter( (asset) => isImageAsset(asset) || isVideoAsset(asset) ); + persistBacklog(); } catch { error = true; } @@ -232,6 +268,7 @@ const useSplit = shouldUseSplitView(assetBacklog); const next = assetBacklog.splice(0, useSplit ? 2 : 1); + persistBacklog(); if (displayingAssets.length) { assetHistory.push(...displayingAssets); @@ -240,10 +277,9 @@ if (assetHistory.length > 250) { assetHistory = assetHistory.slice(-250); } + persistHistory(); - displayingAssets = next; - await updateAssetPromises(); - assetsState = await pickAssets(next); + await showAssets(next); } async function getPreviousAssets() { @@ -253,14 +289,14 @@ const useSplit = shouldUseSplitView(assetHistory.slice(-2)); const next = assetHistory.splice(useSplit ? -2 : -1); + persistHistory(); if (displayingAssets.length) { assetBacklog.unshift(...displayingAssets); + persistBacklog(); } - displayingAssets = next; - await updateAssetPromises(); - assetsState = await pickAssets(next); + await showAssets(next); } function isPortrait(asset: api.AssetResponseDto) { @@ -460,7 +496,45 @@ } }); - getNextAssets(); + // Detect a server restart: the server's asset-routing tracker (BloomFilter) resets on + // restart, so any persisted assets can no longer be resolved and must be dropped. + const currentServerSessionId = $configStore.serverSessionId; + const sessionChanged = + currentServerSessionId == null || $serverSessionIdStore !== currentServerSessionId; + if (sessionChanged) { + assetBacklogStore.set([]); + assetHistoryStore.set([]); + displayingAssetsStore.set([]); + if (currentServerSessionId != null){ + serverSessionIdStore.set(currentServerSessionId); + } else { + clearPersistedStore('serverSessionId'); + } + } + + // Restore the persisted queue, currently-displayed assets, and history. + let restoredDisplaying = false; + if ($configStore.clientPersistAssets && !sessionChanged) { + const storedBacklog = $assetBacklogStore; + if (storedBacklog?.length) { + assetBacklog = storedBacklog; + } + const storedDisplaying = $displayingAssetsStore; + if (storedDisplaying?.length) { + displayingAssets = storedDisplaying; + restoredDisplaying = true; + } + const storedHistory = $assetHistoryStore; + if (storedHistory?.length) { + assetHistory = storedHistory; + } + } + + if (restoredDisplaying) { + showAssets(displayingAssets); + } else { + getNextAssets(); + } return () => { window.removeEventListener('mousemove', showCursor); diff --git a/immichFrame.Web/src/lib/immichFrameApi.ts b/immichFrame.Web/src/lib/immichFrameApi.ts index 85fa762b..88b67a79 100644 --- a/immichFrame.Web/src/lib/immichFrameApi.ts +++ b/immichFrame.Web/src/lib/immichFrameApi.ts @@ -228,6 +228,8 @@ export type ClientSettingsDto = { playAudio?: boolean; layout?: string | null; language?: string | null; + clientPersistAssets?: boolean; + serverSessionId?: string; }; export type IWeather = { location?: string | null; diff --git a/immichFrame.Web/src/lib/stores/persist.store.ts b/immichFrame.Web/src/lib/stores/persist.store.ts index c41e8727..44510a1a 100644 --- a/immichFrame.Web/src/lib/stores/persist.store.ts +++ b/immichFrame.Web/src/lib/stores/persist.store.ts @@ -1,5 +1,6 @@ import { writable } from 'svelte/store'; +import type { AssetResponseDto } from '$lib/immichFrameApi'; function persistStore(key: string, defaultValue: string | null) { const storedValue = localStorage?.getItem(key); @@ -14,6 +15,38 @@ function persistStore(key: string, defaultValue: string | null) { return store; } +function loadPersistedArray(key: string, defaultValue: T[]): T[] { + const storedValue = localStorage?.getItem(key); + if (storedValue == null) { + return defaultValue; + } + + try { + const initialValue = JSON.parse(storedValue); + if (Array.isArray(initialValue)){ + return initialValue as T[]; + } + } catch { + // Corrupt value - fall back to the default. + } + + return defaultValue; +} + +function persistArrayStore(key: string, defaultValue: T[]) { + const store = writable(loadPersistedArray(key, defaultValue)); + + store.subscribe((value) => { + localStorage?.setItem(key, JSON.stringify(value)); + }); + + return store; +} + +export function clearPersistedStore(key: string) { + localStorage?.removeItem(key); +} + function generateGUID() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = (Math.random() * 16) | 0, @@ -23,4 +56,8 @@ function generateGUID() { } export const clientIdentifierStore = persistStore('clientIdentifier', generateGUID()); -export const authSecretStore = persistStore('authSecret', null); \ No newline at end of file +export const authSecretStore = persistStore('authSecret', null); +export const serverSessionIdStore = persistStore('serverSessionId', null); +export const assetBacklogStore = persistArrayStore('assetBacklog', []); +export const assetHistoryStore = persistArrayStore('assetHistory', []); +export const displayingAssetsStore = persistArrayStore('displayingAssets', []); \ No newline at end of file