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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions ImmichFrame.Core/Interfaces/ICustomWidgetService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
using ImmichFrame.Core.Models;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Namespace is missing

public interface ICustomWidgetService
{
Task<List<CustomWidgetData>> GetCustomWidgetData();
}
7 changes: 6 additions & 1 deletion ImmichFrame.Core/Interfaces/IServerSettings.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace ImmichFrame.Core.Interfaces
using ImmichFrame.Core.Models;

namespace ImmichFrame.Core.Interfaces
{
public interface IServerSettings
{
Expand Down Expand Up @@ -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<CustomWidgetSourceConfig> CustomWidgetSources { get; }
public string CustomWidgetPosition { get; }

public void Validate();
}
Expand Down
14 changes: 14 additions & 0 deletions ImmichFrame.Core/Models/CustomWidgetData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace ImmichFrame.Core.Models;

public class CustomWidgetData
{
public string Title { get; set; } = string.Empty;
public List<CustomWidgetItem> 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; }
}
7 changes: 7 additions & 0 deletions ImmichFrame.Core/Models/CustomWidgetSourceConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace ImmichFrame.Core.Models;

public class CustomWidgetSourceConfig
{
public string Url { get; set; } = string.Empty;
public int RefreshMinutes { get; set; } = 10;
}
74 changes: 74 additions & 0 deletions ImmichFrame.Core/Services/CustomWidgetService.cs
Original file line number Diff line number Diff line change
@@ -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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Namespace is missing

public class CustomWidgetService : ICustomWidgetService
{
private readonly IGeneralSettings _settings;
private readonly ILogger<CustomWidgetService> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions());
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could you use the ApiCache here, see OpenWeatherMapService as reference


public CustomWidgetService(IGeneralSettings settings, ILogger<CustomWidgetService> logger, IHttpClientFactory httpClientFactory)
{
_settings = settings;
_logger = logger;
_httpClientFactory = httpClientFactory;
}

public async Task<List<CustomWidgetData>> GetCustomWidgetData()
{
var results = new List<CustomWidgetData>();

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<CustomWidgetData?> 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<CustomWidgetData>();
return data;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching custom widget data from '{Url}'", url);
return null;
}
}
}
30 changes: 30 additions & 0 deletions ImmichFrame.WebApi/Controllers/CustomWidgetController.cs
Original file line number Diff line number Diff line change
@@ -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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'd rename the controller to something like 'WidgetController' - Maybe in the Future there could be more Widgets maybe?

{
private readonly ILogger<AssetController> _logger;
private readonly ICustomWidgetService _customWidgetService;

public CustomWidgetController(ILogger<AssetController> logger, ICustomWidgetService customWidgetService)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Wrong Logger-Type (AssetController)

{
_logger = logger;
_customWidgetService = customWidgetService;
}

[HttpGet(Name = "GetCustomWidgetData")]
public async Task<List<CustomWidgetData>> GetCustomWidgetData(string clientIdentifier = "")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Naming here is ok

{
var sanitizedClientIdentifier = clientIdentifier.SanitizeString();
_logger.LogDebug("Custom widget data requested by '{sanitizedClientIdentifier}'", sanitizedClientIdentifier);
return await _customWidgetService.GetCustomWidgetData();
}
}
}
4 changes: 4 additions & 0 deletions ImmichFrame.WebApi/Models/ClientSettingsDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -64,6 +66,8 @@ public static ClientSettingsDto FromGeneralSettings(IGeneralSettings generalSett
dto.PlayAudio = generalSettings.PlayAudio;
dto.Layout = generalSettings.Layout;
dto.Language = generalSettings.Language;
dto.ShowCustomWidget = generalSettings.ShowCustomWidget;
dto.CustomWidgetPosition = generalSettings.CustomWidgetPosition;
return dto;
}
}
4 changes: 4 additions & 0 deletions ImmichFrame.WebApi/Models/ServerSettings.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using ImmichFrame.Core.Interfaces;
using ImmichFrame.Core.Models;
using ImmichFrame.WebApi.Helpers;
using YamlDotNet.Serialization;

