feat(immich): per-album scheduled/memory dispatch + template tooling

Dispatch: honor {kind}_collection_mode on TrackingConfig — "per_collection"
fans out one event per album; "combined" pools assets as before. Extract
build_immich_dispatch_events shared by cron and test paths.

Assets: collect_scheduled_assets attaches album_name/album_url/album_public_url
to each asset so combined-mode templates can attribute rows to their source
album. Default scheduled_assets templates render a multi-album header with
inline album list and per-row album link; memory_mode follows the same pattern.

UI: "Reset to default" buttons on notification and command template slots
(per-slot and whole-template), backed by new GET /*-template-configs/defaults
endpoints. tracking-configs "Preview template" now opens an inline preview
modal with locale tabs instead of navigating away; Edit button deep-links
with ?edit_slot=<name> so the destination auto-opens the config and scrolls
to the slot. Reset confirmations use ConfirmModal instead of window.confirm.

Fixes:
* NotificationDispatcher._session_ctx infinite recursion when no shared
  aiohttp.ClientSession was passed — broke test dispatch for periodic/
  scheduled/memory (cron path was unaffected).
* telegram-bots /chats/{id}/test now resolves chat.language_override /
  language_code instead of using the raw ?locale query param, matching
  the resolution the tracker-target test endpoint already used.
* scheduled_assets default template no longer emits a blank line between
  header and the first asset when the multi-album branch is taken.
This commit is contained in:
2026-04-24 19:15:54 +03:00
parent be15463fd2
commit b61394f057
40 changed files with 1235 additions and 224 deletions
+22 -13
View File
@@ -1,5 +1,12 @@
import type { ProviderDescriptor } from './types';
/**
* Today's date in ISO (YYYY-MM-DD) — used as the default for
* `periodic_start_date` so new configs anchor to "today" rather than a
* hardcoded date that gets further into the past on every release.
*/
const todayIso = (): string => new Date().toISOString().slice(0, 10);
export const immichDescriptor: ProviderDescriptor = {
type: 'immich',
defaultName: 'Immich',
@@ -58,17 +65,17 @@ export const immichDescriptor: ProviderDescriptor = {
key: 'periodic', legend: 'trackingConfig.periodicSummary', legendHint: 'hints.periodicSummary',
enabledField: 'periodic_enabled', enabledDefault: false,
fields: [
{ key: 'periodic_interval_days', label: 'trackingConfig.intervalDays', type: 'number', min: 1, defaultValue: 1 },
{ key: 'periodic_start_date', label: 'trackingConfig.startDate', type: 'number', defaultValue: '2025-01-01' }, // rendered as date input
{ key: 'periodic_times', label: 'trackingConfig.times', type: 'number', defaultValue: '12:00' }, // rendered as text input
{ key: 'periodic_interval_days', label: 'trackingConfig.intervalDays', type: 'number', min: 1, defaultValue: 1, hint: 'hints.intervalDays' },
{ key: 'periodic_start_date', label: 'trackingConfig.startDate', type: 'date', defaultValue: todayIso, hint: 'hints.periodicStartDate' },
{ key: 'periodic_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '12:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
],
},
{
key: 'scheduled', legend: 'trackingConfig.scheduledAssets', legendHint: 'hints.scheduledAssets',
enabledField: 'scheduled_enabled', enabledDefault: false,
fields: [
{ key: 'scheduled_times', label: 'trackingConfig.times', type: 'number', defaultValue: '09:00' },
{ key: 'scheduled_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'per_collection' },
{ key: 'scheduled_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
{ key: 'scheduled_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'per_collection', hint: 'hints.scheduledAlbumMode' },
{ key: 'scheduled_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10, hint: 'hints.maxAssets' },
{ key: 'scheduled_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' },
{ key: 'scheduled_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0, hint: 'hints.minRating' },
@@ -79,21 +86,21 @@ export const immichDescriptor: ProviderDescriptor = {
key: 'memory', legend: 'trackingConfig.memoryMode', legendHint: 'hints.memoryMode',
enabledField: 'memory_enabled', enabledDefault: false,
fields: [
{ key: 'memory_times', label: 'trackingConfig.times', type: 'number', defaultValue: '09:00' },
{ key: 'memory_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'combined' },
{ key: 'memory_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10 },
{ key: 'memory_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
{ key: 'memory_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'combined', hint: 'hints.memoryAlbumMode' },
{ key: 'memory_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10, hint: 'hints.maxAssets' },
{ key: 'memory_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' },
{ key: 'memory_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0 },
{ key: 'memory_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0, hint: 'hints.minRating' },
{ key: 'memory_favorite_only', label: 'trackingConfig.favoritesOnly', type: 'toggle', defaultValue: false, hint: 'hints.favoritesOnly' },
{ key: 'memory_source', label: 'trackingConfig.memorySource', type: 'grid-select', gridItems: 'memorySourceItems', gridColumns: 2, defaultValue: 'albums' },
{ key: 'memory_source', label: 'trackingConfig.memorySource', type: 'grid-select', gridItems: 'memorySourceItems', gridColumns: 2, defaultValue: 'albums', hint: 'hints.memorySource' },
],
},
{
key: 'quietHours', legend: 'trackingConfig.quietHours', legendHint: 'hints.quietHours',
enabledField: 'quiet_hours_enabled', enabledDefault: false,
fields: [
{ key: 'quiet_hours_start', label: 'trackingConfig.quietHoursStart', type: 'number', defaultValue: '22:00' },
{ key: 'quiet_hours_end', label: 'trackingConfig.quietHoursEnd', type: 'number', defaultValue: '07:00' },
{ key: 'quiet_hours_start', label: 'trackingConfig.quietHoursStart', type: 'time', defaultValue: '22:00' },
{ key: 'quiet_hours_end', label: 'trackingConfig.quietHoursEnd', type: 'time', defaultValue: '07:00' },
],
},
],
@@ -114,7 +121,9 @@ export const immichDescriptor: ProviderDescriptor = {
const warnings: { id: string; name: string; issue: string }[] = [];
// Run shared-link checks in parallel with a concurrency cap so a large
// album set doesn't stall the save button for seconds.
// album set doesn't stall the save button for seconds. Cap of 6 keeps
// the save dialog responsive for users with 50+ albums while staying
// well under typical Immich per-IP rate limits.
const CONCURRENCY = 6;
async function checkOne(albumId: string): Promise<void> {
try {
+5 -2
View File
@@ -47,17 +47,20 @@ export function allProviderTypes(): string[] {
*/
export function buildTrackingFormDefaults(): Record<string, any> {
const defaults: Record<string, any> = {};
// `defaultValue` may be a function (for time-sensitive defaults like
// today's date) so the computed value is fresh each time the form resets.
const resolve = (v: unknown): unknown => (typeof v === 'function' ? (v as () => unknown)() : v);
for (const desc of REGISTRY.values()) {
for (const field of desc.eventFields) {
defaults[field.key] = field.default;
}
for (const extra of desc.extraTrackingFields ?? []) {
defaults[extra.key] = extra.defaultValue ?? '';
defaults[extra.key] = resolve(extra.defaultValue) ?? '';
}
for (const section of desc.featureSections ?? []) {
defaults[section.enabledField] = section.enabledDefault;
for (const f of section.fields) {
defaults[f.key] = f.defaultValue ?? '';
defaults[f.key] = resolve(f.defaultValue) ?? '';
}
for (const cb of section.checkboxes ?? []) {
defaults[cb.key] = cb.default;
+19 -2
View File
@@ -60,14 +60,31 @@ export interface EventTrackingField {
export interface ExtraTrackingField {
key: string;
label: string;
type: 'number' | 'grid-select' | 'toggle';
/**
* Control kind:
* - `number` — numeric spinner
* - `grid-select` — icon-grid chooser (requires `gridItems`)
* - `toggle` — on/off switch
* - `date` — HTML date picker (YYYY-MM-DD)
* - `time` — HTML time picker (HH:MM)
* - `time-list` — comma-separated HH:MM list, validated on blur
*/
type: 'number' | 'grid-select' | 'toggle' | 'date' | 'time' | 'time-list';
/** Grid-select item source function name from grid-items.ts. */
gridItems?: string;
gridColumns?: number;
hint?: string;
/** Inline helper text rendered under the input (not a tooltip). */
inlineHelp?: string;
min?: number;
max?: number;
defaultValue?: string | number | boolean;
/** For time-list: show live validation + auto-normalize on blur. */
validateFormat?: boolean;
/**
* Default value. Can be a function for dynamic values (e.g. today's date)
* evaluated each time the form is reset.
*/
defaultValue?: string | number | boolean | (() => string | number | boolean);
}
/** A feature section like periodic summary, scheduled assets, memory mode. */