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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ImmichFrame.Core/Interfaces/IServerSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
1 change: 1 addition & 0 deletions ImmichFrame.WebApi.Tests/Resources/TestV1.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"ImageFill": true,
"PlayAudio": true,
"Layout": "Layout_TEST",
"ClientPersistAssets": true,
"DownloadImages": true,
"ShowMemories": true,
"ShowFavorites": true,
Expand Down
3 changes: 2 additions & 1 deletion ImmichFrame.WebApi.Tests/Resources/TestV2.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@
"ImagePan": true,
"ImageFill": true,
"PlayAudio": true,
"Layout": "Layout_TEST"
"Layout": "Layout_TEST",
"ClientPersistAssets": true
},
"Accounts": [
{
Expand Down
1 change: 1 addition & 0 deletions ImmichFrame.WebApi.Tests/Resources/TestV2.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ General:
ImageFill: true
PlayAudio: true
Layout: Layout_TEST
ClientPersistAssets: true
Accounts:
- ImmichServerUrl: Account1.ImmichServerUrl_TEST
ApiKey: Account1.ApiKey_TEST
Expand Down
7 changes: 5 additions & 2 deletions ImmichFrame.WebApi/Controllers/ConfigController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using ImmichFrame.Core.Interfaces;
using ImmichFrame.WebApi.Helpers;
using ImmichFrame.WebApi.Models;
using Microsoft.AspNetCore.Mvc;

Expand All @@ -10,19 +11,21 @@ public class ConfigController : ControllerBase
{
private readonly ILogger<AssetController> _logger;
private readonly IGeneralSettings _settings;
private readonly ServerSession _serverSession;

public ConfigController(ILogger<AssetController> logger, IGeneralSettings settings)
public ConfigController(ILogger<AssetController> logger, IGeneralSettings settings, ServerSession serverSession)
{
_logger = logger;
_settings = settings;
_serverSession = serverSession;
}

[HttpGet(Name = "GetConfig")]
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")]
Expand Down
2 changes: 2 additions & 0 deletions ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/// <summary>
Expand Down Expand Up @@ -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() { }
}
Expand Down
11 changes: 11 additions & 0 deletions ImmichFrame.WebApi/Helpers/ServerSession.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace ImmichFrame.WebApi.Helpers;

/// <summary>
/// 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).
/// </summary>
public class ServerSession
{
public string SessionId { get; } = Guid.NewGuid().ToString();
}
6 changes: 5 additions & 1 deletion ImmichFrame.WebApi/Models/ClientSettingsDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
1 change: 1 addition & 0 deletions ImmichFrame.WebApi/Models/ServerSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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() { }
}
Expand Down
1 change: 1 addition & 0 deletions ImmichFrame.WebApi/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ _ _ __ ___ _ __ ___ _ ___| |__ | |_ _ __ __ _ _ __ ___ ___
builder.Services.AddSingleton<IGeneralSettings>(srv => srv.GetRequiredService<IServerSettings>().GeneralSettings);

// Register services
builder.Services.AddSingleton<ImmichFrame.WebApi.Helpers.ServerSession>();
builder.Services.AddSingleton<IWeatherService, OpenWeatherMapService>();
builder.Services.AddSingleton<ICalendarService, IcalCalendarService>();
builder.Services.AddSingleton<IAssetAccountTracker, BloomFilterAssetAccountTracker>();
Expand Down
3 changes: 2 additions & 1 deletion docker/Settings.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@
"ImagePan": false,
"ImageFill": false,
"PlayAudio": false,
"Layout": "splitview"
"Layout": "splitview",
"ClientPersistAssets": false
},
"Accounts": [
{
Expand Down
1 change: 1 addition & 0 deletions docker/Settings.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions docs/docs/getting-started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
90 changes: 82 additions & 8 deletions immichFrame.Web/src/lib/components/home-page/home-page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand All @@ -151,6 +186,7 @@
assetBacklog = assetRequest.data.filter(
(asset) => isImageAsset(asset) || isVideoAsset(asset)
);
persistBacklog();
} catch {
error = true;
}
Expand Down Expand Up @@ -232,6 +268,7 @@

const useSplit = shouldUseSplitView(assetBacklog);
const next = assetBacklog.splice(0, useSplit ? 2 : 1);
persistBacklog();

if (displayingAssets.length) {
assetHistory.push(...displayingAssets);
Expand All @@ -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() {
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions immichFrame.Web/src/lib/immichFrameApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
39 changes: 38 additions & 1 deletion immichFrame.Web/src/lib/stores/persist.store.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -14,6 +15,38 @@ function persistStore(key: string, defaultValue: string | null) {
return store;
}

function loadPersistedArray<T>(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<T>(key: string, defaultValue: T[]) {
const store = writable(loadPersistedArray(key, defaultValue));

store.subscribe((value) => {
localStorage?.setItem(key, JSON.stringify(value));
});

return store;
}
Comment on lines +36 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does localStorage.setItem throw QuotaExceededError when storage limit is exceeded?

💡 Result:

Yes, localStorage.setItem throws a QuotaExceededError when the storage limit is exceeded [1][2][3]. According to the HTML Standard and Web Storage API specifications, if a browser cannot set a new value—either because the storage quota has been reached or because storage has been disabled for the site—it must throw a QuotaExceededError [2][3][4]. To handle this, it is recommended to always wrap calls to localStorage.setItem in a try...catch block [1][5][6]. When implementing error handling, note the following: - In modern browsers, this error is typically represented as a DOMException with the name QuotaExceededError [7]. - Some older browser implementations or specific conditions (such as private browsing mode) may exhibit variations in how this error is surfaced or named (e.g., NS_ERROR_DOM_QUOTA_REACHED in older Firefox versions) [8][9]. - Because localStorage might be unavailable even if the feature check succeeds (e.g., due to user settings or private browsing restrictions), a robust practice is to test for storage availability by attempting to set and then remove a test item before use [10].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file and related callers/usages.
git ls-files | rg 'immichFrame\.Web/src/lib/stores/persist\.store\.ts|immichFrame\.Web/src/lib/stores|persistHistory|persistBacklog|getNextAssets|handleDone|AssetResponseDto'

echo '--- persist.store.ts ---'
cat -n immichFrame.Web/src/lib/stores/persist.store.ts

echo '--- search for persistArrayStore usage ---'
rg -n "persistArrayStore|persistHistory|persistBacklog|localStorage\.?\.setItem|AssetResponseDto" immichFrame.Web/src -A 4 -B 4

echo '--- any explicit storage quota handling in repo ---'
rg -n "QuotaExceededError|quota|localStorage" immichFrame.Web/src -A 2 -B 2

Repository: immichFrame/ImmichFrame

Length of output: 31658


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- AssetResponseDto shape ---'
sed -n '90,170p' immichFrame.Web/src/lib/immichFrameApi.ts

echo '--- home-page persistence flow ---'
sed -n '140,305p' immichFrame.Web/src/lib/components/home-page/home-page.svelte

Repository: immichFrame/ImmichFrame

Length of output: 6809


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "handleDone\\(" immichFrame.Web/src/lib/components/home-page/home-page.svelte -A 3 -B 3

Repository: immichFrame/ImmichFrame

Length of output: 1408


Wrap the localStorage.setItem write in a try/catch.
assetBacklogStore, assetHistoryStore, and displayingAssetsStore persist full AssetResponseDto[] payloads, so a large queue can hit the localStorage quota and throw synchronously inside subscribe(). That rejection will escape persistBacklog()/persistHistory() and abort the transition flow instead of degrading gracefully.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@immichFrame.Web/src/lib/stores/persist.store.ts` around lines 36 - 44, Wrap
the localStorage write in persistArrayStore’s subscribe callback in a try/catch
so quota failures don’t escape synchronously. Update persistArrayStore to catch
errors from localStorage.setItem, and handle them gracefully (for example by
logging or ignoring) while keeping assetBacklogStore, assetHistoryStore, and
displayingAssetsStore transitions alive.


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,
Expand All @@ -23,4 +56,8 @@ function generateGUID() {
}

export const clientIdentifierStore = persistStore('clientIdentifier', generateGUID());
export const authSecretStore = persistStore('authSecret', null);
export const authSecretStore = persistStore('authSecret', null);
export const serverSessionIdStore = persistStore('serverSessionId', null);
export const assetBacklogStore = persistArrayStore<AssetResponseDto>('assetBacklog', []);
export const assetHistoryStore = persistArrayStore<AssetResponseDto>('assetHistory', []);
export const displayingAssetsStore = persistArrayStore<AssetResponseDto>('displayingAssets', []);