Expand Down Expand Up @@ -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<CustomWidgetSourceConfig> CustomWidgetSources { get; set; } = new();
public string CustomWidgetPosition { get; set; } = "top-left";

public void Validate() { }
}
Expand Down
1 change: 1 addition & 0 deletions ImmichFrame.WebApi/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ _ _ __ ___ _ __ ___ _ ___| |__ | |_ _ __ __ _ _ __ ___ ___
// Register services
builder.Services.AddSingleton<IWeatherService, OpenWeatherMapService>();
builder.Services.AddSingleton<ICalendarService, IcalCalendarService>();
builder.Services.AddSingleton<ICustomWidgetService, CustomWidgetService>();
builder.Services.AddSingleton<IAssetAccountTracker, BloomFilterAssetAccountTracker>();
builder.Services.AddSingleton<IAccountSelectionStrategy, TotalAccountImagesSelectionStrategy>();
builder.Services.AddHttpClient(); // Ensures IHttpClientFactory is available
Expand Down
10 changes: 9 additions & 1 deletion docker/Settings.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
5 changes: 5 additions & 0 deletions docker/Settings.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
66 changes: 66 additions & 0 deletions immichFrame.Web/src/lib/components/elements/custom-widget.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<script lang="ts">
import * as api from '$lib/index';
import { onMount } from 'svelte';
import { configStore } from '$lib/stores/config.store';
import { clientIdentifierStore } from '$lib/stores/persist.store';

api.init();

let widgetData: api.CustomWidgetData[] = $state([]);

const positionClasses: Record<string, string> = {
'top-left': 'top-0 left-0',
'top-right': 'top-0 right-0',
'bottom-left': 'bottom-0 left-0',
'bottom-right': 'bottom-0 right-0'
};

let position = $derived(
positionClasses[$configStore.customWidgetPosition ?? 'top-left'] ?? positionClasses['top-left']
);

onMount(() => {
fetchWidgetData();
const interval = setInterval(() => fetchWidgetData(), 60 * 1000);

return () => {
clearInterval(interval);
};
});

async function fetchWidgetData() {
try {
let response = await api.getCustomWidgetData({
clientIdentifier: $clientIdentifierStore
});
if (response.status === 200) {
widgetData = response.data;
}
} catch (err) {
console.error('Error fetching custom widget data:', err);
}
}
</script>

{#if widgetData.length > 0}
<div
class="fixed {position} w-auto z-10 text-primary m-5 max-w-[20%] hidden lg:block md:min-w-[10%]"
>
{#each widgetData as widget}
<div class="bg-gray-600 bg-opacity-90 mb-2 rounded-md p-3">
{#if widget.title}
<p class="text-xs font-bold mb-1">{widget.title}</p>
{/if}
{#each widget.items ?? [] as item}
<div class="flex justify-between items-baseline gap-2 py-0.5">
<span class="text-xs opacity-80">{item.label}</span>
<span class="text-sm font-medium">{item.value}</span>
</div>
{#if item.secondary}
<p class="text-xs opacity-60 text-right -mt-0.5">{item.secondary}</p>
{/if}
{/each}
</div>
{/each}
</div>
{/if}
5 changes: 5 additions & 0 deletions immichFrame.Web/src/lib/components/home-page/home-page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -480,6 +481,10 @@

<Appointments />

{#if $configStore.showCustomWidget}
<CustomWidget />
{/if}

<OverlayControls
next={async () => {
await handleDone(false, true);
Expand Down
26 changes: 24 additions & 2 deletions immichFrame.Web/src/lib/immichFrameApi.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* ImmichFrame.WebApi
* 1.0
Expand All @@ -9,7 +8,7 @@ import * as Oazapfts from "@oazapfts/runtime";
import * as QS from "@oazapfts/runtime/query";
export const defaults: Oazapfts.Defaults<Oazapfts.CustomHeaders> = {
headers: {},
baseUrl: "/",
baseUrl: "/"
};
const oazapfts = Oazapfts.runtime(defaults);
export const servers = {};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Loading