From f868bd1453a3a87268d890b87ac392f1c11d0bb0 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Tue, 30 Dec 2025 17:03:01 +0100 Subject: [PATCH 1/5] Add Entity Framework Core support with SQLite for account overrides - Added Entity Framework Core packages to project. - Implemented ConfigDbContext for managing account overrides. - Created AccountOverrideEntity and corresponding DTO for data representation. - Developed SqliteAccountOverrideStore for data access and manipulation. - Introduced AccountOverrideMerger for applying overrides to account settings. - Updated ConfigController to handle account override retrieval and updates. - Enhanced Program.cs to configure the SQLite database context. - Added tests for account override functionality in ConfigController and AccountOverrideMerger. - Updated Dockerfile and docker-compose files to support SQLite data persistence. --- Directory.Packages.props | 3 + Dockerfile | 6 +- .../ConfigControllerAccountOverridesTests.cs | 96 ++++++++++++ .../Services/AccountOverrideMergerTests.cs | 59 +++++++ .../Controllers/ConfigController.cs | 30 +++- ImmichFrame.WebApi/Data/ConfigDbContext.cs | 28 ++++ .../Data/Converters/JsonValueConverter.cs | 21 +++ .../Data/Entities/AccountOverrideEntity.cs | 27 ++++ ImmichFrame.WebApi/ImmichFrame.WebApi.csproj | 6 + .../Models/AccountOverrideDto.cs | 21 +++ ImmichFrame.WebApi/Program.cs | 41 ++++- .../Services/AccountOverrideMerger.cs | 35 +++++ .../Services/IAccountOverrideStore.cs | 12 ++ .../Services/MergedServerSettingsSnapshot.cs | 20 +++ .../Services/ReloadingImmichFrameLogic.cs | 146 ++++++++++++++++++ .../Services/SqliteAccountOverrideStore.cs | 76 +++++++++ docker/docker-compose.dev.yml | 5 +- docker/docker-compose.yml | 7 +- 18 files changed, 630 insertions(+), 9 deletions(-) create mode 100644 ImmichFrame.WebApi.Tests/Controllers/ConfigControllerAccountOverridesTests.cs create mode 100644 ImmichFrame.WebApi.Tests/Services/AccountOverrideMergerTests.cs create mode 100644 ImmichFrame.WebApi/Data/ConfigDbContext.cs create mode 100644 ImmichFrame.WebApi/Data/Converters/JsonValueConverter.cs create mode 100644 ImmichFrame.WebApi/Data/Entities/AccountOverrideEntity.cs create mode 100644 ImmichFrame.WebApi/Models/AccountOverrideDto.cs create mode 100644 ImmichFrame.WebApi/Services/AccountOverrideMerger.cs create mode 100644 ImmichFrame.WebApi/Services/IAccountOverrideStore.cs create mode 100644 ImmichFrame.WebApi/Services/MergedServerSettingsSnapshot.cs create mode 100644 ImmichFrame.WebApi/Services/ReloadingImmichFrameLogic.cs create mode 100644 ImmichFrame.WebApi/Services/SqliteAccountOverrideStore.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index cd614e30..60a14437 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,9 @@ + + + diff --git a/Dockerfile b/Dockerfile index b4848f9e..60a70d4a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,8 +48,12 @@ ENV APP_VERSION=$VERSION COPY --from=publish-api /app ./ COPY --from=build-node /app/build ./wwwroot -# Set non-privileged user +# Create persistent data dir for SQLite (mounted to /data in docker-compose) ARG APP_UID=1000 +USER root +RUN mkdir -p /data && chown -R ${APP_UID}:${APP_UID} /data + +# Set non-privileged user USER $APP_UID ENTRYPOINT ["dotnet", "ImmichFrame.WebApi.dll"] diff --git a/ImmichFrame.WebApi.Tests/Controllers/ConfigControllerAccountOverridesTests.cs b/ImmichFrame.WebApi.Tests/Controllers/ConfigControllerAccountOverridesTests.cs new file mode 100644 index 00000000..8ca2fbda --- /dev/null +++ b/ImmichFrame.WebApi.Tests/Controllers/ConfigControllerAccountOverridesTests.cs @@ -0,0 +1,96 @@ +using System.Net; +using System.Net.Http.Json; +using ImmichFrame.Core.Interfaces; +using ImmichFrame.WebApi.Models; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace ImmichFrame.WebApi.Tests.Controllers; + +[TestFixture] +public class ConfigControllerAccountOverridesTests +{ + private WebApplicationFactory _factory = null!; + + [TearDown] + public void TearDown() + { + _factory.Dispose(); + } + + [Test] + public async Task PutAccountOverrides_WhenAuthenticationSecretIsSet_RequiresBearerToken() + { + _factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + // isolate DB per test + Environment.SetEnvironmentVariable("IMMICHFRAME_DB_PATH", + Path.Combine(Path.GetTempPath(), $"immichframe-tests-{Guid.NewGuid()}.db")); + + var generalSettings = new GeneralSettings { AuthenticationSecret = "secret" }; + var accountSettings = new ServerAccountSettings + { + ImmichServerUrl = "http://mock", + ApiKey = "k" + }; + var serverSettings = new ServerSettings + { + GeneralSettingsImpl = generalSettings, + AccountsImpl = new List { accountSettings } + }; + + services.AddSingleton(serverSettings); + services.AddSingleton(generalSettings); + }); + }); + + var client = _factory.CreateClient(); + + var put = await client.PutAsJsonAsync("/api/Config/account-overrides", new AccountOverrideDto { ShowFavorites = true }); + Assert.That(put.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + + client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "secret"); + var putOk = await client.PutAsJsonAsync("/api/Config/account-overrides", new AccountOverrideDto { ShowFavorites = true }); + Assert.That(putOk.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + + [Test] + public async Task PutAccountOverrides_WhenAuthenticationSecretIsNotSet_DoesNotRequireAuth() + { + _factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + Environment.SetEnvironmentVariable("IMMICHFRAME_DB_PATH", + Path.Combine(Path.GetTempPath(), $"immichframe-tests-{Guid.NewGuid()}.db")); + + var generalSettings = new GeneralSettings { AuthenticationSecret = null }; + var accountSettings = new ServerAccountSettings + { + ImmichServerUrl = "http://mock", + ApiKey = "k" + }; + var serverSettings = new ServerSettings + { + GeneralSettingsImpl = generalSettings, + AccountsImpl = new List { accountSettings } + }; + + services.AddSingleton(serverSettings); + services.AddSingleton(generalSettings); + }); + }); + + var client = _factory.CreateClient(); + var put = await client.PutAsJsonAsync("/api/Config/account-overrides", new AccountOverrideDto { ShowFavorites = true }); + Assert.That(put.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } +} + + diff --git a/ImmichFrame.WebApi.Tests/Services/AccountOverrideMergerTests.cs b/ImmichFrame.WebApi.Tests/Services/AccountOverrideMergerTests.cs new file mode 100644 index 00000000..be479e72 --- /dev/null +++ b/ImmichFrame.WebApi.Tests/Services/AccountOverrideMergerTests.cs @@ -0,0 +1,59 @@ +using ImmichFrame.WebApi.Models; +using ImmichFrame.WebApi.Services; +using NUnit.Framework; + +namespace ImmichFrame.WebApi.Tests.Services; + +[TestFixture] +public class AccountOverrideMergerTests +{ + [Test] + public void Apply_WhenOverridesAreNullLike_DoesNotChangeBase() + { + var baseAccount = new ServerAccountSettings + { + ImmichServerUrl = "http://mock", + ApiKey = "k", + ShowFavorites = false, + ShowMemories = false, + ShowArchived = false, + ImagesFromDays = 7, + Rating = 3, + Albums = new(), + ExcludedAlbums = new(), + People = new() + }; + + var overrides = new AccountOverrideDto(); // all null + AccountOverrideMerger.Apply(baseAccount, overrides); + + Assert.That(baseAccount.ShowFavorites, Is.False); + Assert.That(baseAccount.ImagesFromDays, Is.EqualTo(7)); + Assert.That(baseAccount.Rating, Is.EqualTo(3)); + } + + [Test] + public void Apply_WhenOverrideSetsEmptyLists_ClearsSelections() + { + var baseAccount = new ServerAccountSettings + { + ImmichServerUrl = "http://mock", + ApiKey = "k", + Albums = new() { Guid.NewGuid() }, + People = new() { Guid.NewGuid() } + }; + + var overrides = new AccountOverrideDto + { + Albums = new List(), + People = new List() + }; + + AccountOverrideMerger.Apply(baseAccount, overrides); + + Assert.That(baseAccount.Albums, Is.Empty); + Assert.That(baseAccount.People, Is.Empty); + } +} + + diff --git a/ImmichFrame.WebApi/Controllers/ConfigController.cs b/ImmichFrame.WebApi/Controllers/ConfigController.cs index 3eca9070..614744f8 100644 --- a/ImmichFrame.WebApi/Controllers/ConfigController.cs +++ b/ImmichFrame.WebApi/Controllers/ConfigController.cs @@ -1,5 +1,7 @@ using ImmichFrame.Core.Interfaces; using ImmichFrame.WebApi.Models; +using ImmichFrame.WebApi.Services; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace ImmichFrame.WebApi.Controllers @@ -10,11 +12,13 @@ public class ConfigController : ControllerBase { private readonly ILogger _logger; private readonly IGeneralSettings _settings; + private readonly IAccountOverrideStore _overrideStore; - public ConfigController(ILogger logger, IGeneralSettings settings) + public ConfigController(ILogger logger, IGeneralSettings settings, IAccountOverrideStore overrideStore) { _logger = logger; _settings = settings; + _overrideStore = overrideStore; } [HttpGet(Name = "GetConfig")] @@ -30,5 +34,29 @@ public string GetVersion() { return System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; } + + [HttpGet("account-overrides")] + public async Task> GetAccountOverrides(CancellationToken ct) + { + var dto = await _overrideStore.GetAsync(ct); + return Ok(dto); + } + + [Authorize] + [HttpPut("account-overrides")] + public async Task PutAccountOverrides([FromBody] AccountOverrideDto dto, CancellationToken ct) + { + if (dto.ImagesFromDays is < 0) + return BadRequest("ImagesFromDays must be >= 0"); + + if (dto.ImagesFromDate.HasValue && dto.ImagesUntilDate.HasValue && dto.ImagesFromDate > dto.ImagesUntilDate) + return BadRequest("ImagesFromDate must be <= ImagesUntilDate"); + + if (dto.Rating is < 0 or > 5) + return BadRequest("Rating must be between 0 and 5"); + + await _overrideStore.UpsertAsync(dto, ct); + return Ok(); + } } } \ No newline at end of file diff --git a/ImmichFrame.WebApi/Data/ConfigDbContext.cs b/ImmichFrame.WebApi/Data/ConfigDbContext.cs new file mode 100644 index 00000000..b7c22f8a --- /dev/null +++ b/ImmichFrame.WebApi/Data/ConfigDbContext.cs @@ -0,0 +1,28 @@ +using ImmichFrame.WebApi.Data.Converters; +using ImmichFrame.WebApi.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace ImmichFrame.WebApi.Data; + +public class ConfigDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet AccountOverrides => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + var entity = modelBuilder.Entity(); + entity.ToTable("AccountOverrides"); + entity.HasKey(x => x.Id); + + entity.Property(x => x.UpdatedAtUtc).IsRequired(); + + // Lists stored as JSON text + entity.Property(x => x.Albums).HasConversion(new JsonValueConverter>()); + entity.Property(x => x.ExcludedAlbums).HasConversion(new JsonValueConverter>()); + entity.Property(x => x.People).HasConversion(new JsonValueConverter>()); + } +} + + diff --git a/ImmichFrame.WebApi/Data/Converters/JsonValueConverter.cs b/ImmichFrame.WebApi/Data/Converters/JsonValueConverter.cs new file mode 100644 index 00000000..6112d71c --- /dev/null +++ b/ImmichFrame.WebApi/Data/Converters/JsonValueConverter.cs @@ -0,0 +1,21 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace ImmichFrame.WebApi.Data.Converters; + +/// +/// Stores complex values as JSON text in a SQLite column. Preserves null vs empty. +/// +public sealed class JsonValueConverter : ValueConverter +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); + + public JsonValueConverter() + : base( + model => model == null ? null : JsonSerializer.Serialize(model, Options), + json => string.IsNullOrWhiteSpace(json) ? default : JsonSerializer.Deserialize(json, Options)) + { + } +} + + diff --git a/ImmichFrame.WebApi/Data/Entities/AccountOverrideEntity.cs b/ImmichFrame.WebApi/Data/Entities/AccountOverrideEntity.cs new file mode 100644 index 00000000..023832fa --- /dev/null +++ b/ImmichFrame.WebApi/Data/Entities/AccountOverrideEntity.cs @@ -0,0 +1,27 @@ +namespace ImmichFrame.WebApi.Data.Entities; + +public class AccountOverrideEntity +{ + public const int SingletonId = 1; + + public int Id { get; set; } = SingletonId; + + // Nullable means "no override". For lists: null = no override, empty = override to empty. + public bool? ShowMemories { get; set; } + public bool? ShowFavorites { get; set; } + public bool? ShowArchived { get; set; } + + public int? ImagesFromDays { get; set; } + public DateTime? ImagesFromDate { get; set; } + public DateTime? ImagesUntilDate { get; set; } + + public List? Albums { get; set; } + public List? ExcludedAlbums { get; set; } + public List? People { get; set; } + + public int? Rating { get; set; } + + public DateTime UpdatedAtUtc { get; set; } +} + + diff --git a/ImmichFrame.WebApi/ImmichFrame.WebApi.csproj b/ImmichFrame.WebApi/ImmichFrame.WebApi.csproj index 3aae6933..35fe1447 100644 --- a/ImmichFrame.WebApi/ImmichFrame.WebApi.csproj +++ b/ImmichFrame.WebApi/ImmichFrame.WebApi.csproj @@ -11,6 +11,12 @@ + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/ImmichFrame.WebApi/Models/AccountOverrideDto.cs b/ImmichFrame.WebApi/Models/AccountOverrideDto.cs new file mode 100644 index 00000000..7ba96cc1 --- /dev/null +++ b/ImmichFrame.WebApi/Models/AccountOverrideDto.cs @@ -0,0 +1,21 @@ +namespace ImmichFrame.WebApi.Models; + +public sealed class AccountOverrideDto +{ + // Nullable means "no override". For lists: null = no override, empty = override to empty. + public bool? ShowMemories { get; set; } + public bool? ShowFavorites { get; set; } + public bool? ShowArchived { get; set; } + + public int? ImagesFromDays { get; set; } + public DateTime? ImagesFromDate { get; set; } + public DateTime? ImagesUntilDate { get; set; } + + public List? Albums { get; set; } + public List? ExcludedAlbums { get; set; } + public List? People { get; set; } + + public int? Rating { get; set; } +} + + diff --git a/ImmichFrame.WebApi/Program.cs b/ImmichFrame.WebApi/Program.cs index f2d68244..c4e3b41c 100644 --- a/ImmichFrame.WebApi/Program.cs +++ b/ImmichFrame.WebApi/Program.cs @@ -5,7 +5,10 @@ using System.Reflection; using ImmichFrame.Core.Logic; using ImmichFrame.Core.Logic.AccountSelection; +using ImmichFrame.WebApi.Data; using ImmichFrame.WebApi.Helpers.Config; +using ImmichFrame.WebApi.Services; +using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); //log the version number @@ -56,17 +59,37 @@ _ _ __ ___ _ __ ___ _ ___| |__ | |_ _ __ __ _ _ __ ___ ___ // Register sub-settings builder.Services.AddSingleton(srv => srv.GetRequiredService().GeneralSettings); +// SQLite-backed override store (single row) +builder.Services.AddDbContext(options => +{ + var configuredPath = Environment.GetEnvironmentVariable("IMMICHFRAME_DB_PATH"); + var defaultPath = Directory.Exists("/data") + ? Path.Combine("/data", "immichframe.db") + : Path.Combine(AppContext.BaseDirectory, "data", "immichframe.db"); + + var dbPath = string.IsNullOrWhiteSpace(configuredPath) ? defaultPath : configuredPath; + + var dbDir = Path.GetDirectoryName(dbPath); + if (!string.IsNullOrWhiteSpace(dbDir)) + { + Directory.CreateDirectory(dbDir); + } + + options.UseSqlite($"Data Source={dbPath}"); +}); +builder.Services.AddScoped(); + // Register services builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddTransient(); builder.Services.AddHttpClient(); // Ensures IHttpClientFactory is available builder.Services.AddTransient>(srv => account => ActivatorUtilities.CreateInstance(srv, account)); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddControllers(); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle @@ -80,6 +103,20 @@ _ _ __ ___ _ __ ___ _ ___| |__ | |_ _ __ __ _ _ __ ___ ___ var app = builder.Build(); +// Ensure DB exists (prefer migrations when present; otherwise create schema) +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + try + { + db.Database.Migrate(); + } + catch + { + db.Database.EnsureCreated(); + } +} + // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { diff --git a/ImmichFrame.WebApi/Services/AccountOverrideMerger.cs b/ImmichFrame.WebApi/Services/AccountOverrideMerger.cs new file mode 100644 index 00000000..9ed7419d --- /dev/null +++ b/ImmichFrame.WebApi/Services/AccountOverrideMerger.cs @@ -0,0 +1,35 @@ +using ImmichFrame.Core.Interfaces; +using ImmichFrame.WebApi.Models; + +namespace ImmichFrame.WebApi.Services; + +internal static class AccountOverrideMerger +{ + public static void Apply(IAccountSettings target, AccountOverrideDto overrides) + { + if (overrides.ShowMemories is bool showMemories) Set(target, nameof(IAccountSettings.ShowMemories), showMemories); + if (overrides.ShowFavorites is bool showFavorites) Set(target, nameof(IAccountSettings.ShowFavorites), showFavorites); + if (overrides.ShowArchived is bool showArchived) Set(target, nameof(IAccountSettings.ShowArchived), showArchived); + + if (overrides.ImagesFromDays is int days) Set(target, nameof(IAccountSettings.ImagesFromDays), days); + if (overrides.ImagesFromDate is DateTime fromDate) Set(target, nameof(IAccountSettings.ImagesFromDate), fromDate); + if (overrides.ImagesUntilDate is DateTime untilDate) Set(target, nameof(IAccountSettings.ImagesUntilDate), untilDate); + + if (overrides.Albums != null) Set(target, nameof(IAccountSettings.Albums), overrides.Albums); + if (overrides.ExcludedAlbums != null) Set(target, nameof(IAccountSettings.ExcludedAlbums), overrides.ExcludedAlbums); + if (overrides.People != null) Set(target, nameof(IAccountSettings.People), overrides.People); + + if (overrides.Rating is int rating) Set(target, nameof(IAccountSettings.Rating), rating); + } + + private static void Set(IAccountSettings target, string propertyName, object? value) + { + // Base settings classes in this repo are mutable concrete types (ServerAccountSettings). + // We keep this reflection helper localized so core interfaces stay clean. + var prop = target.GetType().GetProperty(propertyName); + if (prop == null || !prop.CanWrite) return; + prop.SetValue(target, value); + } +} + + diff --git a/ImmichFrame.WebApi/Services/IAccountOverrideStore.cs b/ImmichFrame.WebApi/Services/IAccountOverrideStore.cs new file mode 100644 index 00000000..31d497cc --- /dev/null +++ b/ImmichFrame.WebApi/Services/IAccountOverrideStore.cs @@ -0,0 +1,12 @@ +using ImmichFrame.WebApi.Models; + +namespace ImmichFrame.WebApi.Services; + +public interface IAccountOverrideStore +{ + Task GetAsync(CancellationToken ct = default); + Task GetVersionAsync(CancellationToken ct = default); + Task UpsertAsync(AccountOverrideDto dto, CancellationToken ct = default); +} + + diff --git a/ImmichFrame.WebApi/Services/MergedServerSettingsSnapshot.cs b/ImmichFrame.WebApi/Services/MergedServerSettingsSnapshot.cs new file mode 100644 index 00000000..82a877c7 --- /dev/null +++ b/ImmichFrame.WebApi/Services/MergedServerSettingsSnapshot.cs @@ -0,0 +1,20 @@ +using ImmichFrame.Core.Interfaces; + +namespace ImmichFrame.WebApi.Services; + +internal sealed class MergedServerSettingsSnapshot : IServerSettings +{ + public required IGeneralSettings GeneralSettings { get; init; } + public required IEnumerable Accounts { get; init; } + + public void Validate() + { + GeneralSettings.Validate(); + foreach (var account in Accounts) + { + account.ValidateAndInitialize(); + } + } +} + + diff --git a/ImmichFrame.WebApi/Services/ReloadingImmichFrameLogic.cs b/ImmichFrame.WebApi/Services/ReloadingImmichFrameLogic.cs new file mode 100644 index 00000000..65754a75 --- /dev/null +++ b/ImmichFrame.WebApi/Services/ReloadingImmichFrameLogic.cs @@ -0,0 +1,146 @@ +using ImmichFrame.Core.Interfaces; +using ImmichFrame.Core.Logic; +using ImmichFrame.WebApi.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace ImmichFrame.WebApi.Services; + +/// +/// Rebuilds the underlying MultiImmichFrameLogicDelegate when the SQLite override "version" changes, +/// so account filter changes apply without a restart. +/// +public sealed class ReloadingImmichFrameLogic : IImmichFrameLogic +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IServerSettings _baseSettings; + private readonly IAccountOverrideStore _overrideStore; + private readonly ILogger _logger; + private readonly SemaphoreSlim _reloadLock = new(1, 1); + + private long _currentVersion = -1; + private IImmichFrameLogic? _current; + + public ReloadingImmichFrameLogic( + IServiceScopeFactory scopeFactory, + IServerSettings baseSettings, + IAccountOverrideStore overrideStore, + ILogger logger) + { + _scopeFactory = scopeFactory; + _baseSettings = baseSettings; + _overrideStore = overrideStore; + _logger = logger; + } + + private async Task GetCurrentAsync(CancellationToken ct = default) + { + var version = await _overrideStore.GetVersionAsync(ct); + var existing = Volatile.Read(ref _current); + if (existing != null && version == Volatile.Read(ref _currentVersion)) + { + return existing; + } + + await _reloadLock.WaitAsync(ct); + try + { + existing = Volatile.Read(ref _current); + if (existing != null && version == Volatile.Read(ref _currentVersion)) + { + return existing; + } + + _logger.LogInformation("Rebuilding ImmichFrame logic due to override version change ({oldVersion} -> {newVersion})", + _currentVersion, version); + + var rebuilt = await BuildAsync(ct); + Volatile.Write(ref _current, rebuilt); + Volatile.Write(ref _currentVersion, version); + return rebuilt; + } + finally + { + _reloadLock.Release(); + } + } + + private async Task BuildAsync(CancellationToken ct) + { + var overrides = await _overrideStore.GetAsync(ct); + var mergedSettings = BuildMergedSettings(overrides); + + using var scope = _scopeFactory.CreateScope(); + var logicFactory = scope.ServiceProvider.GetRequiredService>(); + var strategy = scope.ServiceProvider.GetRequiredService(); + var logger = scope.ServiceProvider.GetRequiredService>(); + + return new MultiImmichFrameLogicDelegate(mergedSettings, logicFactory, logger, strategy); + } + + private IServerSettings BuildMergedSettings(AccountOverrideDto? overrides) + { + // Clone base accounts into new mutable instances so pools rebuild cleanly. + var clonedAccounts = _baseSettings.Accounts + .Select(CloneAccount) + .Cast() + .ToList(); + + if (overrides != null) + { + foreach (var account in clonedAccounts) + { + AccountOverrideMerger.Apply(account, overrides); + } + } + + var snapshot = new MergedServerSettingsSnapshot + { + GeneralSettings = _baseSettings.GeneralSettings, + Accounts = clonedAccounts + }; + + snapshot.Validate(); + return snapshot; + } + + private static ServerAccountSettings CloneAccount(IAccountSettings baseAccount) => new() + { + ImmichServerUrl = baseAccount.ImmichServerUrl, + ApiKey = baseAccount.ApiKey, + ApiKeyFile = null, // avoid re-reading file and avoid ApiKey+ApiKeyFile conflict + ShowMemories = baseAccount.ShowMemories, + ShowFavorites = baseAccount.ShowFavorites, + ShowArchived = baseAccount.ShowArchived, + ImagesFromDays = baseAccount.ImagesFromDays, + ImagesFromDate = baseAccount.ImagesFromDate, + ImagesUntilDate = baseAccount.ImagesUntilDate, + Albums = baseAccount.Albums.ToList(), + ExcludedAlbums = baseAccount.ExcludedAlbums.ToList(), + People = baseAccount.People.ToList(), + Rating = baseAccount.Rating + }; + + public async Task GetNextAsset() + => await (await GetCurrentAsync()).GetNextAsset(); + + public async Task> GetAssets() + => await (await GetCurrentAsync()).GetAssets(); + + public async Task GetAssetInfoById(Guid assetId) + => await (await GetCurrentAsync()).GetAssetInfoById(assetId); + + public async Task> GetAlbumInfoById(Guid assetId) + => await (await GetCurrentAsync()).GetAlbumInfoById(assetId); + + public async Task<(string fileName, string ContentType, Stream fileStream)> GetImage(Guid id) + => await (await GetCurrentAsync()).GetImage(id); + + public async Task GetTotalAssets() + => await (await GetCurrentAsync()).GetTotalAssets(); + + public async Task SendWebhookNotification(IWebhookNotification notification) + => await (await GetCurrentAsync()).SendWebhookNotification(notification); +} + + diff --git a/ImmichFrame.WebApi/Services/SqliteAccountOverrideStore.cs b/ImmichFrame.WebApi/Services/SqliteAccountOverrideStore.cs new file mode 100644 index 00000000..1e737033 --- /dev/null +++ b/ImmichFrame.WebApi/Services/SqliteAccountOverrideStore.cs @@ -0,0 +1,76 @@ +using ImmichFrame.WebApi.Data; +using ImmichFrame.WebApi.Data.Entities; +using ImmichFrame.WebApi.Models; +using Microsoft.EntityFrameworkCore; + +namespace ImmichFrame.WebApi.Services; + +public sealed class SqliteAccountOverrideStore(ConfigDbContext db) : IAccountOverrideStore +{ + public async Task GetAsync(CancellationToken ct = default) + { + var entity = await db.AccountOverrides.AsNoTracking().SingleOrDefaultAsync(x => x.Id == AccountOverrideEntity.SingletonId, ct); + return entity == null ? null : ToDto(entity); + } + + public async Task GetVersionAsync(CancellationToken ct = default) + { + var updatedAt = await db.AccountOverrides.AsNoTracking() + .Where(x => x.Id == AccountOverrideEntity.SingletonId) + .Select(x => (DateTime?)x.UpdatedAtUtc) + .SingleOrDefaultAsync(ct); + + return updatedAt?.Ticks ?? 0; + } + + public async Task UpsertAsync(AccountOverrideDto dto, CancellationToken ct = default) + { + var entity = await db.AccountOverrides.SingleOrDefaultAsync(x => x.Id == AccountOverrideEntity.SingletonId, ct); + if (entity == null) + { + entity = new AccountOverrideEntity + { + Id = AccountOverrideEntity.SingletonId + }; + db.AccountOverrides.Add(entity); + } + + Apply(entity, dto); + entity.UpdatedAtUtc = DateTime.UtcNow; + + await db.SaveChangesAsync(ct); + } + + private static void Apply(AccountOverrideEntity entity, AccountOverrideDto dto) + { + entity.ShowMemories = dto.ShowMemories; + entity.ShowFavorites = dto.ShowFavorites; + entity.ShowArchived = dto.ShowArchived; + + entity.ImagesFromDays = dto.ImagesFromDays; + entity.ImagesFromDate = dto.ImagesFromDate; + entity.ImagesUntilDate = dto.ImagesUntilDate; + + entity.Albums = dto.Albums; + entity.ExcludedAlbums = dto.ExcludedAlbums; + entity.People = dto.People; + + entity.Rating = dto.Rating; + } + + private static AccountOverrideDto ToDto(AccountOverrideEntity entity) => new() + { + ShowMemories = entity.ShowMemories, + ShowFavorites = entity.ShowFavorites, + ShowArchived = entity.ShowArchived, + ImagesFromDays = entity.ImagesFromDays, + ImagesFromDate = entity.ImagesFromDate, + ImagesUntilDate = entity.ImagesUntilDate, + Albums = entity.Albums, + ExcludedAlbums = entity.ExcludedAlbums, + People = entity.People, + Rating = entity.Rating + }; +} + + diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index f0b89ed4..5747f413 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -7,5 +7,6 @@ services: - 8080:8080 env_file: - .env - # volumes: - # - PATH/TO/CONFIG:/app/Config \ No newline at end of file + volumes: + # - PATH/TO/CONFIG:/app/Config + - ../data:/data \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 04b13539..432249c3 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -4,9 +4,10 @@ services: container_name: immichframe image: ghcr.io/immichframe/immichframe:latest restart: on-failure - # volumes: - # - PATH/TO/CONFIG:/app/Config - # - PATH/TO/CUSTOMCSS/custom.css:/app/wwwroot/static/custom.css + volumes: + # - PATH/TO/CONFIG:/app/Config + # - PATH/TO/CUSTOMCSS/custom.css:/app/wwwroot/static/custom.css + - ./data:/data ports: - "8080:8080" env_file: From ee439bf1a0c49b415143825116963ef72e25eb4c Mon Sep 17 00:00:00 2001 From: timonrieger Date: Tue, 30 Dec 2025 18:04:12 +0100 Subject: [PATCH 2/5] build fixes --- .gitignore | 3 +- .../Controllers/ConfigController.cs | 4 +- ImmichFrame.WebApi/Program.cs | 57 ++++++++++++++++++- .../Services/ReloadingImmichFrameLogic.cs | 6 +- docker/docker-compose.dev.yml | 2 +- docker/example.env | 10 ---- 6 files changed, 64 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index 1fc03c3e..b626a118 100755 --- a/.gitignore +++ b/.gitignore @@ -456,4 +456,5 @@ $RECYCLE.BIN/ ## Ignore Settings file ImmichFrame/Settings.json -ImmichFrame.WebApi/Config/Settings.json \ No newline at end of file +ImmichFrame.WebApi/Config/Settings.json +data/ \ No newline at end of file diff --git a/ImmichFrame.WebApi/Controllers/ConfigController.cs b/ImmichFrame.WebApi/Controllers/ConfigController.cs index 614744f8..27b0e940 100644 --- a/ImmichFrame.WebApi/Controllers/ConfigController.cs +++ b/ImmichFrame.WebApi/Controllers/ConfigController.cs @@ -35,7 +35,7 @@ public string GetVersion() return System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; } - [HttpGet("account-overrides")] + [HttpGet("account-overrides", Name = "GetAccountOverrides")] public async Task> GetAccountOverrides(CancellationToken ct) { var dto = await _overrideStore.GetAsync(ct); @@ -43,7 +43,7 @@ public string GetVersion() } [Authorize] - [HttpPut("account-overrides")] + [HttpPut("account-overrides", Name = "PutAccountOverrides")] public async Task PutAccountOverrides([FromBody] AccountOverrideDto dto, CancellationToken ct) { if (dto.ImagesFromDays is < 0) diff --git a/ImmichFrame.WebApi/Program.cs b/ImmichFrame.WebApi/Program.cs index c4e3b41c..29b00e63 100644 --- a/ImmichFrame.WebApi/Program.cs +++ b/ImmichFrame.WebApi/Program.cs @@ -107,13 +107,64 @@ _ _ __ ___ _ __ ___ _ ___| |__ | |_ _ __ __ _ _ __ ___ ___ using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); + var logger = scope.ServiceProvider.GetRequiredService>(); try { - db.Database.Migrate(); + // Check if we have any migrations to apply + var pendingMigrations = db.Database.GetPendingMigrations().ToList(); + if (pendingMigrations.Any()) + { + logger.LogInformation("Applying {Count} pending migrations", pendingMigrations.Count); + db.Database.Migrate(); + } + else + { + // No pending migrations - check if table exists, if not create it manually + logger.LogInformation("No pending migrations, checking if AccountOverrides table exists"); + var tableExists = db.Database.ExecuteSqlRaw( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='AccountOverrides'") > 0; + + if (!tableExists) + { + logger.LogInformation("AccountOverrides table does not exist, creating it manually"); + // Create the table manually since EnsureCreated() doesn't work when migrations history exists + db.Database.ExecuteSqlRaw(@" + CREATE TABLE IF NOT EXISTS AccountOverrides ( + Id INTEGER NOT NULL PRIMARY KEY, + ShowMemories INTEGER, + ShowFavorites INTEGER, + ShowArchived INTEGER, + ImagesFromDays INTEGER, + ImagesFromDate TEXT, + ImagesUntilDate TEXT, + Albums TEXT, + ExcludedAlbums TEXT, + People TEXT, + Rating INTEGER, + UpdatedAtUtc TEXT NOT NULL + )"); + logger.LogInformation("AccountOverrides table created successfully"); + } + else + { + logger.LogInformation("AccountOverrides table already exists"); + } + } } - catch + catch (Exception ex) { - db.Database.EnsureCreated(); + // Fallback: ensure created if migrations fail + logger.LogError(ex, "Database initialization failed, attempting EnsureCreated"); + try + { + db.Database.EnsureCreated(); + logger.LogInformation("Fallback EnsureCreated() completed"); + } + catch (Exception ex2) + { + logger.LogError(ex2, "EnsureCreated also failed"); + throw; + } } } diff --git a/ImmichFrame.WebApi/Services/ReloadingImmichFrameLogic.cs b/ImmichFrame.WebApi/Services/ReloadingImmichFrameLogic.cs index 65754a75..b6f4d10e 100644 --- a/ImmichFrame.WebApi/Services/ReloadingImmichFrameLogic.cs +++ b/ImmichFrame.WebApi/Services/ReloadingImmichFrameLogic.cs @@ -1,3 +1,4 @@ +using ImmichFrame.Core.Api; using ImmichFrame.Core.Interfaces; using ImmichFrame.Core.Logic; using ImmichFrame.WebApi.Models; @@ -131,7 +132,10 @@ public async Task GetAssetInfoById(Guid assetId) => await (await GetCurrentAsync()).GetAssetInfoById(assetId); public async Task> GetAlbumInfoById(Guid assetId) - => await (await GetCurrentAsync()).GetAlbumInfoById(assetId); + { + var current = await GetCurrentAsync(); + return await current.GetAlbumInfoById(assetId); + } public async Task<(string fileName, string ContentType, Stream fileStream)> GetImage(Guid id) => await (await GetCurrentAsync()).GetImage(id); diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 5747f413..893c5bd6 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -2,7 +2,7 @@ services: immichframe: build: context: .. - target: dev + target: final ports: - 8080:8080 env_file: diff --git a/docker/example.env b/docker/example.env index 75e875fd..bd7a93d8 100644 --- a/docker/example.env +++ b/docker/example.env @@ -11,17 +11,7 @@ ApiKey=KEY # ImagePan=false # Layout=splitview # DownloadImages=false -# ShowMemories=false -# ShowFavorites=false -# ShowArchived=false -# ImagesFromDays= -# ImagesFromDate= -# ImagesUntilDate= # RenewImagesDuration=30 -# Rating=5 -# Albums=ALBUM1,ALBUM2 -# ExcludedAlbums=ALBUM3,ALBUM4 -# People=PERSON1,PERSON2 # Webcalendars=https://calendar.mycalendar.com/basic.ics,webcal://calendar.mycalendar.com/basic.ics # RefreshAlbumPeopleInterval=12 # ShowClock=true From 2f9064368862510db41d37ad88d8b7d6f1b7d7df Mon Sep 17 00:00:00 2001 From: timonrieger Date: Tue, 30 Dec 2025 18:28:24 +0100 Subject: [PATCH 3/5] Add account override change notification and SSE support - Introduced IAccountOverrideChangeNotifier and its implementation for notifying clients of account override changes. - Updated ConfigController to handle SSE for account override events and version retrieval. - Enhanced home-page and admin components to support real-time updates and management of account overrides. - Added functionality to refresh asset loading upon override changes. --- .../Controllers/ConfigController.cs | 55 +++- ImmichFrame.WebApi/Program.cs | 1 + .../Services/AccountOverrideChangeNotifier.cs | 59 ++++ .../Services/ReloadingImmichFrameLogic.cs | 18 +- .../lib/components/home-page/home-page.svelte | 39 +++ immichFrame.Web/src/routes/admin/+page.svelte | 264 ++++++++++++++++++ 6 files changed, 428 insertions(+), 8 deletions(-) create mode 100644 ImmichFrame.WebApi/Services/AccountOverrideChangeNotifier.cs create mode 100644 immichFrame.Web/src/routes/admin/+page.svelte diff --git a/ImmichFrame.WebApi/Controllers/ConfigController.cs b/ImmichFrame.WebApi/Controllers/ConfigController.cs index 27b0e940..55bdb9ee 100644 --- a/ImmichFrame.WebApi/Controllers/ConfigController.cs +++ b/ImmichFrame.WebApi/Controllers/ConfigController.cs @@ -3,6 +3,7 @@ using ImmichFrame.WebApi.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using System.Text; namespace ImmichFrame.WebApi.Controllers { @@ -13,12 +14,18 @@ public class ConfigController : ControllerBase private readonly ILogger _logger; private readonly IGeneralSettings _settings; private readonly IAccountOverrideStore _overrideStore; + private readonly IAccountOverrideChangeNotifier _overrideChangeNotifier; - public ConfigController(ILogger logger, IGeneralSettings settings, IAccountOverrideStore overrideStore) + public ConfigController( + ILogger logger, + IGeneralSettings settings, + IAccountOverrideStore overrideStore, + IAccountOverrideChangeNotifier overrideChangeNotifier) { _logger = logger; _settings = settings; _overrideStore = overrideStore; + _overrideChangeNotifier = overrideChangeNotifier; } [HttpGet(Name = "GetConfig")] @@ -42,6 +49,37 @@ public string GetVersion() return Ok(dto); } + [HttpGet("account-overrides/version", Name = "GetAccountOverridesVersion")] + public async Task> GetAccountOverridesVersion(CancellationToken ct) + { + var version = await _overrideStore.GetVersionAsync(ct); + return Ok(version); + } + + /// + /// Server-Sent Events stream: emits the override "version" (ticks) whenever account overrides change. + /// + [HttpGet("account-overrides/events", Name = "GetAccountOverridesEvents")] + public async Task GetAccountOverridesEvents(CancellationToken ct) + { + Response.Headers.Append("Content-Type", "text/event-stream"); + Response.Headers.Append("Cache-Control", "no-cache"); + Response.Headers.Append("X-Accel-Buffering", "no"); + + // Send current version immediately (helps clients initialize). + var initialVersion = await _overrideStore.GetVersionAsync(ct); + await WriteSseAsync("account-overrides", initialVersion.ToString(), ct); + + var reader = _overrideChangeNotifier.Subscribe(ct); + while (!ct.IsCancellationRequested && await reader.WaitToReadAsync(ct)) + { + while (reader.TryRead(out var version)) + { + await WriteSseAsync("account-overrides", version.ToString(), ct); + } + } + } + [Authorize] [HttpPut("account-overrides", Name = "PutAccountOverrides")] public async Task PutAccountOverrides([FromBody] AccountOverrideDto dto, CancellationToken ct) @@ -56,7 +94,22 @@ public async Task PutAccountOverrides([FromBody] AccountOverrideD return BadRequest("Rating must be between 0 and 5"); await _overrideStore.UpsertAsync(dto, ct); + var version = await _overrideStore.GetVersionAsync(ct); + _overrideChangeNotifier.NotifyChanged(version); return Ok(); } + + private async Task WriteSseAsync(string eventName, string data, CancellationToken ct) + { + // SSE format: + // event: + // data: + // \n + var payload = $"event: {eventName}\n" + + $"data: {data}\n\n"; + var bytes = Encoding.UTF8.GetBytes(payload); + await Response.Body.WriteAsync(bytes, ct); + await Response.Body.FlushAsync(ct); + } } } \ No newline at end of file diff --git a/ImmichFrame.WebApi/Program.cs b/ImmichFrame.WebApi/Program.cs index 29b00e63..2d8dd2dd 100644 --- a/ImmichFrame.WebApi/Program.cs +++ b/ImmichFrame.WebApi/Program.cs @@ -78,6 +78,7 @@ _ _ __ ___ _ __ ___ _ ___| |__ | |_ _ __ __ _ _ __ ___ ___ options.UseSqlite($"Data Source={dbPath}"); }); builder.Services.AddScoped(); +builder.Services.AddSingleton(); // Register services builder.Services.AddSingleton(); diff --git a/ImmichFrame.WebApi/Services/AccountOverrideChangeNotifier.cs b/ImmichFrame.WebApi/Services/AccountOverrideChangeNotifier.cs new file mode 100644 index 00000000..fb6188a5 --- /dev/null +++ b/ImmichFrame.WebApi/Services/AccountOverrideChangeNotifier.cs @@ -0,0 +1,59 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; + +namespace ImmichFrame.WebApi.Services; + +public interface IAccountOverrideChangeNotifier +{ + long CurrentVersion { get; } + void NotifyChanged(long version); + ChannelReader Subscribe(CancellationToken ct); +} + +/// +/// In-memory broadcaster for account override changes (best-effort, per-process). +/// Used to notify web clients (SSE) to refresh immediately after admin updates. +/// +public sealed class AccountOverrideChangeNotifier : IAccountOverrideChangeNotifier +{ + private readonly ConcurrentDictionary> _subscribers = new(); + private long _currentVersion; + + public long CurrentVersion => Volatile.Read(ref _currentVersion); + + public void NotifyChanged(long version) + { + Volatile.Write(ref _currentVersion, version); + + foreach (var kv in _subscribers) + { + // Best-effort; if a subscriber is slow it will buffer. + kv.Value.Writer.TryWrite(version); + } + } + + public ChannelReader Subscribe(CancellationToken ct) + { + var id = Guid.NewGuid(); + var channel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + AllowSynchronousContinuations = true + }); + + _subscribers.TryAdd(id, channel); + + ct.Register(() => + { + if (_subscribers.TryRemove(id, out var removed)) + { + removed.Writer.TryComplete(); + } + }); + + return channel.Reader; + } +} + + diff --git a/ImmichFrame.WebApi/Services/ReloadingImmichFrameLogic.cs b/ImmichFrame.WebApi/Services/ReloadingImmichFrameLogic.cs index b6f4d10e..c7feedd1 100644 --- a/ImmichFrame.WebApi/Services/ReloadingImmichFrameLogic.cs +++ b/ImmichFrame.WebApi/Services/ReloadingImmichFrameLogic.cs @@ -15,7 +15,6 @@ public sealed class ReloadingImmichFrameLogic : IImmichFrameLogic { private readonly IServiceScopeFactory _scopeFactory; private readonly IServerSettings _baseSettings; - private readonly IAccountOverrideStore _overrideStore; private readonly ILogger _logger; private readonly SemaphoreSlim _reloadLock = new(1, 1); @@ -25,18 +24,22 @@ public sealed class ReloadingImmichFrameLogic : IImmichFrameLogic public ReloadingImmichFrameLogic( IServiceScopeFactory scopeFactory, IServerSettings baseSettings, - IAccountOverrideStore overrideStore, ILogger logger) { _scopeFactory = scopeFactory; _baseSettings = baseSettings; - _overrideStore = overrideStore; _logger = logger; } private async Task GetCurrentAsync(CancellationToken ct = default) { - var version = await _overrideStore.GetVersionAsync(ct); + long version; + using (var scope = _scopeFactory.CreateScope()) + { + var overrideStore = scope.ServiceProvider.GetRequiredService(); + version = await overrideStore.GetVersionAsync(ct); + } + var existing = Volatile.Read(ref _current); if (existing != null && version == Volatile.Read(ref _currentVersion)) { @@ -68,10 +71,11 @@ private async Task GetCurrentAsync(CancellationToken ct = def private async Task BuildAsync(CancellationToken ct) { - var overrides = await _overrideStore.GetAsync(ct); - var mergedSettings = BuildMergedSettings(overrides); - using var scope = _scopeFactory.CreateScope(); + var overrideStore = scope.ServiceProvider.GetRequiredService(); + var overrides = await overrideStore.GetAsync(ct); + + var mergedSettings = BuildMergedSettings(overrides); var logicFactory = scope.ServiceProvider.GetRequiredService>(); var strategy = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService>(); 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 e1082981..42eeee0f 100644 --- a/immichFrame.Web/src/lib/components/home-page/home-page.svelte +++ b/immichFrame.Web/src/lib/components/home-page/home-page.svelte @@ -58,6 +58,7 @@ let cursorVisible = $state(true); let timeoutId: number; + let overridesEventSource: EventSource | null = null; const clientIdentifier = page.url.searchParams.get('client'); const authsecret = page.url.searchParams.get('authsecret'); @@ -135,6 +136,25 @@ } } + async function refreshAfterOverrideChange() { + // Clear any cached/preloaded state so the next fetch reflects the new override filters. + assetHistory = []; + assetBacklog = []; + imagePromisesDict = {}; + + await loadAssets(); + + // If the page is already running, immediately show a new asset from the refreshed backlog. + if (progressBar) { + try { + progressBar.restart(false); + } catch { + // ignore + } + } + await getNextAssets(); + } + const handleDone = async (previous: boolean = false, instant: boolean = false) => { progressBar.restart(false); $instantTransition = instant; @@ -324,6 +344,20 @@ } }); + // Live updates when admin changes SQLite-backed overrides. + try { + overridesEventSource = new EventSource('/api/Config/account-overrides/events'); + overridesEventSource.onmessage = () => { + // Some browsers only fire 'message' even when 'event:' is set; handle both. + void refreshAfterOverrideChange(); + }; + overridesEventSource.addEventListener('account-overrides', () => { + void refreshAfterOverrideChange(); + }); + } catch { + // If SSE isn't available, the slideshow will still update on the next normal asset refresh. + } + getNextAssets(); return () => { @@ -333,6 +367,11 @@ }); onDestroy(() => { + if (overridesEventSource) { + overridesEventSource.close(); + overridesEventSource = null; + } + if (unsubscribeRestart) { unsubscribeRestart(); } diff --git a/immichFrame.Web/src/routes/admin/+page.svelte b/immichFrame.Web/src/routes/admin/+page.svelte new file mode 100644 index 00000000..02fa73bd --- /dev/null +++ b/immichFrame.Web/src/routes/admin/+page.svelte @@ -0,0 +1,264 @@ + + + + ImmichFrame Admin + + +
+

Admin

+

+ To authenticate, set your + authsecret in local storage (or open the main page once with + ?authsecret=...). This is not required if not using authentication in your .env file. +

+ +
+ + +
+ + {#if loading} +

Loading…

+ {:else} + {#if error} +

{error}

+ {/if} + {#if ok} +

{ok}

+ {/if} + +
+
+ Filters + + + + + + + +
+ + + +
+ +
+ + + +
+
+ +
+ Lists (one GUID per line) + +