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/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..89778645 100644 --- a/ImmichFrame.WebApi/Controllers/ConfigController.cs +++ b/ImmichFrame.WebApi/Controllers/ConfigController.cs @@ -1,6 +1,9 @@ using ImmichFrame.Core.Interfaces; using ImmichFrame.WebApi.Models; +using ImmichFrame.WebApi.Services; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using System.Text; namespace ImmichFrame.WebApi.Controllers { @@ -10,11 +13,28 @@ public class ConfigController : ControllerBase { private readonly ILogger _logger; private readonly IGeneralSettings _settings; + private readonly IAccountOverrideStore _overrideStore; + private readonly IAccountOverrideChangeNotifier _overrideChangeNotifier; + private readonly IGeneralSettingsStore _generalStore; + private readonly GeneralSettingsRuntime _generalRuntime; + private readonly IGeneralSettingsChangeNotifier _generalChangeNotifier; - public ConfigController(ILogger logger, IGeneralSettings settings) + public ConfigController( + ILogger logger, + IGeneralSettings settings, + IAccountOverrideStore overrideStore, + IAccountOverrideChangeNotifier overrideChangeNotifier, + IGeneralSettingsStore generalStore, + GeneralSettingsRuntime generalRuntime, + IGeneralSettingsChangeNotifier generalChangeNotifier) { _logger = logger; _settings = settings; + _overrideStore = overrideStore; + _overrideChangeNotifier = overrideChangeNotifier; + _generalStore = generalStore; + _generalRuntime = generalRuntime; + _generalChangeNotifier = generalChangeNotifier; } [HttpGet(Name = "GetConfig")] @@ -30,5 +50,138 @@ public string GetVersion() { return System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; } + + [HttpGet("account-overrides", Name = "GetAccountOverrides")] + public async Task> GetAccountOverrides(CancellationToken ct) + { + var dto = await _overrideStore.GetAsync(ct); + 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) + { + 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); + var version = await _overrideStore.GetVersionAsync(ct); + _overrideChangeNotifier.NotifyChanged(version); + return Ok(); + } + + [Authorize] + [HttpGet("general-settings", Name = "GetGeneralSettings")] + public async Task> GetGeneralSettings(CancellationToken ct) + { + // Always return a non-null snapshot + var dto = await _generalStore.GetAsync(ct) ?? await _generalStore.GetOrCreateFromBaseAsync(_settings, ct); + return Ok(dto); + } + + [HttpGet("general-settings/version", Name = "GetGeneralSettingsVersion")] + public async Task> GetGeneralSettingsVersion(CancellationToken ct) + { + var version = await _generalStore.GetVersionAsync(ct); + return Ok(version); + } + + /// + /// Server-Sent Events stream: emits the general settings "version" (ticks) whenever general settings change. + /// Kept unauthenticated so browser EventSource works even when AuthenticationSecret is enabled. + /// + [HttpGet("general-settings/events", Name = "GetGeneralSettingsEvents")] + public async Task GetGeneralSettingsEvents(CancellationToken ct) + { + Response.Headers.Append("Content-Type", "text/event-stream"); + Response.Headers.Append("Cache-Control", "no-cache"); + Response.Headers.Append("X-Accel-Buffering", "no"); + + var initialVersion = await _generalStore.GetVersionAsync(ct); + await WriteSseAsync("general-settings", initialVersion.ToString(), ct); + + var reader = _generalChangeNotifier.Subscribe(ct); + while (!ct.IsCancellationRequested && await reader.WaitToReadAsync(ct)) + { + while (reader.TryRead(out var version)) + { + await WriteSseAsync("general-settings", version.ToString(), ct); + } + } + } + + [Authorize] + [HttpPut("general-settings", Name = "PutGeneralSettings")] + public async Task PutGeneralSettings([FromBody] GeneralSettingsDto dto, CancellationToken ct) + { + if (dto.Interval <= 0) return BadRequest("Interval must be > 0"); + if (dto.TransitionDuration < 0) return BadRequest("TransitionDuration must be >= 0"); + if (dto.RenewImagesDuration < 0) return BadRequest("RenewImagesDuration must be >= 0"); + if (dto.RefreshAlbumPeopleInterval < 0) return BadRequest("RefreshAlbumPeopleInterval must be >= 0"); + if (string.IsNullOrWhiteSpace(dto.Language)) return BadRequest("Language is required"); + if (string.IsNullOrWhiteSpace(dto.Style)) return BadRequest("Style is required"); + if (string.IsNullOrWhiteSpace(dto.Layout)) return BadRequest("Layout is required"); + + dto.Webcalendars ??= new(); + + await _generalStore.UpsertAsync(dto, ct); + var version = await _generalStore.GetVersionAsync(ct); + + // Update live runtime settings so weather/calendar/webhook clients update without restart. + _generalRuntime.ApplyFromDb(dto, version); + _generalChangeNotifier.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/Data/ConfigDbContext.cs b/ImmichFrame.WebApi/Data/ConfigDbContext.cs new file mode 100644 index 00000000..f08478cf --- /dev/null +++ b/ImmichFrame.WebApi/Data/ConfigDbContext.cs @@ -0,0 +1,41 @@ +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(); + public DbSet GeneralSettings => 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>()); + + var general = modelBuilder.Entity(); + general.ToTable("GeneralSettings"); + general.HasKey(x => x.Id); + + general.Property(x => x.Language).IsRequired(); + general.Property(x => x.Style).IsRequired(); + general.Property(x => x.Layout).IsRequired(); + general.Property(x => x.UpdatedAtUtc).IsRequired(); + + // List stored as JSON text + general.Property(x => x.Webcalendars).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/Models/GeneralSettingsDto.cs b/ImmichFrame.WebApi/Models/GeneralSettingsDto.cs new file mode 100644 index 00000000..e36ede66 --- /dev/null +++ b/ImmichFrame.WebApi/Models/GeneralSettingsDto.cs @@ -0,0 +1,42 @@ +namespace ImmichFrame.WebApi.Models; + +/// +/// DB-backed general settings. Intentionally excludes AuthenticationSecret (kept in config/env). +/// +public sealed class GeneralSettingsDto +{ + public bool DownloadImages { get; set; } + public string Language { get; set; } = "en"; + public string? ImageLocationFormat { get; set; } + public string? PhotoDateFormat { get; set; } + public int Interval { get; set; } + public double TransitionDuration { get; set; } + public bool ShowClock { get; set; } + public string? ClockFormat { get; set; } + public string? ClockDateFormat { get; set; } + public bool ShowProgressBar { get; set; } + public bool ShowPhotoDate { get; set; } + public bool ShowImageDesc { get; set; } + public bool ShowPeopleDesc { get; set; } + public bool ShowAlbumName { get; set; } + public bool ShowImageLocation { get; set; } + public string? PrimaryColor { get; set; } + public string? SecondaryColor { get; set; } + public string Style { get; set; } = "none"; + public string? BaseFontSize { get; set; } + public bool ShowWeatherDescription { get; set; } + public string? WeatherIconUrl { get; set; } + public bool ImageZoom { get; set; } + public bool ImagePan { get; set; } + public bool ImageFill { get; set; } + public string Layout { get; set; } = "splitview"; + public int RenewImagesDuration { get; set; } + public List Webcalendars { get; set; } = new(); + public int RefreshAlbumPeopleInterval { get; set; } + public string? WeatherApiKey { get; set; } + public string? UnitSystem { get; set; } + public string? WeatherLatLong { get; set; } + public string? Webhook { get; set; } +} + + diff --git a/ImmichFrame.WebApi/Program.cs b/ImmichFrame.WebApi/Program.cs index f2d68244..8ea6d5a8 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 @@ -54,19 +57,43 @@ _ _ __ ___ _ __ ___ _ ___| |__ | |_ _ __ __ _ _ __ ___ ___ builder.Services.AddSingleton(srv => srv.GetRequiredService().LoadConfig(configPath)); // Register sub-settings -builder.Services.AddSingleton(srv => srv.GetRequiredService().GeneralSettings); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(srv => srv.GetRequiredService()); + +// 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(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // 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 +107,132 @@ _ _ __ ___ _ __ ___ _ ___| |__ | |_ _ __ __ _ _ __ ___ ___ 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(); + var logger = scope.ServiceProvider.GetRequiredService>(); + try + { + // 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"); + } + + var generalTableExists = db.Database.ExecuteSqlRaw( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='GeneralSettings'") > 0; + if (!generalTableExists) + { + logger.LogInformation("GeneralSettings table does not exist, creating it manually"); + db.Database.ExecuteSqlRaw(@" + CREATE TABLE IF NOT EXISTS GeneralSettings ( + Id INTEGER NOT NULL PRIMARY KEY, + DownloadImages INTEGER NOT NULL, + Language TEXT NOT NULL, + ImageLocationFormat TEXT, + PhotoDateFormat TEXT, + Interval INTEGER NOT NULL, + TransitionDuration REAL NOT NULL, + ShowClock INTEGER NOT NULL, + ClockFormat TEXT, + ClockDateFormat TEXT, + ShowProgressBar INTEGER NOT NULL, + ShowPhotoDate INTEGER NOT NULL, + ShowImageDesc INTEGER NOT NULL, + ShowPeopleDesc INTEGER NOT NULL, + ShowAlbumName INTEGER NOT NULL, + ShowImageLocation INTEGER NOT NULL, + PrimaryColor TEXT, + SecondaryColor TEXT, + Style TEXT NOT NULL, + BaseFontSize TEXT, + ShowWeatherDescription INTEGER NOT NULL, + WeatherIconUrl TEXT, + ImageZoom INTEGER NOT NULL, + ImagePan INTEGER NOT NULL, + ImageFill INTEGER NOT NULL, + Layout TEXT NOT NULL, + RenewImagesDuration INTEGER NOT NULL, + Webcalendars TEXT, + RefreshAlbumPeopleInterval INTEGER NOT NULL, + WeatherApiKey TEXT, + UnitSystem TEXT, + WeatherLatLong TEXT, + Webhook TEXT, + UpdatedAtUtc TEXT NOT NULL + )"); + logger.LogInformation("GeneralSettings table created successfully"); + } + else + { + logger.LogInformation("GeneralSettings table already exists"); + } + } + } + catch (Exception ex) + { + // 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; + } + } +} + +// Seed general settings from config/env into SQLite (fresh installs) and prime the runtime snapshot. +using (var scope = app.Services.CreateScope()) +{ + var baseSettings = scope.ServiceProvider.GetRequiredService().GeneralSettings; + var store = scope.ServiceProvider.GetRequiredService(); + var runtime = scope.ServiceProvider.GetRequiredService(); + + var dto = await store.GetOrCreateFromBaseAsync(baseSettings); + var versionTicks = await store.GetVersionAsync(); + runtime.ApplyFromDb(dto, versionTicks); +} + // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { 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/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/GeneralSettingsChangeNotifier.cs b/ImmichFrame.WebApi/Services/GeneralSettingsChangeNotifier.cs new file mode 100644 index 00000000..74912940 --- /dev/null +++ b/ImmichFrame.WebApi/Services/GeneralSettingsChangeNotifier.cs @@ -0,0 +1,55 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; + +namespace ImmichFrame.WebApi.Services; + +public interface IGeneralSettingsChangeNotifier +{ + long CurrentVersion { get; } + void NotifyChanged(long version); + ChannelReader Subscribe(CancellationToken ct); +} + +/// +/// In-memory broadcaster for general settings changes (best-effort, per-process). +/// +public sealed class GeneralSettingsChangeNotifier : IGeneralSettingsChangeNotifier +{ + 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) + { + 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/GeneralSettingsRuntime.cs b/ImmichFrame.WebApi/Services/GeneralSettingsRuntime.cs new file mode 100644 index 00000000..31d841e4 --- /dev/null +++ b/ImmichFrame.WebApi/Services/GeneralSettingsRuntime.cs @@ -0,0 +1,102 @@ +using ImmichFrame.Core.Interfaces; +using ImmichFrame.WebApi.Models; + +namespace ImmichFrame.WebApi.Services; + +/// +/// Process-wide, live view of general settings. +/// Backed by SQLite (for all settings except AuthenticationSecret). +/// +public sealed class GeneralSettingsRuntime : IGeneralSettings +{ + private readonly string? _authenticationSecret; + private volatile GeneralSettingsDto _current; + private long _currentVersion; + + public GeneralSettingsRuntime(IServerSettings baseSettings) + { + _authenticationSecret = baseSettings.GeneralSettings.AuthenticationSecret; + _current = new GeneralSettingsDto + { + DownloadImages = baseSettings.GeneralSettings.DownloadImages, + Language = baseSettings.GeneralSettings.Language, + ImageLocationFormat = baseSettings.GeneralSettings.ImageLocationFormat, + PhotoDateFormat = baseSettings.GeneralSettings.PhotoDateFormat, + Interval = baseSettings.GeneralSettings.Interval, + TransitionDuration = baseSettings.GeneralSettings.TransitionDuration, + ShowClock = baseSettings.GeneralSettings.ShowClock, + ClockFormat = baseSettings.GeneralSettings.ClockFormat, + ClockDateFormat = baseSettings.GeneralSettings.ClockDateFormat, + ShowProgressBar = baseSettings.GeneralSettings.ShowProgressBar, + ShowPhotoDate = baseSettings.GeneralSettings.ShowPhotoDate, + ShowImageDesc = baseSettings.GeneralSettings.ShowImageDesc, + ShowPeopleDesc = baseSettings.GeneralSettings.ShowPeopleDesc, + ShowAlbumName = baseSettings.GeneralSettings.ShowAlbumName, + ShowImageLocation = baseSettings.GeneralSettings.ShowImageLocation, + PrimaryColor = baseSettings.GeneralSettings.PrimaryColor, + SecondaryColor = baseSettings.GeneralSettings.SecondaryColor, + Style = baseSettings.GeneralSettings.Style, + BaseFontSize = baseSettings.GeneralSettings.BaseFontSize, + ShowWeatherDescription = baseSettings.GeneralSettings.ShowWeatherDescription, + WeatherIconUrl = baseSettings.GeneralSettings.WeatherIconUrl, + ImageZoom = baseSettings.GeneralSettings.ImageZoom, + ImagePan = baseSettings.GeneralSettings.ImagePan, + ImageFill = baseSettings.GeneralSettings.ImageFill, + Layout = baseSettings.GeneralSettings.Layout, + RenewImagesDuration = baseSettings.GeneralSettings.RenewImagesDuration, + Webcalendars = baseSettings.GeneralSettings.Webcalendars.ToList(), + RefreshAlbumPeopleInterval = baseSettings.GeneralSettings.RefreshAlbumPeopleInterval, + WeatherApiKey = baseSettings.GeneralSettings.WeatherApiKey, + UnitSystem = baseSettings.GeneralSettings.UnitSystem, + WeatherLatLong = baseSettings.GeneralSettings.WeatherLatLong, + Webhook = baseSettings.GeneralSettings.Webhook + }; + _currentVersion = 0; + } + + public long CurrentVersion => Volatile.Read(ref _currentVersion); + + public void ApplyFromDb(GeneralSettingsDto dto, long version) + { + _current = dto; + Volatile.Write(ref _currentVersion, version); + } + + public List Webcalendars => _current.Webcalendars; + public int RefreshAlbumPeopleInterval => _current.RefreshAlbumPeopleInterval; + public string? WeatherApiKey => _current.WeatherApiKey; + public string? WeatherLatLong => _current.WeatherLatLong; + public string? UnitSystem => _current.UnitSystem; + public string? Webhook => _current.Webhook; + public string? AuthenticationSecret => _authenticationSecret; + public int Interval => _current.Interval; + public double TransitionDuration => _current.TransitionDuration; + public bool DownloadImages => _current.DownloadImages; + public int RenewImagesDuration => _current.RenewImagesDuration; + public bool ShowClock => _current.ShowClock; + public string? ClockFormat => _current.ClockFormat; + public string? ClockDateFormat => _current.ClockDateFormat; + public bool ShowProgressBar => _current.ShowProgressBar; + public bool ShowPhotoDate => _current.ShowPhotoDate; + public string? PhotoDateFormat => _current.PhotoDateFormat; + public bool ShowImageDesc => _current.ShowImageDesc; + public bool ShowPeopleDesc => _current.ShowPeopleDesc; + public bool ShowAlbumName => _current.ShowAlbumName; + public bool ShowImageLocation => _current.ShowImageLocation; + public string? ImageLocationFormat => _current.ImageLocationFormat; + public string? PrimaryColor => _current.PrimaryColor; + public string? SecondaryColor => _current.SecondaryColor; + public string Style => _current.Style; + public string? BaseFontSize => _current.BaseFontSize; + public bool ShowWeatherDescription => _current.ShowWeatherDescription; + public string? WeatherIconUrl => _current.WeatherIconUrl; + public bool ImageZoom => _current.ImageZoom; + public bool ImagePan => _current.ImagePan; + public bool ImageFill => _current.ImageFill; + public string Layout => _current.Layout; + public string Language => _current.Language; + + public void Validate() { } +} + + 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..c7feedd1 --- /dev/null +++ b/ImmichFrame.WebApi/Services/ReloadingImmichFrameLogic.cs @@ -0,0 +1,154 @@ +using ImmichFrame.Core.Api; +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 ILogger _logger; + private readonly SemaphoreSlim _reloadLock = new(1, 1); + + private long _currentVersion = -1; + private IImmichFrameLogic? _current; + + public ReloadingImmichFrameLogic( + IServiceScopeFactory scopeFactory, + IServerSettings baseSettings, + ILogger logger) + { + _scopeFactory = scopeFactory; + _baseSettings = baseSettings; + _logger = logger; + } + + private async Task GetCurrentAsync(CancellationToken ct = default) + { + 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)) + { + 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) + { + 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>(); + + 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) + { + 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); + + 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/ImmichFrame.WebApi/Services/SqliteGeneralSettingsStore.cs b/ImmichFrame.WebApi/Services/SqliteGeneralSettingsStore.cs new file mode 100644 index 00000000..5f472692 --- /dev/null +++ b/ImmichFrame.WebApi/Services/SqliteGeneralSettingsStore.cs @@ -0,0 +1,181 @@ +using ImmichFrame.Core.Interfaces; +using ImmichFrame.WebApi.Data; +using ImmichFrame.WebApi.Data.Entities; +using ImmichFrame.WebApi.Models; +using Microsoft.EntityFrameworkCore; + +namespace ImmichFrame.WebApi.Services; + +public interface IGeneralSettingsStore +{ + Task GetAsync(CancellationToken ct = default); + Task GetVersionAsync(CancellationToken ct = default); + Task GetOrCreateFromBaseAsync(IGeneralSettings baseSettings, CancellationToken ct = default); + Task UpsertAsync(GeneralSettingsDto dto, CancellationToken ct = default); +} + +public sealed class SqliteGeneralSettingsStore(ConfigDbContext db) : IGeneralSettingsStore +{ + public async Task GetAsync(CancellationToken ct = default) + { + var entity = await db.GeneralSettings.AsNoTracking() + .SingleOrDefaultAsync(x => x.Id == GeneralSettingsEntity.SingletonId, ct); + return entity == null ? null : ToDto(entity); + } + + public async Task GetVersionAsync(CancellationToken ct = default) + { + var updatedAt = await db.GeneralSettings.AsNoTracking() + .Where(x => x.Id == GeneralSettingsEntity.SingletonId) + .Select(x => (DateTime?)x.UpdatedAtUtc) + .SingleOrDefaultAsync(ct); + + return updatedAt?.Ticks ?? 0; + } + + public async Task GetOrCreateFromBaseAsync(IGeneralSettings baseSettings, CancellationToken ct = default) + { + var existing = await db.GeneralSettings.SingleOrDefaultAsync(x => x.Id == GeneralSettingsEntity.SingletonId, ct); + if (existing != null) + { + return ToDto(existing); + } + + var entity = new GeneralSettingsEntity + { + Id = GeneralSettingsEntity.SingletonId + }; + Apply(entity, baseSettings); + entity.UpdatedAtUtc = DateTime.UtcNow; + db.GeneralSettings.Add(entity); + await db.SaveChangesAsync(ct); + return ToDto(entity); + } + + public async Task UpsertAsync(GeneralSettingsDto dto, CancellationToken ct = default) + { + var entity = await db.GeneralSettings.SingleOrDefaultAsync(x => x.Id == GeneralSettingsEntity.SingletonId, ct); + if (entity == null) + { + entity = new GeneralSettingsEntity + { + Id = GeneralSettingsEntity.SingletonId + }; + db.GeneralSettings.Add(entity); + } + + Apply(entity, dto); + entity.UpdatedAtUtc = DateTime.UtcNow; + await db.SaveChangesAsync(ct); + } + + private static void Apply(GeneralSettingsEntity entity, IGeneralSettings s) + { + entity.DownloadImages = s.DownloadImages; + entity.Language = s.Language; + entity.ImageLocationFormat = s.ImageLocationFormat; + entity.PhotoDateFormat = s.PhotoDateFormat; + entity.Interval = s.Interval; + entity.TransitionDuration = s.TransitionDuration; + entity.ShowClock = s.ShowClock; + entity.ClockFormat = s.ClockFormat; + entity.ClockDateFormat = s.ClockDateFormat; + entity.ShowProgressBar = s.ShowProgressBar; + entity.ShowPhotoDate = s.ShowPhotoDate; + entity.ShowImageDesc = s.ShowImageDesc; + entity.ShowPeopleDesc = s.ShowPeopleDesc; + entity.ShowAlbumName = s.ShowAlbumName; + entity.ShowImageLocation = s.ShowImageLocation; + entity.PrimaryColor = s.PrimaryColor; + entity.SecondaryColor = s.SecondaryColor; + entity.Style = s.Style; + entity.BaseFontSize = s.BaseFontSize; + entity.ShowWeatherDescription = s.ShowWeatherDescription; + entity.WeatherIconUrl = s.WeatherIconUrl; + entity.ImageZoom = s.ImageZoom; + entity.ImagePan = s.ImagePan; + entity.ImageFill = s.ImageFill; + entity.Layout = s.Layout; + entity.RenewImagesDuration = s.RenewImagesDuration; + entity.Webcalendars = s.Webcalendars.ToList(); + entity.RefreshAlbumPeopleInterval = s.RefreshAlbumPeopleInterval; + entity.WeatherApiKey = s.WeatherApiKey; + entity.UnitSystem = s.UnitSystem; + entity.WeatherLatLong = s.WeatherLatLong; + entity.Webhook = s.Webhook; + } + + private static void Apply(GeneralSettingsEntity entity, GeneralSettingsDto dto) + { + entity.DownloadImages = dto.DownloadImages; + entity.Language = dto.Language; + entity.ImageLocationFormat = dto.ImageLocationFormat; + entity.PhotoDateFormat = dto.PhotoDateFormat; + entity.Interval = dto.Interval; + entity.TransitionDuration = dto.TransitionDuration; + entity.ShowClock = dto.ShowClock; + entity.ClockFormat = dto.ClockFormat; + entity.ClockDateFormat = dto.ClockDateFormat; + entity.ShowProgressBar = dto.ShowProgressBar; + entity.ShowPhotoDate = dto.ShowPhotoDate; + entity.ShowImageDesc = dto.ShowImageDesc; + entity.ShowPeopleDesc = dto.ShowPeopleDesc; + entity.ShowAlbumName = dto.ShowAlbumName; + entity.ShowImageLocation = dto.ShowImageLocation; + entity.PrimaryColor = dto.PrimaryColor; + entity.SecondaryColor = dto.SecondaryColor; + entity.Style = dto.Style; + entity.BaseFontSize = dto.BaseFontSize; + entity.ShowWeatherDescription = dto.ShowWeatherDescription; + entity.WeatherIconUrl = dto.WeatherIconUrl; + entity.ImageZoom = dto.ImageZoom; + entity.ImagePan = dto.ImagePan; + entity.ImageFill = dto.ImageFill; + entity.Layout = dto.Layout; + entity.RenewImagesDuration = dto.RenewImagesDuration; + entity.Webcalendars = dto.Webcalendars ?? new(); + entity.RefreshAlbumPeopleInterval = dto.RefreshAlbumPeopleInterval; + entity.WeatherApiKey = dto.WeatherApiKey; + entity.UnitSystem = dto.UnitSystem; + entity.WeatherLatLong = dto.WeatherLatLong; + entity.Webhook = dto.Webhook; + } + + private static GeneralSettingsDto ToDto(GeneralSettingsEntity e) => new() + { + DownloadImages = e.DownloadImages, + Language = e.Language, + ImageLocationFormat = e.ImageLocationFormat, + PhotoDateFormat = e.PhotoDateFormat, + Interval = e.Interval, + TransitionDuration = e.TransitionDuration, + ShowClock = e.ShowClock, + ClockFormat = e.ClockFormat, + ClockDateFormat = e.ClockDateFormat, + ShowProgressBar = e.ShowProgressBar, + ShowPhotoDate = e.ShowPhotoDate, + ShowImageDesc = e.ShowImageDesc, + ShowPeopleDesc = e.ShowPeopleDesc, + ShowAlbumName = e.ShowAlbumName, + ShowImageLocation = e.ShowImageLocation, + PrimaryColor = e.PrimaryColor, + SecondaryColor = e.SecondaryColor, + Style = e.Style, + BaseFontSize = e.BaseFontSize, + ShowWeatherDescription = e.ShowWeatherDescription, + WeatherIconUrl = e.WeatherIconUrl, + ImageZoom = e.ImageZoom, + ImagePan = e.ImagePan, + ImageFill = e.ImageFill, + Layout = e.Layout, + RenewImagesDuration = e.RenewImagesDuration, + Webcalendars = e.Webcalendars.ToList(), + RefreshAlbumPeopleInterval = e.RefreshAlbumPeopleInterval, + WeatherApiKey = e.WeatherApiKey, + UnitSystem = e.UnitSystem, + WeatherLatLong = e.WeatherLatLong, + Webhook = e.Webhook + }; +} + + diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index f0b89ed4..893c5bd6 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -2,10 +2,11 @@ services: immichframe: build: context: .. - target: dev + target: final ports: - 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: diff --git a/docker/example.env b/docker/example.env index 75e875fd..2c3758ad 100644 --- a/docker/example.env +++ b/docker/example.env @@ -5,44 +5,3 @@ ApiKey=KEY # ApiKeyFile=/path/to/key # AuthenticationSecret= -# Interval=10 -# TransitionDuration=2 -# ImageZoom=true -# 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 -# ClockFormat=hh:mm -# ClockDateFormat=eee, MMM d -# ShowProgressBar=true -# ShowPhotoDate=true -# PhotoDateFormat=yyyy-MM-dd -# ShowImageDesc=true -# ShowPeopleDesc=true -# ShowAlbumName=true -# ShowImageLocation=true -# ImageLocationFormat=City,State,Country -# PrimaryColor=#F5DEB3 -# SecondaryColor=#000000 -# Style=none -# BaseFontSize=17px -# WeatherApiKey= -# ShowWeatherDescription=true -# WeatherIconUrl=https://openweathermap.org/img/wn/{IconId}.png -# UnitSystem=imperial -# WeatherLatLong= -# Language=en -# Webhook= 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..bb6c7a3b 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,29 @@ let cursorVisible = $state(true); let timeoutId: number; + let overridesEventSource: EventSource | null = null; + let generalEventSource: EventSource | null = null; + + // Keep document CSS vars in sync with config changes + $effect(() => { + if ($configStore.primaryColor) { + document.documentElement.style.setProperty('--primary-color', $configStore.primaryColor); + } else { + document.documentElement.style.removeProperty('--primary-color'); + } + + if ($configStore.secondaryColor) { + document.documentElement.style.setProperty('--secondary-color', $configStore.secondaryColor); + } else { + document.documentElement.style.removeProperty('--secondary-color'); + } + + if ($configStore.baseFontSize) { + document.documentElement.style.fontSize = $configStore.baseFontSize; + } else { + document.documentElement.style.fontSize = ''; + } + }); const clientIdentifier = page.url.searchParams.get('client'); const authsecret = page.url.searchParams.get('authsecret'); @@ -135,6 +158,38 @@ } } + 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(); + } + + async function refreshClientConfig() { + try { + const res = await api.getConfig({ clientIdentifier: $clientIdentifierStore }); + if (res.status === 200) { + configStore.ps(res.data); + // restart progress to reflect new interval/visual settings immediately + restartProgress.set(true); + } + } catch { + // ignore + } + } + const handleDone = async (previous: boolean = false, instant: boolean = false) => { progressBar.restart(false); $instantTransition = instant; @@ -300,17 +355,10 @@ onMount(() => { window.addEventListener('mousemove', showCursor); window.addEventListener('click', showCursor); - if ($configStore.primaryColor) { - document.documentElement.style.setProperty('--primary-color', $configStore.primaryColor); - } - - if ($configStore.secondaryColor) { - document.documentElement.style.setProperty('--secondary-color', $configStore.secondaryColor); - } - - if ($configStore.baseFontSize) { - document.documentElement.style.fontSize = $configStore.baseFontSize; - } + // initial config-driven CSS + if ($configStore.primaryColor) document.documentElement.style.setProperty('--primary-color', $configStore.primaryColor); + if ($configStore.secondaryColor) document.documentElement.style.setProperty('--secondary-color', $configStore.secondaryColor); + if ($configStore.baseFontSize) document.documentElement.style.fontSize = $configStore.baseFontSize; unsubscribeRestart = restartProgress.subscribe((value) => { if (value) { @@ -324,6 +372,33 @@ } }); + // 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. + } + + // Live updates when admin changes general (client) settings. + try { + generalEventSource = new EventSource('/api/Config/general-settings/events'); + generalEventSource.onmessage = () => { + void refreshClientConfig(); + }; + generalEventSource.addEventListener('general-settings', () => { + void refreshClientConfig(); + }); + } catch { + // ignore + } + getNextAssets(); return () => { @@ -333,6 +408,15 @@ }); onDestroy(() => { + if (overridesEventSource) { + overridesEventSource.close(); + overridesEventSource = null; + } + if (generalEventSource) { + generalEventSource.close(); + generalEventSource = 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..58fd2dba --- /dev/null +++ b/immichFrame.Web/src/routes/admin/+page.svelte @@ -0,0 +1,579 @@ + + + + ImmichFrame Admin + + + + Admin Configuration + + 📖 Need help with configuration? See the{' '} + + documentation + . + + + 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. + + + + Current authsecret + + + + {#if loading} + Loading… + {:else} + {#if error} + {error} + {/if} + {#if ok} + {ok} + {/if} + + + + General settings + + + + Interval (seconds) + + + + + Transition duration (seconds) + + + + + + + + Download images + + + + Image zoom + + + + Image pan + + + + + + + Image fill + + + + Show clock + + + + Show progress bar + + + + + + Layout + + + + Style + + + + + + + Renew images duration (minutes) + + + + Language + + + + + + + + Show photo date + + + + Show album name + + + + + + Photo date format + + + + Base font size (e.g. 17px) + + + + + + + + Show image description + + + + Show people description + + + + + + + Show image location + + + Image location format + + + + + + + Primary color + + + + Secondary color + + + + + + + Clock format + + + + Clock date format + + + + + + + Webcalendars (one URL per line) + + + + + + + Refresh album/people interval (hours) + + + + Webhook URL + + + + + + + Weather API key + + + + + Show weather description + + + + + + Weather icon URL template + + + + Unit system + + + + + + + Weather lat/long (e.g. 40.7128,74.0060) + + + + + + + Filters + + + + Show memories + + + + + Show favorites + + + + + Show archived + + + + + Images from last N days (0 = disabled) + + + + + Minimum rating (0–5) + + + + + + + Images from date (YYYY-MM-DD, optional) + + + + + Images until date (YYYY-MM-DD, optional) + + + + + + + Lists (one GUID per line) + + + Albums (include) + + + + + Excluded albums + + + + + People + + + + + + {saving ? 'Saving…' : 'Save'} + + + {/if} + + +
+ đź“– Need help with configuration? See the{' '} + + documentation + . +
+ 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. +
authsecret
?authsecret=...
Loading…
{error}
{ok}