Run prettier on all relevant files

This commit is contained in:
Claudio Ortolina
2026-05-05 10:45:25 +01:00
parent 0b1f839091
commit 5888324cc7
17 changed files with 447 additions and 404 deletions
+3 -1
View File
@@ -154,6 +154,7 @@ mix scrobble.audit --format json
### Understanding the Audit Report ### Understanding the Audit Report
The audit report shows: The audit report shows:
- Total number of scrobbled tracks - Total number of scrobbled tracks
- Artists with missing MusicBrainz IDs (grouped by artist name) - Artists with missing MusicBrainz IDs (grouped by artist name)
- Albums with missing MusicBrainz IDs (grouped by album title and artist) - Albums with missing MusicBrainz IDs (grouped by album title and artist)
@@ -166,6 +167,7 @@ After identifying issues, you can:
1. **Create Scrobble Rules**: Navigate to the Scrobble Rules page in the web interface and add rules to map artist or album names to their correct MusicBrainz IDs. 1. **Create Scrobble Rules**: Navigate to the Scrobble Rules page in the web interface and add rules to map artist or album names to their correct MusicBrainz IDs.
2. **Apply Rules**: Use the "Apply Rules" button in the Scrobble Rules page to update existing tracks, or run in IEx: 2. **Apply Rules**: Use the "Apply Rules" button in the Scrobble Rules page to update existing tracks, or run in IEx:
```elixir ```elixir
MusicLibrary.ScrobbleRules.apply_all_rules() MusicLibrary.ScrobbleRules.apply_all_rules()
``` ```
@@ -173,12 +175,12 @@ After identifying issues, you can:
3. **Re-audit**: Run the audit again to verify the fixes worked. 3. **Re-audit**: Run the audit again to verify the fixes worked.
The application also provides helper functions in the `MusicLibrary.ScrobbleActivity` context: The application also provides helper functions in the `MusicLibrary.ScrobbleActivity` context:
- `count_tracks_missing_artist_musicbrainz_id/0` - `count_tracks_missing_artist_musicbrainz_id/0`
- `count_tracks_missing_album_musicbrainz_id/0` - `count_tracks_missing_album_musicbrainz_id/0`
- `get_artists_missing_musicbrainz_id/1` - `get_artists_missing_musicbrainz_id/1`
- `get_albums_missing_musicbrainz_id/1` - `get_albums_missing_musicbrainz_id/1`
## Deployment ## Deployment
The application is deployed via Coolify, using a Docker Compose strategy. The application is deployed via Coolify, using a Docker Compose strategy.
+5 -2
View File
@@ -16,7 +16,10 @@
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &); @custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
/* Make LiveView wrapper divs transparent for layout */ /* Make LiveView wrapper divs transparent for layout */
[data-phx-session], [data-phx-teleported-src] { display: contents } [data-phx-session],
[data-phx-teleported-src] {
display: contents;
}
@layer base { @layer base {
a, a,
@@ -37,7 +40,7 @@
@theme { @theme {
--font-sans: "InterVariable", sans-serif; --font-sans: "InterVariable", sans-serif;
--font-sans--font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11'; --font-sans--font-feature-settings: "cv02", "cv03", "cv04", "cv11";
--animate-shake: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; --animate-shake: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
--animate-shine: shine 3s ease infinite; --animate-shine: shine 3s ease infinite;
--animate-equalizer-bar: equalizer-bar 1.2s ease-in-out infinite; --animate-equalizer-bar: equalizer-bar 1.2s ease-in-out infinite;
+36 -29
View File
@@ -33,12 +33,12 @@ import { createLiveToastHook } from "live_toast";
import banner from "./banner"; import banner from "./banner";
// the duration for each toast to stay on screen in ms // the duration for each toast to stay on screen in ms
const duration = 4000 const duration = 4000;
// how many toasts to show on screen at once // how many toasts to show on screen at once
const maxItems = 3 const maxItems = 3;
const liveToastHook = createLiveToastHook(duration, maxItems) const liveToastHook = createLiveToastHook(duration, maxItems);
let Hooks = FluxonHooks; let Hooks = FluxonHooks;
Hooks.FormatNumber = FormatNumberHook; Hooks.FormatNumber = FormatNumberHook;
@@ -56,8 +56,8 @@ const liveSocket = new LiveSocket("/live", Socket, {
params: (view) => { params: (view) => {
return { return {
_csrf_token: csrfToken, _csrf_token: csrfToken,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
} };
}, },
hooks: { ...Hooks, ...colocatedHooks }, hooks: { ...Hooks, ...colocatedHooks },
dom: { dom: {
@@ -121,32 +121,39 @@ window.liveSocket = liveSocket;
// 2. click on elements to jump to their definitions in your code editor // 2. click on elements to jump to their definitions in your code editor
// //
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
window.addEventListener("phx:live_reload:attached", ({ detail: reloader }) => { window.addEventListener(
// Enable server log streaming to client. "phx:live_reload:attached",
// Disable with reloader.disableServerLogs() ({ detail: reloader }) => {
reloader.enableServerLogs() // Enable server log streaming to client.
// Disable with reloader.disableServerLogs()
reloader.enableServerLogs();
// Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component // Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component
// //
// * click with "c" key pressed to open at caller location // * click with "c" key pressed to open at caller location
// * click with "d" key pressed to open at function component definition location // * click with "d" key pressed to open at function component definition location
let keyDown let keyDown;
window.addEventListener("keydown", e => keyDown = e.key) window.addEventListener("keydown", (e) => (keyDown = e.key));
window.addEventListener("keyup", e => keyDown = null) window.addEventListener("keyup", (e) => (keyDown = null));
window.addEventListener("click", e => { window.addEventListener(
if (keyDown === "c") { "click",
e.preventDefault() (e) => {
e.stopImmediatePropagation() if (keyDown === "c") {
reloader.openEditorAtCaller(e.target) e.preventDefault();
} else if (keyDown === "d") { e.stopImmediatePropagation();
e.preventDefault() reloader.openEditorAtCaller(e.target);
e.stopImmediatePropagation() } else if (keyDown === "d") {
reloader.openEditorAtDef(e.target) e.preventDefault();
} e.stopImmediatePropagation();
}, true) reloader.openEditorAtDef(e.target);
}
},
true,
);
window.liveReloader = reloader window.liveReloader = reloader;
}) },
);
} }
// Credit: https://andrewtimberlake.com/blog/2025/03/see-what-liveview-changes-are-being-made // Credit: https://andrewtimberlake.com/blog/2025/03/see-what-liveview-changes-are-being-made
+5 -2
View File
@@ -1,6 +1,7 @@
export default function banner() { export default function banner() {
const version = document.querySelector('meta[name="version"]').content; const version = document.querySelector('meta[name="version"]').content;
console.log(`%c console.log(
`%c
███╗ ███╗██╗ ██╗███████╗██╗ ██████╗ ██╗ ██╗██████╗ ██████╗ █████╗ ██████╗ ██╗ ██╗ ███╗ ███╗██╗ ██╗███████╗██╗ ██████╗ ██╗ ██╗██████╗ ██████╗ █████╗ ██████╗ ██╗ ██╗
████╗ ████║██║ ██║██╔════╝██║██╔════╝ ██║ ██║██╔══██╗██╔══██╗██╔══██╗██╔══██╗╚██╗ ██╔╝ ████╗ ████║██║ ██║██╔════╝██║██╔════╝ ██║ ██║██╔══██╗██╔══██╗██╔══██╗██╔══██╗╚██╗ ██╔╝
██╔████╔██║██║ ██║███████╗██║██║ ██║ ██║██████╔╝██████╔╝███████║██████╔╝ ╚████╔╝ ██╔████╔██║██║ ██║███████╗██║██║ ██║ ██║██████╔╝██████╔╝███████║██████╔╝ ╚████╔╝
@@ -8,5 +9,7 @@ export default function banner() {
██║ ╚═╝ ██║╚██████╔╝███████║██║╚██████╗ ███████╗██║██████╔╝██║ ██║██║ ██║██║ ██║ ██║ ██║ ╚═╝ ██║╚██████╔╝███████║██║╚██████╗ ███████╗██║██████╔╝██║ ██║██║ ██║██║ ██║ ██║
╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═════╝ ╚══════╝╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═════╝ ╚══════╝╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝
Version: ${version} Version: ${version}
`, "font-family: IBM Plex Mono,monospace; font-size: 12px"); `,
"font-family: IBM Plex Mono,monospace; font-size: 12px",
);
} }
+11 -4
View File
@@ -1,4 +1,8 @@
export default function createNavigationHook({ getContainer, inputId, onSelect }) { export default function createNavigationHook({
getContainer,
inputId,
onSelect,
}) {
return { return {
mounted() { mounted() {
this.selectedIndex = -1; this.selectedIndex = -1;
@@ -89,13 +93,16 @@ export default function createNavigationHook({ getContainer, inputId, onSelect }
if (searchInput) { if (searchInput) {
searchInput.focus(); searchInput.focus();
} }
} else if (this.selectedIndex >= 0 && this.selectedIndex < results.length) { } else if (
this.selectedIndex >= 0 &&
this.selectedIndex < results.length
) {
const selectedResult = results[this.selectedIndex]; const selectedResult = results[this.selectedIndex];
selectedResult.setAttribute("aria-selected", "true"); selectedResult.setAttribute("aria-selected", "true");
selectedResult.scrollIntoView({ selectedResult.scrollIntoView({
block: "nearest", block: "nearest",
behavior: "smooth" behavior: "smooth",
}); });
} }
}, },
@@ -105,6 +112,6 @@ export default function createNavigationHook({ getContainer, inputId, onSelect }
if (!container) return []; if (!container) return [];
return Array.from(container.querySelectorAll('[role="option"]')); return Array.from(container.querySelectorAll('[role="option"]'));
} },
}; };
} }
+2 -2
View File
@@ -4,5 +4,5 @@ export default {
}, },
updated() { updated() {
this.el.innerText = parseInt(this.el.innerText).toLocaleString(); this.el.innerText = parseInt(this.el.innerText).toLocaleString();
} },
} };
+1 -1
View File
@@ -9,5 +9,5 @@ export default createNavigationHook({
} else { } else {
hook.navigateUp(); hook.navigateUp();
} }
} },
}); });
+1 -1
View File
@@ -9,5 +9,5 @@ export default createNavigationHook({
} else { } else {
hook.navigateUp(); hook.navigateUp();
} }
} },
}); });
+1 -1
View File
@@ -28,7 +28,7 @@ export default {
onEnd: () => { onEnd: () => {
const items = this.el.querySelectorAll("[data-sortable-item]"); const items = this.el.querySelectorAll("[data-sortable-item]");
const recordIds = Array.from(items).map( const recordIds = Array.from(items).map(
(item) => item.dataset.recordId (item) => item.dataset.recordId,
); );
const payload = { record_ids: recordIds }; const payload = { record_ids: recordIds };
@@ -2,5 +2,5 @@ import createNavigationHook from "./create-navigation-hook";
export default createNavigationHook({ export default createNavigationHook({
getContainer: () => document.getElementById("universal-search-root"), getContainer: () => document.getElementById("universal-search-root"),
inputId: "universal-search-input" inputId: "universal-search-input",
}); });
@@ -1,9 +1,10 @@
--- ---
id: doc-10 id: doc-10
title: 'Deepseek v4 Pro, xhigh analysis' title: "Deepseek v4 Pro, xhigh analysis"
type: other type: other
created_date: '2026-05-04 15:11' created_date: "2026-05-04 15:11"
--- ---
# Nerves Deployment Feasibility Analysis for Music Library # Nerves Deployment Feasibility Analysis for Music Library
> Research report — do not implement. May 2026. > Research report — do not implement. May 2026.
@@ -29,26 +30,26 @@ steps.
## 1. Native-extensions audit ## 1. Native-extensions audit
Every dependency that ships a NIF or binary artefact was reviewed for ARM / Every dependency that ships a NIF or binary artefact was reviewed for ARM /
Nerves compatibility. Devonly tools (esbuild, tailwind, credo, etc.) are Nerves compatibility. Devonly tools (esbuild, tailwind, credo, etc.) are
excluded — they never reach the firmware. excluded — they never reach the firmware.
| Dependency | Purpose | NIF type | Precompiled ARM? | Nerves feasibility | | Dependency | Purpose | NIF type | Precompiled ARM? | Nerves feasibility |
|---|---|---|---|---| | ----------------------------------- | ------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **exqlite**`ecto_sqlite3` | SQLite driver | C NIF (`cc_precompiler`) | ✅ `aarch64-linux-gnu/musl` (no `armv7-*`) | **🟢 Good** — `force_build: true` compiles from source inside the Nerves toolchain. Explicitly mentioned in Exqlites README as an embedded use-case. | | **exqlite**`ecto_sqlite3` | SQLite driver | C NIF (`cc_precompiler`) | ✅ `aarch64-linux-gnu/musl` (no `armv7-*`) | **🟢 Good** — `force_build: true` compiles from source inside the Nerves toolchain. Explicitly mentioned in Exqlites README as an embedded use-case. |
| **vix** → libvips | Image processing (covers, thumbnails) | C NIF (`elixir_make`) | ❌ No ARM binaries advertised | **🔴 High risk** — libvips is ~30 MB with transitive deps (libjpeg, libpng, libwebp, etc.). Each needs Buildroot integration or crosscompilation. Biggest blocker for a lean firmware. | | **vix** → libvips | Image processing (covers, thumbnails) | C NIF (`elixir_make`) | ❌ No ARM binaries advertised | **🔴 High risk** — libvips is ~30 MB with transitive deps (libjpeg, libpng, libwebp, etc.). Each needs Buildroot integration or crosscompilation. Biggest blocker for a lean firmware. |
| **mdex** → comrak + ammonia | Markdown → HTML | Rust NIF (RustlerPrecompiled) | ⚠️ Standard Rustler targets (`aarch64-unknown-linux-gnu`, possibly `arm-*`) | **🟡 Needs crosscompilation** — RustlerPrecompiled targets use different triples than Nerves (`aarch64-nerves-linux-gnu`). Precompiled `.so` likely wont load. Crosscompile from source with a Nervesaware Rust toolchain. | | **mdex** → comrak + ammonia | Markdown → HTML | Rust NIF (RustlerPrecompiled) | ⚠️ Standard Rustler targets (`aarch64-unknown-linux-gnu`, possibly `arm-*`) | **🟡 Needs crosscompilation** — RustlerPrecompiled targets use different triples than Nerves (`aarch64-nerves-linux-gnu`). Precompiled `.so` likely wont load. Crosscompile from source with a Nervesaware Rust toolchain. |
| **lumis** → treesitter | Syntax highlighting (in mdex) | Rust NIF (RustlerPrecompiled) | Same as mdex | **🟡 Same situation** — part of the mdex stack. Treesitter grammars add perlanguage compilation but the NIF itself is manageable. | | **lumis** → treesitter | Syntax highlighting (in mdex) | Rust NIF (RustlerPrecompiled) | Same as mdex | **🟡 Same situation** — part of the mdex stack. Treesitter grammars add perlanguage compilation but the NIF itself is manageable. |
| **typst** | PDF generation (tracklists) | Rust NIF (RustlerPrecompiled) | Same as mdex | **🔴 Heavy** — a full typesetting engine. Large binary, plus the same targettriple mismatch. Likely overkill for embedded PDFs. | | **typst** | PDF generation (tracklists) | Rust NIF (RustlerPrecompiled) | Same as mdex | **🔴 Heavy** — a full typesetting engine. Large binary, plus the same targettriple mismatch. Likely overkill for embedded PDFs. |
| **dominant_colors** → kmeans_colors | Colour extraction from covers | Rust NIF (`rustler_precompiled ~> 0.7`) | Same as mdex | **🟢 Small** — trivial NIF. Crosscompilation is straightforward. Low risk. | | **dominant_colors** → kmeans_colors | Colour extraction from covers | Rust NIF (`rustler_precompiled ~> 0.7`) | Same as mdex | **🟢 Small** — trivial NIF. Crosscompilation is straightforward. Low risk. |
| **esbuild** | JS bundling | Go binary | — | **N/A** — dev only, never deployed. | | **esbuild** | JS bundling | Go binary | — | **N/A** — dev only, never deployed. |
| **tailwind** | CSS generation | Go binary | — | **N/A** — dev only, never deployed. | | **tailwind** | CSS generation | Go binary | — | **N/A** — dev only, never deployed. |
### Targetarchitecture specifics ### Targetarchitecture specifics
| Target | CPU | Nerves toolchain triple | Precompiledbinary match? | | Target | CPU | Nerves toolchain triple | Precompiledbinary match? |
|---|---|---|---| | --------- | ------------------------- | ------------------------------ | --------------------------------------------------------------------------------- |
| RPi 4 / 5 | CortexA72 / A76 (64bit) | `aarch64-nerves-linux-gnu` | ⚠️ `aarch64-linux-gnu` *may* be ABIcompatible (exqlite). Rust NIFs need rebuild. | | RPi 4 / 5 | CortexA72 / A76 (64bit) | `aarch64-nerves-linux-gnu` | ⚠️ `aarch64-linux-gnu` _may_ be ABIcompatible (exqlite). Rust NIFs need rebuild. |
| RPi 3 | CortexA53 (32bit) | `armv7-nerves-linux-gnueabihf` | ❌ No precompiled binaries match. Everything must be compiled from source. | | RPi 3 | CortexA53 (32bit) | `armv7-nerves-linux-gnueabihf` | ❌ No precompiled binaries match. Everything must be compiled from source. |
**Recommendation**: Target RPi 4 or 5 (64bit) first — the precompiled ecosystem is **Recommendation**: Target RPi 4 or 5 (64bit) first — the precompiled ecosystem is
far better and the 32bit path adds unnecessary friction. far better and the 32bit path adds unnecessary friction.
@@ -60,10 +61,10 @@ far better and the 32bit path adds unnecessary friction.
The application loads two runtime extensions via Exqlites `load_extensions` The application loads two runtime extensions via Exqlites `load_extensions`
config: config:
| Extension | Source | ARM status | Notes | | Extension | Source | ARM status | Notes |
|---|---|---|---| | ----------- | ---------------------------------------------------------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| **vec0** | [sqlite-vec](https://github.com/asg017/sqlite-vec) | ✅ **Good** | “Written in pure C, no dependencies, runs anywhere SQLite runs (… Raspberry Pis, etc.)” — explicitly tested on ARM. Compilable for any Nerves target. | | **vec0** | [sqlite-vec](https://github.com/asg017/sqlite-vec) | ✅ **Good** | “Written in pure C, no dependencies, runs anywhere SQLite runs (… Raspberry Pis, etc.)” — explicitly tested on ARM. Compilable for any Nerves target. |
| **unicode** | likely [sqlean](https://github.com/nalgeon/sqlean) (`text` module or bundle) | ✅ **ARM64 available** | Sqlean ships `sqlean-linux-arm64.zip`. For ARMv7, compile from C source. | | **unicode** | likely [sqlean](https://github.com/nalgeon/sqlean) (`text` module or bundle) | ✅ **ARM64 available** | Sqlean ships `sqlean-linux-arm64.zip`. For ARMv7, compile from C source. |
### Loading mechanism ### Loading mechanism
@@ -79,7 +80,7 @@ crosscompiler, place them in the appropriate arch subdirectory, and Exqlite w
load them at connection time. load them at connection time.
**Unknown**: Whether the `unicode` extension is built from the exact same sqlean **Unknown**: Whether the `unicode` extension is built from the exact same sqlean
release as production, or compiled independently. Sync the build flags and version release as production, or compiled independently. Sync the build flags and version
to avoid subtle behavioural differences. to avoid subtle behavioural differences.
--- ---
@@ -90,13 +91,13 @@ This is the **hardest problem** and the most architecturally open of the three.
### What Litestream does (and doesnt) ### What Litestream does (and doesnt)
| Capability | Litestream | Needed for device ↔ cloud sync | | Capability | Litestream | Needed for device ↔ cloud sync |
|---|---|---| | ----------------------------------- | ---------- | ------------------------------ |
| Unidirectional backup (app → S3) | ✅ | ❌ | | Unidirectional backup (app → S3) | ✅ | ❌ |
| Disaster recovery (restore from S3) | ✅ | ❌ | | Disaster recovery (restore from S3) | ✅ | ❌ |
| Bidirectional sync | ❌ | ✅ | | Bidirectional sync | ❌ | ✅ |
| Conflict detection / resolution | ❌ | ✅ | | Conflict detection / resolution | ❌ | ✅ |
| Multiwriter support | ❌ | ✅ | | Multiwriter support | ❌ | ✅ |
Litestream is purely an asynchronous backup tool. It continuously streams WAL pages Litestream is purely an asynchronous backup tool. It continuously streams WAL pages
to S3. It has **no mechanism** for a second instance to push writes back, no merge to S3. It has **no mechanism** for a second instance to push writes back, no merge
@@ -141,7 +142,7 @@ Never write the same record type from both sides:
- **Production server**: records, embeddings, notes, record sets, artist info. - **Production server**: records, embeddings, notes, record sets, artist info.
- **Nerves device**: scrobbles, listening stats, playback logs. - **Nerves device**: scrobbles, listening stats, playback logs.
- Each instance has its own SQLite database. They sync via API calls (e.g., device - Each instance has its own SQLite database. They sync via API calls (e.g., device
pushes scrobbles to production, production pushes new records to device). pushes scrobbles to production, production pushes new records to device).
- **Pros**: No databaselevel conflicts. Clear ownership boundaries. - **Pros**: No databaselevel conflicts. Clear ownership boundaries.
@@ -184,24 +185,24 @@ Define a custom sync protocol with:
Even with a sync strategy in place, Litestream could still run on the Nerves device Even with a sync strategy in place, Litestream could still run on the Nerves device
for **local disaster recovery** — streaming the devices own WAL to a separate S3 for **local disaster recovery** — streaming the devices own WAL to a separate S3
path. This is independent from the production Litestream instance. path. This is independent from the production Litestream instance.
--- ---
## Summary of risks ## Summary of risks
| Risk | Severity | Mitigation | | Risk | Severity | Mitigation |
|---|---|---| | -------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------- |
| **vix / libvips on Nerves** | 🔴 High | Replace image processing with a lighter alternative, or offload to the production server via API. | | **vix / libvips on Nerves** | 🔴 High | Replace image processing with a lighter alternative, or offload to the production server via API. |
| **Datasync strategy** | 🔴 High | No offtheshelf solution. Start with readonly replica (option A), iterate toward separate write domains (option B). | | **Datasync strategy** | 🔴 High | No offtheshelf solution. Start with readonly replica (option A), iterate toward separate write domains (option B). |
| **typst on embedded** | 🟡 Medium | Switch to serverside PDF generation; client downloads the result. | | **typst on embedded** | 🟡 Medium | Switch to serverside PDF generation; client downloads the result. |
| **Rust NIF crosscompilation** | 🟡 Medium | Set up Nerves Rust crosscompilation. Works but adds build complexity. Each library needs individual testing. | | **Rust NIF crosscompilation** | 🟡 Medium | Set up Nerves Rust crosscompilation. Works but adds build complexity. Each library needs individual testing. |
| **ARMv7 (RPi 3) vs AArch64 (RPi 4/5)** | 🟡 Medium | RPi 4/5 (64bit) has much better precompiledbinary support. RPi 3 requires everything compiled from source. | | **ARMv7 (RPi 3) vs AArch64 (RPi 4/5)** | 🟡 Medium | RPi 4/5 (64bit) has much better precompiledbinary support. RPi 3 requires everything compiled from source. |
| **Buildroot package deps (libvips)** | 🟡 Medium | Each transitive C library needs a Buildroot package definition or a custom crosscompile step. | | **Buildroot package deps (libvips)** | 🟡 Medium | Each transitive C library needs a Buildroot package definition or a custom crosscompile step. |
| **Firmware size** | 🟡 Medium | libvips + typst + sqlite extensions → image could exceed 200 MB. Typical Nerves firmware is 2080 MB. | | **Firmware size** | 🟡 Medium | libvips + typst + sqlite extensions → image could exceed 200 MB. Typical Nerves firmware is 2080 MB. |
| **sqlitevec on ARM** | 🟢 Low | Explicitly supported. Pure C, no deps. Compiles trivially with the Nerves toolchain. | | **sqlitevec on ARM** | 🟢 Low | Explicitly supported. Pure C, no deps. Compiles trivially with the Nerves toolchain. |
| **exqlite on Nerves** | 🟢 Low | Designed for embedded use. Source compilation wellsupported and documented. | | **exqlite on Nerves** | 🟢 Low | Designed for embedded use. Source compilation wellsupported and documented. |
| **dominant\_colors** | 🟢 Low | Tiny Rust NIF. Straightforward crosscompilation. | | **dominant_colors** | 🟢 Low | Tiny Rust NIF. Straightforward crosscompilation. |
--- ---
@@ -2,8 +2,9 @@
id: doc-11 id: doc-11
title: Nerves Deployment Research — Consolidated Summary title: Nerves Deployment Research — Consolidated Summary
type: other type: other
created_date: '2026-05-04 15:25' created_date: "2026-05-04 15:25"
--- ---
# Nerves Deployment Research — Consolidated Summary # Nerves Deployment Research — Consolidated Summary
> Synthesized from three parallel research reports (Opus 4.7 xhigh, GPT 5.5 high, Deepseek v4 Pro xhigh) on 2026-05-04. > Synthesized from three parallel research reports (Opus 4.7 xhigh, GPT 5.5 high, Deepseek v4 Pro xhigh) on 2026-05-04.
@@ -113,6 +114,7 @@ Litestream is asynchronous backup and disaster recovery. It streams WAL pages to
#### D. Offline-Capable Writes (Most Complex) #### D. Offline-Capable Writes (Most Complex)
Options if offline writes are required: Options if offline writes are required:
- **Application-level outbox**: Local command log + idempotency keys + push to production on reconnect. - **Application-level outbox**: Local command log + idempotency keys + push to production on reconnect.
- **CRDTs** (Automerge, Yjs): True offline-first, no Elixir/SQLite bridge exists. - **CRDTs** (Automerge, Yjs): True offline-first, no Elixir/SQLite bridge exists.
- **Event sourcing**: Append-only mutation log, replay to reconstruct state. Complex. - **Event sourcing**: Append-only mutation log, replay to reconstruct state. Complex.
@@ -149,18 +151,18 @@ Options if offline writes are required:
## 5. Risk Summary ## 5. Risk Summary
| Risk | Severity | Mitigation | | Risk | Severity | Mitigation |
|---|---|---| | ----------------------------- | --------- | ------------------------------------------------------------------------- |
| vix / libvips on Nerves | 🔴 High | Offload image processing to production API, or build custom Nerves system | | vix / libvips on Nerves | 🔴 High | Offload image processing to production API, or build custom Nerves system |
| Data-sync strategy | 🔴 High | Start read-only, iterate toward write-through API | | Data-sync strategy | 🔴 High | Start read-only, iterate toward write-through API |
| typst on embedded | 🟡 Medium | Server-side PDF generation | | typst on embedded | 🟡 Medium | Server-side PDF generation |
| Rust NIF cross-compilation | 🟡 Medium | Set up Nerves Rust cross-compilation; test each library | | Rust NIF cross-compilation | 🟡 Medium | Set up Nerves Rust cross-compilation; test each library |
| ARMv7 (RPi 3) vs AArch64 | 🟡 Medium | Target RPi 4/5 (64-bit) only | | ARMv7 (RPi 3) vs AArch64 | 🟡 Medium | Target RPi 4/5 (64-bit) only |
| Buildroot deps (libvips, ICU) | 🟡 Medium | Custom Nerves system; ~15-30 min Buildroot rebuild | | Buildroot deps (libvips, ICU) | 🟡 Medium | Custom Nerves system; ~15-30 min Buildroot rebuild |
| Firmware size | 🟡 Medium | libvips + typst + extensions could exceed 200 MB; set 120 MB budget | | Firmware size | 🟡 Medium | libvips + typst + extensions could exceed 200 MB; set 120 MB budget |
| sqlite-vec / unicode on ARM | 🟢 Low | Pure C, no deps. Cross-compile in Buildroot | | sqlite-vec / unicode on ARM | 🟢 Low | Pure C, no deps. Cross-compile in Buildroot |
| exqlite on Nerves | 🟢 Low | Designed for embedded; source compilation documented | | exqlite on Nerves | 🟢 Low | Designed for embedded; source compilation documented |
| dominant_colors | 🟢 Low | Tiny Rust NIF, straightforward | | dominant_colors | 🟢 Low | Tiny Rust NIF, straightforward |
--- ---
@@ -188,6 +190,7 @@ All three reports collapse into a single question:
## Sources ## Sources
All three reports include extensive source references. See individual documents for full citation lists: All three reports include extensive source references. See individual documents for full citation lists:
- `doc-8 - Opus-4.7-xhigh-analysis.md` - `doc-8 - Opus-4.7-xhigh-analysis.md`
- `doc-9 - GPT-5.5-high-analysis.md` - `doc-9 - GPT-5.5-high-analysis.md`
- `doc-10 - Deepseek-v4-Pro-xhigh-analysis.md` - `doc-10 - Deepseek-v4-Pro-xhigh-analysis.md`
@@ -1,29 +1,30 @@
--- ---
id: doc-8 id: doc-8
title: 'Opus 4.7, xhigh analysis' title: "Opus 4.7, xhigh analysis"
type: other type: other
created_date: '2026-05-04 15:08' created_date: "2026-05-04 15:08"
updated_date: '2026-05-04 15:10' updated_date: "2026-05-04 15:10"
--- ---
# Nerves Deployment Research — Findings # Nerves Deployment Research — Findings
Three parallel research agents covered NIFs, SQLite extensions, and Litestream replication semantics. The user's concerns were well-founded: there are real blockers, but most are solvable with custom Nerves system work. The Litestream question reframes the architecture more than expected. Three parallel research agents covered NIFs, SQLite extensions, and Litestream replication semantics. The user's concerns were well-founded: there are real blockers, but most are solvable with custom Nerves system work. The Litestream question reframes the architecture more than expected.
## 1. Native libraries (NIFs on aarch64-linux-musl) ## 1. Native libraries (NIFs on aarch64-linux-musl)
| Library | aarch64-gnu | aarch64-musl | Status | | Library | aarch64-gnu | aarch64-musl | Status |
|---|---|---|---| | -------------------------- | ----------- | ------------ | ---------------------------------------------------------------------------------- |
| `mdex` | ✅ | ✅ | Fine | | `mdex` | ✅ | ✅ | Fine |
| `lumis` | ✅ | ✅ | Fine | | `lumis` | ✅ | ✅ | Fine |
| `typst` | ✅ | ✅ | **Nerves-aware** — has `@nerves_rust_target_triple_mapping` in mix.exs | | `typst` | ✅ | ✅ | **Nerves-aware** — has `@nerves_rust_target_triple_mapping` in mix.exs |
| `ecto_sqlite3` / `exqlite` | ✅ | ✅ | Driver fine; designed with embedded use in mind. Extensions are the issue (see §2) | | `ecto_sqlite3` / `exqlite` | ✅ | ✅ | Driver fine; designed with embedded use in mind. Extensions are the issue (see §2) |
| `dominant_colors` | likely | unknown | Verify GitHub releases; if missing, request musl target from maintainer | | `dominant_colors` | likely | unknown | Verify GitHub releases; if missing, request musl target from maintainer |
| `vix` | partial | ❌ | **Major blocker** | | `vix` | partial | ❌ | **Major blocker** |
| `cloak_ecto` | n/a | n/a | Pure Elixir + `:crypto` | | `cloak_ecto` | n/a | n/a | Pure Elixir + `:crypto` |
**`vix` is the dominant blocker.** Two compounding issues: **`vix` is the dominant blocker.** Two compounding issues:
- It loads the NIF at *compile time* (macro-driven introspection), which breaks cross-compilation on stock Nerves systems. fhunleth (Nerves core) discussed this in a 2023 ElixirForum thread; the conclusion was "no easy Elixir-level fix." - It loads the NIF at _compile time_ (macro-driven introspection), which breaks cross-compilation on stock Nerves systems. fhunleth (Nerves core) discussed this in a 2023 ElixirForum thread; the conclusion was "no easy Elixir-level fix."
- libvips itself is not in `nerves_system_rpi4` / `nerves_system_rpi5`. You'd need a **custom Nerves system** with `BR2_PACKAGE_LIBVIPS=y` in Buildroot, then `VIX_COMPILATION_MODE=PLATFORM_PROVIDED_LIBVIPS`. - libvips itself is not in `nerves_system_rpi4` / `nerves_system_rpi5`. You'd need a **custom Nerves system** with `BR2_PACKAGE_LIBVIPS=y` in Buildroot, then `VIX_COMPILATION_MODE=PLATFORM_PROVIDED_LIBVIPS`.
This is doable (1530 min Buildroot rebuild, well-documented) but it commits you to maintaining a custom Nerves system. That single decision affects everything downstream — once you're building custom systems, ICU and sqlite-vec become "while we're at it" additions. This is doable (1530 min Buildroot rebuild, well-documented) but it commits you to maintaining a custom Nerves system. That single decision affects everything downstream — once you're building custom systems, ICU and sqlite-vec become "while we're at it" additions.
@@ -51,7 +52,7 @@ Both extensions are blockers on stock Nerves systems for the same root reason
## 3. Litestream replication — this is the architectural pivot ## 3. Litestream replication — this is the architectural pivot
**Headline:** Litestream is *not* a multi-master replicator. It's a one-way backup tool. If the Nerves device writes locally, those writes are lost on the next restore — no conflict detection, no reconciliation, no merge. **Headline:** Litestream is _not_ a multi-master replicator. It's a one-way backup tool. If the Nerves device writes locally, those writes are lost on the next restore — no conflict detection, no reconciliation, no merge.
**Two viable replica modes today (both newer than the docs you may remember):** **Two viable replica modes today (both newer than the docs you may remember):**
@@ -83,6 +84,7 @@ The **biggest non-obvious unknown** is the Litestream architecture decision. Goi
## Sources ## Sources
### NIF / Phoenix-on-Nerves ### NIF / Phoenix-on-Nerves
- [Underjord: LiveView on Nerves](https://underjord.io/liveview-on-nerves.html) - [Underjord: LiveView on Nerves](https://underjord.io/liveview-on-nerves.html)
- [ElixirForum: Cross Compiling with NIF as compilation dependency (vix/Nerves thread)](https://elixirforum.com/t/cross-compiling-with-nif-as-compilation-dependency/59821) - [ElixirForum: Cross Compiling with NIF as compilation dependency (vix/Nerves thread)](https://elixirforum.com/t/cross-compiling-with-nif-as-compilation-dependency/59821)
- [Nerves docs: Compiling Non-BEAM Code](https://hexdocs.pm/nerves/compiling-non-beam-code.html) - [Nerves docs: Compiling Non-BEAM Code](https://hexdocs.pm/nerves/compiling-non-beam-code.html)
@@ -96,6 +98,7 @@ The **biggest non-obvious unknown** is the Litestream architecture decision. Goi
- [Customizing Your Nerves System](https://hexdocs.pm/nerves/customizing-systems.html) - [Customizing Your Nerves System](https://hexdocs.pm/nerves/customizing-systems.html)
### SQLite extensions ### SQLite extensions
- [Releases · asg017/sqlite-vec](https://github.com/asg017/sqlite-vec/releases) - [Releases · asg017/sqlite-vec](https://github.com/asg017/sqlite-vec/releases)
- [Fix for musl compile PR #199 · asg017/sqlite-vec](https://github.com/asg017/sqlite-vec/pull/199) - [Fix for musl compile PR #199 · asg017/sqlite-vec](https://github.com/asg017/sqlite-vec/pull/199)
- [Build and install exqlite with sqlcipher into nerves rpi5 image — ElixirForum](https://elixirforum.com/t/build-and-install-exqlite-with-sqlcipher-into-nerves-rpi5-image/75089) - [Build and install exqlite with sqlcipher into nerves rpi5 image — ElixirForum](https://elixirforum.com/t/build-and-install-exqlite-with-sqlcipher-into-nerves-rpi5-image/75089)
@@ -105,6 +108,7 @@ The **biggest non-obvious unknown** is the Litestream architecture decision. Goi
- [Advanced Configuration — Nerves](https://hexdocs.pm/nerves/advanced-configuration.html) - [Advanced Configuration — Nerves](https://hexdocs.pm/nerves/advanced-configuration.html)
### Litestream ### Litestream
- [Live Read Replication — Litestream (tip)](https://tip.litestream.io/guides/read-replica/) - [Live Read Replication — Litestream (tip)](https://tip.litestream.io/guides/read-replica/)
- [VFS Read Replicas — Litestream](https://litestream.io/guides/vfs/) - [VFS Read Replicas — Litestream](https://litestream.io/guides/vfs/)
- [How it works — Litestream](https://litestream.io/how-it-works/) - [How it works — Litestream](https://litestream.io/how-it-works/)
@@ -1,9 +1,10 @@
--- ---
id: doc-9 id: doc-9
title: 'GPT 5.5, high analysis' title: "GPT 5.5, high analysis"
type: other type: other
created_date: '2026-05-04 15:11' created_date: "2026-05-04 15:11"
--- ---
# Nerves Deployment Research Report # Nerves Deployment Research Report
Date: 2026-05-04 Date: 2026-05-04
+238 -235
View File
@@ -11,6 +11,7 @@ Uses SQLite (via `ecto_sqlite3`) with three databases: one for the app, one for
jobs (Oban), and one for telemetry metrics. All schemas use `binary_id` primary keys. jobs (Oban), and one for telemetry metrics. All schemas use `binary_id` primary keys.
Key capabilities: Key capabilities:
- Browse/search collected and wishlisted records - Browse/search collected and wishlisted records
- Import metadata from MusicBrainz, enrich with Discogs/Wikipedia/Last.fm - Import metadata from MusicBrainz, enrich with Discogs/Wikipedia/Last.fm
- Scrobble tracks to Last.fm, import listening history - Scrobble tracks to Last.fm, import listening history
@@ -49,11 +50,11 @@ when `single_line_logging` is `true` — not a supervised process.
## Database & Repos ## Database & Repos
| Repo | DB file (dev) | Purpose | | Repo | DB file (dev) | Purpose |
|------|---------------|---------| | ----------------------------- | -------------------------------------- | ------------------------------------------------------ |
| `MusicLibrary.Repo` | `data/music_library_dev.db` | All application data | | `MusicLibrary.Repo` | `data/music_library_dev.db` | All application data |
| `MusicLibrary.BackgroundRepo` | `data/music_library_background_dev.db` | Oban job queue | | `MusicLibrary.BackgroundRepo` | `data/music_library_background_dev.db` | Oban job queue |
| `MusicLibrary.TelemetryRepo` | `data/music_library_telemetry_dev.db` | Telemetry metrics history (persistent across restarts) | | `MusicLibrary.TelemetryRepo` | `data/music_library_telemetry_dev.db` | Telemetry metrics history (persistent across restarts) |
SQLite extensions loaded at runtime: `unicode`, `vec0` (vector search). SQLite extensions loaded at runtime: `unicode`, `vec0` (vector search).
@@ -64,26 +65,27 @@ write to it directly; insert/update the `records` table instead.
## Schemas ## Schemas
| Schema | Table | PK | Key Fields | | Schema | Table | PK | Key Fields |
|--------|-------|----|------------| | ------------------------------------------ | ------------------------ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `Records.Record` | `records` | `id` (binary_id) | title, type, format, cover_url, cover_hash, musicbrainz_id, genres[], release_date, purchased_at, dominant_colors[], embeds_many :artists | | `Records.Record` | `records` | `id` (binary_id) | title, type, format, cover_url, cover_hash, musicbrainz_id, genres[], release_date, purchased_at, dominant_colors[], embeds_many :artists |
| `Records.RecordEmbedding` | `record_embeddings` | `id` | embedding (Float32 vector), text_representation, belongs_to :record | | `Records.RecordEmbedding` | `record_embeddings` | `id` | embedding (Float32 vector), text_representation, belongs_to :record |
| `Records.SearchIndex` | `records_search_index` | `id` | FTS5 mirror of records (virtual, trigger-synced) | | `Records.SearchIndex` | `records_search_index` | `id` | FTS5 mirror of records (virtual, trigger-synced) |
| `Records.RecordRelease` | `record_releases` | none | record_id, release_id, cover_hash, purchased_at — read-only, no PK | | `Records.RecordRelease` | `record_releases` | none | record_id, release_id, cover_hash, purchased_at — read-only, no PK |
| `Records.ArtistRecord` | `artist_records` | composite | musicbrainz_id, record_id — DB view joining artists to records | | `Records.ArtistRecord` | `artist_records` | composite | musicbrainz_id, record_id — DB view joining artists to records |
| `Artists.Artist` | — | — | Embedded schema (name, sort_name, musicbrainz_id, joinphrase) | | `Artists.Artist` | — | — | Embedded schema (name, sort_name, musicbrainz_id, joinphrase) |
| `Artists.ArtistInfo` | `artist_infos` | `id` | musicbrainz_data, discogs_data, wikipedia_data, lastfm_data, image_data_hash | | `Artists.ArtistInfo` | `artist_infos` | `id` | musicbrainz_data, discogs_data, wikipedia_data, lastfm_data, image_data_hash |
| `Assets.Asset` | `assets` | `hash` (SHA256) | content (binary), format, properties (map) | | `Assets.Asset` | `assets` | `hash` (SHA256) | content (binary), format, properties (map) |
| `Notes.Note` | `notes` | `id` | entity (:record/:artist), content, musicbrainz_id | | `Notes.Note` | `notes` | `id` | entity (:record/:artist), content, musicbrainz_id |
| `RecordSets.RecordSet` | `record_sets` | `id` | name, description, has_many :items | | `RecordSets.RecordSet` | `record_sets` | `id` | name, description, has_many :items |
| `RecordSets.RecordSetItem` | `record_set_items` | `id` | position, belongs_to :record_set, belongs_to :record | | `RecordSets.RecordSetItem` | `record_set_items` | `id` | position, belongs_to :record_set, belongs_to :record |
| `ScrobbleRules.ScrobbleRule` | `scrobble_rules` | `id` (integer) | type (:album/:artist), match_value, target_musicbrainz_id, enabled | | `ScrobbleRules.ScrobbleRule` | `scrobble_rules` | `id` (integer) | type (:album/:artist), match_value, target_musicbrainz_id, enabled |
| `OnlineStoreTemplates.OnlineStoreTemplate` | `online_store_templates` | `id` | name, url_template, enabled | | `OnlineStoreTemplates.OnlineStoreTemplate` | `online_store_templates` | `id` | name, url_template, enabled |
| `Secrets.Secret` | `secrets` | `name` (string) | value (encrypted binary) | | `Secrets.Secret` | `secrets` | `name` (string) | value (encrypted binary) |
| `Chats.Chat` | `chats` | `id` (binary_id) | entity (:record/:artist/:collection), musicbrainz_id, topic, has_many :messages | | `Chats.Chat` | `chats` | `id` (binary_id) | entity (:record/:artist/:collection), musicbrainz_id, topic, has_many :messages |
| `Chats.Message` | `chat_messages` | `id` (binary_id) | role, content, position, belongs_to :chat | | `Chats.Message` | `chat_messages` | `id` (binary_id) | role, content, position, belongs_to :chat |
Last.fm schemas (separate, not Ecto-persisted to main DB): Last.fm schemas (separate, not Ecto-persisted to main DB):
- `LastFm.Track` — scrobbled tracks from Last.fm API responses - `LastFm.Track` — scrobbled tracks from Last.fm API responses
- `LastFm.Album`, `LastFm.Artist` — parsed API responses - `LastFm.Album`, `LastFm.Artist` — parsed API responses
@@ -91,78 +93,78 @@ Last.fm schemas (separate, not Ecto-persisted to main DB):
## Contexts (lib/music_library/) ## Contexts (lib/music_library/)
| Context | Schemas | Responsibility | | Context | Schemas | Responsibility |
|---------|---------|---------------| | ---------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Records` | Record, RecordEmbedding, SearchIndex | CRUD, search, import from MusicBrainz, cover/genre/color management, PubSub notifications | | `Records` | Record, RecordEmbedding, SearchIndex | CRUD, search, import from MusicBrainz, cover/genre/color management, PubSub notifications |
| `Collection` | Record (via SearchIndex) | Querying collected records (purchased_at != nil), stats, collected artist IDs, collection summary for AI chat | | `Collection` | Record (via SearchIndex) | Querying collected records (purchased_at != nil), stats, collected artist IDs, collection summary for AI chat |
| `Wishlist` | Record (via SearchIndex) | Querying wishlisted records (purchased_at is nil) | | `Wishlist` | Record (via SearchIndex) | Querying wishlisted records (purchased_at is nil) |
| `Artists` | ArtistInfo, ArtistRecord | Artist metadata from MusicBrainz/Discogs/Wikipedia/Last.fm, images, search | | `Artists` | ArtistInfo, ArtistRecord | Artist metadata from MusicBrainz/Discogs/Wikipedia/Last.fm, images, search |
| `Assets` | Asset | Binary asset storage (covers, artist images), cache tracking, pruning unreferenced assets | | `Assets` | Asset | Binary asset storage (covers, artist images), cache tracking, pruning unreferenced assets |
| `Notes` | Note | Free-text notes for records and artists | | `Notes` | Note | Free-text notes for records and artists |
| `Chats` | Chat, Message, StreamProvider, RecordChat, ArtistChat, CollectionChat | Persistent AI chat conversations for records, artists, and the collection, streaming AI chat behaviour and entity-specific implementations | | `Chats` | Chat, Message, StreamProvider, RecordChat, ArtistChat, CollectionChat | Persistent AI chat conversations for records, artists, and the collection, streaming AI chat behaviour and entity-specific implementations |
| `RecordSets` | RecordSet, RecordSetItem | User-curated record groupings with ordering | | `RecordSets` | RecordSet, RecordSetItem | User-curated record groupings with ordering |
| `ScrobbleRules` | ScrobbleRule | Rules to remap Last.fm scrobble data to correct MusicBrainz IDs; searchable by match_value/target/description, orderable by alphabetical or inserted_at | | `ScrobbleRules` | ScrobbleRule | Rules to remap Last.fm scrobble data to correct MusicBrainz IDs; searchable by match_value/target/description, orderable by alphabetical or inserted_at |
| `ScrobbleActivity` | — | Scrobbling releases/media/tracks to Last.fm | | `ScrobbleActivity` | — | Scrobbling releases/media/tracks to Last.fm |
| `ListeningStats` | (LastFm.Track, RecordRelease, ArtistRecord, ArtistInfo) | Scrobble persistence, refresh scheduling, listening analytics, track CRUD, search, listing: scrobble counts, artist play counts (from DB), recent activity, top albums/artists by period | | `ListeningStats` | (LastFm.Track, RecordRelease, ArtistRecord, ArtistInfo) | Scrobble persistence, refresh scheduling, listening analytics, track CRUD, search, listing: scrobble counts, artist play counts (from DB), recent activity, top albums/artists by period |
| `OnlineStoreTemplates` | OnlineStoreTemplate | URL templates for buying records online; searchable by name/description | | `OnlineStoreTemplates` | OnlineStoreTemplate | URL templates for buying records online; searchable by name/description |
| `Errors` | ErrorTracker.Error, ErrorTracker.Occurrence | Read-only queries for production error data tracked by ErrorTracker; filtered listing with pagination, single error with preloaded occurrences and computed counts | | `Errors` | ErrorTracker.Error, ErrorTracker.Occurrence | Read-only queries for production error data tracked by ErrorTracker; filtered listing with pagination, single error with preloaded occurrences and computed counts |
| `Search` | (cross-context) | Universal search dispatcher across collection, wishlist, artists, record sets (delegates to domain contexts) | | `Search` | (cross-context) | Universal search dispatcher across collection, wishlist, artists, record sets (delegates to domain contexts) |
| `Secrets` | Secret | Encrypted key-value storage (CRUD + delete) | | `Secrets` | Secret | Encrypted key-value storage (CRUD + delete) |
| `BarcodeScan` | (Result struct) | Barcode → MusicBrainz lookup workflow, async batch import for multiple new records | | `BarcodeScan` | (Result struct) | Barcode → MusicBrainz lookup workflow, async batch import for multiple new records |
| `Maintenance` | (Oban.Job, LastFm.Track) | Background job monitoring, database vacuum/optimize, scrobble data quality diagnostics | | `Maintenance` | (Oban.Job, LastFm.Track) | Background job monitoring, database vacuum/optimize, scrobble data quality diagnostics |
--- ---
## Business Logic Modules ## Business Logic Modules
| Module | Purpose | | Module | Purpose |
|--------|---------| | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Records.SearchParser` | Parses search syntax: `artist:X`, `album:X`, `genre:"Y"`, `format:cd`, `type:album`, `purchase_year:2024`, `release_year:2024`, free text | | `Records.SearchParser` | Parses search syntax: `artist:X`, `album:X`, `genre:"Y"`, `format:cd`, `type:album`, `purchase_year:2024`, `release_year:2024`, free text |
| `ListeningStats.SearchParser` | Parses scrobbled tracks search syntax: `record:X`, `album_mbid:X`, `artist_mbid:X`, `artist:X`, `album:X`, `track:X`, free text | | `ListeningStats.SearchParser` | Parses scrobbled tracks search syntax: `record:X`, `album_mbid:X`, `artist_mbid:X`, `artist:X`, `album:X`, `track:X`, free text |
| `Records.Similarity` | Embedding generation and async enqueue (OpenAI, enriched with Last.fm tags, skips API call when text representation unchanged), artist-cascade regeneration when upstream metadata changes, cosine-distance search (sqlite-vec) | | `Records.Similarity` | Embedding generation and async enqueue (OpenAI, enriched with Last.fm tags, skips API call when text representation unchanged), artist-cascade regeneration when upstream metadata changes, cosine-distance search (sqlite-vec) |
| `Records.TracklistPdf` | Generates 120mm×120mm PDF tracklist from record + release data (Typst) | | `Records.TracklistPdf` | Generates 120mm×120mm PDF tracklist from record + release data (Typst) |
| `Batch` | Generic batch runner: stream + transaction + error accumulation | | `Batch` | Generic batch runner: stream + transaction + error accumulation |
| `Records.Batch` | Batch operations: refresh all MusicBrainz data, generate all embeddings (uses `Batch`) | | `Records.Batch` | Batch operations: refresh all MusicBrainz data, generate all embeddings (uses `Batch`) |
| `Artists.Batch` | Batch refresh: MusicBrainz, Discogs, Wikipedia, Last.fm for all artists (uses `Batch`) | | `Artists.Batch` | Batch refresh: MusicBrainz, Discogs, Wikipedia, Last.fm for all artists (uses `Batch`) |
| `Req.RateLimiter` | ETS-backed Req request step enforcing per-API minimum intervals between requests | | `Req.RateLimiter` | ETS-backed Req request step enforcing per-API minimum intervals between requests |
| `Req.RateLimiter.Clock` | Behaviour for time operations (allows test clock injection) | | `Req.RateLimiter.Clock` | Behaviour for time operations (allows test clock injection) |
| `Req.RateLimiter.SystemClock` | Real clock implementation using System.monotonic_time | | `Req.RateLimiter.SystemClock` | Real clock implementation using System.monotonic_time |
| `Assets.Cache` | ETS-based asset cache with TTL (7-day TTL, TTL-only invalidation since assets are content-addressable and immutable) | | `Assets.Cache` | ETS-based asset cache with TTL (7-day TTL, TTL-only invalidation since assets are content-addressable and immutable) |
| `Assets.Image` / `Assets.Transform` | Image processing via Vix (libvips) | | `Assets.Image` / `Assets.Transform` | Image processing via Vix (libvips) |
| `Colors.Extractor` | Behaviour for dominant color extraction (configurable, allows test stubbing) | | `Colors.Extractor` | Behaviour for dominant color extraction (configurable, allows test stubbing) |
| `Colors.KMeansExtractor` | Color extraction via K-Means clustering (dominant_colors library), implements `Colors.Extractor` | | `Colors.KMeansExtractor` | Color extraction via K-Means clustering (dominant_colors library), implements `Colors.Extractor` |
| `Chats.StreamProvider` | Behaviour for streaming AI chat (`stream_response/3` callback) | | `Chats.StreamProvider` | Behaviour for streaming AI chat (`stream_response/3` callback) |
| `Chats.RecordChat` | Chat implementation for records (OpenAI streaming, web search enabled) | | `Chats.RecordChat` | Chat implementation for records (OpenAI streaming, web search enabled) |
| `Chats.ArtistChat` | Chat implementation for artists (OpenAI streaming, uses Wikipedia/artist context) | | `Chats.ArtistChat` | Chat implementation for artists (OpenAI streaming, uses Wikipedia/artist context) |
| `Chats.CollectionChat` | Chat implementation for the collection (OpenAI streaming via gpt-5.1, uses collection summary as context) | | `Chats.CollectionChat` | Chat implementation for the collection (OpenAI streaming via gpt-5.1, uses collection summary as context) |
| `Chats.Prompt` | Builds complete chat prompts by interpolating identity, content, and approach guidelines | | `Chats.Prompt` | Builds complete chat prompts by interpolating identity, content, and approach guidelines |
| `Country` | Country code (alpha-2, alpha-3, subdivision, IETF) to flag emoji conversion | | `Country` | Country code (alpha-2, alpha-3, subdivision, IETF) to flag emoji conversion |
| `ErrorTracker.ErrorNotifier` | GenServer: attaches to ErrorTracker telemetry, skips muted errors, throttles repeated errors, dispatches email notifications | | `ErrorTracker.ErrorNotifier` | GenServer: attaches to ErrorTracker telemetry, skips muted errors, throttles repeated errors, dispatches email notifications |
| `ErrorTracker.ErrorNotifier.Email` | Builds and sends Swoosh error notification emails with stack trace formatting | | `ErrorTracker.ErrorNotifier.Email` | Builds and sends Swoosh error notification emails with stack trace formatting |
| `ErrorIgnorer` | ErrorTracker.Ignorer implementation: filters non-actionable errors (e.g., NoRouteError from bot scanners) | | `ErrorIgnorer` | ErrorTracker.Ignorer implementation: filters non-actionable errors (e.g., NoRouteError from bot scanners) |
| `MusicLibrary.ErrorResponse` | Behaviour for structured API error responses (`retryable?/1`, `retry_delay_seconds/1`) — per-API `ErrorResponse` modules implement this | | `MusicLibrary.ErrorResponse` | Behaviour for structured API error responses (`retryable?/1`, `retry_delay_seconds/1`) — per-API `ErrorResponse` modules implement this |
| `MusicLibrary.HttpError` | Default HTTP status → kind mapping (`:rate_limit`, `:server_error`, `:timeout`, `:auth_error`, `:not_found`, `:client_error`, `:unknown`) used as baseline by per-API `ErrorResponse` modules | | `MusicLibrary.HttpError` | Default HTTP status → kind mapping (`:rate_limit`, `:server_error`, `:timeout`, `:auth_error`, `:not_found`, `:client_error`, `:unknown`) used as baseline by per-API `ErrorResponse` modules |
| `MusicLibrary.RetryDelay` | Parses and clamps provider retry/reset headers into Oban snooze delays for structured API errors | | `MusicLibrary.RetryDelay` | Parses and clamps provider retry/reset headers into Oban snooze delays for structured API errors |
| `MusicLibrary.Worker.ErrorHandler` | Translates per-API `ErrorResponse` structs into Oban tuples — `{:snooze, seconds}` for retryable, `{:cancel, reason}` for permanent | | `MusicLibrary.Worker.ErrorHandler` | Translates per-API `ErrorResponse` structs into Oban tuples — `{:snooze, seconds}` for retryable, `{:cancel, reason}` for permanent |
| `MusicLibraryWeb.RecordsOnThisDayEmail` | Builds and sends daily "records on this day" email with cover images, anniversary styling | | `MusicLibraryWeb.RecordsOnThisDayEmail` | Builds and sends daily "records on this day" email with cover images, anniversary styling |
| `MusicLibrary.Mailer` | Swoosh mailer (Mailgun in prod, local adapter in dev) | | `MusicLibrary.Mailer` | Swoosh mailer (Mailgun in prod, local adapter in dev) |
| `FormatNumber` | Number formatting utility | | `FormatNumber` | Number formatting utility |
| `QueryReporter` | Dev-only Ecto telemetry reporter: captures executed SQL to a log file with interpolated params, source locations, and timing. Activated at runtime via `start/1` / `stop/0` | | `QueryReporter` | Dev-only Ecto telemetry reporter: captures executed SQL to a log file with interpolated params, source locations, and timing. Activated at runtime via `start/1` / `stop/0` |
| `MusicLibrary.Logger.SingleLineFormatter` | Production-only Logger.Formatter safety net: replaces embedded newlines (`\n`) with escaped `\\n` in all log messages, ensuring every physical log line is exactly one log event | | `MusicLibrary.Logger.SingleLineFormatter` | Production-only Logger.Formatter safety net: replaces embedded newlines (`\n`) with escaped `\\n` in all log messages, ensuring every physical log line is exactly one log event |
--- ---
## External API Integrations ## External API Integrations
| Module | API | Rate limit | Purpose | | Module | API | Rate limit | Purpose |
|--------|-----|-----------|---------| | --------------------------------- | -------------------- | ---------- | --------------------------------------------------------------------------------------------------- |
| `MusicBrainz` / `MusicBrainz.API` | musicbrainz.org | 1000 ms | Release/artist metadata, search | | `MusicBrainz` / `MusicBrainz.API` | musicbrainz.org | 1000 ms | Release/artist metadata, search |
| `LastFm` / `LastFm.API` | last.fm | 500 ms | Scrobbling, listening history, artist info (tags, similar artists), user profile/session validation | | `LastFm` / `LastFm.API` | last.fm | 500 ms | Scrobbling, listening history, artist info (tags, similar artists), user profile/session validation |
| `Discogs` / `Discogs.API` | discogs.com | 2000 ms | Artist profiles, images | | `Discogs` / `Discogs.API` | discogs.com | 2000 ms | Artist profiles, images |
| `Wikipedia` / `Wikipedia.API` | wikipedia.org | 1000 ms | Artist biographies | | `Wikipedia` / `Wikipedia.API` | wikipedia.org | 1000 ms | Artist biographies |
| `BraveSearch` / `BraveSearch.API` | search.brave.com | 1000 ms | Cover art and artist image search | | `BraveSearch` / `BraveSearch.API` | search.brave.com | 1000 ms | Cover art and artist image search |
| `OpenAI` / `OpenAI.API` | api.openai.com | 250 ms | Text embeddings for similarity, streaming chat via Responses API (gpt-4.1/gpt-5.1 + web search) | | `OpenAI` / `OpenAI.API` | api.openai.com | 250 ms | Text embeddings for similarity, streaming chat via Responses API (gpt-4.1/gpt-5.1 + web search) |
| `MusicLibrary.Mailer` | Mailgun (via Swoosh) | — | Transactional email delivery (error notifications, daily digest) | | `MusicLibrary.Mailer` | Mailgun (via Swoosh) | — | Transactional email delivery (error notifications, daily digest) |
Each has a `Config` module reading from application env. All HTTP clients use `Req` with Each has a `Config` module reading from application env. All HTTP clients use `Req` with
per-API rate limiting (`Req.RateLimiter`, ETS-backed). In tests, all HTTP calls are per-API rate limiting (`Req.RateLimiter`, ETS-backed). In tests, all HTTP calls are
@@ -183,73 +185,73 @@ HTTP 429 into `:rate_limit` vs `:auth_error` by reading the body `code`
### Queues ### Queues
| Queue | Concurrency | Purpose | | Queue | Concurrency | Purpose |
|-------|-------------|---------| | -------------- | ----------- | ------------------------------------------------------------------- |
| `default` | 10 | General async tasks | | `default` | 10 | General async tasks |
| `heavy_writes` | 1 | DB-intensive or serialized operations | | `heavy_writes` | 1 | DB-intensive or serialized operations |
| `music_brainz` | 1 | MusicBrainz calls (rate-limited at Req layer via `Req.RateLimiter`) | | `music_brainz` | 1 | MusicBrainz calls (rate-limited at Req layer via `Req.RateLimiter`) |
| `discogs` | 1 | Discogs calls (rate-limited at Req layer via `Req.RateLimiter`) | | `discogs` | 1 | Discogs calls (rate-limited at Req layer via `Req.RateLimiter`) |
| `wikipedia` | 1 | Wikipedia calls | | `wikipedia` | 1 | Wikipedia calls |
| `last_fm` | 1 | Last.fm calls (rate-limited at Req layer via `Req.RateLimiter`) | | `last_fm` | 1 | Last.fm calls (rate-limited at Req layer via `Req.RateLimiter`) |
### Plugins (prod) ### Plugins (prod)
| Plugin | Config | Purpose | | Plugin | Config | Purpose |
|--------|--------|---------| | ------------------------ | --------------------------- | ------------------------------------------------------------ |
| `Oban.Plugins.Pruner` | `max_age: 43200` (12h) | Prune completed/cancelled/discarded jobs older than 12 hours | | `Oban.Plugins.Pruner` | `max_age: 43200` (12h) | Prune completed/cancelled/discarded jobs older than 12 hours |
| `Oban.Plugins.Reindexer` | `schedule: "@weekly"` | Weekly reindex of Oban tables for query performance | | `Oban.Plugins.Reindexer` | `schedule: "@weekly"` | Weekly reindex of Oban tables for query performance |
| `Oban.Plugins.Cron` | `timezone: "Europe/London"` | Scheduled recurring workers (see Cron Workers table) | | `Oban.Plugins.Cron` | `timezone: "Europe/London"` | Scheduled recurring workers (see Cron Workers table) |
### On-Demand Workers ### On-Demand Workers
| Worker | Queue | Trigger | | Worker | Queue | Trigger |
|--------|-------|---------| | ----------------------------------- | ------------ | --------------------------------------------------------------------------- |
| `FetchArtistInfo` | default | Artist page visit / import (also fetches Last.fm data inline) | | `FetchArtistInfo` | default | Artist page visit / import (also fetches Last.fm data inline) |
| `FetchArtistLastFmData` | last_fm | Manual / batch | | `FetchArtistLastFmData` | last_fm | Manual / batch |
| `FetchArtistImage` | heavy_writes | Artist info fetched | | `FetchArtistImage` | heavy_writes | Artist info fetched |
| `RefreshCover` | heavy_writes | Manual action / import | | `RefreshCover` | heavy_writes | Manual action / import |
| `ImportFromMusicbrainzRelease` | music_brainz | Barcode scan batch import (2+ new records) | | `ImportFromMusicbrainzRelease` | music_brainz | Barcode scan batch import (2+ new records) |
| `ImportFromMusicbrainzReleaseGroup` | music_brainz | Cart-style multi-record import in AddRecord component (2+ records selected) | | `ImportFromMusicbrainzReleaseGroup` | music_brainz | Cart-style multi-record import in AddRecord component (2+ records selected) |
| `PopulateGenres` | heavy_writes | Manual action (chains → GenerateRecordEmbedding) | | `PopulateGenres` | heavy_writes | Manual action (chains → GenerateRecordEmbedding) |
| `GenerateRecordEmbedding` | heavy_writes | Manual / after genre population (delegates to Similarity, skips unchanged) | | `GenerateRecordEmbedding` | heavy_writes | Manual / after genre population (delegates to Similarity, skips unchanged) |
| `RecordRefreshMusicBrainzData` | music_brainz | Manual / batch | | `RecordRefreshMusicBrainzData` | music_brainz | Manual / batch |
| `ArtistRefreshMusicBrainzData` | music_brainz | Manual / batch | | `ArtistRefreshMusicBrainzData` | music_brainz | Manual / batch |
| `ArtistRefreshDiscogsData` | discogs | Manual / batch | | `ArtistRefreshDiscogsData` | discogs | Manual / batch |
| `ArtistRefreshWikipediaData` | wikipedia | Manual / batch | | `ArtistRefreshWikipediaData` | wikipedia | Manual / batch |
| `PruneArtistInfo` | default | Record deleted (cleanup orphaned artist data) | | `PruneArtistInfo` | default | Record deleted (cleanup orphaned artist data) |
| `RecordRefreshAllMusicBrainzData` | music_brainz | Manual / cron (bulk refresh via Records.Batch) | | `RecordRefreshAllMusicBrainzData` | music_brainz | Manual / cron (bulk refresh via Records.Batch) |
| `RecordGenerateAllEmbeddings` | heavy_writes | Manual / cron (bulk generate via Records.Batch) | | `RecordGenerateAllEmbeddings` | heavy_writes | Manual / cron (bulk generate via Records.Batch) |
| `ArtistRefreshAllMusicBrainzData` | music_brainz | Manual / cron (bulk refresh via Artists.Batch) | | `ArtistRefreshAllMusicBrainzData` | music_brainz | Manual / cron (bulk refresh via Artists.Batch) |
| `ArtistRefreshAllDiscogsData` | discogs | Manual / cron (bulk refresh via Artists.Batch) | | `ArtistRefreshAllDiscogsData` | discogs | Manual / cron (bulk refresh via Artists.Batch) |
| `ArtistRefreshAllWikipediaData` | wikipedia | Manual / cron (bulk refresh via Artists.Batch) | | `ArtistRefreshAllWikipediaData` | wikipedia | Manual / cron (bulk refresh via Artists.Batch) |
| `RefreshScrobbles` | last_fm | Cron / manual (fetch recent Last.fm scrobbles) | | `RefreshScrobbles` | last_fm | Cron / manual (fetch recent Last.fm scrobbles) |
| `BackfillScrobbledTracks` | heavy_writes | Manual (self-chaining batch import) | | `BackfillScrobbledTracks` | heavy_writes | Manual (self-chaining batch import) |
| `SendRecordsOnThisDayEmail` | default | Cron (daily "records on this day" email) | | `SendRecordsOnThisDayEmail` | default | Cron (daily "records on this day" email) |
### Cron Workers ### Cron Workers
| Schedule | Worker | Queue | | Schedule | Worker | Queue |
|----------|--------|-------| | ------------------ | --------------------------------- | ------------ |
| Every 12h | `ApplyScrobbleRules` | heavy_writes | | Every 12h | `ApplyScrobbleRules` | heavy_writes |
| Every 12h | `PruneAssetCache` | default | | Every 12h | `PruneAssetCache` | default |
| Daily 2 AM | `PruneAssets` | default | | Daily 2 AM | `PruneAssets` | default |
| Daily 3 AM | `RepoVacuum` | heavy_writes | | Daily 3 AM | `RepoVacuum` | heavy_writes |
| Daily 4 AM | `RepoOptimize` | heavy_writes | | Daily 4 AM | `RepoOptimize` | heavy_writes |
| Monthly 1st, 6 AM | `RecordRefreshAllMusicBrainzData` | music_brainz | | Monthly 1st, 6 AM | `RecordRefreshAllMusicBrainzData` | music_brainz |
| Monthly 1st, 7 AM | `RecordGenerateAllEmbeddings` | heavy_writes | | Monthly 1st, 7 AM | `RecordGenerateAllEmbeddings` | heavy_writes |
| Monthly 1st, 8 AM | `ArtistRefreshAllMusicBrainzData` | music_brainz | | Monthly 1st, 8 AM | `ArtistRefreshAllMusicBrainzData` | music_brainz |
| Monthly 1st, 9 AM | `ArtistRefreshAllDiscogsData` | discogs | | Monthly 1st, 9 AM | `ArtistRefreshAllDiscogsData` | discogs |
| Monthly 1st, 10 AM | `ArtistRefreshAllWikipediaData` | wikipedia | | Monthly 1st, 10 AM | `ArtistRefreshAllWikipediaData` | wikipedia |
| Daily 7 AM | `SendRecordsOnThisDayEmail` | default | | Daily 7 AM | `SendRecordsOnThisDayEmail` | default |
| Every 5 min | `RefreshScrobbles` | last_fm | | Every 5 min | `RefreshScrobbles` | last_fm |
--- ---
## PubSub Topics ## PubSub Topics
| PubSub | Topic Pattern | Message | Used By | | PubSub | Topic Pattern | Message | Used By |
|--------|---------------|---------|---------| | ---------------- | -------------------------- | ------------------- | ------------------------------------------------------------------ |
| `:music_library` | `"records:#{id}"` | `{:update, record}` | CollectionLive.Show, WishlistLive.Show — real-time record updates | | `:music_library` | `"records:#{id}"` | `{:update, record}` | CollectionLive.Show, WishlistLive.Show — real-time record updates |
| `:music_library` | `"listening_stats:update"` | `%{track_count: n}` | StatsLive.Index, ScrobbledTracksLive.Index — new scrobbles arrived | | `:music_library` | `"listening_stats:update"` | `%{track_count: n}` | StatsLive.Index, ScrobbledTracksLive.Index — new scrobbles arrived |
--- ---
@@ -259,91 +261,92 @@ HTTP 429 into `:rate_limit` vs `:auth_error` by reading the body `code`
### Router Structure ### Router Structure
All authenticated routes live inside a single `live_session` with three `on_mount` hooks: All authenticated routes live inside a single `live_session` with three `on_mount` hooks:
- `StaticAssets` — detects app updates, shows toast - `StaticAssets` — detects app updates, shows toast
- `GetTimezone` — reads timezone from connect params - `GetTimezone` — reads timezone from connect params
- `ShowToast` — enables `put_toast!/2` in LiveViews - `ShowToast` — enables `put_toast!/2` in LiveViews
### LiveViews ### LiveViews
| LiveView | Route | Purpose | | LiveView | Route | Purpose |
|----------|-------|---------| | ------------------------------- | --------------------------------------- | -------------------------------------------------------------------- |
| `StatsLive.Index` | `/` | Dashboard: counts, recent activity, records on this day | | `StatsLive.Index` | `/` | Dashboard: counts, recent activity, records on this day |
| `CollectionLive.Index` | `/collection` | Browse/search collected records (grid/list, paginated) | | `CollectionLive.Index` | `/collection` | Browse/search collected records (grid/list, paginated) |
| `CollectionLive.Show` | `/collection/:id` | Record detail: metadata, scrobbles, similar, colors | | `CollectionLive.Show` | `/collection/:id` | Record detail: metadata, scrobbles, similar, colors |
| `WishlistLive.Index` | `/wishlist` | Browse/search wishlisted records | | `WishlistLive.Index` | `/wishlist` | Browse/search wishlisted records |
| `WishlistLive.Show` | `/wishlist/:id` | Wishlist record detail with store links | | `WishlistLive.Show` | `/wishlist/:id` | Wishlist record detail with store links |
| `ArtistLive.Show` | `/artists/:musicbrainz_id` | Artist bio, discography, similar artists | | `ArtistLive.Show` | `/artists/:musicbrainz_id` | Artist bio, discography, similar artists |
| `RecordSetLive.Index` | `/record-sets` | Browse/manage curated record sets | | `RecordSetLive.Index` | `/record-sets` | Browse/manage curated record sets |
| `RecordSetLive.Show` | `/record-sets/:id` | Set detail with reorderable items | | `RecordSetLive.Show` | `/record-sets/:id` | Set detail with reorderable items |
| `ScrobbleLive.Index` | `/scrobble` | Search MusicBrainz release groups to scrobble | | `ScrobbleLive.Index` | `/scrobble` | Search MusicBrainz release groups to scrobble |
| `ScrobbleLive.ReleaseGroupShow` | `/scrobble/:rg_id` | List releases within a release group | | `ScrobbleLive.ReleaseGroupShow` | `/scrobble/:rg_id` | List releases within a release group |
| `ScrobbleLive.ReleaseShow` | `/scrobble/:rg_id/releases/:release_id` | Select tracks and scrobble (uses `Release` live_component) | | `ScrobbleLive.ReleaseShow` | `/scrobble/:rg_id/releases/:release_id` | Select tracks and scrobble (uses `Release` live_component) |
| `ScrobbledTracksLive.Index` | `/scrobbled-tracks` | Browse/search Last.fm history | | `ScrobbledTracksLive.Index` | `/scrobbled-tracks` | Browse/search Last.fm history |
| `ScrobbleRulesLive.Index` | `/scrobble-rules` | Browse/search/sort scrobble remapping rules (paginated, 50 per page) | | `ScrobbleRulesLive.Index` | `/scrobble-rules` | Browse/search/sort scrobble remapping rules (paginated, 50 per page) |
| `OnlineStoreTemplateLive.Index` | `/online-store-templates` | Manage store URL templates | | `OnlineStoreTemplateLive.Index` | `/online-store-templates` | Manage store URL templates |
| `MaintenanceLive.Index` | `/maintenance` | Admin: batch jobs, DB maintenance, Last.fm connection | | `MaintenanceLive.Index` | `/maintenance` | Admin: batch jobs, DB maintenance, Last.fm connection |
### LiveComponents ### LiveComponents
| Component | Used In | Purpose | | Component | Used In | Purpose |
|-----------|---------|---------| | ------------------------------ | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `RecordForm` | Collection/Wishlist (edit) | Record editing: cover search, genre autocomplete, color picker, file upload | | `RecordForm` | Collection/Wishlist (edit) | Record editing: cover search, genre autocomplete, color picker, file upload |
| `ArtistLive.Form` | ArtistLive.Show | Edit artist image (upload + Brave image search) | | `ArtistLive.Form` | ArtistLive.Show | Edit artist image (upload + Brave image search) |
| `RecordSetLive.Form` | RecordSetLive.Index | Create/edit record set | | `RecordSetLive.Form` | RecordSetLive.Index | Create/edit record set |
| `RecordSetLive.RecordPicker` | RecordSetLive.Show | Search and add records to set | | `RecordSetLive.RecordPicker` | RecordSetLive.Show | Search and add records to set |
| `ScrobbledTracksLive.Form` | ScrobbledTracksLive.Index | Edit scrobbled track | | `ScrobbledTracksLive.Form` | ScrobbledTracksLive.Index | Edit scrobbled track |
| `ScrobbleRulesLive.Form` | ScrobbleRulesLive.Index | Create/edit scrobble rule | | `ScrobbleRulesLive.Form` | ScrobbleRulesLive.Index | Create/edit scrobble rule |
| `ScrobbleRulePicker` | ScrobbledTracksLive.Index, StatsLive.Index | Search records and create scrobble rules inline | | `ScrobbleRulePicker` | ScrobbledTracksLive.Index, StatsLive.Index | Search records and create scrobble rules inline |
| `OnlineStoreTemplateLive.Form` | OnlineStoreTemplateLive.Index | Create/edit store template | | `OnlineStoreTemplateLive.Form` | OnlineStoreTemplateLive.Index | Create/edit store template |
| `StatsLive.TopByPeriod` | StatsLive.TopAlbums, StatsLive.TopArtists | Generic period-tabbed stats display (7d, 30d, 90d, 1y, all-time) | | `StatsLive.TopByPeriod` | StatsLive.TopAlbums, StatsLive.TopArtists | Generic period-tabbed stats display (7d, 30d, 90d, 1y, all-time) |
| `StatsLive.TopAlbums` | StatsLive.Index | Top albums by period (uses TopByPeriod) | | `StatsLive.TopAlbums` | StatsLive.Index | Top albums by period (uses TopByPeriod) |
| `StatsLive.TopArtists` | StatsLive.Index | Top artists by period (uses TopByPeriod) | | `StatsLive.TopArtists` | StatsLive.Index | Top artists by period (uses TopByPeriod) |
| `UniversalSearchLive.Index` | Layout (global) | Cmd+K search modal with quick actions (add to wishlist/collection, scrobble, collection chat) | | `UniversalSearchLive.Index` | Layout (global) | Cmd+K search modal with quick actions (add to wishlist/collection, scrobble, collection chat) |
| `Chat` | CollectionLive.Index, CollectionLive.Show, WishlistLive.Show, ArtistLive.Show | AI chat sheet (OpenAI streaming, configurable per entity) | | `Chat` | CollectionLive.Index, CollectionLive.Show, WishlistLive.Show, ArtistLive.Show | AI chat sheet (OpenAI streaming, configurable per entity) |
| `Notes` | CollectionLive.Show, WishlistLive.Show, ArtistLive.Show | Markdown note rendering and editing | | `Notes` | CollectionLive.Show, WishlistLive.Show, ArtistLive.Show | Markdown note rendering and editing |
| `AddRecord` | CollectionLive.Index, WishlistLive.Index | MusicBrainz import interface | | `AddRecord` | CollectionLive.Index, WishlistLive.Index | MusicBrainz import interface |
| `BarcodeScanner` | CollectionLive.Index | Barcode scanning UI (uses barcode-detector JS) | | `BarcodeScanner` | CollectionLive.Index | Barcode scanning UI (uses barcode-detector JS) |
| `Release` | CollectionLive.Show, ScrobbleLive.ReleaseShow | MusicBrainz release display with scrobble (form-based with auto-recovery) | | `Release` | CollectionLive.Show, ScrobbleLive.ReleaseShow | MusicBrainz release display with scrobble (form-based with auto-recovery) |
### Shared Component Modules (lib/music_library_web/components/) ### Shared Component Modules (lib/music_library_web/components/)
| Module | Purpose | | Module | Purpose |
|--------|---------| | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `CoreComponents` | Forms, buttons, icons, tables, flash messages | | `CoreComponents` | Forms, buttons, icons, tables, flash messages |
| `Layouts` | Application layout templates, navigation components (`dropdown_nav/1`) | | `Layouts` | Application layout templates, navigation components (`dropdown_nav/1`) |
| `RecordComponents` | Record cards, cover images, artist images, labels, grids, release status icon badge, shared show-page sections (title, external links, genres, releases, timestamps, debug) | | `RecordComponents` | Record cards, cover images, artist images, labels, grids, release status icon badge, shared show-page sections (title, external links, genres, releases, timestamps, debug) |
| `ChartComponents` | Charts for stats dashboard | | `ChartComponents` | Charts for stats dashboard |
| `StatsComponents` | Stats dashboard widgets (`section/1` layout, counters, album preview, records on this day) | | `StatsComponents` | Stats dashboard widgets (`section/1` layout, counters, album preview, records on this day) |
| `ScrobbleComponents` | Scrobble activity displays: status badges, import dropdowns, metadata tooltips, and record-matching UI | | `ScrobbleComponents` | Scrobble activity displays: status badges, import dropdowns, metadata tooltips, and record-matching UI |
| `SearchComponents` | Search result rendering | | `SearchComponents` | Search result rendering |
| `CartComponents` | `cart_sidebar/1` — shared cart aside used by `AddRecord` and `BarcodeScanner` | | `CartComponents` | `cart_sidebar/1` — shared cart aside used by `AddRecord` and `BarcodeScanner` |
| `Pagination` | Pagination UI and logic | | `Pagination` | Pagination UI and logic |
### Web Utility Modules (lib/music_library_web/) ### Web Utility Modules (lib/music_library_web/)
| Module | Purpose | | Module | Purpose |
|--------|---------| | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `ErrorMessages` | Maps internal error terms (atoms, structs) to user-friendly gettext strings via `friendly_message/1` | | `ErrorMessages` | Maps internal error terms (atoms, structs) to user-friendly gettext strings via `friendly_message/1` |
| `Markdown` | Markdown-to-HTML conversion (MDEx with ammonia sanitization) with `[[double bracket]]` link syntax and streaming document support for chat | | `Markdown` | Markdown-to-HTML conversion (MDEx with ammonia sanitization) with `[[double bracket]]` link syntax and streaming document support for chat |
| `Duration` | Milliseconds to human-readable duration formatting | | `Duration` | Milliseconds to human-readable duration formatting |
| `Auth` | Authentication plugs: login password check, API token validation, session enforcement | | `Auth` | Authentication plugs: login password check, API token validation, session enforcement |
| `ArtistLive.Biography` | Artist biography building/rendering from Wikipedia and Last.fm data | | `ArtistLive.Biography` | Artist biography building/rendering from Wikipedia and Last.fm data |
| `LiveHelpers.Params` | URL query param parsing: pagination, search query, sort order, display mode, fallback index | | `LiveHelpers.Params` | URL query param parsing: pagination, search query, sort order, display mode, fallback index |
| `LiveHelpers.IndexActions` | Shared index page logic (search, pagination, import, delete, display mode) for Collection/Wishlist index pages, parameterized by config map | | `LiveHelpers.IndexActions` | Shared index page logic (search, pagination, import, delete, display mode) for Collection/Wishlist index pages, parameterized by config map |
| `LiveHelpers.RecordActions` | Shared record action handlers (refresh cover, genres, embeddings, MusicBrainz data) for Collection/Wishlist show pages | | `LiveHelpers.RecordActions` | Shared record action handlers (refresh cover, genres, embeddings, MusicBrainz data) for Collection/Wishlist show pages |
| — | Logster v2 handles `[:phoenix, :socket_connected]` telemetry — not a custom module | | — | Logster v2 handles `[:phoenix, :socket_connected]` telemetry — not a custom module |
### Controllers ### Controllers
| Controller | Routes | Purpose | | Controller | Routes | Purpose |
|------------|--------|---------| | ---------------------- | ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
| `SessionController` | `/login`, `/sessions/create` | Login/logout | | `SessionController` | `/login`, `/sessions/create` | Login/logout |
| `HealthController` | `/health` | Health check | | `HealthController` | `/health` | Health check |
| `LastFmController` | `/auth/last_fm/callback` | Last.fm OAuth | | `LastFmController` | `/auth/last_fm/callback` | Last.fm OAuth |
| `ArchiveController` | `/backup`, `/api/v1/backup` | Database backup download (API route requires token) | | `ArchiveController` | `/backup`, `/api/v1/backup` | Database backup download (API route requires token) |
| `AssetController` | `/assets/:transform_payload`, `/public/assets/:transform_payload`, `/api/v1/assets/:transform_payload` | Serve images with transforms (public route for emails, API route requires token) | | `AssetController` | `/assets/:transform_payload`, `/public/assets/:transform_payload`, `/api/v1/assets/:transform_payload` | Serve images with transforms (public route for emails, API route requires token) |
| `CollectionController` | `/api/v1/collection/*` | JSON API for collection queries | | `CollectionController` | `/api/v1/collection/*` | JSON API for collection queries |
| `ErrorController` | `/api/v1/errors`, `/api/v1/errors/:id` | JSON API for production error queries (requires Bearer token) | | `ErrorController` | `/api/v1/errors`, `/api/v1/errors/:id` | JSON API for production error queries (requires Bearer token) |
--- ---
@@ -355,26 +358,26 @@ All authenticated routes live inside a single `live_session` with three `on_moun
### JS Hooks ### JS Hooks
| Hook | Type | Purpose | | Hook | Type | Purpose |
|------|------|---------| | --------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------- |
| `FormatNumber` | External (`assets/js/hooks/`) | Client-side number formatting | | `FormatNumber` | External (`assets/js/hooks/`) | Client-side number formatting |
| `UniversalSearchNavigation` | External | Keyboard navigation in search modal (via `create-navigation-hook` factory) | | `UniversalSearchNavigation` | External | Keyboard navigation in search modal (via `create-navigation-hook` factory) |
| `RecordPickerNavigation` | External | Keyboard navigation in record picker (via `create-navigation-hook` factory) | | `RecordPickerNavigation` | External | Keyboard navigation in record picker (via `create-navigation-hook` factory) |
| `RulePickerNavigation` | External | Keyboard navigation in scrobble rule picker (via `create-navigation-hook` factory) | | `RulePickerNavigation` | External | Keyboard navigation in scrobble rule picker (via `create-navigation-hook` factory) |
| `SortableList` | External (`assets/js/hooks/`) | Drag-and-drop reordering of record set items (uses sortablejs) | | `SortableList` | External (`assets/js/hooks/`) | Drag-and-drop reordering of record set items (uses sortablejs) |
| `LiveToast` | External (via `createLiveToastHook`) | Toast notification rendering | | `LiveToast` | External (via `createLiveToastHook`) | Toast notification rendering |
| Various `.ColocatedHooks` | Colocated (in .heex) | Inline hooks prefixed with `.` (includes `.ScrollBottom` for Chat) | | Various `.ColocatedHooks` | Colocated (in .heex) | Inline hooks prefixed with `.` (includes `.ScrollBottom` for Chat) |
### JS Event Listeners (app.js) ### JS Event Listeners (app.js)
All events are namespaced with `music_library:` prefix. All events are namespaced with `music_library:` prefix.
| Event | Action | | Event | Action |
|-------|--------| | -------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `music_library:clipcopy` | Copy text to clipboard | | `music_library:clipcopy` | Copy text to clipboard |
| `music_library:scroll_top` | Scroll window to top | | `music_library:scroll_top` | Scroll window to top |
| `music_library:confetti` | Trigger canvas-confetti animation | | `music_library:confetti` | Trigger canvas-confetti animation |
| `music_library:download` | Decode base64 blob and trigger browser file download (dispatched via `push_event`, prefixed `phx:` on client) | | `music_library:download` | Decode base64 blob and trigger browser file download (dispatched via `push_event`, prefixed `phx:` on client) |
### NPM Dependencies ### NPM Dependencies
@@ -390,26 +393,26 @@ All events are namespaced with `music_library:` prefix.
### Test Support ### Test Support
| Module | Purpose | | Module | Purpose |
|--------|---------| | ----------------- | ------------------------------------------- |
| `ConnCase` | HTTP test setup, auto-logged-in session | | `ConnCase` | HTTP test setup, auto-logged-in session |
| `DataCase` | Database test setup with Ecto sandbox | | `DataCase` | Database test setup with Ecto sandbox |
| `LiveTestHelpers` | `escape/1` for HTML-escaped text assertions | | `LiveTestHelpers` | `escape/1` for HTML-escaped text assertions |
### Fixture Modules (test/support/fixtures/) ### Fixture Modules (test/support/fixtures/)
| Module | Creates | | Module | Creates |
|--------|---------| | ------------------------------------------- | ----------------------------- |
| `MusicLibrary.RecordsFixtures` | Records with MusicBrainz data | | `MusicLibrary.RecordsFixtures` | Records with MusicBrainz data |
| `MusicLibrary.RecordSetsFixtures` | Record sets with items | | `MusicLibrary.RecordSetsFixtures` | Record sets with items |
| `MusicLibrary.OnlineStoreTemplatesFixtures` | Store templates | | `MusicLibrary.OnlineStoreTemplatesFixtures` | Store templates |
| `MusicLibrary.ArtistInfoFixtures` | ArtistInfo records | | `MusicLibrary.ArtistInfoFixtures` | ArtistInfo records |
| `ScrobbleRulesFixtures` | Scrobble rules | | `ScrobbleRulesFixtures` | Scrobble rules |
| `ScrobbledTracksFixtures` | Last.fm tracks | | `ScrobbledTracksFixtures` | Last.fm tracks |
| `Discogs.ArtistFixtures` | Discogs API responses | | `Discogs.ArtistFixtures` | Discogs API responses |
| `LastFm.ArtistFixtures` | Last.fm API responses | | `LastFm.ArtistFixtures` | Last.fm API responses |
| `MusicBrainz.*Fixtures` | MusicBrainz API responses | | `MusicBrainz.*Fixtures` | MusicBrainz API responses |
| `Wikipedia.Fixtures` | Wikipedia API responses | | `Wikipedia.Fixtures` | Wikipedia API responses |
### Test Styles ### Test Styles
+56 -53
View File
@@ -19,12 +19,12 @@ push via GitHub Actions.
## Hosting ## Hosting
| Component | Technology | | Component | Technology |
|-----------|-----------| | ----------------- | ----------------------------------------- |
| Orchestration | Coolify (self-hosted) | | Orchestration | Coolify (self-hosted) |
| Container runtime | Docker | | Container runtime | Docker |
| SSL termination | Coolify reverse proxy | | SSL termination | Coolify reverse proxy |
| HTTP redirect | HTTP 301 → HTTPS (enforced by app config) | | HTTP redirect | HTTP 301 → HTTPS (enforced by app config) |
The Docker image is a multi-stage build: The Docker image is a multi-stage build:
@@ -42,11 +42,11 @@ Fluxon UI (licensed dependency) is fetched during build via Docker build secrets
Three separate SQLite databases, each managed by its own Ecto repo: Three separate SQLite databases, each managed by its own Ecto repo:
| Repo | Purpose | Cache size | Pool size | | Repo | Purpose | Cache size | Pool size |
|------|---------|------------|-----------| | ----------------------------- | ---------------------------- | ---------- | ------------------------ |
| `MusicLibrary.Repo` | Application data | 128 MB | `$POOL_SIZE` (default 5) | | `MusicLibrary.Repo` | Application data | 128 MB | `$POOL_SIZE` (default 5) |
| `MusicLibrary.BackgroundRepo` | Oban job queue | 16 MB | `$POOL_SIZE` (default 5) | | `MusicLibrary.BackgroundRepo` | Oban job queue | 16 MB | `$POOL_SIZE` (default 5) |
| `MusicLibrary.TelemetryRepo` | Persistent telemetry metrics | 4 MB | 2 | | `MusicLibrary.TelemetryRepo` | Persistent telemetry metrics | 4 MB | 2 |
All databases use incremental auto-vacuum. Paths are configured via environment variables All databases use incremental auto-vacuum. Paths are configured via environment variables
(`DATABASE_PATH`, `BACKGROUND_DATABASE_PATH`, `TELEMETRY_DATABASE_PATH`). (`DATABASE_PATH`, `BACKGROUND_DATABASE_PATH`, `TELEMETRY_DATABASE_PATH`).
@@ -60,13 +60,13 @@ All databases use incremental auto-vacuum. Paths are configured via environment
Configured inline in `compose.yaml`. Runs as a separate Docker Compose service Configured inline in `compose.yaml`. Runs as a separate Docker Compose service
(`litestream/litestream:0.5.11-scratch`) sharing the database volume. (`litestream/litestream:0.5.11-scratch`) sharing the database volume.
| Setting | Value | | Setting | Value |
|---------|-------| | ------------- | --------------------------------------------------------- |
| S3 endpoint | `https://nbg1.your-objectstorage.com` | | S3 endpoint | `https://nbg1.your-objectstorage.com` |
| Bucket | `ffmusiclibrary` | | Bucket | `ffmusiclibrary` |
| Sync interval | 60 minutes | | Sync interval | 60 minutes |
| Retention | 672 hours (28 days) | | Retention | 672 hours (28 days) |
| Healthcheck | `litestream databases` every 30s (timeout 10s, 3 retries) | | Healthcheck | `litestream databases` every 30s (timeout 10s, 3 retries) |
Credentials via environment: `LITESTREAM_ACCESS_KEY_ID`, `LITESTREAM_SECRET_ACCESS_KEY`. Credentials via environment: `LITESTREAM_ACCESS_KEY_ID`, `LITESTREAM_SECRET_ACCESS_KEY`.
@@ -132,10 +132,10 @@ Fluxon (private dependency) is configured with a dedicated registry entry.
### Deployment credentials ### Deployment credentials
| Secret/Variable | Purpose | | Secret/Variable | Purpose |
|-----------------|---------| | ------------------ | ---------------------------------------- |
| `COOLIFY_TOKEN` | API authentication (GitHub secret) | | `COOLIFY_TOKEN` | API authentication (GitHub secret) |
| `COOLIFY_HOST` | Coolify server address (GitHub variable) | | `COOLIFY_HOST` | Coolify server address (GitHub variable) |
| `COOLIFY_APP_UUID` | Application identifier (GitHub variable) | | `COOLIFY_APP_UUID` | Application identifier (GitHub variable) |
--- ---
@@ -144,32 +144,32 @@ Fluxon (private dependency) is configured with a dedicated registry entry.
### Required ### Required
| Variable | Purpose | | Variable | Purpose |
|----------|---------| | -------------------------- | ---------------------------------------------------- |
| `DATABASE_PATH` | Absolute path to main SQLite database | | `DATABASE_PATH` | Absolute path to main SQLite database |
| `BACKGROUND_DATABASE_PATH` | Absolute path to background jobs database | | `BACKGROUND_DATABASE_PATH` | Absolute path to background jobs database |
| `TELEMETRY_DATABASE_PATH` | Absolute path to telemetry database | | `TELEMETRY_DATABASE_PATH` | Absolute path to telemetry database |
| `SECRET_KEY_BASE` | Phoenix secret (`mix phx.gen.secret`) | | `SECRET_KEY_BASE` | Phoenix secret (`mix phx.gen.secret`) |
| `CLOAK_ENCRYPTION_KEY` | Base64-encoded 32-byte AES key for encrypted secrets | | `CLOAK_ENCRYPTION_KEY` | Base64-encoded 32-byte AES key for encrypted secrets |
| `LOGIN_PASSWORD` | Web login password | | `LOGIN_PASSWORD` | Web login password |
| `API_TOKEN` | Bearer token for API endpoints | | `API_TOKEN` | Bearer token for API endpoints |
| `MAILGUN_API_KEY` | Mailgun API key | | `MAILGUN_API_KEY` | Mailgun API key |
### Optional ### Optional
| Variable | Default | Purpose | | Variable | Default | Purpose |
|----------|---------|---------| | ------------------------------- | ------------------------- | ----------------------------- |
| `SERVICE_FQDN_WEB` | `example.com` | Application domain | | `SERVICE_FQDN_WEB` | `example.com` | Application domain |
| `PORT` | `4000` | HTTP listen port | | `PORT` | `4000` | HTTP listen port |
| `POOL_SIZE` | `5` | Database connection pool size | | `POOL_SIZE` | `5` | Database connection pool size |
| `MAILGUN_DOMAIN` | `mailgun.fullyforged.com` | Mailgun sending domain | | `MAILGUN_DOMAIN` | `mailgun.fullyforged.com` | Mailgun sending domain |
| `DEFAULT_TIMEZONE` | `Europe/London` | Application timezone | | `DEFAULT_TIMEZONE` | `Europe/London` | Application timezone |
| `OPENAI_KEY` | — | OpenAI API (embeddings, chat) | | `OPENAI_KEY` | — | OpenAI API (embeddings, chat) |
| `DISCOGS_PERSONAL_ACCESS_TOKEN` | — | Discogs API | | `DISCOGS_PERSONAL_ACCESS_TOKEN` | — | Discogs API |
| `BRAVE_SEARCH_API_KEY` | — | Brave Search API | | `BRAVE_SEARCH_API_KEY` | — | Brave Search API |
| `LAST_FM_API_KEY` | — | Last.fm API | | `LAST_FM_API_KEY` | — | Last.fm API |
| `LAST_FM_SHARED_SECRET` | — | Last.fm scrobbling auth | | `LAST_FM_SHARED_SECRET` | — | Last.fm scrobbling auth |
| `LAST_FM_USER` | — | Last.fm username | | `LAST_FM_USER` | — | Last.fm username |
--- ---
@@ -276,22 +276,25 @@ Pi extensions provide additional tools for production observability without manu
or browser access. Each extension reads its own environment variables from the pi or browser access. Each extension reads its own environment variables from the pi
runtime environment (not server-side config). runtime environment (not server-side config).
| Extension | Tools | Env vars | | Extension | Tools | Env vars |
|-----------|-------|----------| | ------------- | ------------------------------------------------------------------- | ------------------------------------------------------------ |
| `prod-logs` | `fetch_production_logs` | `PI_COOLIFY_HOST`, `PI_COOLIFY_APP_UUID`, `PI_COOLIFY_TOKEN` | | `prod-logs` | `fetch_production_logs` | `PI_COOLIFY_HOST`, `PI_COOLIFY_APP_UUID`, `PI_COOLIFY_TOKEN` |
| `prod-errors` | `fetch_production_errors`, `fetch_production_error`, `/prod-errors` | `PI_API_TOKEN`, `PI_SERVICE_FQDN_WEB` | | `prod-errors` | `fetch_production_errors`, `fetch_production_error`, `/prod-errors` | `PI_API_TOKEN`, `PI_SERVICE_FQDN_WEB` |
**`prod-logs` env vars:** **`prod-logs` env vars:**
- `PI_COOLIFY_HOST` — Coolify server base URL (e.g., `https://coolify.example.com`) - `PI_COOLIFY_HOST` — Coolify server base URL (e.g., `https://coolify.example.com`)
- `PI_COOLIFY_APP_UUID` — Application UUID in Coolify - `PI_COOLIFY_APP_UUID` — Application UUID in Coolify
- `PI_COOLIFY_TOKEN` — Coolify API Bearer token - `PI_COOLIFY_TOKEN` — Coolify API Bearer token
**`prod-errors` tools and command:** **`prod-errors` tools and command:**
- `fetch_production_errors` — List/filter errors via LLM tool - `fetch_production_errors` — List/filter errors via LLM tool
- `fetch_production_error` — Single error detail via LLM tool - `fetch_production_error` — Single error detail via LLM tool
- `/prod-errors` — Interactive TUI for browsing errors (list, detail, filter toggles) - `/prod-errors` — Interactive TUI for browsing errors (list, detail, filter toggles)
**`prod-errors` env vars:** **`prod-errors` env vars:**
- `PI_API_TOKEN` — Must match the `API_TOKEN` env var on the production server (used for Bearer auth on `/api/v1/*`) - `PI_API_TOKEN` — Must match the `API_TOKEN` env var on the production server (used for Bearer auth on `/api/v1/*`)
- `PI_SERVICE_FQDN_WEB` — Production domain with protocol (e.g., `https://musiclibrary.claudio-ortolina.org`, no trailing slash) - `PI_SERVICE_FQDN_WEB` — Production domain with protocol (e.g., `https://musiclibrary.claudio-ortolina.org`, no trailing slash)
@@ -301,10 +304,10 @@ runtime environment (not server-side config).
Mailgun via Swoosh (`Swoosh.Adapters.Mailgun`). Mailgun via Swoosh (`Swoosh.Adapters.Mailgun`).
| Setting | Value | | Setting | Value |
|---------|-------| | ------- | ------------------------------------ |
| From | `postmaster@mailgun.fullyforged.com` | | From | `postmaster@mailgun.fullyforged.com` |
| To | `claudio@fullyforged.com` | | To | `claudio@fullyforged.com` |
Used for error notifications and the daily "records on this day" digest email. Used for error notifications and the daily "records on this day" digest email.
+6
View File
@@ -22,3 +22,9 @@ mix gettext.extract --merge
debug_msg "Running shellcheck..." debug_msg "Running shellcheck..."
fd . 'scripts/' --exclude '*.hurl' -t file --exec shellcheck --color fd . 'scripts/' --exclude '*.hurl' -t file --exec shellcheck --color
debug_msg "Running prettier..."
prettier --write '.pi/extensions/**/*.{ts,js,json}'
prettier --write 'assets/css/**/*.css' 'assets/js/**/*.js'
prettier --write 'docs/**/*.md' 'docs/**/*.livemd' 'README.md'
prettier --write 'backlog/archive/**/*.md' 'backlog/completed/**/*.md' 'backlog/tasks/**/*.md' 'backlog/docs/**/*.md'