Skip to content

Latest commit

 

History

History
79 lines (61 loc) · 23.5 KB

CLAUDE.md

File metadata and controls

79 lines (61 loc) · 23.5 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

MeuralManager is a Docker-hosted web app for cleaning up a Meural ("Canvas" digital art frame) cloud account. Meural doesn't delete uploaded images when you delete a playlist that contained them, so orphaned uploads silently pile up against the account's storage quota. This app finds:

  • uploaded images that aren't referenced by any playlist, and
  • playlists that aren't loaded on any Canvas frame,

and lets the user optionally back up the images (as a downloadable ZIP) before deleting the orphans from the Meural account. It also has full playlist management: browse playlists and their images (with thumbnails), create/rename/delete playlists, add existing uploads or newly-uploaded local images to a playlist, remove images from a playlist, and install a playlist onto a Canvas frame. An optional, off-by-default Immich integration lets the user browse their self-hosted Immich photo library (by date, album, or person) and upload photos straight into a Meural playlist.

Commands

dotnet build MeuralManager.slnx              # build everything
dotnet run --project src/MeuralManager.Web   # run the app (browse to the printed localhost URL, then sign in)
dotnet test tests/MeuralManager.Tests        # unit tests (pure logic only, no Meural calls)
docker compose up --build               # build and run the container (see docker-compose.yml)

There is no .sln file — this repo uses the newer .slnx XML solution format (MeuralManager.slnx). tests/MeuralManager.Tests (xUnit) covers the pure logic: orphan/unused detection, file naming, frame orientations and the crop suggestion, the What's new decision. There is no lint config.

Workflow

When the user gives feedback or requests changes in a back-and-forth (bug reports, tweaks, "also do X"), don't implement each one immediately as it comes in. Acknowledge it, queue it, and keep gathering — only start making changes once the user explicitly says "go" (or an equally unambiguous confirmation). This lets them batch up a full list of feedback before any code gets touched. Purely informational questions (no code change requested) can still be answered right away.

Git workflow

Never run git commit or git push unless the user explicitly asks for it in that turn. Finish the change and leave it uncommitted in the working tree; tell the user it's ready and wait for an explicit go-ahead before committing (and again before pushing — a commit approval does not imply a push approval, and approval from an earlier turn does not carry forward to later changes).

When the user does ask you to commit:

  • Write clean, specific commit messages describing why, not just what changed.

Architecture

