# SmartCovers - AI Agent Instructions ## Project Overview **Description**: Jellyfin plugin that provides fallback cover-image extraction for books, audiobooks, comics, magazines, and music libraries. Works alongside built-in providers as a safety net — when they fail to find a cover or crash on mislabeled embedded art, SmartCovers steps in. As a final fallback, searches Open Library and Google Books for cover images automatically. **Architecture Pattern**: Monolith - single deployable unit (Jellyfin plugin DLL) **Visibility**: Public repository ### Repository - **URL**: https://github.com/GeiserX/smart-covers - **Platform**: GitHub - **Plugin GUID**: `82eef869-3f18-4678-968d-06efc10b60cf` - **Previous names**: `jellyfin-plugin-book-cover` / "Book Cover" (v5.0.0.0), `jelly-covers` / "Jelly Covers" (v6.0.0.0). No backward-compatibility code remains; users on old names must re-enable the provider. ## Technology Stack ### Languages - C# (.NET 9.0) - HTML / JavaScript (config page — vanilla JS, no framework) ### Frameworks & Libraries - Jellyfin Plugin API (10.11+): `BasePlugin`, `IDynamicImageProvider`, `IHasWebPages`, `IPluginServiceRegistrator` - System.IO.Compression (EPUB ZIP archive handling) - SharpCompress (CBZ/CBR comic archives — content-sniffed ZIP/RAR reading; **merged into SmartCovers.dll by CI via ILRepack** — Jellyfin 10.11 ships no SharpCompress, so an external reference fails the plugin scan) - System.Net.Http (online cover fetching) - System.Text.Json (Open Library / Google Books API parsing) - System.Diagnostics.Process (ffmpeg shell-out for audio art) - PDFtoImage + SkiaSharp (in-process PDF first-page rendering via the bundled PDFium native library) ### External Binaries | Binary | Required For | Discovery | |--------|-------------|-----------| | `ffmpeg` | Audio covers | `/usr/lib/jellyfin-ffmpeg/ffmpeg` first, then PATH — cached | Optional. If absent, audio art extraction is silently skipped (warning logged once). PDF rendering uses no external binary — it runs in-process via the bundled PDFium native library. ## Architecture ``` Plugin.cs Entry point, IHasWebPages (config UI, sidebar entry) ├── Configuration/ │ ├── PluginConfiguration.cs Settings: DPI, JPEG quality, timeout, online fetch toggle │ └── configPage.html Admin UI — Jellyfin emby-* components, per-library toggle ├── CoverImageProvider.cs IDynamicImageProvider: PDF, EPUB, CBZ/CBR, MOBI, audio, folder, sidecar ├── BookIdentity.cs Which folder names the book (skips CD1/Disco 3/" CD 4") ├── MobiCoverExtractor.cs MOBI/AZW cover from the PalmDB image records (EXTH tag 201) ├── NaturalStringComparer.cs Digit-aware ordering (page-2 before page-10) for comic pages ├── CoverStatusController.cs REST API: GET /SmartCovers/Status ├── OnlineCoverFetcher.cs Open Library + Google Books (last resort): ISBN, fields, free text ├── BookNameParser.cs Anna's Archive names, ISBN checksums, author lists and tags ├── EpubOpf.cs EPUB package document: title, creator, ISBNs ├── GeneratedCover.cs Drawn title+author cover (SkiaSharp locals only, see the GetTypes() trap) ├── CoverOutcomeLog.cs Per-item outcome the provider leaves for the scheduled task ├── LibraryContext.cs Library content type and physical root of an item ├── RefreshMissingCoversTask.cs Scheduled task: retry items that still have no cover └── PluginServiceRegistrar.cs Registers CoverImageProvider as singleton ``` ### Scheduled Task — "Refresh items missing a cover" Jellyfin runs image providers once, when an item is new, and never returns to a miss. Anything scanned before the plugin was installed — or while a dependency failed to load, or while an online catalogue was rate-limited — stays blank forever however well the plugin works afterwards. The task walks `Book`, `AudioBook` and single-book `Folder` items that have no primary image, in libraries where SmartCovers is an enabled image fetcher, and refreshes each one's images (`ImageRefreshMode.FullRefresh`, metadata untouched, existing images kept). Whether SmartCovers is enabled for an item is answered by `IProviderManager.GetImageProviders(item, options)` rather than by reading `LibraryOptions` — that resolves the library's `ImageFetchers` and its fall back to the global metadata options exactly as a real refresh would, so there is no second copy of the rule to drift. Default trigger is daily at 04:00; it can also be run by hand from Dashboard -> Scheduled Tasks. Discovery is automatic: Jellyfin finds it via `GetExports<IScheduledTask>`, so it is NOT registered in `PluginServiceRegistrar` (registering it there would build a second instance). ### Extraction Pipeline For each `Book` or `AudioBook` item without a cover, Jellyfin calls `CoverImageProvider.GetImage()`. Methods tried in order: 1. **EPUB** — Opens ZIP archive. 3-tier search: by filename (`cover`, `portada`, `front`, `frontcover`, `book_cover`), by path (any image with `cover` in archive path), by size (largest image >5 KB). 2. **CBZ/CBR** — Opens via SharpCompress with content sniffing (mislabeled archives still work; RAR4/RAR5/solid supported). Explicit cover-named entry wins, else first page in natural sort order (`page-2` before `page-10`). Junk (`__MACOSX/`, dot-files), tiny images (<1 KB) skipped; every candidate verified by magic bytes. 3. **PDF** — Renders page 1 in-process via PDFtoImage (PDFium) + SkiaSharp, encoded as JPEG. DPI and JPEG quality configurable. Skipped cleanly if the bundled PDFium native cannot load on the host. 4. **Audio file** — `ffmpeg -vcodec copy` raw-copies embedded art stream. Format detected via magic bytes (JPEG, PNG, GIF, BMP, WebP), stripping leading null padding. 5. **Audio sidecar** — Checks for `cover.jpg`, `folder.jpg`, `front.jpg`, `poster.jpg`, `thumb.jpg` next to the file. 6. **Folder audiobook** — Sidecar images in directory, then embedded art from first audio file. 7. **Folder poster** — a track takes the Primary image Jellyfin already holds for its book folder (Jellyfin never hands it down). 8. **Online fallback** — ISBN (name or EPUB OPF) → Open Library fields → Open Library free text (author-checked) → Google Books, per reading: (title, author), (title), swapped, main title. 9. **Drawn cover** (`EnableGeneratedCovers`, off by default) — `GeneratedCover.cs`, only after a definite miss, never after a network error. Each method returns a `DynamicImageResponse` with a `MemoryStream`. One image at a time, typically under 1 MB. ### Why Raw Stream Copy Jellyfin's built-in Image Extractor uses ffmpeg to decode embedded artwork. This fails when the codec tag doesn't match the actual data — common in MP3 files where JPEG art is tagged as PNG in ID3. SmartCovers uses `-vcodec copy` (no decoding) and identifies format from magic bytes. ### Online Cover Fetching `OnlineCoverFetcher.ParseBookInfo()` cleans audiobook filenames — strips format tags `(Mp3)`, locale tags `[Castellano]`, Audible codes, year suffixes, series indicators. Extracts author from `(year, author)` pattern. Splits on last ` - ` for `Title - Author`. Search order: Open Library (title + author) → Google Books (highest resolution) → Open Library (title only). Static `HttpClient` with `SocketsHttpHandler`, 15s timeout. Images <1000 bytes rejected as placeholders; `CoverImageCheck` rejects any image wider than 1.5x its height and Google's "image not available" picture (difference hash). Google Books results must name the book's author, like Open Library free text. No API key required; an optional Google Books key avoids its shared quota. ## Configuration Editable via **Dashboard → SmartCovers** (sidebar entry via `EnableInMainMenu = true`). | Property | Type | Default | Description | |----------|------|---------|-------------| | `Dpi` | `int` | `150` | PDF rendering resolution | | `JpegQuality` | `int` | `85` | PDF JPEG output quality (1–100). Honored via `SKBitmap.Encode` during PDF rendering. | | `TimeoutSeconds` | `int` | `30` | Max time per PDF render / ffmpeg invocation | | `EnableOnlineCoverFetch` | `bool` | `true` | Online fallback via Open Library and Google Books | | `GoogleBooksApiKey` | `string` | `""` | Optional; keyless Google Books often answers 429 for every request (shared daily quota used up) | | `EnableGeneratedCovers` | `bool` | `false` | Draw a plain title and author cover when nothing real exists; marked so a real cover replaces it | ### Per-Library Toggle Config page queries `Library/VirtualFolders`, shows each library's status. Enable/Disable buttons update `ImageFetchers` via `POST Library/VirtualFolders/LibraryOptions`. Refresh Images button triggers metadata rescan. ### API | Method | Path | Auth | Returns | |--------|------|------|---------| | `GET` | `/SmartCovers/Status` | Admin (`RequiresElevation`) | `{ PdfRenderingAvailable, ffmpegAvailable, onlineCoverFetchEnabled }` — `PdfRenderingAvailable` is a bool reporting whether the bundled PDFium native loaded | ## Development Guidelines ### Build ```bash dotnet build SmartCovers/SmartCovers.csproj -c Release # Output: SmartCovers/bin/Release/net9.0/SmartCovers.dll ``` ### Deploy Copy DLL to `<jellyfin-config>/plugins/SmartCovers_<version>/` and restart Jellyfin. Or install from plugin catalog: ``` https://raw.githubusercontent.com/GeiserX/smart-covers/main/manifest.json ``` ### CI/CD Two workflows, deliberately separate: 1. **Build & release** (`.github/workflows/build.yml`) — on push to `main` and on PRs. Builds, tests, ILRepack-merges SharpCompress into `SmartCovers.dll`, packages the zip. On a version bump (new tag) it also creates the GitHub Release and prints the zip's MD5 to the job summary. It does **NOT** touch `manifest.json` or GitHub Pages. 2. **Docs and catalog** (`.github/workflows/docs.yml`) — on any push to `main` that changes `manifest.json` (or via manual `workflow_dispatch`). Builds the documentation site and publishes it together with the committed `manifest.json` to GitHub Pages. Retryable and independent of releases. **The catalog users add is the GitHub Pages URL** (`https://geiserx.github.io/smart-covers/manifest.json`), which serves the **committed** `manifest.json` — the single source of truth, with full version history. (The raw `raw.githubusercontent.com/.../main/manifest.json` URL serves the same file and also works.) Release process (manifest is edited by hand — never auto-committed): 1. Bump `<AssemblyVersion>` + `<FileVersion>` in `SmartCovers.csproj` AND `version` in `build.yaml` (they must match; tags are `v7.3.2.0` format). Merge to `main` → build.yml releases + prints the MD5. 2. Prepend the new version entry to `manifest.json` (real checksum from the release asset / the job summary), commit, push to `main`. That push triggers docs.yml → the catalog updates within a minute. If the deploy hits a transient "try again later", just re-run docs.yml (or `workflow_dispatch` it) — no new release needed. ### Config Page - Jellyfin custom elements: `emby-input`, `emby-button`, `emby-select`, `emby-checkbox` - Standard CSS classes only (`inputContainer`, `selectContainer`, etc.) — no custom CSS - All logic in `SmartCoversConfig` namespace object - Embedded resource — changes require DLL rebuild ## Boundaries ### Always (do without asking) - Read any file in the project - Modify source files in `SmartCovers/` - Run build commands - Fix compiler warnings or errors - Update documentation and README ### Ask First - Add NuGet dependencies (affects DLL size and compatibility) - Change the plugin GUID (breaks update path for existing users) - Modify the CI/CD workflow - Change supported item types (`Supports()` method) - Add new API endpoints - Restart Jellyfin — always confirm before restarting, as it interrupts active streams for all users ### Never - Commit secrets or API keys - Force push to git - Reuse existing version tags - Use string concatenation for process arguments (command injection risk) ## Code Style - Use C# conventions: PascalCase for public members, camelCase with underscore prefix for private fields - Prefer `async/await` with `.ConfigureAwait(false)` throughout - Guard external process calls with timeouts and cancellation tokens - Use `Process.StartInfo.ArgumentList` (never string interpolation) for shell commands - Clean up temp files in `finally` blocks using `Guid.NewGuid()` names - Catch `Exception ex when (ex is not OperationCanceledException)` — never swallow cancellation - Keep memory allocation minimal: one `MemoryStream` per extraction, no buffering across items ## Troubleshooting | Problem | Cause | Fix | |---------|-------|-----| | No PDF covers | Bundled PDFium native can't load on this host | Check the log for `pdfium native library`; PDF covers are disabled while all other features keep working | | No audio covers | `ffmpeg` missing | Check `which ffmpeg` inside container | | Plugin doesn't run | Higher-priority provider already supplied cover | Delete existing cover, rescan library | | No online covers | Disabled or no internet | Check toggle in settings; verify access to `openlibrary.org`, `googleapis.com` | | Config page not in sidebar | `EnableInMainMenu` missing | Verify `Plugin.cs` returns it in `PluginPageInfo` | ## Learned Patterns Things discovered during development that save time and prevent mistakes: - **Sidebar visibility**: `EnableInMainMenu = true` on `PluginPageInfo` is the ONLY way to get a plugin config page into Jellyfin's dashboard sidebar. `MenuSection`, `MenuIcon`, and `DisplayName` properties do NOT exist — don't waste time trying them. - **Config page styling**: NEVER use custom CSS. Jellyfin's built-in `emby-*` components and standard classes (`inputContainer`, `fieldDescription`, etc.) handle everything. Custom styling breaks across Jellyfin themes. - **Library API**: `GET Library/VirtualFolders` returns libraries. Each has `LibraryOptions.TypeOptions[].ImageFetchers[]` — an array of enabled provider names. `POST Library/VirtualFolders/LibraryOptions` with the full `LibraryOptions` object updates them. You must send the complete object, not a partial update. - **Memory footprint**: The plugin processes one item at a time, returns one small `MemoryStream` per extraction (typically <1 MB). No caching, no buffering across items. It has been verified NOT to contribute to Jellyfin memory issues during library scans. - **Jellyfin SDK pinning**: ALWAYS pin `Jellyfin.Controller` and `Jellyfin.Model` to the MINIMUM supported minor version (e.g., `10.11.0`), NEVER use wildcards like `10.11.*`. Wildcards resolve to the latest patch at build time (e.g., 10.11.8), which breaks users on older patch versions (e.g., 10.11.6) with `ReflectionTypeLoadException`. All plugin APIs used are stable across patch versions, so pinning to `.0` ensures maximum compatibility. - **Catalog publish is decoupled from releases** (learned the hard way, issue #20): Pages used to be deployed *inside* the release job, which (a) regenerated a **single-entry** manifest that diverged from the committed full-history one, and (b) meant a transient `actions/deploy-pages` "Deployment failed, try again later" stranded the catalog on the previous version until the *next* release — v7.3.2.0 shipped as a release while the catalog kept advertising 7.3.1.0, so installers still saw `NotSupported`. Now `docs.yml` publishes the committed `manifest.json` on every manifest change (and on manual dispatch), so it is retryable and always matches the repo. Do NOT re-add Pages steps to `build.yml`. **Verify a release end-to-end at the CATALOG URL** (`https://geiserx.github.io/smart-covers/manifest.json`), not just the repo file — a green release run does not prove the catalog updated. - **Awesome-list PRs**: Open PRs exist at `awesome-jellyfin/awesome-jellyfin` and `quozd/awesome-dotnet` referencing this plugin. If the plugin is renamed again, those PRs need updating (branch content + PR title/body). - **Deploy path on production**: your server's Jellyfin config directory, `plugins/SmartCovers_<version>/`. Old plugin folders may have FUSE hidden files while Jellyfin is running — restart first, then delete. - **SharpCompress access styles are mutually exclusive** (0.49.x): per-entry random access (`entry.OpenEntryStream()`) THROWS on solid RAR archives (`unpacked file size does not match header`), and the forward reader (`archive.ExtractAllEntries()`) THROWS on everything that is NOT solid/7z (`can only be used on solid archives or 7Zip archives`). Branch on `archive.IsSolid` — there is no single code path. - **Plugin dependency DLLs resolve via the Default ALC, not the plugin folder**: `PluginLoadContext`'s `AssemblyDependencyResolver` is built with the plugin *directory* (no deps.json) so it resolves nothing; resolution falls back to Jellyfin's Default ALC. A dependency Jellyfin does NOT ship either needs the `AssemblyLoadContext.Default.Resolving` handler in `Plugin.cs` (the PDFtoImage approach — viable only for types that never land in compiler-generated fields, see the GetTypes() trap below) or must be ILRepack-merged into `SmartCovers.dll` (the SharpCompress approach — the only safe option when the dependency's types appear in async state machines or cached lambdas). - **The meta.json `assemblies` whitelist controls scanning, not resolution**: non-whitelisted DLLs in the plugin folder are simply not loaded/`GetTypes()`-scanned at startup (the scan is what breaks on version-mismatched refs like SkiaSharp) but remain on disk and fully resolvable at runtime. **Exception:** PDFtoImage still ships renamed to `.lib` — the packaging step, `Plugin.cs` resolver, and README all assume that name today; retiring the rename is a known follow-up that must change all three together. - **The GetTypes() scan resolves compiler-generated FIELDS before any plugin code runs** — this is the `NotSupported` trap (bit v7.3.0.0/v7.3.1.0 on Jellyfin 10.11.11, issue #17 follow-up). Async state machines hoist locals into struct fields and cached lambdas become `<>c` fields, so a type like `IArchive`/`Comparison<IArchiveEntry>` used as an async local or static lambda puts the dependency into a *loadable type's field*, which `GetTypes()` must resolve at scan time — BEFORE `static Plugin()` registers any `Resolving` handler. PDFtoImage survives as a lazy dependency only because its types appear solely as locals in sync lambdas/methods (never generated-type fields). **Fix and policy: any managed dependency Jellyfin doesn't ship gets ILRepack-merged (`/internalize`) into SmartCovers.dll by the CI** — no external reference, no scan risk, no runtime resolution games; CI also re-runs the whole suite against the merged DLL with the dependency deleted as a regression gate. - **Jellyfin 10.11 ships NO SharpCompress** (verified: v10.11.11 tag's `Directory.Packages.props` and `MediaBrowser.Providers.csproj` have no reference; the live 10.11 server's binaries contain none). Master DOES pin SharpCompress (0.49.1) via MediaBrowser.Providers — a master-branch fact that must never be assumed for release-line servers. Bookshelf ships its own `SharpCompress.dll 0.38` inside its plugin folder (loaded into Bookshelf's own ALC — irrelevant to us since the merge). - **Jellyfin swallows provider exceptions and returns nothing to the caller** (`ItemImageProvider.RefreshFromProvider` counts a failure and logs it). The scheduled task therefore learns why an item stayed blank only through `CoverOutcomeLog`, a DI singleton the provider writes per item id. A `failed` counter around `RefreshSingleItem` can never be above 0. - **Keyless Google Books is dead in practice**: its shared anonymous project has a daily quota of 0, so every call answers 429 `RESOURCE_EXHAUSTED`. The fetcher pauses Google Books for 6 h after a 429/403 (or a 400 on a keyed request: key rejected) and logs one warning naming quota or key; a configured key lifts the pause. - **Open Library's covers host rate-limits lookups by ISBN/OCLC to about 100 per 5 min per IP** (`/b/isbn/`, `/b/oclc/`); lookups by cover id (`/b/id/`) are not limited that way. ISBN and OCLC therefore go through `search.json?isbn=`/`?oclc=` and the image through `/b/id/<cover_i>`. After a 429/403/5xx the fetcher stops asking Open Library for the rest of the lookup and pauses it for 5 min across items. - **Jellyfin enables SmartCovers for plain `Folder` items in every library** (no library has a Folder TypeOptions entry, and the global default does not disable it). Without the books-library guard (`LibraryContext.IsBookLibrary`) movie folders were sent to Open Library and queued by the nightly task (999 of 1490 candidates on one server). - **`BaseItem.GetTopParent()` is not virtual and walks the static `BaseItem.LibraryManager`**, so tests cannot fake it. Library kind and root come from the injected `ILibraryManager` (`GetContentType`, `GetCollectionFolders` → `CollectionFolder.PhysicalLocations`). A CollectionFolder's own `Path` is the virtual folder under the config dir, not the media path. - **RAR 7.x cannot author RAR4 archives** (creation removed; extraction still works). The RAR4 test fixture is hand-crafted by `SmartCovers.Tests/Fixtures/make-fixtures.py` (spec-conformant stored RAR 1.5-format blocks, verified with `unrar t`). ## ⚠️ Security Notice > **Do not commit secrets to the repository or to the live app.** > Always use secure standards to transmit sensitive information. > Use environment variables, secret managers, or secure vaults for credentials. **🔍 Security Audit Recommendation:** When making changes that involve authentication, data handling, API endpoints, or dependencies, proactively offer to perform a security review of the affected code. --- *Generated by [LynxPrompt](https://lynxprompt.geiser.cloud)* <!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:6cd5cc61 --> ## Beads Issue Tracker This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands. ### Quick Reference ```bash bd ready # Find available work bd show <id> # View issue details bd update <id> --claim # Claim work bd close <id> # Complete work ``` ### Rules - Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists - Run `bd prime` for detailed command reference and session close protocol - Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files **Architecture in one line:** issues live in a local Dolt DB; sync uses `refs/dolt/data` on your git remote; `.beads/issues.jsonl` is a passive export. See https://github.com/gastownhall/beads/blob/main/docs/SYNC_CONCEPTS.md for details and anti-patterns. ## Agent Context Profiles The managed Beads block is task-tracking guidance, not permission to override repository, user, or orchestrator instructions. - **Conservative (default)**: Use `bd` for task tracking. Do not run git commits, git pushes, or Dolt remote sync unless explicitly asked. At handoff, report changed files, validation, and suggested next commands. - **Minimal**: Keep tool instruction files as pointers to `bd prime`; use the same conservative git policy unless active instructions say otherwise. - **Team-maintainer**: Only when the repository explicitly opts in, agents may close beads, run quality gates, commit, and push as part of session close. A current "do not commit" or "do not push" instruction still wins. ## Session Completion This protocol applies when ending a Beads implementation workflow. It is subordinate to explicit user, repository, and orchestrator instructions. 1. **File issues for remaining work** - Create beads for anything that needs follow-up 2. **Run quality gates** (if code changed) - Tests, linters, builds 3. **Update issue status** - Close finished work, update in-progress items 4. **Handle git/sync by active profile**: ```bash # Conservative/minimal/default: report status and proposed commands; wait for approval. git status # Team-maintainer opt-in only, unless current instructions forbid it: git pull --rebase git push git status ``` 5. **Hand off** - Summarize changes, validation, issue status, and any blocked sync/commit/push step **Critical rules:** - Explicit user or orchestrator instructions override this Beads block. - Do not commit or push without clear authority from the active profile or the current user request. - If a required sync or push is blocked, stop and report the exact command and error. <!-- END BEADS INTEGRATION --> ## Where the tracker syncs This repo is public, so its tracker syncs only to the private remote named by `sync.remote` in `.beads/config.yaml`. The block above says sync uses "your git remote". Here that never means this GitHub repo. Don't add it as a Dolt remote and don't push `refs/dolt/*` to it.