diff --git a/ImmichFrame.Core/Interfaces/ICustomWidgetService.cs b/ImmichFrame.Core/Interfaces/ICustomWidgetService.cs new file mode 100644 index 00000000..017f2d1f --- /dev/null +++ b/ImmichFrame.Core/Interfaces/ICustomWidgetService.cs @@ -0,0 +1,6 @@ +using ImmichFrame.Core.Models; + +public interface ICustomWidgetService +{ + Task> GetCustomWidgetData(); +} diff --git a/ImmichFrame.Core/Interfaces/IServerSettings.cs b/ImmichFrame.Core/Interfaces/IServerSettings.cs index fea6c442..f63581a7 100644 --- a/ImmichFrame.Core/Interfaces/IServerSettings.cs +++ b/ImmichFrame.Core/Interfaces/IServerSettings.cs @@ -1,4 +1,6 @@ -namespace ImmichFrame.Core.Interfaces +using ImmichFrame.Core.Models; + +namespace ImmichFrame.Core.Interfaces { public interface IServerSettings { @@ -66,6 +68,9 @@ public interface IGeneralSettings public bool PlayAudio { get; } public string Layout { get; } public string Language { get; } + public bool ShowCustomWidget { get; } + public List CustomWidgetSources { get; } + public string CustomWidgetPosition { get; } public void Validate(); } diff --git a/ImmichFrame.Core/Models/CustomWidgetData.cs b/ImmichFrame.Core/Models/CustomWidgetData.cs new file mode 100644 index 00000000..82bd8dc3 --- /dev/null +++ b/ImmichFrame.Core/Models/CustomWidgetData.cs @@ -0,0 +1,14 @@ +namespace ImmichFrame.Core.Models; + +public class CustomWidgetData +{ + public string Title { get; set; } = string.Empty; + public List Items { get; set; } = new(); +} + +public class CustomWidgetItem +{ + public string Label { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public string? Secondary { get; set; } +} diff --git a/ImmichFrame.Core/Models/CustomWidgetSourceConfig.cs b/ImmichFrame.Core/Models/CustomWidgetSourceConfig.cs new file mode 100644 index 00000000..500a5750 --- /dev/null +++ b/ImmichFrame.Core/Models/CustomWidgetSourceConfig.cs @@ -0,0 +1,7 @@ +namespace ImmichFrame.Core.Models; + +public class CustomWidgetSourceConfig +{ + public string Url { get; set; } = string.Empty; + public int RefreshMinutes { get; set; } = 10; +} diff --git a/ImmichFrame.Core/Services/CustomWidgetService.cs b/ImmichFrame.Core/Services/CustomWidgetService.cs new file mode 100644 index 00000000..0010b944 --- /dev/null +++ b/ImmichFrame.Core/Services/CustomWidgetService.cs @@ -0,0 +1,74 @@ +using System.Net.Http.Json; +using ImmichFrame.Core.Interfaces; +using ImmichFrame.Core.Models; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +public class CustomWidgetService : ICustomWidgetService +{ + private readonly IGeneralSettings _settings; + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); + + public CustomWidgetService(IGeneralSettings settings, ILogger logger, IHttpClientFactory httpClientFactory) + { + _settings = settings; + _logger = logger; + _httpClientFactory = httpClientFactory; + } + + public async Task> GetCustomWidgetData() + { + var results = new List(); + + foreach (var source in _settings.CustomWidgetSources) + { + if (string.IsNullOrWhiteSpace(source.Url)) + continue; + + try + { + var cacheKey = $"customwidget_{source.Url}"; + var data = await _cache.GetOrCreateAsync(cacheKey, async entry => + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(source.RefreshMinutes); + return await FetchWidgetData(source.Url); + }); + + if (data != null) + results.Add(data); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to fetch custom widget data from '{Url}'", source.Url); + } + } + + return results; + } + + private async Task FetchWidgetData(string url) + { + var client = _httpClientFactory.CreateClient(); + + try + { + var response = await client.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Custom widget source '{Url}' returned status {StatusCode}", url, response.StatusCode); + return null; + } + + var data = await response.Content.ReadFromJsonAsync(); + return data; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching custom widget data from '{Url}'", url); + return null; + } + } +} diff --git a/ImmichFrame.WebApi/Controllers/CustomWidgetController.cs b/ImmichFrame.WebApi/Controllers/CustomWidgetController.cs new file mode 100644 index 00000000..8537abd3 --- /dev/null +++ b/ImmichFrame.WebApi/Controllers/CustomWidgetController.cs @@ -0,0 +1,30 @@ +using ImmichFrame.Core.Interfaces; +using ImmichFrame.Core.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace ImmichFrame.WebApi.Controllers +{ + [ApiController] + [Route("api/[controller]")] + [Authorize] + public class CustomWidgetController : ControllerBase + { + private readonly ILogger _logger; + private readonly ICustomWidgetService _customWidgetService; + + public CustomWidgetController(ILogger logger, ICustomWidgetService customWidgetService) + { + _logger = logger; + _customWidgetService = customWidgetService; + } + + [HttpGet(Name = "GetCustomWidgetData")] + public async Task> GetCustomWidgetData(string clientIdentifier = "") + { + var sanitizedClientIdentifier = clientIdentifier.SanitizeString(); + _logger.LogDebug("Custom widget data requested by '{sanitizedClientIdentifier}'", sanitizedClientIdentifier); + return await _customWidgetService.GetCustomWidgetData(); + } + } +} diff --git a/ImmichFrame.WebApi/Models/ClientSettingsDto.cs b/ImmichFrame.WebApi/Models/ClientSettingsDto.cs index ff0f9e75..25cf71f3 100644 --- a/ImmichFrame.WebApi/Models/ClientSettingsDto.cs +++ b/ImmichFrame.WebApi/Models/ClientSettingsDto.cs @@ -32,6 +32,8 @@ public class ClientSettingsDto public bool PlayAudio { get; set; } public string Layout { get; set; } public string Language { get; set; } + public bool ShowCustomWidget { get; set; } + public string CustomWidgetPosition { get; set; } public static ClientSettingsDto FromGeneralSettings(IGeneralSettings generalSettings) { @@ -64,6 +66,8 @@ public static ClientSettingsDto FromGeneralSettings(IGeneralSettings generalSett dto.PlayAudio = generalSettings.PlayAudio; dto.Layout = generalSettings.Layout; dto.Language = generalSettings.Language; + dto.ShowCustomWidget = generalSettings.ShowCustomWidget; + dto.CustomWidgetPosition = generalSettings.CustomWidgetPosition; return dto; } } \ No newline at end of file diff --git a/ImmichFrame.WebApi/Models/ServerSettings.cs b/ImmichFrame.WebApi/Models/ServerSettings.cs index 74d0fb8e..bef86db0 100644 --- a/ImmichFrame.WebApi/Models/ServerSettings.cs +++ b/ImmichFrame.WebApi/Models/ServerSettings.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using ImmichFrame.Core.Interfaces; +using ImmichFrame.Core.Models; using ImmichFrame.WebApi.Helpers; using YamlDotNet.Serialization; @@ -72,6 +73,9 @@ public class GeneralSettings : IGeneralSettings, IConfigSettable public string? WeatherLatLong { get; set; } = "40.7128,74.0060"; public string? Webhook { get; set; } public string? AuthenticationSecret { get; set; } + public bool ShowCustomWidget { get; set; } = false; + public List CustomWidgetSources { get; set; } = new(); + public string CustomWidgetPosition { get; set; } = "top-left"; public void Validate() { } } diff --git a/ImmichFrame.WebApi/Program.cs b/ImmichFrame.WebApi/Program.cs index f2d68244..89651a7a 100644 --- a/ImmichFrame.WebApi/Program.cs +++ b/ImmichFrame.WebApi/Program.cs @@ -59,6 +59,7 @@ _ _ __ ___ _ __ ___ _ ___| |__ | |_ _ __ __ _ _ __ ___ ___ // Register services builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHttpClient(); // Ensures IHttpClientFactory is available diff --git a/docker/Settings.example.json b/docker/Settings.example.json index a86a4d00..52145ace 100644 --- a/docker/Settings.example.json +++ b/docker/Settings.example.json @@ -35,7 +35,15 @@ "ImagePan": false, "ImageFill": false, "PlayAudio": false, - "Layout": "splitview" + "Layout": "splitview", + "ShowCustomWidget": false, + "CustomWidgetPosition": "top-left", + "CustomWidgetSources": [ + { + "Url": "http://example:3001/api/stats", + "RefreshMinutes": 10 + } + ] }, "Accounts": [ { diff --git a/docker/Settings.example.yml b/docker/Settings.example.yml index 173b31a5..fc92f9fa 100644 --- a/docker/Settings.example.yml +++ b/docker/Settings.example.yml @@ -34,6 +34,11 @@ General: ImageFill: false PlayAudio: false Layout: splitview + ShowCustomWidget: false + CustomWidgetPosition: 'top-left' + CustomWidgetSources: + - Url: 'http://example:3001/api/stats' + RefreshMinutes: 10 Accounts: - ImmichServerUrl: REQUIRED # Exactly one of ApiKey or ApiKeyFile must be set. diff --git a/immichFrame.Web/src/lib/components/elements/custom-widget.svelte b/immichFrame.Web/src/lib/components/elements/custom-widget.svelte new file mode 100644 index 00000000..381a3c44 --- /dev/null +++ b/immichFrame.Web/src/lib/components/elements/custom-widget.svelte @@ -0,0 +1,66 @@ + + +{#if widgetData.length > 0} + +{/if} 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 da7379d1..d0629161 100644 --- a/immichFrame.Web/src/lib/components/home-page/home-page.svelte +++ b/immichFrame.Web/src/lib/components/home-page/home-page.svelte @@ -11,6 +11,7 @@ import ErrorElement from '../elements/error-element.svelte'; import Clock from '../elements/clock.svelte'; import Appointments from '../elements/appointments.svelte'; + import CustomWidget from '../elements/custom-widget.svelte'; import LoadingElement from '../elements/LoadingElement.svelte'; import { page } from '$app/state'; import { ProgressBarLocation, ProgressBarStatus } from '../elements/progress-bar.types'; @@ -480,6 +481,10 @@ + {#if $configStore.showCustomWidget} + + {/if} + { await handleDone(false, true); diff --git a/immichFrame.Web/src/lib/immichFrameApi.ts b/immichFrame.Web/src/lib/immichFrameApi.ts index e8dae934..4e000212 100644 --- a/immichFrame.Web/src/lib/immichFrameApi.ts +++ b/immichFrame.Web/src/lib/immichFrameApi.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ /** * ImmichFrame.WebApi * 1.0 @@ -9,7 +8,7 @@ import * as Oazapfts from "@oazapfts/runtime"; import * as QS from "@oazapfts/runtime/query"; export const defaults: Oazapfts.Defaults = { headers: {}, - baseUrl: "/", + baseUrl: "/" }; const oazapfts = Oazapfts.runtime(defaults); export const servers = {}; @@ -177,6 +176,15 @@ export type ImageResponse = { photoDate: string | null; imageLocation: string | null; }; +export type CustomWidgetItem = { + label?: string | null; + value?: string | null; + secondary?: string | null; +}; +export type CustomWidgetData = { + title?: string | null; + items?: CustomWidgetItem[] | null; +}; export type IAppointment = { startTime?: string; duration?: string; @@ -214,6 +222,8 @@ export type ClientSettingsDto = { playAudio?: boolean; layout?: string | null; language?: string | null; + showCustomWidget?: boolean; + customWidgetPosition?: string | null; }; export type IWeather = { location?: string | null; @@ -297,6 +307,18 @@ export function getRandomImageAndInfo({ clientIdentifier }: { ...opts }); } +export function getCustomWidgetData({ clientIdentifier }: { + clientIdentifier?: string; +} = {}, opts?: Oazapfts.RequestOpts) { + return oazapfts.fetchJson<{ + status: 200; + data: CustomWidgetData[]; + }>(`/api/CustomWidget${QS.query(QS.explode({ + clientIdentifier + }))}`, { + ...opts + }); +} export function getAppointments({ clientIdentifier }: { clientIdentifier?: string; } = {}, opts?: Oazapfts.RequestOpts) { diff --git a/openApi/swagger.json b/openApi/swagger.json index cbc4d800..6d7cca0d 100644 --- a/openApi/swagger.json +++ b/openApi/swagger.json @@ -303,6 +303,55 @@ } } }, + "/api/CustomWidget": { + "get": { + "tags": [ + "CustomWidget" + ], + "operationId": "GetCustomWidgetData", + "parameters": [ + { + "name": "clientIdentifier", + "in": "query", + "schema": { + "type": "string", + "default": "" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CustomWidgetData" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CustomWidgetData" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CustomWidgetData" + } + } + } + } + } + } + } + }, "/api/Calendar": { "get": { "tags": [ @@ -942,6 +991,48 @@ "language": { "type": "string", "nullable": true + }, + "showCustomWidget": { + "type": "boolean" + }, + "customWidgetPosition": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "CustomWidgetData": { + "type": "object", + "properties": { + "title": { + "type": "string", + "nullable": true + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CustomWidgetItem" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "CustomWidgetItem": { + "type": "object", + "properties": { + "label": { + "type": "string", + "nullable": true + }, + "value": { + "type": "string", + "nullable": true + }, + "secondary": { + "type": "string", + "nullable": true } }, "additionalProperties": false