Two projects, split so all Meural networking/business logic is reusable and UI-agnostic:

  • src/MeuralManager.Core (net10.0 class library) — everything that talks to Meural or touches disk, with no dependency on the web UI:

    • Api/MeuralApiClient.cs — the only class that makes Meural API calls (api.meural.com/v1, x-meural-api-version: 4). Delegates authentication to Api/NetgearAuthenticator.cs, which implements Netgear Accounts' current Cognito-backed login (CUSTOM_AUTH, falling back to USER_PASSWORD_AUTH for unmigrated accounts, then exchanging the Cognito session for a Meural OAuth token pair) — the same flow Meural's own Home Assistant integration uses (github.com/GuySie/ha-meural). A missing User-Agent header will get requests blocked by Netgear's WAF, so both classes always send one. Paginated list endpoints (user/items, user/galleries, user/devices, devices/{id}/galleries) all go through the shared GetAllPagesAsync<T> helper.
    • Api/ImmichApiClient.cs — talks to the user's own Immich server (<server>/api/, auth is just an x-api-key header): connection test (users/me), the photo query (search/metadata newest-first, or search/smart for free-text, both paged 100 at a time and filterable by date range/album/person), albums, people, and image bytes. GetBestImageAsync/DownloadForUploadAsync pick what Meural can actually ingest: the original if it's a JPEG/PNG, otherwise Immich's converted fullsize JPEG, falling back to preview (HEIC/RAW originals would otherwise be rejected). Each download goes in its own per-asset subfolder so two photos sharing a filename can't collide.
    • Models/ — records mapping the Meural API JSON: MeuralItem, MeuralGallery, MeuralDevice, MeuralPage<T> (paginated list envelope), MeuralItemEnvelope/GalleryEnvelope (single-object envelopes, used by GET items/{id} and the POST/PUT galleries responses). ImmichModels.cs holds the Immich equivalents (ImmichAsset, ImmichAlbum, ImmichPerson, ImmichAssetFilter, ImmichSettings).
    • Services/CleanupService.cs — pure set-difference logic (FindOrphanItems, FindUnusedGalleries) plus the delete loops (DeleteItemsAsync/DeleteGalleriesAsync), which impose a deliberate 1-second delay between deletes to avoid hammering the API.
    • Services/BackupService.cs — downloads item images to disk before deletion, using a separate, unauthenticated HttpClient (images live on a different CDN host, not api.meural.com).
    • Services/PlaylistService.cs — bulk playlist-membership operations (AddItemsToGalleryAsync, RemoveItemsFromGalleryAsync, UploadAndAddToGalleryAsync), same loop/progress/cancellation/politeness-delay shape as CleanupService's delete loops.
    • Services/ImageDownloader.cs — one shared, unauthenticated HttpClient used only for thumbnails (many short-lived requests over the app's lifetime, vs. BackupService's per-operation client for one-shot bulk downloads).
    • Services/FileNaming.cs — filename sanitization, image-extension guessing, and content-type guessing (for multipart upload), shared across the backup and upload paths.
    • Data/PlaylistCacheStore.cs — a local SQLite cache of playlists/items/devices/frame-assignments, so browsing doesn't have to hit the (slow) Meural API on every read. MeuralManager.Web keys one of these per signed-in account.
    • Every long-running method takes an IProgress<string>? (for status/log messages) and a CancellationToken — there is no Console.* or other I/O baked into Core, so the same methods drive both a UI log/toast and cancellation.
    • The Meural API is not publicly documented. Read/delete endpoints and the login flow were reverse-engineered from Meural's official Home Assistant integration. The playlist-CRUD endpoints (GET galleries/{id}/items, POST galleries, PUT galleries/{id}, POST/DELETE galleries/{galleryId}/items/{itemId}, POST items multipart upload, PUT items/{id}) were verified by reading the actual fetch() calls and request bodies in davemorin/meural-manager (github.com/davemorin/meural-manager), an open-source, working Meural web manager — not guessed. If a future endpoint is needed, check that repo (or a similar working client) before assuming a REST shape.
    • The Immich API, unlike Meural's, is officially documented — every server ships an OpenAPI spec (open-api/immich-openapi-specs.json in github.com/immich-app/immich, fetchable per release tag). ImmichApiClient was checked against the v3.2.2 spec (endpoints, request/response fields, thumbnail size enum, and the API-key permissions it needs: asset.read, asset.view, asset.download, album.read, person.read, user.read). Check the spec for the user's version before assuming a shape — don't work from memory. Immich's people list has no per-person photo count, so the People tab doesn't show one.
  • src/MeuralManager.Web (net10.0, Blazor Server) — the UI, packaged for Docker via Dockerfile/docker-compose.yml:

    • Program.cs wires up the Blazor Web App pipeline (interactive server render mode, no prerendering so JS interop/localStorage is available immediately), Data Protection key persistence (DATA_PROTECTION_KEYS_PATH), and the backup-download minimal API endpoint. Static files go through app.MapStaticAssets(), and App.razor references each one as @Assets["css/app.css"] so it gets a content-fingerprinted URL (cached forever in a published build, no-cache and served live from disk in Development) — a new script or stylesheet must be added there the same way, not as a plain path.
    • Version. Services/AppInfo.cs reads the running version from the assembly's InformationalVersion, which is dev in the .csproj and set to the release number by the Docker build (ARG VERSION, -p:InformationalVersion=$VERSION); the publish workflow passes the git tag without its "v", or dev-<sha> for a manual run. Nothing is bumped by hand at a release; the tag is the version. Components/Shared/WhatsNewBanner.razor (in MainLayout) tells each account once, after an update, "Meural Manager was updated to X", linking to the release notes, until it's closed; the rule is in Services/WhatsNew.cs (an account with no LastSeenVersion starts at the running version, dev builds never announce).
    • Error, not found, reconnect. App.razor renders every page interactively except ones marked [ExcludeFromInteractiveRouting]: that's Pages/Error.razor (where UseExceptionHandler sends crashes outside Development), which renders statically in Layout/PlainLayout.razor so it shows even when the circuit is what broke. Pages/NotFound.razor is the Router's NotFoundPage, and Program.cs re-executes empty 4xx responses to /not-found (UseStatusCodePagesWithReExecute, at the top level: inside a UseWhen branch the re-executed request is never routed and stays an empty 404). The minimal-API endpoints (API, image cache, Immich proxy, backups) opt out with .WithMetadata(new SkipStatusCodePages()), so they keep their plain 401/404s; a new endpoint needs the same. Layout/ReconnectModal.razor (from the .NET 10 template, driven by wwwroot/js/reconnect.js, styled in app.css) replaces Blazor's default reconnect box.
    • Release notes are CHANGELOG.md at the repository root (one ## X.Y.Z - YYYY-MM-DD section per version, newest first, with ### New / ### Improved / ### Fixed bullets written for users). It's embedded into the app at build time (see the .csproj; the Dockerfile copies it into the build context) and rendered by Services/ReleaseNotesContent.cs with Markdig for the /release-notes page. A GitHub release's body is that version's section, so the two can't drift.
    • Services/MeuralSessionState.cs is the per-circuit (one browser tab = one session) hub: holds the signed-in MeuralApiClient, owns the one shared full-account "scan" (items/galleries/devices/frame-assignments) that the Dashboard, Playlists, Orphan Uploads, and Unused Playlists pages all read from instead of each re-fetching independently, and backs that scan with a per-account PlaylistCacheStore (<cache root>/<email>/meural-cache.db) so it survives a container restart. Every mutation (create/rename/delete/add/remove/upload/install-on-frame) patches this cache in place rather than forcing a rescan.
    • Services/WebSessionStore.cs persists the login session client-side via ProtectedLocalStorage (encrypted with ASP.NET Core Data Protection) — it has to live in the browser, since it's what tells the app which account (and so which DB) to open. Services/UserPreferencesStore.cs splits UI preferences by scope behind one StoredPreferences record: the feature toggles (Picture of the Moment, crop, remote control, Immich) are per-account rows in the SQLite settings table (MeuralSessionState.Load/SaveFeatureTogglesAsync, so they follow the account across browsers and survive clearing site data), while the Playlists and Immich splitter widths stay in ProtectedLocalStorage because they depend on one browser's screen. Toggles a previous version stored in the browser are migrated into the DB on first load after sign-in (a value the account already saved always wins), so LoadAsync only returns the account's toggles once signed in — MainLayout loads them on sign-in, not before.
    • Immich plumbing. Whether the integration is on is a per-account toggle (UserPreferencesStore.ImmichEnabled, default off — most users don't run Immich, so the nav entry, settings fields, and /immich page are all hidden/redirected until they opt in). The server URL and API key are per-account, stored in the same SQLite settings table as the AI settings with the key Data-Protection-encrypted (MeuralSessionState.Load/SaveImmichSettingsAsync); turning the toggle off keeps them. MeuralSessionState also tracks which Immich photo ids have already been uploaded from here (Load/AddImmichUploadedAssetIdsAsync, so the grid can badge them and warn before a re-upload) and hands out the shared ImmichApiClient (GetImmichClientAsync). Browsers never see the API key: Services/ImmichProxyRegistry.cs (singleton, token → client) plus Services/ImmichEndpoints.cs (/immich-proxy/{token}/asset|image|person/...) stream thumbnails and crop-dialog images from Immich server-side. The token is random, generated once per account and kept in the settings table (stable, so the browser's HTTP cache keeps working) — a capability URL, the same trust model as ImageCacheEndpoints.
    • Homepage widget. Services/HomepageEndpoints.cs serves GET /api/homepage/stats ({ playlists, images, lastScan }, straight from the account's cache via PlaylistCacheStore.GetSummaryAsync — three COUNT(*)/meta reads, no Meural call) for a gethomepage.dev customapi widget. Homepage polls with no session, so it sends a per-account token in X-API-Key; only the token's SHA-256 is stored (HomepageApiTokenHash in the settings table), and the endpoint finds the account by checking each <cache root>/*/meural-cache.db for a matching hash. Unlike the outgoing API keys this one is never Data-Protection-encrypted — it's only ever verified, never read back — so Settings shows it once, on generation.
    • Services/BackupArchiveService.cs + BackupCleanupService.cs — backups are staged into a temp directory, zipped, and served once via /backups/{id}/download; a background service sweeps old zips.
    • Components/Layout/MainLayout.razor gates the whole app on MeuralSessionState.IsAuthenticated (showing Components/Shared/Login.razor, which handles the password + MFA-challenge flow, otherwise) and renders a non-dismissable full-screen overlay while Session.IsScanning is true so navigation can't interrupt a scan.
    • Components/Pages/ — Dashboard (scan trigger + status, tool shortcuts, "Picture of the Moment"), Playlists (resizable three-pane browser: sortable playlist table, item thumbnail grid, image preview), OrphanUploads, UnusedGalleries, Settings (also where each frame's orientation, 16:9 or 9:16, is set — see below), Immich (three tabs — Photos timeline grouped by day with date/text filters, Albums, People — all showing the same paged photo grid, as the first of three resizable panes: photos | upload basket | preview. A plain click on a photo previews it; the ✓ circle or shift-click adds it to the basket. The basket is the selection — it persists across tabs, each entry has an editable name (defaults to the filename, and is what the Meural item gets called since the upload names items after the file) with per-entry Claude/ChatGPT suggest buttons plus "AI rename all", and the target playlist picker and Upload button live at its bottom, along with a "Show what's already in this playlist" checkbox that lists the playlist's existing images (any source, straight from the scan cache — no Meural call) read-only below the upload entries; clicking one shows it in the preview pane. AI naming sends Immich's preview rendition through the bytes overload of ImageNamingService.SuggestNameAsync, since an Immich photo can't be fetched from a plain URL. Uploads reuse the shared CropDialog and PlaylistService.UploadAndAddToGalleryAsync, and a photo that fails to upload stays in the basket for a retry).
    • The upload basket is source-agnostic. Services/UploadBasket.cs holds everything that doesn't depend on where photos come from — the entries (BasketEntry, whose Source is the page's own photo object), chosen names, the target playlist, the "already in this playlist" list, AI naming, and the upload itself (UploadAsync: crop-format hint, upload, saving originals for Revert, refreshing the playlist, removing what made it). A page supplies only the source-specific parts as callbacks — a PrepareUploadAsync (turn one entry into a file, cropping it if the crop tool is on), an AI-image loader, and an onUploaded hook — and renders the state with UploadBasketPane and UploadPreviewPane. Because the grid, basket pane and preview pane are sibling components, the model raises Changed on every mutation and the page re-renders them all; don't mutate the basket without going through its methods. Immich uses it today; a new photo source should too rather than copying the basket.
    • A/B comparison lives in the same shared pieces. "Compare" in the preview pane pins the previewed photo as A (UploadBasket.CompareEntry); while it's set, PreviewEntry is B, replaced by every photo clicked next. "Keep A" / "Keep B" (KeepCompared) puts the winner in the basket, takes the loser out if it was in, and keeps the winner pinned as A for the next challenger; "Done" (EndCompare) returns to the single preview. UploadPreviewPane shows the pair side by side — stacked while the pane is taller than wide, next to each other once it's wider, via a container query — or in flip mode, both in the same spot with a click or Space switching between them. The photo grid marks the two tiles with A and B (ImmichPhotoGrid.Comparing).
    • Preview panes (Playlists, All Pictures, Immich) share one layout in app.css: .preview-image-wrap takes all the height the pane has left (flex: 1 1 0) and the object-fit: contain image scales up or down to fill it, with .preview-info (name, details, actions) pinned below — so resizing the pane resizes the picture whatever its orientation. Don't put a max-height back on .preview-image.
    • Unreadable saved keys. The API keys (Claude, OpenAI, Immich) are Data-Protection-encrypted in the settings table, and TryUnprotect turns a value it can't decrypt into "not set" so the app keeps working — which makes a key look like it vanished. MeuralSessionState.GetUnreadableSecretsAsync() reports which ones are still stored but undecryptable; Settings shows a "can't be read — enter it again" message for them, and the Immich page says so instead of "isn't set up yet".
    • Crop format follows the frames. Each frame's orientation (FrameOrientation, Vertical by default) is stored per account as one FrameOrientations row (id:h,id:v) in the settings table and held in memory on MeuralSessionState.FrameOrientations. MeuralSessionState.GetCropSuggestion(galleryId) looks at every frame the playlist is installed on and returns horizontal only if all of them are set to horizontal — a mix, an unset frame, or a playlist on no frame all come out vertical (9:16). Every place that opens the crop dialog for a playlist (Playlists upload, the Playlists preview's crop-and-replace buttons, Immich upload) uses it as the starting aspect; the dialog still lets the user switch.
    • Components/Shared/ — small reusable pieces used across pages: ConfirmDialog/PromptDialog (imperative modal dialogs awaited from code; ConfirmDialog.AskChoiceAsync adds a second non-cancel option for questions like "skip those / upload again"), AddExistingItemsModal (searchable thumbnail picker), ActivityLogPanel (starts collapsed everywhere and only the user's click opens it — never open it from code), Toast (transient success/error banner), CropDialog (16:9/9:16 frame-overlay crop), BrandMark (the picture-frame-and-M logo, an inline SVG shared by the sidebar, the login screen, the Dashboard header and the scan overlay, where Busy="true" makes it stand in for a spinner. Compact="true", used by the sidebar, leaves out the mat line and enlarges the M so it reads at a small size; the motions' slide distance follows via the --bm-slide CSS variable. The M is Meural's own mark, a filled shape and not a stroke, which is why the README and the sign-in screen carry a "not affiliated" line, and why the "draw" motion paints it through a mask (a wide pen line along the shape's middle, starting in the loop at the bottom); the app's accent is Meural's bronze, #bc8f68, in both themes. The svg also holds a small grayscale painting of The Scream inside a mask, hidden until its motion plays and flipped with filter: invert(1) on light surfaces. wwwroot/js/brand-motion.js animates it with one of three motions picked at random — swap, draw, scream, whose keyframes are in app.css — once on hover/focus of the login or Dashboard logo (the sidebar's is deliberately still — the user found motion there too much), once when a PlayOnLoad logo appears, and in a loop while Busy; it only adds a play-* class to the svg, so Blazor re-renders don't interrupt a motion, and nothing moves under prefers-reduced-motion; the sidebar's nav icons are inline SVGs in NavMenu.razor, all 24px outline/1.8 stroke to stay consistent), ImmichPhotoGrid (day-grouped photo grid — click previews, the ✓ circle or shift-click selects — that loads more via wwwroot/js/infinite-scroll.js's IntersectionObserver, rooted on the enclosing .pane since the grid scrolls inside one). The Playlists and Immich pages share wwwroot/js/splitter.js for their resizable panes; a handle's persisted width is keyed by data-pane-key (defaulting to list/preview), and init returns whether the container existed yet so a page that renders its layout late can retry after a render. Don't name a component parameter Assets — it collides with ComponentBase.Assets in .NET 10.
    • Hand-written CSS design system in wwwroot/css/app.css — no component library. The theme is picked per account in Settings (AppTheme: System, the default, follows prefers-color-scheme; Dark or Light force one as <html data-theme>), saved in the settings table (MeuralSessionState.Load/SaveThemeAsync) and applied by wwwroot/js/theme.js on sign-in and on change. That script also keeps the last theme in the browser's localStorage so an inline script in App.razor can apply it before first paint. Every dark rule therefore exists twice, under @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) … } and under :root[data-theme="dark"], and the two must stay identical (same for light-only rules).

Conventions

  • Verify external APIs, don't work from memory. Before coding against any library or service (Meural, Immich, Claude, OpenAI, a NuGet package), check its current official docs, spec or a working client. Model IDs and SDK shapes change.
  • Dialogs never close on a click outside them (a stray click shouldn't throw away what was typed or a crop that was set up); Cancel, or Escape where offered, does. This applies to every modal: ConfirmDialog, PromptDialog, CropDialog, AddExistingItemsModal.
  • Gotcha: Blazor renders a bool attribute value true as an empty attribute and drops it for false, so aria-pressed="@x" never matches [aria-pressed="true"]. Write aria-pressed="@(x ? "true" : "false")" (same for aria-expanded, aria-checked).
  • Gotcha: a component callback passed as a plain Func<...> (not EventCallback) doesn't re-render the parent; call StateHasChanged after it changes state.
  • Gotcha: table cells are never display: flex (it breaks the row lines); put a <div> with the flex layout inside the cell.