This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
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.
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.
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.
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.
Two projects, split so all Meural networking/business logic is reusable and UI-agnostic:
-
src/MeuralManager.Core(net10.0class 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 toApi/NetgearAuthenticator.cs, which implements Netgear Accounts' current Cognito-backed login (CUSTOM_AUTH, falling back toUSER_PASSWORD_AUTHfor 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 missingUser-Agentheader 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 sharedGetAllPagesAsync<T>helper.Api/ImmichApiClient.cs— talks to the user's own Immich server (<server>/api/, auth is just anx-api-keyheader): connection test (users/me), the photo query (search/metadatanewest-first, orsearch/smartfor free-text, both paged 100 at a time and filterable by date range/album/person), albums, people, and image bytes.GetBestImageAsync/DownloadForUploadAsyncpick what Meural can actually ingest: the original if it's a JPEG/PNG, otherwise Immich's convertedfullsizeJPEG, falling back topreview(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 byGET items/{id}and the POST/PUTgalleriesresponses).ImmichModels.csholds 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, unauthenticatedHttpClient(images live on a different CDN host, notapi.meural.com).Services/PlaylistService.cs— bulk playlist-membership operations (AddItemsToGalleryAsync,RemoveItemsFromGalleryAsync,UploadAndAddToGalleryAsync), same loop/progress/cancellation/politeness-delay shape asCleanupService's delete loops.Services/ImageDownloader.cs— one shared, unauthenticatedHttpClientused 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.Webkeys one of these per signed-in account.- Every long-running method takes an
IProgress<string>?(for status/log messages) and aCancellationToken— there is noConsole.*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 itemsmultipart upload,PUT items/{id}) were verified by reading the actualfetch()calls and request bodies indavemorin/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.jsonin github.com/immich-app/immich, fetchable per release tag).ImmichApiClientwas checked against the v3.2.2 spec (endpoints, request/response fields, thumbnailsizeenum, 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 viaDockerfile/docker-compose.yml:Program.cswires 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 throughapp.MapStaticAssets(), andApp.razorreferences each one as@Assets["css/app.css"]so it gets a content-fingerprinted URL (cached forever in a published build,no-cacheand 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.csreads the running version from the assembly'sInformationalVersion, which isdevin the.csprojand set to the release number by the Docker build (ARG VERSION,-p:InformationalVersion=$VERSION); the publish workflow passes the git tag without its "v", ordev-<sha>for a manual run. Nothing is bumped by hand at a release; the tag is the version.Components/Shared/WhatsNewBanner.razor(inMainLayout) 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 inServices/WhatsNew.cs(an account with noLastSeenVersionstarts at the running version, dev builds never announce). - Error, not found, reconnect.
App.razorrenders every page interactively except ones marked[ExcludeFromInteractiveRouting]: that'sPages/Error.razor(whereUseExceptionHandlersends crashes outside Development), which renders statically inLayout/PlainLayout.razorso it shows even when the circuit is what broke.Pages/NotFound.razoris the Router'sNotFoundPage, andProgram.csre-executes empty 4xx responses to/not-found(UseStatusCodePagesWithReExecute, at the top level: inside aUseWhenbranch 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 bywwwroot/js/reconnect.js, styled inapp.css) replaces Blazor's default reconnect box. - Release notes are
CHANGELOG.mdat the repository root (one## X.Y.Z - YYYY-MM-DDsection per version, newest first, with### New/### Improved/### Fixedbullets 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 byServices/ReleaseNotesContent.cswith Markdig for the/release-notespage. A GitHub release's body is that version's section, so the two can't drift. Services/MeuralSessionState.csis the per-circuit (one browser tab = one session) hub: holds the signed-inMeuralApiClient, 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-accountPlaylistCacheStore(<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.cspersists the login session client-side viaProtectedLocalStorage(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.cssplits UI preferences by scope behind oneStoredPreferencesrecord: 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 inProtectedLocalStoragebecause 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), soLoadAsynconly returns the account's toggles once signed in —MainLayoutloads 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/immichpage 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.MeuralSessionStatealso 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 sharedImmichApiClient(GetImmichClientAsync). Browsers never see the API key:Services/ImmichProxyRegistry.cs(singleton, token → client) plusServices/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 asImageCacheEndpoints. - Homepage widget.
Services/HomepageEndpoints.csservesGET /api/homepage/stats({ playlists, images, lastScan }, straight from the account's cache viaPlaylistCacheStore.GetSummaryAsync— threeCOUNT(*)/meta reads, no Meural call) for a gethomepage.devcustomapiwidget. Homepage polls with no session, so it sends a per-account token inX-API-Key; only the token's SHA-256 is stored (HomepageApiTokenHashin the settings table), and the endpoint finds the account by checking each<cache root>/*/meural-cache.dbfor 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.razorgates the whole app onMeuralSessionState.IsAuthenticated(showingComponents/Shared/Login.razor, which handles the password + MFA-challenge flow, otherwise) and renders a non-dismissable full-screen overlay whileSession.IsScanningis 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'spreviewrendition through the bytes overload ofImageNamingService.SuggestNameAsync, since an Immich photo can't be fetched from a plain URL. Uploads reuse the sharedCropDialogandPlaylistService.UploadAndAddToGalleryAsync, and a photo that fails to upload stays in the basket for a retry).- The upload basket is source-agnostic.
Services/UploadBasket.csholds everything that doesn't depend on where photos come from — the entries (BasketEntry, whoseSourceis 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 — aPrepareUploadAsync(turn one entry into a file, cropping it if the crop tool is on), an AI-image loader, and anonUploadedhook — and renders the state withUploadBasketPaneandUploadPreviewPane. Because the grid, basket pane and preview pane are sibling components, the model raisesChangedon 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,PreviewEntryis 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.UploadPreviewPaneshows 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-wraptakes all the height the pane has left (flex: 1 1 0) and theobject-fit: containimage 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 amax-heightback on.preview-image. - Unreadable saved keys. The API keys (Claude, OpenAI, Immich) are Data-Protection-encrypted in the settings table, and
TryUnprotectturns 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 oneFrameOrientationsrow (id:h,id:v) in the settings table and held in memory onMeuralSessionState.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.AskChoiceAsyncadds 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, whereBusy="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-slideCSS 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 withfilter: invert(1)on light surfaces.wwwroot/js/brand-motion.jsanimates it with one of three motions picked at random — swap, draw, scream, whose keyframes are inapp.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 aPlayOnLoadlogo appears, and in a loop whileBusy; it only adds aplay-*class to the svg, so Blazor re-renders don't interrupt a motion, and nothing moves underprefers-reduced-motion; the sidebar's nav icons are inline SVGs inNavMenu.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 viawwwroot/js/infinite-scroll.js's IntersectionObserver, rooted on the enclosing.panesince the grid scrolls inside one). The Playlists and Immich pages sharewwwroot/js/splitter.jsfor their resizable panes; a handle's persisted width is keyed bydata-pane-key(defaulting tolist/preview), andinitreturns whether the container existed yet so a page that renders its layout late can retry after a render. Don't name a component parameterAssets— it collides withComponentBase.Assetsin .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, followsprefers-color-scheme; Dark or Light force one as<html data-theme>), saved in the settings table (MeuralSessionState.Load/SaveThemeAsync) and applied bywwwroot/js/theme.json sign-in and on change. That script also keeps the last theme in the browser's localStorage so an inline script inApp.razorcan 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).
- 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
trueas an empty attribute and drops it forfalse, soaria-pressed="@x"never matches[aria-pressed="true"]. Writearia-pressed="@(x ? "true" : "false")"(same foraria-expanded,aria-checked). - Gotcha: a component callback passed as a plain
Func<...>(notEventCallback) doesn't re-render the parent; callStateHasChangedafter 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.