Skip to content

feat: Discogs music scraper plugin (#87)#100

Open
fabiodalez-dev wants to merge 28 commits intomainfrom
feature/87-discogs-plugin
Open

feat: Discogs music scraper plugin (#87)#100
fabiodalez-dev wants to merge 28 commits intomainfrom
feature/87-discogs-plugin

Conversation

@fabiodalez-dev
Copy link
Copy Markdown
Owner

@fabiodalez-dev fabiodalez-dev commented Mar 30, 2026

Summary

New optional plugin that integrates with the Discogs API for music metadata scraping (CD, LP, vinyl, cassette).

Closes #87

How it works

  • Registers as a scraping source via the existing hook system (scrape.sources, scrape.fetch.custom, scrape.data.modify)
  • Searches by barcode (EAN) first (most reliable for physical media), falls back to title search
  • Fetches full release details and maps to Pinakes fields:
Discogs Pinakes
Artists Autore
Labels Editore
Year Anno pubblicazione
Tracklist Descrizione
Images Copertina
Barcode EAN
Genres Genere
Format (CD/Vinyl/etc) Formato

Configuration

  • Optional Discogs personal access token (Settings > Plugin) for 60 req/min instead of 25
  • No token required for basic functionality
  • 1-second delay between API calls for rate limiting

Files

  • storage/plugins/discogs/DiscogsPlugin.php — Main plugin class
  • storage/plugins/discogs/wrapper.php — PluginManager compatibility wrapper
  • storage/plugins/discogs/plugin.json — Plugin manifest
  • storage/plugins/discogs/README.md — Documentation
  • .gitignore — Added discogs plugin exceptions

Test plan

  • Install and activate plugin from Admin > Plugins
  • Search a music CD by barcode (EAN)
  • Search a vinyl record by title
  • Verify cover, tracklist, artist, label are populated
  • Verify rate limiting (1s between requests)
  • Test without API token (should work with 25 req/min limit)
  • PHPStan level 1 passes

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Discogs-backed music scraper with MusicBrainz/Deezer cover enrichment and admin-configurable API token; plugin install/activation UI.
  • UI Improvements

    • Media-type (tipo_media) support across import/export, filters, badges/icons, media-aware detail pages (MusicAlbum schema, tracklist formatting) and settings page with token redaction.
  • Localization

    • Added IT/EN/DE translations for media/music labels and messages.
  • Tests

    • Multiple Playwright E2E suites for scraping, import, media-type and plugin flows.
  • Chores

    • DB migration adding tipo_media and composite index; installer/updater and plugin loading robustness improvements.

New plugin that integrates with Discogs API for music metadata:
- Search by barcode (EAN) or title+artist
- Maps: Artist → Author, Label → Publisher, Tracklist → Description
- Covers from Discogs images
- Format detection: CD, Vinyl, Cassette
- Optional API token for higher rate limits (60 vs 25 req/min)
- Same hook pattern as OpenLibrary (scrape.sources/fetch/modify)
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 30, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a Discogs-backed multi-source music scraper plugin, introduces a canonical tipo_media enum with migration and indexes, implements MediaLabels utilities and UI/API support for media types, refactors plugin/installer/updater plumbing, and adds end-to-end Playwright tests and admin plugin settings.

Changes

Cohort / File(s) Summary
Discogs plugin
storage/plugins/discogs/...
\storage/plugins/discogs/DiscogsPlugin.php`, `storage/plugins/discogs/plugin.json`, `storage/plugins/discogs/wrapper.php`, `storage/plugins/discogs/README.md`, `storage/plugins/discogs/views/settings.php``
New Discogs multi-source scraper: Discogs/MusicBrainz/CAA/Deezer integration, API helpers, hook registration and DB persistence, admin settings UI, wrapper for legacy PluginManager loading, and documentation.
Media type core & schema
app/Support/MediaLabels.php, installer/database/schema.sql, installer/database/migrations/migrate_0.5.4.sql
Adds MediaLabels helper (normalize/infer/labels/icons/tracklist) and DB schema changes: libri.tipo_media ENUM, composite index, migration with backfill and index swaps.
Plugin system & updater
app/Support/PluginManager.php, app/Support/BundledPlugins.php, app/Support/Updater.php, installer/classes/Installer.php
Refactors plugin instantiation, adds getPluginInstance(), centralizes bundled list in BundledPlugins::LIST, and implements staging/backup swap + strict removal for bundled-plugin updates.
Plugin admin pages & routing
app/Controllers/PluginController.php, app/Routes/web.php, app/Views/admin/plugins.php, app/Support/RouteTranslator.php
Adds GET /admin/plugins/{id}/settings handler and PluginController.settingsPage; registers per-plugin settings link for Discogs and adds plugins fallback route.
Scraping, enrichment & API callers
app/Controllers/ScrapeController.php, storage/plugins/discogs/...
Discogs-first barcode/title search with MusicBrainz fallback, Cover Art Archive and optional Deezer cover enrichment; ensures tipo_media resolution before ISBN normalization; unified mapping into internal payload.
Import/CSV/LibraryThing controllers
app/Controllers/CsvImportController.php, app/Controllers/LibraryThingImportController.php
Schema-aware CSV/TSV parsing: accepts tipo_media headers, conditionally includes tipo_media in SQL only if column exists, dynamic bind-param construction, and parsing normalization.
Controllers: other APIs & filters
app/Controllers/LibriApiController.php, app/Controllers/FrontendController.php, app/Controllers/PublicApiController.php, app/Controllers/LibriController.php
Thread tipo_media through list APIs, filters, exports and controller logic; adds query param support and conditional WHERE clauses when DB column exists; expose tipo_media in public responses.
Repository & business logic
app/Models/BookRepository.php
Normalize/resolve tipo_media on create/update/updateOptionals using MediaLabels and normalizeEnumValue before persisting.
Views & frontend UI
app/Views/...
\app/Views/frontend/book-detail.php`, `app/Views/libri/scheda_libro.php`, `app/Views/frontend/catalog-grid.php`, `app/Views/frontend/catalog.php`, `app/Views/libri/index.php`, `app/Views/libri/partials/book_form.php``
Media-aware rendering and inputs: Schema.org type switching, media badges/icons, context-aware labels, tracklist formatting, catalog tipo_media filter UI and form tipo_media select with import auto-populate.
Translations & locales
locale/*
\locale/en_US.json`, `locale/de_DE.json`, `locale/it_IT.json``
Added localization keys for media types, artist/barcode/tracklist/discography and Discogs token messages across locales.
Tests & Playwright
tests/*
\tests/discogs-plugin.spec.js`, `tests/discogs-import.spec.js`, `tests/discogs-advanced.spec.js`, `tests/multisource-scraping.spec.js`, `tests/pr100-media-types.spec.js`, `tests/playwright.config.js``, ...
Multiple new serial E2E suites covering plugin activation, scraping/import flows, tipo_media behaviors, catalog/filtering, CSV export and UI differences; Playwright limited to single worker.
Installer & schema tooling
installer/database/migrate_0.5.4.sql, installer/database/schema.sql, installer/classes/Installer.php
Idempotent migration adding tipo_media enum, composite index management, backfill updates, and installer index adjustments.
Project config & packaging
.gitignore, version.json
Un-ignores Discogs plugin root while keeping most contents ignored and allowing specific file types and views/*.php; adds test-results/ ignore entry; bumps version to 0.5.4.
Autoload updates
vendor/composer/...
Composer autoload classmap entries added for App\Support\BundledPlugins and App\Support\MediaLabels.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant User as Admin/User
    participant App as Application
    participant Discogs as Discogs API
    participant MB as MusicBrainz API
    participant CAA as Cover Art Archive
    participant Deezer as Deezer API
    participant DB as Database

    User->>App: Import/scrape request (barcode/title) or admin import
    App->>Discogs: Search release by barcode
    Discogs-->>App: Release list / none

    alt Discogs found
        App->>Discogs: Fetch release details
        Discogs-->>App: Release metadata + images
    else Discogs not found
        App->>MB: Search release by barcode
        MB-->>App: Release metadata
        App->>CAA: Fetch cover by MB release id
        CAA-->>App: cover image or none
    end

    App->>App: Map/normalize payload (tipo_media, tracks, cover, ids)
    alt Missing cover or genres
        App->>Deezer: Search album by artist+title
        Deezer-->>App: cover_xl
    end

    App->>DB: Merge/persist merged data (including tipo_media)
    DB-->>App: OK
    App-->>User: Return populated form / saved record
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I hopped through codes and album art,

Discogs hummed and found its part.
Tipo media stitched with care,
Badges gleam and tracklists share,
Books and discs—together, smart.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 56.52% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main feature: introducing a Discogs music scraper plugin.
Linked Issues check ✅ Passed The PR comprehensively implements all requirements from issue #87: music metadata scraping, Discogs integration, field mapping, optional API token support, and rate limiting.
Out of Scope Changes check ✅ Passed The PR includes extensive in-scope changes to support music media types beyond just the Discogs plugin: tipo_media column, field label adaptation, frontend/backend filtering, API extensions, database migrations, and localization updates.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/87-discogs-plugin

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
storage/plugins/discogs/DiscogsPlugin.php (1)

85-89: Keep hook registration in one source of truth.

setPluginId() immediately calls ensureHooksRegistered(), while the same hook names and priorities already live in storage/plugins/discogs/plugin.json. That leaves hook metadata duplicated in two places, and the COUNT(*) > 0 check is too weak to catch partial drift. Prefer manifest-driven registration, or validate the expected hook set explicitly.

Also applies to: 94-155

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/discogs/DiscogsPlugin.php` around lines 85 - 89, The call to
ensureHooksRegistered() from setPluginId() duplicates hook metadata that already
lives in storage/plugins/discogs/plugin.json; remove the implicit registration
here and instead drive hook registration from the manifest (plugin.json) or add
an explicit validation that compares the manifest's expected hook
names/priorities with what's persisted and only registers missing/incorrect
entries. Concretely, stop calling ensureHooksRegistered() inside setPluginId(),
implement a single manifest-driven routine (e.g., load and parse
storage/plugins/discogs/plugin.json) to register hooks (or to diff and fix
persisted hooks), and/or update ensureHooksRegistered()/the registration
functions referenced in the block around lines 94-155 to accept the manifest as
the source of truth and perform exact set comparison (not COUNT(*) > 0) before
inserting/updating hooks.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 225-259: Centralize and implement token-aware throttling inside
the DiscogsPlugin::apiRequest method so callers like the search/fetch sequence
in the try block (calls to apiRequest from within DiscogsPlugin where
getSetting('api_token') is used) no longer have to sleep manually; detect
whether a token is present (token truthy from getSetting or passed into
apiRequest) and enforce a minimum inter-request delay of ~1.0s for authenticated
requests and ~2.4s for anonymous requests, tracking the timestamp of the last
request per-plugin (or per-token/anonymous bucket) to sleep the remaining time
before performing the HTTP call, and remove/avoid hardcoded usleep calls (e.g.,
the usleep(1000000) calls around search and release fetch) so rate limiting is
handled centrally in apiRequest.
- Around line 232-245: The fallback currently does a generic q= search with only
$isbn and then trusts $searchResult['results'][0] which can return an unrelated
release; update the logic in the DiscogsPlugin method that calls
$this->apiRequest so that after the search you verify the candidate release
actually contains the barcode/isbn before accepting it (e.g., inspect the
returned release's identifiers/barcodes fields for a match to $isbn), and if no
exact barcode is found return $currentResult instead of using results[0];
alternatively, remove the generic q= fallback and immediately return
$currentResult when the barcode lookup fails.

---

Nitpick comments:
In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 85-89: The call to ensureHooksRegistered() from setPluginId()
duplicates hook metadata that already lives in
storage/plugins/discogs/plugin.json; remove the implicit registration here and
instead drive hook registration from the manifest (plugin.json) or add an
explicit validation that compares the manifest's expected hook names/priorities
with what's persisted and only registers missing/incorrect entries. Concretely,
stop calling ensureHooksRegistered() inside setPluginId(), implement a single
manifest-driven routine (e.g., load and parse
storage/plugins/discogs/plugin.json) to register hooks (or to diff and fix
persisted hooks), and/or update ensureHooksRegistered()/the registration
functions referenced in the block around lines 94-155 to accept the manifest as
the source of truth and perform exact set comparison (not COUNT(*) > 0) before
inserting/updating hooks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b53824a1-80c6-433c-8656-8439478dee1d

📥 Commits

Reviewing files that changed from the base of the PR and between d0cf881 and 1f59d6a.

📒 Files selected for processing (5)
  • .gitignore
  • storage/plugins/discogs/DiscogsPlugin.php
  • storage/plugins/discogs/README.md
  • storage/plugins/discogs/plugin.json
  • storage/plugins/discogs/wrapper.php

- MediaLabels helper: dynamic field labels based on format (Artista,
  Etichetta, Tracce for music media)
- Frontend book-detail: labels adapt for CD/vinyl/cassette
- Admin scheda_libro: labels adapt for music
- Discogs plugin settings page with API token input
- Rate limiting centralized in apiRequest() (1s auth, 2.5s anon)
- Removed unreliable generic q= search fallback
- Translations: IT/EN/DE for all music labels
- Add discogs to BUNDLED_PLUGINS in PluginManager and Updater
- 7 E2E tests: plugin install/activate, settings page, MediaLabels
  for music vs regular books, barcode label on frontend, scraping
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/Views/frontend/book-detail.php (1)

1699-1717: ⚠️ Potential issue | 🟡 Minor

Music imports still fall through to the EAN label.

storage/plugins/discogs/DiscogsPlugin.php stores the scanned code in ean and leaves isbn13 empty. This change only renames the isbn13 row, so real Discogs items still hit the unchanged EAN block below and never show the new "Barcode" label. Prefer the ean value/label when $isMusic is true.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/frontend/book-detail.php` around lines 1699 - 1717, The music
imports put the scanned code in $book['ean'] so adjust the ISBN/EAN rendering to
prefer EAN for music: update the block that currently uses $book['isbn13'] (and
the label logic using $isMusic) to display $book['ean'] when $isMusic and
$book['ean'] is not empty (fall back to $book['isbn13'] otherwise), and change
the EAN meta-item block to either skip for music or show the label "Barcode"
when $isMusic (and use $book['ean'] as the value); target the conditional blocks
using $isMusic, $book['isbn13'] and $book['ean'] in the book-detail.php snippet
so Discogs-imported items display the Barcode label and value correctly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Support/MediaLabels.php`:
- Around line 14-29: isMusic currently does substring matching against
self::$musicFormats (including 'audio'), so strings like
"audiolibro"/"audiobook" match; change isMusic to only accept exact normalized
tokens or word-boundary matches: in the isMusic(?string $formato) method
normalize (trim, strtolower), then either split on non-alphanumeric characters
and check each resulting token for exact membership in self::$musicFormats or
use a single preg_match that checks
\b(music|cd|vinyl|lp|cassette|cd_audio|vinile|cassetta|musik)\b; also remove or
avoid plain substring checks for 'audio' (make it a separate token if you keep
it) so audiobook strings no longer match.

In `@app/Views/frontend/book-detail.php`:
- Around line 2178-2180: The fallback label uses the parent page's $isMusic;
change the ternary to use the related item's format flag so each related card
decides its own label—e.g. when rendering <?=
htmlspecialchars($related['autori'] ?? __($isMusic ? 'Artista sconosciuto' :
'Autore sconosciuto')...) ?> replace $isMusic with ($related['isMusic'] ??
$isMusic) (or the related item's format key if named differently) so the
translation call uses the related item's isMusic flag while keeping
htmlspecialchars, ENT_QUOTES and UTF-8 intact.

In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 220-255: The early-return guard that skips Discogs when any
non-Discogs source set a title prevents Discogs-only fields from ever being
merged; locate the conditional starting with is_array($currentResult) &&
!empty($currentResult['title']) and either remove that short-circuit or change
it to check a real completeness signal (e.g. a boolean flag like
$currentResult['complete'] === true or a specific required-field check) so that
mergeBookData($currentResult, $discogsData) and BookDataMerger::merge() still
run for partial upstream results; ensure mapReleaseToPinakes($release,
$firstResult, $isbn) output is merged rather than skipped.
- Around line 604-648: getSetting()/saveSettings() in DiscogsPlugin currently
read/write plaintext from plugin_settings while
PluginController::updateSettings() uses PluginManager::setSetting() (which
stores encrypted "ENC:..." values), causing inconsistent handling of api_token;
update DiscogsPlugin to always use PluginManager's API for settings so
encryption/decryption is consistent: replace direct DB reads/writes in
getSetting() and saveSettings() to call
PluginManager::getSetting()/PluginManager::setSetting() (or the project
equivalent), ensure the resolved pluginId path still works when fetching via
PluginManager, and make sure the token passed to the Discogs client is the
decrypted value returned by PluginManager::getSetting() (i.e., strip/handle
"ENC:" transparently via the manager).

In `@storage/plugins/discogs/views/settings.php`:
- Around line 96-100: The form currently round-trips the masked value from
DiscogsPlugin::getSettings() through the password input (id/name "api_token"),
which causes literal asterisks to overwrite the real token on save; change the
view in settings.php to render the password input value as empty when a token
exists and instead render a separate indicator/hidden field (e.g., "has_token")
to show presence of a saved token, and update the server-side save handler
(e.g., DiscogsPlugin::saveSettings() or equivalent) to only update the stored
token when a non-empty "api_token" is submitted (ignore empty values and/or the
"has_token" flag), so opening and saving without editing does not replace the
real token.
- Around line 133-137: Replace the hardcoded href that calls
url('/admin/plugins') with the named-route helper (e.g., route_path('plugins')
or RouteTranslator::route('plugins')) and pass that result through
htmlspecialchars(…, ENT_QUOTES, 'UTF-8') before output; update the anchor in
settings.php so it uses the named route helper instead of the literal
"/admin/plugins" to keep routing and localization consistent (refer to
route_path and RouteTranslator::route as the functions to use).

In `@tests/discogs-plugin.spec.js`:
- Around line 183-202: The test currently fetches the admin URL
(`/admin/libri/${bookId}`) so it never exercises the frontend view or the
EAN→Barcode label logic in app/Views/frontend/book-detail.php; update the test
'6. Frontend: music book shows Barcode instead of ISBN-13' to request the public
book page (use the public route the app serves for book details instead of
`/admin/libri/${bookId}`, e.g. the frontend path built from BASE and the book
identifier/slug), then assert that the returned HTML for that public page
contains "Barcode" (or the music-aware labels) and not the ISBN label; keep the
same test setup (inserting the record with TEST_BARCODE and selecting bookId)
but replace the admin fetch using page.request.get with a fetch to the frontend
URL so the EAN→Barcode behavior in app/Views/frontend/book-detail.php is
actually validated.
- Around line 77-137: The test uses broad page-wide selectors so operations may
act on other plugins and silent no-ops hide failures; scope all locators to the
Discogs card (discogsCard) before clicking install (installBtn), activate
(activateBtn) or opening settings (settingsLink), assert discogsCard.isVisible()
and throw/fail if missing, assert that dbQuery shows activation
(pluginActivated) after activateBtn.click(), and explicitly assert
tokenField.isVisible() (instead of silently continuing) so the test fails when
Discogs is not installed/activated or the API token field is absent.

---

Outside diff comments:
In `@app/Views/frontend/book-detail.php`:
- Around line 1699-1717: The music imports put the scanned code in $book['ean']
so adjust the ISBN/EAN rendering to prefer EAN for music: update the block that
currently uses $book['isbn13'] (and the label logic using $isMusic) to display
$book['ean'] when $isMusic and $book['ean'] is not empty (fall back to
$book['isbn13'] otherwise), and change the EAN meta-item block to either skip
for music or show the label "Barcode" when $isMusic (and use $book['ean'] as the
value); target the conditional blocks using $isMusic, $book['isbn13'] and
$book['ean'] in the book-detail.php snippet so Discogs-imported items display
the Barcode label and value correctly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f1ff7bca-6875-4950-9817-c1b642b81b57

📥 Commits

Reviewing files that changed from the base of the PR and between 1f59d6a and 498c2bc.

📒 Files selected for processing (12)
  • .gitignore
  • app/Controllers/PluginController.php
  • app/Support/MediaLabels.php
  • app/Support/PluginManager.php
  • app/Support/Updater.php
  • app/Views/frontend/book-detail.php
  • app/Views/libri/scheda_libro.php
  • locale/de_DE.json
  • locale/en_US.json
  • storage/plugins/discogs/DiscogsPlugin.php
  • storage/plugins/discogs/views/settings.php
  • tests/discogs-plugin.spec.js
✅ Files skipped from review due to trivial changes (5)
  • .gitignore
  • app/Support/Updater.php
  • app/Support/PluginManager.php
  • locale/de_DE.json
  • locale/en_US.json

ScrapeController::normalizeIsbnFields() was auto-populating isbn13
from the search term. For Discogs results (source=discogs), the
search term is a barcode/EAN, not an ISBN — skip ISBN auto-population.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
tests/discogs-import.spec.js (2)

109-112: The artist check is currently a no-op.

This block computes authorCount but never asserts it, so the suite does not actually verify the Artists → Autore mapping from this PR.

Suggested fix
-    const authorItems = page.locator('#autori-wrapper .choices__item--selectable, .choices__item.choices__item--selectable');
+    const authorItems = page.locator('#autori-wrapper .choices__item.choices__item--selectable');
     const authorCount = await authorItems.count().catch(() => 0);
+    if (authorCount === 0) {
+      throw new Error('No artist chips were populated by the Discogs import');
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-import.spec.js` around lines 109 - 112, The test computes
authorCount from the locator authorItems but never asserts it; update the test
(in tests/discogs-import.spec.js) to assert that authorCount is greater than
zero (e.g., use your test framework's expect/assert on authorCount) so the
"Artists → Autore" mapping is actually verified; refer to the authorItems
locator and the authorCount variable when adding the assertion.

179-184: Keep the music-format guard aligned with production.

app/Support/MediaLabels.php:14-33 treats lp, cassetta, cassette, audio, musik, and music as music too. This subset can skip the label assertion even when the saved item is a valid music import.

Suggested fix
-    if (formato && ['cd_audio', 'vinile', 'cd', 'vinyl'].some(f => formato.toLowerCase().includes(f))) {
+    if (formato && ['cd_audio', 'vinile', 'cassetta', 'vinyl', 'lp', 'cd', 'cassette', 'audio', 'musik', 'music']
+      .some(f => formato.toLowerCase().includes(f))) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-import.spec.js` around lines 179 - 184, The music-format guard
in the test only checks ['cd_audio','vinile','cd','vinyl'] but must match the
production list used by MediaLabels (include tokens like 'lp', 'cassetta',
'cassette', 'audio', 'musik', 'music' as well); update the array used with
formato.toLowerCase().includes(...) in the test to include those additional
tokens so the conditional around hasMusicLabel (which checks content for
'Etichetta'/'Label'/'Anno di Uscita'/'Release Year') mirrors the production
MediaLabels behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/discogs-import.spec.js`:
- Around line 77-79: The test currently treats a missing import button or empty
titolo as a skip (using importBtn.isVisible(...).catch(() => false) then
test.skip), but after plugin activation those cases indicate a real product
failure because fetchFromDiscogs() can return previous results on errors; update
the test to fail fast instead: replace the test.skip branches at the import
button check (importBtn.isVisible) and the titolo empty check with explicit
assertions that throw or use test.fail/assert to fail the test when the import
button is not visible or titolo is empty, and reference the fetchFromDiscogs()
behavior in the failure message to aid debugging.
- Around line 50-53: The test teardown in test.afterAll currently only removes
rows by EAN via dbExec("DELETE FROM libri WHERE ean = '0720642442524'...") but
the import logic also persists ISBN-13 (see save paths around lines saving
isbn13), so modify the cleanup to delete rows where either ean = '0720642442524'
OR isbn13 = '0720642442524' (or run two deletes) using the same dbExec call(s);
update the test.afterAll block to remove both persistence paths so stale rows
don't survive between test runs.
- Around line 151-160: The test currently uses test.skip when dbQuery returns no
row, which hides a failed persistence; update the block that checks book and
bookByIsbn so that a missing DB row fails the test instead of skipping: replace
the test.skip(bookByIsbn === '', ...) call with an assertion or explicit failure
(e.g., expect(bookByIsbn).not.toBe('') or throw new Error(...)) and keep the
subsequent expect(bookByIsbn.toLowerCase()).toContain('nevermind') path; locate
the logic around the book variable, the dbQuery(...) call and the test.skip
invocation to make this change.

---

Nitpick comments:
In `@tests/discogs-import.spec.js`:
- Around line 109-112: The test computes authorCount from the locator
authorItems but never asserts it; update the test (in
tests/discogs-import.spec.js) to assert that authorCount is greater than zero
(e.g., use your test framework's expect/assert on authorCount) so the "Artists →
Autore" mapping is actually verified; refer to the authorItems locator and the
authorCount variable when adding the assertion.
- Around line 179-184: The music-format guard in the test only checks
['cd_audio','vinile','cd','vinyl'] but must match the production list used by
MediaLabels (include tokens like 'lp', 'cassetta', 'cassette', 'audio', 'musik',
'music' as well); update the array used with formato.toLowerCase().includes(...)
in the test to include those additional tokens so the conditional around
hasMusicLabel (which checks content for 'Etichetta'/'Label'/'Anno di
Uscita'/'Release Year') mirrors the production MediaLabels behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9563aa2a-a47d-44d2-8396-94e770ceb55c

📥 Commits

Reviewing files that changed from the base of the PR and between 498c2bc and ea3a5d0.

📒 Files selected for processing (1)
  • tests/discogs-import.spec.js

The book form submit shows a SweetAlert 'Are you sure?' dialog.
The test now clicks the confirm button before waiting for navigation.
All 6 import tests pass including save + DB verify + label check.
- MediaLabels::formatDisplayName() maps cd_audio→"CD Audio", vinile→"Vinile" etc.
- All format names translatable via __() (IT/EN/DE)
- Frontend book-detail: format shows display name, not raw key
- Admin scheda_libro: same format display name
- Discogs plugin: tracklist now generates <ol> HTML, not plain text
- Frontend: music description section shows formatted tracklist with <ol>
- MediaLabels::formatTracklist() for legacy plain-text tracklists
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
app/Support/MediaLabels.php (1)

14-29: ⚠️ Potential issue | 🟠 Major

Stop using substring matches for music detection.

Line 28 still matches short fragments like audio, cd, and lp, so audiolibro/audiobook and other unrelated formats can flip the UI to music labels. Normalize to a canonical token and do exact matches or word-boundary checks instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Support/MediaLabels.php` around lines 14 - 29, The isMusic method in
MediaLabels currently uses substring checks against self::$musicFormats which
causes false positives (e.g., "audiolibro"); update it to normalize the input
(trim, lowercase) then split/tokenize on non-word boundaries into canonical
tokens and check for exact token membership instead of str_contains; also
normalize/replace entries in the $musicFormats list to canonical tokens (e.g.,
"cd", "vinyl", "cassette", "lp", "music", "audio") and perform either an
in_array lookup or a word-boundary regex match against those tokens in isMusic
to avoid matching substrings like "audiobook".
🧹 Nitpick comments (6)
app/Views/libri/scheda_libro.php (2)

8-9: Unused variable $isMusic.

The variable is declared but not referenced in the changed code. The label switching is done inline by passing $libro['formato'] directly to MediaLabels::label() calls. Consider removing if not used elsewhere in this file.

#!/bin/bash
# Verify if $isMusic is used anywhere in the file
rg '\$isMusic' app/Views/libri/scheda_libro.php
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/libri/scheda_libro.php` around lines 8 - 9, Remove the unused local
variable $isMusic which is assigned via MediaLabels::isMusic($libro['formato']
?? null) but never referenced; instead, delete the assignment line and rely on
the existing inline MediaLabels::label(...) calls (or if you intended to use
$isMusic, replace the inline calls to reference $isMusic where appropriate).
Ensure you remove only the unused $isMusic declaration and do not alter
MediaLabels::label or other logic.

147-147: Consider escaping MediaLabels::label() output for consistency.

Line 379 properly escapes the format display name with HtmlHelper::e(), but lines 147, 337, and 366 output labels directly. While these are likely safe translation strings, escaping ensures consistent defense-in-depth.

🔧 Suggested fix for consistent escaping
-            <span class="font-medium"><?= \App\Support\MediaLabels::label('editore', $libro['formato'] ?? null) ?>:</span>
+            <span class="font-medium"><?= App\Support\HtmlHelper::e(\App\Support\MediaLabels::label('editore', $libro['formato'] ?? null)) ?>:</span>

Apply similar changes to lines 337 and 366.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/libri/scheda_libro.php` at line 147, Escape the output of
MediaLabels::label() for defense-in-depth: replace direct echoing of
\App\Support\MediaLabels::label(...) with
HtmlHelper::e(\App\Support\MediaLabels::label(...)) wherever it is used (e.g.,
the occurrences that currently render labels), and apply the same change to the
other two label usages so all label outputs are consistently wrapped with
HtmlHelper::e().
tests/discogs-import.spec.js (4)

63-63: Prefer condition-based waits over fixed timeouts.

waitForTimeout(2000) is brittle and can cause flaky tests or unnecessary slowdowns. Consider waiting for a specific DOM condition or network idle state instead.

Suggested fix
-      await page.waitForTimeout(2000);
+      // Wait for plugin registration to complete
+      await page.waitForFunction(() => {
+        return document.querySelector('[data-plugin="discogs"]') !== null ||
+               document.readyState === 'complete';
+      }, { timeout: 5000 }).catch(() => {});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-import.spec.js` at line 63, Replace the brittle fixed delay at
page.waitForTimeout(2000) with a condition-based wait: identify a stable DOM
element or network condition that signals the app is ready (e.g., a specific
selector or an API response) and use page.waitForSelector('...') or
page.waitForResponse(...) / page.waitForLoadState('networkidle') instead of
page.waitForTimeout; update the test in tests/discogs-import.spec.js where
page.waitForTimeout(2000) is called so it waits for the specific DOM selector or
response relevant to the import flow.

110-112: Unnecessary .catch(() => 0) on count().

Playwright's count() returns a Promise<number> that resolves to 0 for no matches—it doesn't reject. The catch masks unexpected errors.

Suggested fix
-    const authorCount = await authorItems.count().catch(() => 0);
+    const authorCount = await authorItems.count();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-import.spec.js` around lines 110 - 112, The call to
authorItems.count() is wrapped with an unnecessary .catch(() => 0) which masks
real errors; remove the .catch and simply await authorItems.count() (locator:
authorItems from page.locator('#autori-wrapper .choices__item--selectable,
.choices__item.choices__item--selectable')) so you receive the actual numeric
count (Playwright resolves to 0 when no matches) and let unexpected errors
surface normally.

117-122: Complex OR assertion is hard to debug on failure.

When this fails, the error message won't indicate which field was checked or what values were found.

Suggested fix for clearer failure messages
     // Check EAN field has the barcode
     const eanValue = await page.locator('input[name="ean"]').inputValue();
     // The barcode might be in isbn13 or ean depending on the scraper
     const isbn13Value = await page.locator('input[name="isbn13"]').inputValue();
-    expect(eanValue === TEST_BARCODE || isbn13Value === TEST_BARCODE ||
-           eanValue.includes('720642442524') || isbn13Value.includes('720642442524')).toBe(true);
+    const barcodeFound = eanValue === TEST_BARCODE || isbn13Value === TEST_BARCODE ||
+                         eanValue.includes('720642442524') || isbn13Value.includes('720642442524');
+    expect(barcodeFound, `Expected barcode in ean="${eanValue}" or isbn13="${isbn13Value}"`).toBe(true);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-import.spec.js` around lines 117 - 122, The complex OR
assertion using eanValue, isbn13Value, and TEST_BARCODE is hard to debug;
replace it with clearer checks: first compute eanValue and isbn13Value as
already done, then assert each possibility in separate expectations (or a single
expectation that includes a diagnostic message) so test failure shows which
field/value failed; reference the eanValue and isbn13Value variables and the
TEST_BARCODE constant in tests/discogs-import.spec.js (replace the combined
expect(...) with individual expects that check eanValue === TEST_BARCODE,
isbn13Value === TEST_BARCODE, eanValue.includes('720642442524'), and
isbn13Value.includes('720642442524') or a single expect with a descriptive
failure message containing the actual values).

86-88: Replace fixed 8-second timeout with a condition-based wait.

Fixed timeouts are brittle—too short causes flakiness, too long wastes CI time. Wait for the scrape result indicator instead.

Suggested fix
     // Wait for scraping response (Discogs needs time + rate limits)
-    // The scraping service tries multiple sources — wait up to 20s
-    await page.waitForTimeout(8000);
+    // Wait for title field to be populated or loading indicator to disappear
+    await page.waitForFunction(
+      () => {
+        const title = document.querySelector('input[name="titolo"]');
+        const loading = document.querySelector('.scrape-loading, .spinner');
+        return (title && title.value !== '') || !loading;
+      },
+      { timeout: 20000 }
+    ).catch(() => {});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-import.spec.js` around lines 86 - 88, Replace the brittle fixed
delay (page.waitForTimeout) with a condition-based wait that polls for the
scraping result indicator: remove the await page.waitForTimeout(8000) call and
instead use page.waitForSelector(...) or page.waitForFunction(...) to wait up to
the same maximum (e.g., 20s) for the element/text that signals the scrape
completed (the DOM selector or text shown by the scraping service), and keep the
existing test flow after that; this change touches the page.waitForTimeout usage
in the test and uses page.waitForSelector/page.waitForFunction to await the
scrape result.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Support/MediaLabels.php`:
- Around line 90-91: The helper returns raw external tracklist text ($text) when
($tracks === false || count($tracks) < 2) and in the similar branch at lines
104-105; instead of returning $text raw, HTML-escape/encode it (e.g., via the
framework's escape helper or htmlspecialchars) before returning so the helper
consistently returns safe HTML fragments; update the branches that return $text
to return the escaped version and keep any intentional HTML whitelisting as a
separate, explicit path.

In `@locale/de_DE.json`:
- Around line 3517-3527: The German locale adds overly specific and a duplicate
media label: change the value for the "Cartaceo" key from "Taschenbuch" to a
more generic term like "Druckausgabe" (or "Print") so
MediaLabels::formatDisplayName() doesn't force a paperback meaning, and remove
or consolidate the duplicate "Altro" key (the second "Altro": "Sonstiges" entry)
so there is only one "Altro" translation in the file.

In `@tests/discogs-import.spec.js`:
- Line 28: The test suite runs files in parallel despite test.describe.serial in
tests/discogs-import.spec.js; to avoid cross-file DB race conditions set
Playwright workers to 1: update tests/playwright.config.js to add or set
workers: 1 in the exported config object, or alter the test NPM script in
package.json to append the flag --workers=1 so all test files run with a single
worker; ensure you apply the change globally so tests like
discogs-import.spec.js, discogs-plugin.spec.js, admin-features.spec.js, and
email-notifications.spec.js do not execute concurrently.

---

Duplicate comments:
In `@app/Support/MediaLabels.php`:
- Around line 14-29: The isMusic method in MediaLabels currently uses substring
checks against self::$musicFormats which causes false positives (e.g.,
"audiolibro"); update it to normalize the input (trim, lowercase) then
split/tokenize on non-word boundaries into canonical tokens and check for exact
token membership instead of str_contains; also normalize/replace entries in the
$musicFormats list to canonical tokens (e.g., "cd", "vinyl", "cassette", "lp",
"music", "audio") and perform either an in_array lookup or a word-boundary regex
match against those tokens in isMusic to avoid matching substrings like
"audiobook".

---

Nitpick comments:
In `@app/Views/libri/scheda_libro.php`:
- Around line 8-9: Remove the unused local variable $isMusic which is assigned
via MediaLabels::isMusic($libro['formato'] ?? null) but never referenced;
instead, delete the assignment line and rely on the existing inline
MediaLabels::label(...) calls (or if you intended to use $isMusic, replace the
inline calls to reference $isMusic where appropriate). Ensure you remove only
the unused $isMusic declaration and do not alter MediaLabels::label or other
logic.
- Line 147: Escape the output of MediaLabels::label() for defense-in-depth:
replace direct echoing of \App\Support\MediaLabels::label(...) with
HtmlHelper::e(\App\Support\MediaLabels::label(...)) wherever it is used (e.g.,
the occurrences that currently render labels), and apply the same change to the
other two label usages so all label outputs are consistently wrapped with
HtmlHelper::e().

In `@tests/discogs-import.spec.js`:
- Line 63: Replace the brittle fixed delay at page.waitForTimeout(2000) with a
condition-based wait: identify a stable DOM element or network condition that
signals the app is ready (e.g., a specific selector or an API response) and use
page.waitForSelector('...') or page.waitForResponse(...) /
page.waitForLoadState('networkidle') instead of page.waitForTimeout; update the
test in tests/discogs-import.spec.js where page.waitForTimeout(2000) is called
so it waits for the specific DOM selector or response relevant to the import
flow.
- Around line 110-112: The call to authorItems.count() is wrapped with an
unnecessary .catch(() => 0) which masks real errors; remove the .catch and
simply await authorItems.count() (locator: authorItems from
page.locator('#autori-wrapper .choices__item--selectable,
.choices__item.choices__item--selectable')) so you receive the actual numeric
count (Playwright resolves to 0 when no matches) and let unexpected errors
surface normally.
- Around line 117-122: The complex OR assertion using eanValue, isbn13Value, and
TEST_BARCODE is hard to debug; replace it with clearer checks: first compute
eanValue and isbn13Value as already done, then assert each possibility in
separate expectations (or a single expectation that includes a diagnostic
message) so test failure shows which field/value failed; reference the eanValue
and isbn13Value variables and the TEST_BARCODE constant in
tests/discogs-import.spec.js (replace the combined expect(...) with individual
expects that check eanValue === TEST_BARCODE, isbn13Value === TEST_BARCODE,
eanValue.includes('720642442524'), and isbn13Value.includes('720642442524') or a
single expect with a descriptive failure message containing the actual values).
- Around line 86-88: Replace the brittle fixed delay (page.waitForTimeout) with
a condition-based wait that polls for the scraping result indicator: remove the
await page.waitForTimeout(8000) call and instead use page.waitForSelector(...)
or page.waitForFunction(...) to wait up to the same maximum (e.g., 20s) for the
element/text that signals the scrape completed (the DOM selector or text shown
by the scraping service), and keep the existing test flow after that; this
change touches the page.waitForTimeout usage in the test and uses
page.waitForSelector/page.waitForFunction to await the scrape result.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 52c3815c-fa32-4a11-81f2-e258f66516db

📥 Commits

Reviewing files that changed from the base of the PR and between ea3a5d0 and 873e73f.

📒 Files selected for processing (8)
  • app/Controllers/ScrapeController.php
  • app/Support/MediaLabels.php
  • app/Views/frontend/book-detail.php
  • app/Views/libri/scheda_libro.php
  • locale/de_DE.json
  • locale/en_US.json
  • storage/plugins/discogs/DiscogsPlugin.php
  • tests/discogs-import.spec.js
✅ Files skipped from review due to trivial changes (2)
  • app/Controllers/ScrapeController.php
  • locale/en_US.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/Views/frontend/book-detail.php

Comment on lines +3517 to +3527
"Cartaceo": "Taschenbuch",
"eBook": "eBook",
"Audiolibro": "Hörbuch",
"CD Audio": "Audio-CD",
"Vinile": "Schallplatte",
"LP": "LP",
"Cassetta": "Kassette",
"DVD": "DVD",
"Blu-ray": "Blu-ray",
"Digitale": "Digital",
"Altro": "Sonstiges",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clean up the new German media labels.

MediaLabels::formatDisplayName() will surface Cartaceo as Taschenbuch, which is too specific for a generic print/physical format, and Line 3527 duplicates the existing Altro key from Line 207. Druckausgabe or Print would be a closer translation here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@locale/de_DE.json` around lines 3517 - 3527, The German locale adds overly
specific and a duplicate media label: change the value for the "Cartaceo" key
from "Taschenbuch" to a more generic term like "Druckausgabe" (or "Print") so
MediaLabels::formatDisplayName() doesn't force a paperback meaning, and remove
or consolidate the duplicate "Altro" key (the second "Altro": "Sonstiges" entry)
so there is only one "Altro" translation in the file.

Database:
- New ENUM column tipo_media (libro/disco/audiolibro/dvd/altro)
- Migration auto-populates from existing formato values
- Index idx_libri_tipo_media for efficient filtering

Backend:
- MediaLabels: allTypes(), icon(), schemaOrgType(), inferTipoMedia()
- All 7 controllers updated: filter, import/export, scraping
- BookRepository: hasColumn guard for backward compat
- Discogs plugin auto-sets tipo_media='disco'

Admin UI:
- Book form: tipo_media dropdown selector
- Book list: icon column + filter dropdown
- Book detail: media type badge

Frontend:
- Catalog: tipo_media filter in sidebar
- Book detail: Schema.org dynamic type (Book/MusicAlbum/Audiobook/Movie)
- Catalog grid: media type badge on non-book cards
- Format display names: cd_audio → "CD Audio" (translated)

Translations: IT/EN/DE for all media types and labels
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
app/Controllers/ScrapeController.php (1)

76-99: ⚠️ Potential issue | 🟠 Major

Discogs plugin cannot process non-ISBN barcodes due to validation gate.

The byIsbn() method validates input with isValidIsbn() (line 89), which accepts only ISBN-10 (10 chars) or ISBN-13 (13 digits) formats with strict checksums. Non-ISBN EAN/UPC codes (e.g., 12-digit EAN) are rejected before scrape.fetch.custom is invoked. Discogs plugin registers only scrape.sources, scrape.fetch.custom, and scrape.data.modify hooks—no scrape.isbn.validate bypass to widen acceptance. No alternative barcode entrypoint exists.

The guard at normalizeIsbnFields() (lines 776–780) preserves source === 'discogs' data, but the plugin never receives non-ISBN barcode inputs due to the validation wall at line 94. Barcode-first imports will fail with a 400 error before reaching the plugin.

To enable EAN/UPC support as documented, either: (1) relax isValidIsbn() to accept EAN formats, (2) add a scrape.isbn.validate hook in Discogs plugin to bypass validation, or (3) create a separate barcode endpoint.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/ScrapeController.php` around lines 76 - 99, byIsbn()
currently rejects non-ISBN barcodes so Discogs never sees EAN/UPC inputs; update
the validation path: change isValidIsbn() to also accept common barcode formats
(EAN-13, UPC-A 12, EAN-8) and ensure the cleaned value ($cleanIsbn) is
normalized (strip non-digits, uppercase) before validation, keeping the existing
hook call (\App\Support\Hooks::apply('scrape.isbn.validate', ...)); this lets
barcode values pass the validation gate and reach scrape.fetch.custom and
normalizeIsbnFields() where source === 'discogs' is preserved.
app/Controllers/CsvImportController.php (1)

1229-1254: ⚠️ Potential issue | 🟠 Major

Guard the UPDATE libri with the soft-delete predicate.

findExistingBook() filters deleted rows, but this standalone update can still hit a record that gets soft-deleted between lookup and write. Add the predicate here so the write path matches the repo contract.

🛡️ Minimal fix
-            WHERE id = ?
+            WHERE id = ? AND deleted_at IS NULL
As per coding guidelines, `Every query on libri table MUST include soft-delete condition: AND deleted_at IS NULL`.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/CsvImportController.php` around lines 1229 - 1254, The UPDATE
statement in CsvImportController (the standalone "UPDATE libri SET ... WHERE id
= ?") must include the soft-delete predicate to match findExistingBook() and
avoid updating a row that was soft-deleted between lookup and write; modify the
SQL in the CsvImportController update prepared statement to add "AND deleted_at
IS NULL" (so the WHERE clause becomes "WHERE id = ? AND deleted_at IS NULL") so
every write to the libri table respects the soft-delete contract.
♻️ Duplicate comments (2)
app/Support/MediaLabels.php (2)

93-95: ⚠️ Potential issue | 🔴 Critical

Escape the fallback text before returning HTML.

These branches hand raw external text back from a helper that otherwise emits HTML fragments, so callers can end up echoing untrusted markup whenever the parser does not split the tracklist.

Safer fallback
-        if ($tracks === false || count($tracks) < 2) {
-            return $text; // Not a tracklist, return as-is
-        }
+        if ($tracks === false || count($tracks) < 2) {
+            return nl2br(htmlspecialchars($text, ENT_QUOTES, 'UTF-8'), false);
+        }
@@
-        if (empty($items)) {
-            return $text;
-        }
+        if ($items === []) {
+            return nl2br(htmlspecialchars($text, ENT_QUOTES, 'UTF-8'), false);
+        }

Also applies to: 107-108

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Support/MediaLabels.php` around lines 93 - 95, The helper returns raw
external $text when parsing fails (if $tracks === false || count($tracks) < 2
and similarly at lines 107-108), which can emit untrusted markup; change those
fallback returns to return an escaped string instead (e.g., replace return $text
with return e($text) in Laravel or return htmlspecialchars($text, ENT_QUOTES,
'UTF-8') where e() isn't available) so callers always get safely escaped HTML
when $tracks or $text are returned.

14-35: ⚠️ Potential issue | 🟠 Major

Tighten the music matcher so audiobooks stay out of it.

str_contains($lower, 'audio') makes values like audiolibro / audiobook return true, which flips labels and tracklist rendering to music-specific UI for audiobook records.

Suggested tightening
-    /** `@var` array<int, string> */
-    private static array $musicFormats = [
-        'cd_audio', 'vinile', 'cassetta', 'vinyl', 'lp', 'cd', 'cassette', 'audio', 'musik', 'music'
-    ];
+    /** `@var` array<int, string> */
+    private const MUSIC_FORMATS = [
+        'cd_audio',
+        'vinile',
+        'audiocassetta',
+        'cassetta',
+        'vinyl',
+        'lp',
+        'cd',
+        'cassette',
+    ];
@@
-        foreach (self::$musicFormats as $musicFormat) {
-            if (str_contains($lower, $musicFormat)) {
-                return true;
-            }
-        }
-        return false;
+        if (in_array($lower, self::MUSIC_FORMATS, true)) {
+            return true;
+        }
+
+        return preg_match('/\b(?:music|musik)\b/i', $lower) === 1;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Support/MediaLabels.php` around lines 14 - 35, The isMusic method
currently treats any occurrence of tokens like "audio" as music (via
str_contains), causing "audiolibro"/"audiobook" to be misclassified; update
MediaLabels::isMusic to match whole words or token boundaries instead of
substring matches: replace the str_contains loop with a regex word-boundary
check (e.g., use preg_match with \b and preg_quote for each entry) or remove
overly-generic tokens like "audio" from the $musicFormats list and only keep
explicit music identifiers; reference the static property
MediaLabels::$musicFormats and the method MediaLabels::isMusic when making the
change. Ensure null/empty handling remains unchanged and that matching is
case-insensitive.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Controllers/CsvImportController.php`:
- Line 773: When processing CSV columns for tipo_media, don't blindly synthesize
or overwrite on updates: in updateBook() only set tipo_media when the CSV
actually contains a non-null value (e.g., check array_key_exists and that the
value is not null/empty) and normalize that provided value via your media helper
instead of inferring; do not call MediaLabels::inferTipoMedia() in updateBook().
Leave inference with MediaLabels::inferTipoMedia() only in insertBook() when
creating a new record. Apply the same pattern to the other CSV upsert paths
referenced (the other places using 'tipo_media' around the file) so updates
preserve existing tipo_media when the CSV omits the column and inserts still get
a sensible default.

In `@app/Controllers/LibriController.php`:
- Line 652: Remove the hardcoded default that sets 'tipo_media' => 'libro' in
the payload so that scraped_tipo_media can be used by updateOptionals($id,
array_merge($data, $fields)); specifically, stop seeding 'tipo_media' in the
code block that currently inserts 'tipo_media' => 'libro' (and the duplicate
instance around the other occurrence noted) so the merged payload does not
always override scraped_tipo_media — leave tipo_media absent or null unless an
explicit, authoritative tipo_media is provided.

In `@app/Models/BookRepository.php`:
- Around line 646-649: The update path currently normalizes a missing tipo_media
to 'libro', overwriting existing values; change the logic in BookRepository so
tipo_media is only persisted when the caller explicitly provided it (check
array_key_exists('tipo_media', $data) or isset($data['tipo_media']) before
calling $addSet). In other words, keep the existing DB value unless tipo_media
is present in $data, and when present continue using normalizeEnumValue($val,
'tipo_media', 'libro') and $addSet('tipo_media', 's', ...).

In `@app/Support/MediaLabels.php`:
- Around line 41-53: The formatDisplayName mapping and lookup are missing common
token aliases (e.g., 'cd', 'vinyl', 'cassette', 'blu_ray' and other
spelling/underscore/hyphen variants), so update the private static array
$formatDisplayNames to include these alias keys pointing to the same display
values (e.g., add 'cd' => 'CD', 'vinyl' => 'Vinile'/'Vinyl' as appropriate,
'cassette' => 'Cassetta', 'blu_ray' => 'Blu-ray', plus any
space/underscore/hyphen variants), and also ensure formatDisplayName()
normalizes the incoming token (lowercase and normalize
underscores/hyphens/spaces) before lookup; apply the same alias
additions/normalization to the other equivalent mappings referenced in the
comment (the arrays/methods around the other ranges) so all code paths accept
the same tokens.

In `@app/Views/frontend/catalog-grid.php`:
- Around line 41-43: The badge outputs unescaped helper values into HTML
attributes (span title and i class) using
MediaLabels::tipoMediaDisplayName($book['tipo_media']) and
MediaLabels::icon($book['tipo_media']); wrap both outputs with
htmlspecialchars(..., ENT_QUOTES, 'UTF-8') before echoing so translated labels
or icon names with quotes won’t break markup, e.g. escape the title value and
the class string for the <i> element while preserving the existing conditional
on $book['tipo_media'].

In `@app/Views/frontend/catalog.php`:
- Around line 1302-1326: The new tipo_media filter only updates the server but
the client never reapplies active states or label mapping; add a client-side
sync path analogous to syncAvailabilityActiveState() that listens for tipo_media
changes and updates the sidebar links' active class and the summary via
updateActiveFiltersDisplay(), and expose a JS label map built from
MediaLabels::allTypes() (emit the value=>label pairs from PHP into the page) so
updateActiveFiltersDisplay() shows human-friendly labels instead of raw enum
keys; ensure updateFilter('tipo_media', ...) triggers the same AJAX/DOM-refresh
flow as availability so the active state persists after AJAX updates.
- Around line 1318-1321: The inline JS argument currently passes raw PHP
interpolation for '<?= $tmValue ?>' into the onclick that calls
updateFilter('tipo_media', ...); fix by serializing the PHP value with
json_encode using JSON_HEX_TAG so the JS receives a safely escaped string;
update the onclick to call updateFilter('tipo_media', <serialized tmValue>)
where <serialized tmValue> is produced by json_encode($tmValue, JSON_HEX_TAG) to
prevent breaking quotes/HTML and follow the project's escaping guideline.

In `@app/Views/libri/index.php`:
- Around line 688-698: The render currently shows the enum key for tipo_media;
load the localized label map from MediaLabels::allTypes() into JS (e.g., a
mediaLabels object) and use it in the tipo_media column render: replace title
and displayed tooltip text with mediaLabels[data] || data (falling back to the
raw value). Do the same replacement where the active-filter chip is built (the
component that renders the selected tipo_media filter) so both the tooltip and
the chip show the localized label instead of the enum key.
- Around line 314-315: The DataTables order and CSS column selectors must be
updated to account for the new tipo_media column: change the JS order config
currently using order: [[3, 'asc']] so it sorts the Info/title column instead of
Cover (update it to order: [[4, 'asc']]); update the CSS selector that uses
nth-child(4) for text wrapping to nth-child(5) so it targets Info/title; and
update the mobile-hide rules that currently use nth-child(4)/hide the Media icon
so they instead target the Cover/Media positions as intended (use nth-child(4)
for hiding the Media icon per the comment). Locate and change the exact
occurrences of the strings order: [[3, 'asc']], nth-child(4) (text-wrap) and the
two nth-child rules around the mobile hide comments to the corrected indices.

In `@app/Views/libri/partials/book_form.php`:
- Around line 424-433: The rendered select must only use keys from
MediaLabels::allTypes(), so ensure any incoming scraper value (e.g.
data.tipo_media assigned to $book['tipo_media']) is validated/mapped before
rendering or saving; replace direct use of $book['tipo_media'] with a normalized
value derived by checking array_key_exists($book['tipo_media'],
MediaLabels::allTypes()) (or intersecting keys) and falling back to a default
like 'libro' (or null) — update the view logic around the select and the code
path that assigns $book['tipo_media'] from scraper input to perform this
whitelist check so saved and displayed values always match
MediaLabels::allTypes() keys.
- Around line 427-428: The option value is rendered raw in the loop over
\App\Support\MediaLabels::allTypes() (variables $value and $meta) — update the
option tag to escape the attribute by wrapping $value with
htmlspecialchars($value, ENT_QUOTES, 'UTF-8') when outputting the value
attribute (keep the comparison ($book['tipo_media'] ?? 'libro') === $value using
the original unescaped $value). Ensure ENT_QUOTES and UTF-8 are used to follow
the repo view-escaping guideline.

---

Outside diff comments:
In `@app/Controllers/CsvImportController.php`:
- Around line 1229-1254: The UPDATE statement in CsvImportController (the
standalone "UPDATE libri SET ... WHERE id = ?") must include the soft-delete
predicate to match findExistingBook() and avoid updating a row that was
soft-deleted between lookup and write; modify the SQL in the CsvImportController
update prepared statement to add "AND deleted_at IS NULL" (so the WHERE clause
becomes "WHERE id = ? AND deleted_at IS NULL") so every write to the libri table
respects the soft-delete contract.

In `@app/Controllers/ScrapeController.php`:
- Around line 76-99: byIsbn() currently rejects non-ISBN barcodes so Discogs
never sees EAN/UPC inputs; update the validation path: change isValidIsbn() to
also accept common barcode formats (EAN-13, UPC-A 12, EAN-8) and ensure the
cleaned value ($cleanIsbn) is normalized (strip non-digits, uppercase) before
validation, keeping the existing hook call
(\App\Support\Hooks::apply('scrape.isbn.validate', ...)); this lets barcode
values pass the validation gate and reach scrape.fetch.custom and
normalizeIsbnFields() where source === 'discogs' is preserved.

---

Duplicate comments:
In `@app/Support/MediaLabels.php`:
- Around line 93-95: The helper returns raw external $text when parsing fails
(if $tracks === false || count($tracks) < 2 and similarly at lines 107-108),
which can emit untrusted markup; change those fallback returns to return an
escaped string instead (e.g., replace return $text with return e($text) in
Laravel or return htmlspecialchars($text, ENT_QUOTES, 'UTF-8') where e() isn't
available) so callers always get safely escaped HTML when $tracks or $text are
returned.
- Around line 14-35: The isMusic method currently treats any occurrence of
tokens like "audio" as music (via str_contains), causing
"audiolibro"/"audiobook" to be misclassified; update MediaLabels::isMusic to
match whole words or token boundaries instead of substring matches: replace the
str_contains loop with a regex word-boundary check (e.g., use preg_match with \b
and preg_quote for each entry) or remove overly-generic tokens like "audio" from
the $musicFormats list and only keep explicit music identifiers; reference the
static property MediaLabels::$musicFormats and the method MediaLabels::isMusic
when making the change. Ensure null/empty handling remains unchanged and that
matching is case-insensitive.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5bb4cce7-9e90-491a-aac6-f9542e2d3b3c

📥 Commits

Reviewing files that changed from the base of the PR and between 873e73f and f1786b0.

📒 Files selected for processing (22)
  • app/Controllers/CsvImportController.php
  • app/Controllers/FrontendController.php
  • app/Controllers/LibraryThingImportController.php
  • app/Controllers/LibriApiController.php
  • app/Controllers/LibriController.php
  • app/Controllers/PublicApiController.php
  • app/Controllers/ScrapeController.php
  • app/Models/BookRepository.php
  • app/Support/MediaLabels.php
  • app/Views/frontend/book-detail.php
  • app/Views/frontend/catalog-grid.php
  • app/Views/frontend/catalog.php
  • app/Views/libri/index.php
  • app/Views/libri/partials/book_form.php
  • app/Views/libri/scheda_libro.php
  • installer/database/migrations/migrate_0.5.4.sql
  • installer/database/schema.sql
  • locale/de_DE.json
  • locale/en_US.json
  • locale/it_IT.json
  • storage/plugins/discogs/DiscogsPlugin.php
  • test-results/.last-run.json
✅ Files skipped from review due to trivial changes (4)
  • test-results/.last-run.json
  • installer/database/schema.sql
  • locale/it_IT.json
  • app/Views/libri/scheda_libro.php
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/Views/frontend/book-detail.php

Comment on lines +1302 to +1326
<!-- Media Type -->
<div class="filter-section">
<div class="filter-title">
<i class="fas fa-compact-disc"></i>
<?= __("Tipo Media") ?>
</div>
<div class="filter-options">
<?php
$currentTipo = $filters['tipo_media'] ?? '';
$tipoFilters = ['' => ['icon' => 'fa-th-large', 'label' => __('Tutti i media')]];
foreach (\App\Support\MediaLabels::allTypes() as $tmValue => $tmMeta) {
$tipoFilters[$tmValue] = ['icon' => $tmMeta['icon'], 'label' => __($tmMeta['label'])];
}
foreach ($tipoFilters as $tmValue => $tmInfo):
$isActive = $currentTipo === (string)$tmValue;
?>
<a href="#"
class="filter-option <?= $isActive ? 'active' : '' ?>"
onclick="updateFilter('tipo_media', '<?= $tmValue ?>'); return false;">
<i class="fas <?= $tmInfo['icon'] ?> me-1"></i>
<?= $tmInfo['label'] ?>
</a>
<?php endforeach; ?>
</div>
</div>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

tipo_media is only half-wired on the client.

After updateFilter('tipo_media', ...), the sidebar list never reapplies its .active state, and updateActiveFiltersDisplay() falls back to raw enum keys (disco, dvd, …) because this filter has no value-label mapping. Mirroring syncAvailabilityActiveState() with a media-type sync path and emitting a label map from MediaLabels::allTypes() would keep the new filter consistent after AJAX updates.

Also applies to: 1539-1556, 1614-1667

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/frontend/catalog.php` around lines 1302 - 1326, The new tipo_media
filter only updates the server but the client never reapplies active states or
label mapping; add a client-side sync path analogous to
syncAvailabilityActiveState() that listens for tipo_media changes and updates
the sidebar links' active class and the summary via
updateActiveFiltersDisplay(), and expose a JS label map built from
MediaLabels::allTypes() (emit the value=>label pairs from PHP into the page) so
updateActiveFiltersDisplay() shows human-friendly labels instead of raw enum
keys; ensure updateFilter('tipo_media', ...) triggers the same AJAX/DOM-refresh
flow as availability so the active state persists after AJAX updates.

- Add extractCredits() for Discogs extra artists (producers, engineers)
- Map all Discogs fields: styles→keywords, notes, weight, price,
  track count, catalog number, physical description, credits, URL
- Fix isMusic() false positive on "audiolibro" (exact match, not substring)
- Escape formatTracklist() fallback text (XSS prevention)
- updateBasic() only persists tipo_media when explicitly provided
- Add format display name aliases (cd, vinyl, cassette, blu_ray)
- EAN→Barcode label for music in frontend book-detail
- HTML escaping in catalog-grid badge, catalog filter, book_form option
- Remove tipo_media default from LibriController (let scraped values flow)
- CSV import: null tipo_media on update preserves existing value
- DataTable column index shift after tipo_media column
- German "Cartaceo" → "Druckausgabe"
- Remove Discogs early-return guard (let merge fill missing fields)
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@CodeRabbit full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 30, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/Support/PluginManager.php (1)

45-53: ⚠️ Potential issue | 🟠 Major

Bundling Discogs here makes the “optional” plugin auto-activate.

Anything in BUNDLED_PLUGINS is auto-registered with is_active = 1 by autoRegisterBundledPlugins(). Adding discogs here means the next deploy/update turns it on for everyone, which bypasses the documented install/activate flow for an optional integration. Either keep Discogs out of the bundled allowlists, or add a bundled-but-disabled registration path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Support/PluginManager.php` around lines 45 - 53, The BUNDLED_PLUGINS
constant currently includes 'discogs', which causes autoRegisterBundledPlugins()
to auto-activate that integration; remove 'discogs' from the BUNDLED_PLUGINS
array in the PluginManager class so it is not auto-registered, or alternatively
implement a bundled-but-disabled path inside autoRegisterBundledPlugins() that
sets is_active = 0 for specific plugin keys (e.g., special-case 'discogs' in
autoRegisterBundledPlugins() to create the record but leave is_active false).
Ensure references to BUNDLED_PLUGINS and autoRegisterBundledPlugins() are
updated consistently.
♻️ Duplicate comments (13)
app/Views/frontend/catalog.php (1)

1539-1553: ⚠️ Potential issue | 🟡 Minor

tipo_media filter still lacks active-state sync and human-readable value labels.

After updates, the active tag can still display raw enum values (disco, dvd, …), and only availability has a dedicated active-state sync path.

Also applies to: 1614-1653

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/frontend/catalog.php` around lines 1539 - 1553, The updateFilter
function currently only triggers syncAvailabilityActiveState for 'disponibilita'
but not for 'tipo_media', and active tags show raw enum values; add a dedicated
sync for tipo_media (e.g., call a new or existing syncTipoMediaActiveState
function from updateFilter when key === 'tipo_media') and ensure
updateActiveFiltersDisplay (or the function that builds active tag labels) maps
tipo_media enum keys ('disco','dvd', etc.) to human-readable labels before
rendering; update any helper that reads currentFilters to use this mapping so
active-state styles and display text are consistent.
app/Views/libri/partials/book_form.php (1)

3615-3624: ⚠️ Potential issue | 🟡 Minor

Validate data.tipo_media against allowed option keys before assignment.

The JavaScript handler sets tipoMediaSelect.value directly from data.tipo_media without validating that the value exists in the rendered <select> options. While ScrapeController uses MediaLabels::inferTipoMedia() which returns valid keys, plugins like the Discogs scraper could return different values (e.g., 'cd', 'vinyl'), causing the select to show no selection or an invalid state.

🛡️ Proposed fix to validate against allowed options
             // Auto-set tipo_media from scraped data
             try {
                 if (data.tipo_media) {
                     const tipoMediaSelect = document.getElementById('tipo_media');
-                    if (tipoMediaSelect) {
-                        tipoMediaSelect.value = data.tipo_media;
+                    if (tipoMediaSelect && tipoMediaSelect.querySelector(`option[value="${CSS.escape(data.tipo_media)}"]`)) {
+                        tipoMediaSelect.value = data.tipo_media;
                     }
                 }
             } catch (err) {
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/libri/partials/book_form.php` around lines 3615 - 3624, The handler
sets tipo_mediaSelect.value directly from data.tipo_media without verifying the
value exists in the rendered select options; update the logic around the
tipo_media assignment (where data.tipo_media, tipoMediaSelect are referenced) to
first check whether an option with that value exists (e.g., via
tipoMediaSelect.querySelector or iterating tipoMediaSelect.options) and only set
tipoMediaSelect.value if a matching option is present; if not present, skip the
assignment or set a safe fallback, and keep references to
ScrapeController/MediaLabels::inferTipoMedia in mind for expected keys.
app/Views/libri/index.php (1)

688-697: ⚠️ Potential issue | 🟡 Minor

Render the localized media label here too.

The filter dropdown is localized, but the table tooltip and active-filter chip still show the raw enum key (disco, audiolibro, ...). That leaves the UI partially untranslated and inconsistent with the filter options.

Also applies to: 885-886

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/libri/index.php` around lines 688 - 697, The media column render
currently sets the icon title to the raw enum key (in the render function for
the column with data: 'tipo_media'), causing untranslated tooltips; update that
render to look up the localized label (use the same localization map/function
used by the filter dropdown) and include it in the title/tooltip alongside the
icon (e.g., title = localizedLabel || data). Also update the active-filter chip
rendering (the code that builds the filter chips around lines 885-886) to
display the localized label instead of the raw enum key, using the same
localization lookup so both tooltip and chip match the dropdown translations.
storage/plugins/discogs/views/settings.php (2)

14-18: ⚠️ Potential issue | 🟠 Major

Don’t save the masked placeholder back as the token.

DiscogsPlugin::getSettings() returns ******** when a token exists, and this view posts that value back through saveSettings(). Opening the page and saving without edits will overwrite the real token with literal asterisks. Keep the field empty, show token presence separately, and only overwrite the stored value on an explicit replace/clear action.

Also applies to: 96-100

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/discogs/views/settings.php` around lines 14 - 18, The form is
currently writing the literal mask back into storage because the view posts the
masked value; update the POST handling in the settings view so you only
overwrite the stored token when the user explicitly provides a new value or
requests a clear: when handling the save (the block using
\App\Support\Csrf::validate and building $settings with 'api_token'), treat
empty string or the mask value (DiscogsPlugin::getSettings() mask like
"********") as “no change” (i.e., do not include api_token in $settings), and
add/handle an explicit clear action (e.g., a separate checkbox or button whose
POST field like clear_discogs_token signals to set api_token to empty) so
saveSettings()/DiscogsPlugin::saveSettings() only updates the token on an
explicit replace or clear.

133-137: ⚠️ Potential issue | 🟡 Minor

Use the named plugins route here.

Hardcoding the admin path in a view makes this link drift-prone if routes are renamed or localized. Build the href from the route helper and escape that result in the attribute.

As per coding guidelines "Never use hardcoded routes. Use route_path('key') or RouteTranslator::route('key') instead of hardcoded paths like /accedi or /login" and "Use htmlspecialchars() with ENT_QUOTES and UTF-8 encoding for route_path() calls in HTML attributes".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/discogs/views/settings.php` around lines 133 - 137, The view
hardcodes "/admin/plugins" in url('/admin/plugins'); replace that with the named
route helper and escape it: call route_path('plugins.index') (or the correct
named key for the admin plugins route) and wrap the result in
htmlspecialchars(..., ENT_QUOTES, 'UTF-8') for the href; alternatively use
RouteTranslator::route('plugins.index') if your app uses that helper. Update the
anchor to use htmlspecialchars(route_path('YOUR_ROUTE_KEY'), ENT_QUOTES,
'UTF-8') instead of url('/admin/plugins').
app/Views/frontend/book-detail.php (1)

2187-2189: ⚠️ Potential issue | 🟡 Minor

Use the related item’s own media type for this fallback.

This still keys off the parent page’s $isMusic, so a mixed related-items section can show "Artista sconosciuto" or "Autore sconosciuto" based on the current book instead of the related card.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/frontend/book-detail.php` around lines 2187 - 2189, The fallback
text for author vs artist currently uses the parent page's $isMusic; change it
to use the related item's media flag so each related card decides its own label.
Update the expression in the related card (where $related['autori'] is used) to
compute a per-card flag from the related data, e.g. $relatedIsMusic =
$related['isMusic'] ?? $related['media_type'] === 'music' ?? $isMusic, and then
use that ($relatedIsMusic) instead of $isMusic when choosing __('Artista
sconosciuto' : 'Autore sconosciuto'); ensure you reference the $related array
keys that exist in your dataset (replace 'isMusic'/'media_type' with the actual
key if different).
tests/discogs-plugin.spec.js (2)

91-118: ⚠️ Potential issue | 🟠 Major

Scope install/activate/settings to the Discogs card and assert they exist.

Line 94 clicks the first Installa button on the page if the generic Discogs locator misses, so this suite can mutate a different plugin and then silently continue. Root the install, activate, and settings locators in the Discogs card and fail if that card or its controls are missing.

Also applies to: 129-137

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-plugin.spec.js` around lines 91 - 118, The test currently
clicks a generic first "Installa" / "Attiva" button which can target another
plugin; scope all install/activate/settings locators to the Discogs card (use
the existing discogsCard locator and call discogsCard.locator(...) or similar)
and assert the card and each control are present/visible before interacting,
failing the test if the discogsCard or its install/activate/settings controls
are missing; update usage of installBtn and activateBtn to be rooted off
discogsCard and replace silent .catch(() => false) flows with explicit
assertions so the test cannot mutate a different plugin.

193-202: ⚠️ Potential issue | 🟠 Major

This "frontend" assertion still exercises the admin page.

Line 194 requests /admin/libri/${bookId}, so it never validates the public detail template or the music-specific Barcode label there. Resolve the real public book URL and assert against that response instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-plugin.spec.js` around lines 193 - 202, The test is requesting
the admin URL (`page.request.get(`${BASE}/admin/libri/${bookId}`)`) but intends
to assert the public book detail template; change the request to the public path
(`page.request.get(`${BASE}/libri/${bookId}`)`), keep the same HTML checks for
'Barcode' or music-specific labels, and update the comment to indicate it's
validating the public detail page rather than the admin page; reference the
variables BASE and bookId and the request call to locate the change.
tests/discogs-import.spec.js (4)

155-164: ⚠️ Potential issue | 🟠 Major

Treat a missing persisted row as a failure, not a skip.

Line 160 skips when neither lookup finds the saved record. That hides a broken save path and lets the remaining checks run on invalid state.

Suggested fix
-    if (book === '') {
-      // Try isbn13
-      const bookByIsbn = dbQuery(
-        `SELECT titolo, isbn13, formato FROM libri WHERE isbn13 LIKE '%720642442524%' AND deleted_at IS NULL LIMIT 1`
-      );
-      test.skip(bookByIsbn === '', 'CD not found in database');
-      if (bookByIsbn) {
-        expect(bookByIsbn.toLowerCase()).toContain('nevermind');
-      }
-      return;
-    }
-
-    expect(book.toLowerCase()).toContain('nevermind');
+    let persisted = book;
+    if (persisted === '') {
+      persisted = dbQuery(
+        `SELECT titolo, isbn13, formato FROM libri WHERE isbn13 LIKE '%720642442524%' AND deleted_at IS NULL LIMIT 1`
+      );
+    }
+
+    expect(persisted).not.toBe('');
+    expect(persisted.toLowerCase()).toContain('nevermind');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-import.spec.js` around lines 155 - 164, The test currently
treats a missing persisted row as a skip (see the book variable check and
test.skip call). Replace the test.skip call with a failing assertion so that
when dbQuery (the bookByIsbn lookup) returns no result the test fails
immediately; e.g., assert or test.fail with a clear message that the expected DB
row was not saved, and only run the
expect(bookByIsbn.toLowerCase()).toContain(...) when bookByIsbn is truthy.
Ensure you update the block around the book, dbQuery, test.skip, and expect
references to reflect this change.

77-79: ⚠️ Potential issue | 🟠 Major

Fail this flow when import UI or scrape output is missing.

Lines 77-79 and 94-97 currently turn real regressions into skips. After plugin activation, a missing import button or an empty titolo means the Discogs path is broken.

Suggested fix
-    if (!await importBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
-      test.skip(true, 'Import button not visible — no scraping plugin active');
-      return;
-    }
+    await expect(importBtn).toBeVisible({ timeout: 5000 });
@@
-    if (titleValue === '') {
-      // Scraping may have failed (rate limit, network). Check if any source populated data
-      test.skip(true, 'Scraping did not return data (possibly rate limited)');
-      return;
-    }
+    expect(
+      titleValue,
+      `Discogs scrape returned no data for barcode ${TEST_BARCODE}`
+    ).not.toBe('');

Also applies to: 94-97

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-import.spec.js` around lines 77 - 79, The current checks
silently skip on missing UI/scrape results; instead fail the test when the
import button or the scraped titolo is missing by replacing the skip logic with
a failing assertion or explicit throw—locate the importBtn visibility check
(importBtn.isVisible(...)) and change the branch to call test.fail(...) or throw
new Error('Import button not visible — no scraping plugin active'), and likewise
find the titolo emptiness check (the variable titolo and its test.skip branch)
and replace it with test.fail(...) or throw new Error('Scrape produced empty
titolo — Discogs path broken') so missing UI or empty scrape output causes a
test failure.

28-28: ⚠️ Potential issue | 🟠 Major

serial here does not prevent cross-file DB races.

Line 28 only serializes tests inside this file. Playwright can still run the other DB-backed specs in parallel unless the runner is pinned to one worker via /tmp/run-e2e.sh or Playwright config.

Expected result: either workers: 1 appears in Playwright config or the E2E runner passes --workers=1, and /tmp/run-e2e.sh exports the required E2E_* variables.

#!/bin/bash
set -e

echo "=== Playwright config ==="
fd -i 'playwright.config.*' -x sh -c '
  echo "--- $1 ---"
  sed -n "1,200p" "$1" | rg -n "workers|fullyParallel|testDir"
' sh {}

echo
echo "=== package.json scripts ==="
fd -i 'package.json' -x sh -c '
  echo "--- $1 ---"
  rg -n "\"(e2e|playwright|test)\"" "$1" -A2 -B1
' sh {}

echo
echo "=== /tmp/run-e2e.sh ==="
if [ -f /tmp/run-e2e.sh ]; then
  sed -n '1,200p' /tmp/run-e2e.sh | rg -n 'playwright|workers|E2E_'
else
  echo "/tmp/run-e2e.sh not available in sandbox"
fi

Based on learnings, "E2E tests with Playwright require /tmp/run-e2e.sh to set DB/admin credentials as env vars. Always use --workers=1 for serial execution".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-import.spec.js` at line 28, The test uses
test.describe.serial('Discogs Import: full scraping flow') which only serializes
tests inside this file and does not prevent cross-file DB races; update the E2E
runner so Playwright runs with a single worker (either set workers: 1 in the
Playwright config or ensure the test runner is invoked with --workers=1) and
verify /tmp/run-e2e.sh exports the required E2E_* environment variables
(DB/admin creds) before launching Playwright; ensure any CI or package.json e2e
script that calls Playwright includes the --workers=1 flag or the config change
so DB-backed specs cannot run in parallel.

50-52: ⚠️ Potential issue | 🟠 Major

Clean up both barcode persistence paths.

Line 52 only deletes by ean, but Lines 118-122 and 157-159 treat isbn13 as a valid save target too. A previous run can leave the isbn13 row behind and make the DB assertions pass against stale data.

Suggested fix
 test.afterAll(async () => {
   // Cleanup test data
-  try { dbExec("DELETE FROM libri WHERE ean = '0720642442524' AND deleted_at IS NULL"); } catch {}
+  try {
+    const normalizedBarcode = TEST_BARCODE.slice(1);
+    dbExec(
+      `DELETE FROM libri
+       WHERE (ean = '${TEST_BARCODE}' OR isbn13 LIKE '%${normalizedBarcode}%')
+         AND deleted_at IS NULL`
+    );
+  } catch {}
   await context?.close();
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-import.spec.js` around lines 50 - 52, The test.afterAll cleanup
only deletes rows by ean and misses rows saved under isbn13; update the cleanup
in the test.afterAll block that calls dbExec so it removes both persistence
paths for the barcode used in tests (targeting the libri table), e.g. run a
single DELETE that includes "WHERE (ean = '0720642442524' OR isbn13 =
'0720642442524') AND deleted_at IS NULL" or execute two dbExec deletes (one for
ean and one for isbn13) to ensure no stale rows remain for the test barcode.
storage/plugins/discogs/DiscogsPlugin.php (1)

696-752: ⚠️ Potential issue | 🟠 Major

Don't round-trip api_token through raw SQL.

Lines 725-739 and 796-813 read and write the Discogs personal access token verbatim from plugin_settings. That stores a secret in plaintext and creates a second settings path that can drift from the app's centralized plugin-settings handling.

Also applies to: 761-820

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/discogs/DiscogsPlugin.php` around lines 696 - 752, The
getSetting()/getSettings() code directly queries plugin_settings and returns the
api_token plaintext; remove this direct SQL access and stop round-tripping the
secret through plugin_settings. Replace the manual SQL in getSetting (and any
code that writes api_token) with calls to the app's central plugin-settings
API/manager (use the existing centralized accessor used elsewhere in the
project) so the Discogs plugin reads/writes the token through the unified
settings layer and preserves secret handling (no plaintext return in getSettings
— mask or omit the token). Update any references to $this->pluginId/$this->db in
getSetting to use that centralized API instead of raw queries.
🧹 Nitpick comments (1)
app/Models/BookRepository.php (1)

1000-1000: tipo_media is not normalized in updateOptionals.

Unlike createBasic and updateBasic, this path stores tipo_media (and scraped_tipo_media) without passing through normalizeEnumValue(). If a scraper returns an unexpected value, it could violate the DB enum constraint.

Consider normalizing the value:

Suggested improvement
             } else {
-                $cols[$c] = $data[$c];
+                $cols[$c] = $c === 'tipo_media'
+                    ? $this->normalizeEnumValue((string) $data[$c], 'tipo_media', 'libro')
+                    : $data[$c];
             }

And similarly for scraped_tipo_media:

         if ($this->hasColumn('tipo_media') && !array_key_exists('tipo_media', $cols) && !empty($data['scraped_tipo_media'])) {
-            $cols['tipo_media'] = (string) $data['scraped_tipo_media'];
+            $cols['tipo_media'] = $this->normalizeEnumValue((string) $data['scraped_tipo_media'], 'tipo_media', 'libro');
         }

Also applies to: 1055-1057

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Models/BookRepository.php` at line 1000, In updateOptionals, the loop
that assigns optional fields (foreach over ['numero_pagine', 'ean', ...
'tipo_media']) currently writes tipo_media and scraped_tipo_media without
normalization; modify updateOptionals to pass values for 'tipo_media' and
'scraped_tipo_media' through the same normalizeEnumValue() used by
createBasic/updateBasic before persisting to ensure DB enum constraints aren't
violated (identify the assignment site inside updateOptionals where tipo_media
and scraped_tipo_media are set and replace direct assignment with
normalizeEnumValue($value) for those keys).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Controllers/PluginController.php`:
- Around line 398-406: The code currently ignores the boolean result of
$this->pluginManager->setSetting($pluginId, 'api_token', $apiToken, true) so the
endpoint can return success even if persistence failed; update the handler in
PluginController to capture the return value (e.g. $saved =
$this->pluginManager->setSetting(...)), and if $saved is false return an error
JSON response and appropriate non-200 status (and do not claim has_token true),
otherwise continue to return the success payload; adjust the response body and
status accordingly so the API accurately reflects whether setSetting succeeded.

In `@app/Support/MediaLabels.php`:
- Around line 21-38: The function isMusic should let the provided $tipoMedia be
authoritative: change isMusic so the first check is if ($tipoMedia !== null)
return $tipoMedia === 'disco'; i.e. if tipo_media is set, return true only for
the explicit music type(s) you consider music (e.g. 'disco') and otherwise
return false, instead of falling back to evaluating $formato; update the
early-return logic in isMusic to use $tipoMedia as the decisive value.
- Around line 165-182: The inferTipoMedia function misclassifies cassette
formats like "audiocassetta" as libro; update the matching list inside
inferTipoMedia to include the cassette variants used elsewhere (e.g., add
patterns such as 'audiocassetta', 'audio_cassetta', 'audio-cassetta', and any
spaced forms like 'audio cassetta') so those inputs hit the cassette branch and
return 'disco' (modify the array in the foreach that currently contains
['cd_audio', 'vinile', 'lp', 'cd', 'vinyl', 'cassetta', 'cassette'] to include
the additional cassette variants).

In `@app/Views/frontend/book-detail.php`:
- Around line 27-29: The media-type detection is duplicated: $isMusic is derived
from both 'tipo_media' and fallback 'formato' using
\App\Support\MediaLabels::isMusic, but the hero badge and schemaOrgType() still
read 'tipo_media' directly; amend the template to resolve the media type once
(e.g., compute $isMusic and/or a normalized $resolvedMediaType using
MediaLabels::isMusic and the 'formato' fallback) and then pass or use that
resolved value when rendering the hero badge, the tracklist/music labels, and
when calling schemaOrgType() so all places (hero badge, JSON‑LD, and labels)
consistently treat records inferred as music from formato as music.

In `@app/Views/frontend/catalog.php`:
- Around line 1321-1322: Escape the dynamic icon class and label when rendering
the media filter option: replace unescaped uses of $tmInfo['icon'] and
$tmInfo['label'] in the view with htmlspecialchars(..., ENT_QUOTES, 'UTF-8')
(e.g., wrap $tmInfo['icon'] passed into the <i class="fas ..."> and wrap
$tmInfo['label'] output) so both the HTML attribute and visible text are
properly escaped; update the occurrences in frontend/catalog.php where
$tmInfo['icon'] and $tmInfo['label'] are rendered.

In `@app/Views/libri/scheda_libro.php`:
- Around line 8-10: The header badge is still reading only libro['tipo_media']
while $isMusic uses a fallback to formato; fix by resolving a single media type
variable (e.g., $mediaType = $libro['tipo_media'] ?? $libro['formato'] ?? '')
and use that for both the music detection (via
\App\Support\MediaLabels::isMusic($libro['formato'] ?? null,
$libro['tipo_media'] ?? null) or better
\App\Support\MediaLabels::isMusic($mediaType)) and the header badge logic
(replace direct uses of libro['tipo_media'] in the badge rendering and the block
around lines 72-75 with the resolved $mediaType) so the badge and $isMusic
detection use the same fallback source.

In `@installer/database/migrations/migrate_0.5.4.sql`:
- Around line 1-38: The migration file migrate_0.5.4.sql is named for a higher
version than the packaged release (pinakes-v0.5.3.zip.sha256) so
Updater::runMigrations() (which only runs migrations with version <= $toVersion)
will skip creating libri.tipo_media; fix by either renaming migrate_0.5.4.sql to
migrate_0.5.3.sql so its version ≤ the packaged release, or increment the
packaged release/version to 0.5.4 (and update pinakes-v0.5.3.zip.sha256
accordingly) so the migration is applied—ensure the migration filename version
matches the release version used by Updater::runMigrations().

In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 203-206: When Discogs barcode lookup returns no hits within
fetchFromDiscogs(), don't return immediately; implement the promised
title/artist fallback by using the provided $currentResult (access
$currentResult->title and $currentResult->author) to perform a query-based
search before giving up. Update the barcode-null handling path (and the similar
block around the earlier return) to call the existing query/search routine with
those title/author values as a targeted fallback, and only return after this
fallback has been attempted and exhausted.

In `@storage/plugins/discogs/README.md`:
- Around line 49-70: The README and current implementation use a fixed 1s pause
between Discogs requests which allows ~60/min and will cause 429s for
unauthenticated usage; update the plugin's rate-limiting logic to be token-aware
by checking the `api_token` setting and applying a different throttle: when
`api_token` is present allow 1s between requests (≈60/min), otherwise set the
delay to at least 2.4s per request (≈25/min) or implement a tokenless budget of
25 requests/min; also add 429 handling with exponential backoff/retry and log
details so the path that reads `api_token` and the rate limiter (the
module/function that enforces the 1-second pause) uses the new token-aware
delays.
- Around line 15-17: Update the fallback search example in the README: replace
the incorrect barcode placeholder in the second step (`GET
/database/search?q={ean}&type=release`) with a title-based query such as `GET
/database/search?q={title}&type=release` or `GET
/database/search?q={title}+{artist}&type=release` to match the described
second-pass title/title+artist search; keep the first and third steps (`GET
/database/search?barcode={ean}&type=release` and `GET /releases/{id}`)
unchanged.

In `@test-results/.last-run.json`:
- Around line 1-4: Remove the ephemeral test runner state file (.last-run.json)
from version control and stop committing it: delete the tracked file from the
repo (git rm --cached .last-run.json or remove then commit), add an entry for
.last-run.json to your .gitignore (or the appropriate test-results/ pattern),
and commit the change so future Playwright runs won't create PR churn; verify no
other ephemeral test artifacts are still tracked in the repository.

In `@tests/discogs-import.spec.js`:
- Around line 181-188: The test's guard uses the `formato` query which can be
wrong and skips the label assertion; change the DB query to fetch `tipo_media`
instead (use dbQuery to SELECT tipo_media FROM libri WHERE id = ${bookId}) and
replace the `if (formato && ['cd_audio' ...].some(...))` check with a check that
runs the label assertion when `tipo_media === 'disco'`; keep the `hasMusicLabel`
check (looking at `content.includes('Etichetta')` / `Label` / `Anno di Uscita` /
`Release Year'`) and `expect(hasMusicLabel).toBe(true)` so the assertion doesn't
become a no-op when `formato` mapping is wrong.

---

Outside diff comments:
In `@app/Support/PluginManager.php`:
- Around line 45-53: The BUNDLED_PLUGINS constant currently includes 'discogs',
which causes autoRegisterBundledPlugins() to auto-activate that integration;
remove 'discogs' from the BUNDLED_PLUGINS array in the PluginManager class so it
is not auto-registered, or alternatively implement a bundled-but-disabled path
inside autoRegisterBundledPlugins() that sets is_active = 0 for specific plugin
keys (e.g., special-case 'discogs' in autoRegisterBundledPlugins() to create the
record but leave is_active false). Ensure references to BUNDLED_PLUGINS and
autoRegisterBundledPlugins() are updated consistently.

---

Duplicate comments:
In `@app/Views/frontend/book-detail.php`:
- Around line 2187-2189: The fallback text for author vs artist currently uses
the parent page's $isMusic; change it to use the related item's media flag so
each related card decides its own label. Update the expression in the related
card (where $related['autori'] is used) to compute a per-card flag from the
related data, e.g. $relatedIsMusic = $related['isMusic'] ??
$related['media_type'] === 'music' ?? $isMusic, and then use that
($relatedIsMusic) instead of $isMusic when choosing __('Artista sconosciuto' :
'Autore sconosciuto'); ensure you reference the $related array keys that exist
in your dataset (replace 'isMusic'/'media_type' with the actual key if
different).

In `@app/Views/frontend/catalog.php`:
- Around line 1539-1553: The updateFilter function currently only triggers
syncAvailabilityActiveState for 'disponibilita' but not for 'tipo_media', and
active tags show raw enum values; add a dedicated sync for tipo_media (e.g.,
call a new or existing syncTipoMediaActiveState function from updateFilter when
key === 'tipo_media') and ensure updateActiveFiltersDisplay (or the function
that builds active tag labels) maps tipo_media enum keys ('disco','dvd', etc.)
to human-readable labels before rendering; update any helper that reads
currentFilters to use this mapping so active-state styles and display text are
consistent.

In `@app/Views/libri/index.php`:
- Around line 688-697: The media column render currently sets the icon title to
the raw enum key (in the render function for the column with data:
'tipo_media'), causing untranslated tooltips; update that render to look up the
localized label (use the same localization map/function used by the filter
dropdown) and include it in the title/tooltip alongside the icon (e.g., title =
localizedLabel || data). Also update the active-filter chip rendering (the code
that builds the filter chips around lines 885-886) to display the localized
label instead of the raw enum key, using the same localization lookup so both
tooltip and chip match the dropdown translations.

In `@app/Views/libri/partials/book_form.php`:
- Around line 3615-3624: The handler sets tipo_mediaSelect.value directly from
data.tipo_media without verifying the value exists in the rendered select
options; update the logic around the tipo_media assignment (where
data.tipo_media, tipoMediaSelect are referenced) to first check whether an
option with that value exists (e.g., via tipoMediaSelect.querySelector or
iterating tipoMediaSelect.options) and only set tipoMediaSelect.value if a
matching option is present; if not present, skip the assignment or set a safe
fallback, and keep references to ScrapeController/MediaLabels::inferTipoMedia in
mind for expected keys.

In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 696-752: The getSetting()/getSettings() code directly queries
plugin_settings and returns the api_token plaintext; remove this direct SQL
access and stop round-tripping the secret through plugin_settings. Replace the
manual SQL in getSetting (and any code that writes api_token) with calls to the
app's central plugin-settings API/manager (use the existing centralized accessor
used elsewhere in the project) so the Discogs plugin reads/writes the token
through the unified settings layer and preserves secret handling (no plaintext
return in getSettings — mask or omit the token). Update any references to
$this->pluginId/$this->db in getSetting to use that centralized API instead of
raw queries.

In `@storage/plugins/discogs/views/settings.php`:
- Around line 14-18: The form is currently writing the literal mask back into
storage because the view posts the masked value; update the POST handling in the
settings view so you only overwrite the stored token when the user explicitly
provides a new value or requests a clear: when handling the save (the block
using \App\Support\Csrf::validate and building $settings with 'api_token'),
treat empty string or the mask value (DiscogsPlugin::getSettings() mask like
"********") as “no change” (i.e., do not include api_token in $settings), and
add/handle an explicit clear action (e.g., a separate checkbox or button whose
POST field like clear_discogs_token signals to set api_token to empty) so
saveSettings()/DiscogsPlugin::saveSettings() only updates the token on an
explicit replace or clear.
- Around line 133-137: The view hardcodes "/admin/plugins" in
url('/admin/plugins'); replace that with the named route helper and escape it:
call route_path('plugins.index') (or the correct named key for the admin plugins
route) and wrap the result in htmlspecialchars(..., ENT_QUOTES, 'UTF-8') for the
href; alternatively use RouteTranslator::route('plugins.index') if your app uses
that helper. Update the anchor to use
htmlspecialchars(route_path('YOUR_ROUTE_KEY'), ENT_QUOTES, 'UTF-8') instead of
url('/admin/plugins').

In `@tests/discogs-import.spec.js`:
- Around line 155-164: The test currently treats a missing persisted row as a
skip (see the book variable check and test.skip call). Replace the test.skip
call with a failing assertion so that when dbQuery (the bookByIsbn lookup)
returns no result the test fails immediately; e.g., assert or test.fail with a
clear message that the expected DB row was not saved, and only run the
expect(bookByIsbn.toLowerCase()).toContain(...) when bookByIsbn is truthy.
Ensure you update the block around the book, dbQuery, test.skip, and expect
references to reflect this change.
- Around line 77-79: The current checks silently skip on missing UI/scrape
results; instead fail the test when the import button or the scraped titolo is
missing by replacing the skip logic with a failing assertion or explicit
throw—locate the importBtn visibility check (importBtn.isVisible(...)) and
change the branch to call test.fail(...) or throw new Error('Import button not
visible — no scraping plugin active'), and likewise find the titolo emptiness
check (the variable titolo and its test.skip branch) and replace it with
test.fail(...) or throw new Error('Scrape produced empty titolo — Discogs path
broken') so missing UI or empty scrape output causes a test failure.
- Line 28: The test uses test.describe.serial('Discogs Import: full scraping
flow') which only serializes tests inside this file and does not prevent
cross-file DB races; update the E2E runner so Playwright runs with a single
worker (either set workers: 1 in the Playwright config or ensure the test runner
is invoked with --workers=1) and verify /tmp/run-e2e.sh exports the required
E2E_* environment variables (DB/admin creds) before launching Playwright; ensure
any CI or package.json e2e script that calls Playwright includes the --workers=1
flag or the config change so DB-backed specs cannot run in parallel.
- Around line 50-52: The test.afterAll cleanup only deletes rows by ean and
misses rows saved under isbn13; update the cleanup in the test.afterAll block
that calls dbExec so it removes both persistence paths for the barcode used in
tests (targeting the libri table), e.g. run a single DELETE that includes "WHERE
(ean = '0720642442524' OR isbn13 = '0720642442524') AND deleted_at IS NULL" or
execute two dbExec deletes (one for ean and one for isbn13) to ensure no stale
rows remain for the test barcode.

In `@tests/discogs-plugin.spec.js`:
- Around line 91-118: The test currently clicks a generic first "Installa" /
"Attiva" button which can target another plugin; scope all
install/activate/settings locators to the Discogs card (use the existing
discogsCard locator and call discogsCard.locator(...) or similar) and assert the
card and each control are present/visible before interacting, failing the test
if the discogsCard or its install/activate/settings controls are missing; update
usage of installBtn and activateBtn to be rooted off discogsCard and replace
silent .catch(() => false) flows with explicit assertions so the test cannot
mutate a different plugin.
- Around line 193-202: The test is requesting the admin URL
(`page.request.get(`${BASE}/admin/libri/${bookId}`)`) but intends to assert the
public book detail template; change the request to the public path
(`page.request.get(`${BASE}/libri/${bookId}`)`), keep the same HTML checks for
'Barcode' or music-specific labels, and update the comment to indicate it's
validating the public detail page rather than the admin page; reference the
variables BASE and bookId and the request call to locate the change.

---

Nitpick comments:
In `@app/Models/BookRepository.php`:
- Line 1000: In updateOptionals, the loop that assigns optional fields (foreach
over ['numero_pagine', 'ean', ... 'tipo_media']) currently writes tipo_media and
scraped_tipo_media without normalization; modify updateOptionals to pass values
for 'tipo_media' and 'scraped_tipo_media' through the same normalizeEnumValue()
used by createBasic/updateBasic before persisting to ensure DB enum constraints
aren't violated (identify the assignment site inside updateOptionals where
tipo_media and scraped_tipo_media are set and replace direct assignment with
normalizeEnumValue($value) for those keys).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 739bc1b7-a6b4-4f3f-8906-47428c6773b5

📥 Commits

Reviewing files that changed from the base of the PR and between d0cf881 and ed8694c.

📒 Files selected for processing (33)
  • .gitignore
  • app/Controllers/CsvImportController.php
  • app/Controllers/FrontendController.php
  • app/Controllers/LibraryThingImportController.php
  • app/Controllers/LibriApiController.php
  • app/Controllers/LibriController.php
  • app/Controllers/PluginController.php
  • app/Controllers/PublicApiController.php
  • app/Controllers/ScrapeController.php
  • app/Models/BookRepository.php
  • app/Support/MediaLabels.php
  • app/Support/PluginManager.php
  • app/Support/Updater.php
  • app/Views/frontend/book-detail.php
  • app/Views/frontend/catalog-grid.php
  • app/Views/frontend/catalog.php
  • app/Views/libri/index.php
  • app/Views/libri/partials/book_form.php
  • app/Views/libri/scheda_libro.php
  • installer/database/migrations/migrate_0.5.4.sql
  • installer/database/schema.sql
  • locale/de_DE.json
  • locale/en_US.json
  • locale/it_IT.json
  • pinakes-v0.5.3.zip.sha256
  • storage/plugins/discogs/DiscogsPlugin.php
  • storage/plugins/discogs/README.md
  • storage/plugins/discogs/plugin.json
  • storage/plugins/discogs/views/settings.php
  • storage/plugins/discogs/wrapper.php
  • test-results/.last-run.json
  • tests/discogs-import.spec.js
  • tests/discogs-plugin.spec.js

Comment on lines +15 to +17
1. Ricerca per barcode (EAN/UPC) -- `GET /database/search?barcode={ean}&type=release`
2. Se nessun risultato, ricerca per query -- `GET /database/search?q={ean}&type=release`
3. Recupero dettagli completi della release -- `GET /releases/{id}`
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix the fallback search example.

Line 16 still shows the barcode placeholder in the fallback query. The PR objective says the second pass is title search, so this doc should describe the actual title/title+artist query instead of q={ean}.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/discogs/README.md` around lines 15 - 17, Update the fallback
search example in the README: replace the incorrect barcode placeholder in the
second step (`GET /database/search?q={ean}&type=release`) with a title-based
query such as `GET /database/search?q={title}&type=release` or `GET
/database/search?q={title}+{artist}&type=release` to match the described
second-pass title/title+artist search; keep the first and third steps (`GET
/database/search?barcode={ean}&type=release` and `GET /releases/{id}`)
unchanged.

- isMusic(): tipoMedia is now authoritative (no formato fallback when set)
- inferTipoMedia(): add audiocassetta to disco formats
- Settings: empty password field, skip save on empty, "Token configurato" indicator
- PluginController: check setSetting result, return 500 on failure
- Resolve tipoMedia once for badge + Schema.org in book-detail and scheda_libro
- README: remove fallback search, document token-aware rate limiting
- Escape catalog filter icon/label with htmlspecialchars
- normalizeEnumValue in updateOptionals for tipo_media
- Remove test-results/.last-run.json from tracking
- Add "Token configurato" translations (IT/EN/DE)
1. Admin list tipo_media=disco filter via DataTable API
2. CSV export includes tipo_media column with correct value
3. Schema.org tipo_media stored as 'disco' vs 'libro' in DB
4. Admin detail: music labels + tracklist vs book labels + prose
5. Edit persistence: tipo_media='disco' survives form save
Was showing raw 'Tracklist: 1. Come Together (4:19) 2. Something...'
as plain text. Now calls MediaLabels::formatTracklist() which converts
to <ol class='tracklist'> with individual <li> per track.
Also shows music icon + 'Tracklist' label instead of 'Descrizione'.
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@CodeRabbit full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 30, 2026

✅ Actions performed

Full review triggered.

Single plugin, three sources in cascade:
1. Discogs (barcode search, primary)
2. MusicBrainz + Cover Art Archive (barcode fallback, open data)
3. Deezer (cover HD enrichment by title+artist)

- MusicBrainz: barcode search, full release with artists/labels/recordings
- Cover Art Archive: front cover in high resolution
- Deezer: cover_xl (1000x1000) when other sources lack images
- Each API has independent rate limiting (1s Discogs, 1.1s MB, 1s Deezer)
- Plugin display name: "Music Scraper (Discogs, MusicBrainz, Deezer)"
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
app/Support/Updater.php (1)

39-47: ⚠️ Potential issue | 🟠 Major

Rollback does not cover bundled plugin files.

Adding discogs here means installUpdate() will update storage/plugins/discogs via updateBundledPlugins(), but backupAppFiles() / restoreAppFiles() never snapshot storage/plugins. If install or migrations fail after that copy, rollback restores the core and leaves Discogs on the newer version.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Support/Updater.php` around lines 39 - 47, The rollback path doesn't
snapshot storage/plugins so updating bundled plugins (BUNDLED_PLUGINS used by
updateBundledPlugins() called from installUpdate()) can leave plugins upgraded
when core is restored; modify backupAppFiles() and restoreAppFiles() to include
the storage/plugins directory (or alternatively make updateBundledPlugins()
write plugin updates to a temporary location and atomically move them only after
install/migrations succeed), ensuring any plugin paths touched by
BUNDLED_PLUGINS are backed up and restored on failure.
app/Support/PluginManager.php (1)

45-53: ⚠️ Potential issue | 🟠 Major

This makes Discogs auto-active, not optional.

autoRegisterBundledPlugins() inserts bundled plugins with is_active = 1 and immediately runs onActivate(). Once the Discogs folder ships in the app package, startup/update will silently enable it for every instance instead of waiting for an admin opt-in.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Support/PluginManager.php` around lines 45 - 53, The bundled "discogs"
entry is being auto-enabled because autoRegisterBundledPlugins() inserts entries
with is_active = 1 and immediately calls onActivate(); change the behavior so
bundled optional plugins are registered but not auto-activated: either remove
'discogs' from the BUNDLED_PLUGINS constant and add it to a new
BUNDLED_OPTIONAL_PLUGINS list, or modify autoRegisterBundledPlugins() to insert
with is_active = 0 and skip calling onActivate() for items flagged optional
(check for a new optionalPlugins array or a lookup), keeping onActivate() calls
only for truly required plugins; update references to BUNDLED_PLUGINS,
autoRegisterBundledPlugins(), is_active and onActivate() accordingly.
app/Controllers/CsvImportController.php (1)

1231-1256: ⚠️ Potential issue | 🟡 Minor

Add the soft-delete guard to this libri update.

findExistingBook() already filters out soft-deleted rows, but this write still updates by id alone. A concurrent soft-delete between lookup and update will mutate archived data, and the query also violates the repository rule for libri.

🩹 Minimal fix
-            WHERE id = ?
+            WHERE id = ? AND deleted_at IS NULL

As per coding guidelines, "Every query on libri table MUST include soft-delete condition: AND deleted_at IS NULL".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/CsvImportController.php` around lines 1231 - 1256, The UPDATE
statement prepared in CsvImportController (the $stmt prepared for table libri)
must include the soft-delete guard so we only update non-deleted rows; modify
the WHERE clause from "WHERE id = ?" to "WHERE id = ? AND deleted_at IS NULL"
(or equivalent parameterized condition) so a concurrent soft-delete won’t allow
updates to archived rows; ensure the change is applied to the prepared SQL used
by the update path that complements findExistingBook().
♻️ Duplicate comments (14)
app/Views/libri/partials/book_form.php (1)

3615-3624: ⚠️ Potential issue | 🟡 Minor

Whitelist and normalize scraped tipo_media before assigning it.

At Line 3620, assigning raw data.tipo_media can silently miss valid select keys (e.g., provider sends CD/vinyl) and leave the form in an inconsistent state. Restrict assignment to known keys from MediaLabels::allTypes().

🔧 Suggested fix
             // Auto-set tipo_media from scraped data
             try {
                 if (data.tipo_media) {
                     const tipoMediaSelect = document.getElementById('tipo_media');
-                    if (tipoMediaSelect) {
-                        tipoMediaSelect.value = data.tipo_media;
+                    const allowedTipoMedia = new Set(<?= json_encode(array_keys(\App\Support\MediaLabels::allTypes()), JSON_HEX_TAG) ?>);
+                    const normalizedTipoMedia = String(data.tipo_media).trim().toLowerCase();
+                    if (tipoMediaSelect && allowedTipoMedia.has(normalizedTipoMedia)) {
+                        tipoMediaSelect.value = normalizedTipoMedia;
                     }
                 }
             } catch (err) {
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/libri/partials/book_form.php` around lines 3615 - 3624, The code
sets the select `#tipo_media` directly from data.tipo_media which may contain
variants (e.g., "CD", "vinyl") and leaves the select unset; instead whitelist
and normalize the incoming value against the canonical keys from
MediaLabels::allTypes(): create a mapping of allowed keys/aliases (server-side
output of MediaLabels::allTypes() into a JS object or array), normalize
data.tipo_media (trim, lower/upper-case as needed), look up the normalized value
in that whitelist (or map aliases to canonical keys) and only assign
tipoMediaSelect.value when a match exists; keep the try/catch but log or
silently skip when no valid match is found to avoid inconsistent form state.
app/Views/frontend/catalog.php (1)

1302-1326: ⚠️ Potential issue | 🟡 Minor

tipo_media filter is still only partially wired on the client.

Line 1302-1326 adds the filter UI, but there is still no client-side active-state sync path (like availability), and active-filter tags still show raw enum keys rather than user-facing labels.

Suggested patch
@@
-                            <a href="#"
-                               class="filter-option <?= $isActive ? 'active' : '' ?>"
+                            <a href="#"
+                               class="filter-option <?= $isActive ? 'active' : '' ?>"
+                               data-media-filter="<?= htmlspecialchars((string) $tmValue, ENT_QUOTES, 'UTF-8') ?>"
                                onclick="updateFilter('tipo_media', <?= htmlspecialchars(json_encode((string) $tmValue, JSON_HEX_TAG | JSON_HEX_APOS), ENT_QUOTES, 'UTF-8') ?>); return false;">
@@
 $i18nTranslations = [
@@
     'errore_caricamento' => __('Errore nel caricamento. Riprova.')
 ];
 $i18nJson = json_encode($i18nTranslations, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG);
+$mediaTypeLabels = ['' => __('Tutti i media')];
+foreach (\App\Support\MediaLabels::allTypes() as $tmValue => $tmMeta) {
+    $mediaTypeLabels[(string) $tmValue] = __($tmMeta['label']);
+}
+$mediaTypeLabelsJson = json_encode($mediaTypeLabels, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG);
@@
 const i18n = {$i18nJson};
+const MEDIA_TYPE_LABELS = {$mediaTypeLabelsJson};
@@
     syncAvailabilityActiveState();
+    syncMediaTypeActiveState();
 });
@@
     if (key === 'disponibilita') {
         syncAvailabilityActiveState();
+    } else if (key === 'tipo_media') {
+        syncMediaTypeActiveState();
     }
@@
+function syncMediaTypeActiveState() {
+    const currentValue = currentFilters.tipo_media || '';
+    document.querySelectorAll('[data-media-filter]').forEach((option) => {
+        option.classList.toggle('active', (option.dataset.mediaFilter || '') === currentValue);
+    });
+}
+
 function updateActiveFiltersDisplay() {
@@
-        } else if (filterKey === 'genere_id') {
+        } else if (filterKey === 'genere_id') {
             displayValue = currentGenreName || value;
+        } else if (filterKey === 'tipo_media') {
+            displayValue = MEDIA_TYPE_LABELS[value] || value;
         }

Also applies to: 1460-1460, 1622-1622

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/frontend/catalog.php` around lines 1302 - 1326, The tipo_media UI
is added server-side but not fully wired to the client: ensure the client-side
active-state sync mirrors how availability works by having
updateFilter('tipo_media', ...) trigger the same JS path that sets/unsets the
.filter-option.active class and updates the active-filter tags; use the
user-facing labels from MediaLabels::allTypes() (the $tipoFilters entries /
$tmInfo['label']) when rendering active-filter tags instead of raw enum keys so
displayed tags show localized labels, and ensure any JS that reads currentTipo
(filters['tipo_media']) uses the stringified values you json_encode in the
onclick to match server/state comparison.
app/Controllers/CsvImportController.php (1)

773-775: ⚠️ Potential issue | 🟠 Major

Normalize tipo_media before binding it to the enum column.

parseCsvRow() now forwards the raw CSV cell unchanged, and both new SQL branches persist that value directly. Inputs like CD, Vinyl, Audiobook, or localized labels won't match the libri.tipo_media enum, so the import can fail or store an unintended value. Normalize/validate the CSV value to the canonical enum (libro, disco, audiolibro, dvd, altro) before it reaches the query.

Also applies to: 1269-1292, 1360-1390

app/Views/frontend/book-detail.php (1)

2192-2193: ⚠️ Potential issue | 🟡 Minor

Use each related card's media flag for the fallback author label.

This still keys off the parent page's $isMusic, so a mixed related-items section can show Artista sconosciuto on a book card or Autore sconosciuto on a music card.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/frontend/book-detail.php` around lines 2192 - 2193, The fallback
author label currently uses the parent $isMusic flag; change it to use the
related item’s own media flag by switching the ternary to check $related's
isMusic flag first (e.g. $related['isMusic'] ?? $isMusic) so the fallback text
for <?= 'autori' ?> is chosen per related card rather than the parent page.
storage/plugins/discogs/views/settings.php (1)

138-142: ⚠️ Potential issue | 🟡 Minor

Use the named plugins route for this backlink.

This link is still bound to /admin/plugins, so it will drift if the admin route is renamed or localized. Use the existing named route helper and escape that result in the href.

As per coding guidelines "Never use hardcoded routes. Use route_path('key') or RouteTranslator::route('key') instead of hardcoded paths like /accedi or /login."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/discogs/views/settings.php` around lines 138 - 142, Replace
the hardcoded url('/admin/plugins') used in the anchor href with the named-route
helper and escape its result; specifically, in the anchor where
htmlspecialchars(url('/admin/plugins')...) is used, call
htmlspecialchars(route_path('key'), ENT_QUOTES, 'UTF-8') (or
RouteTranslator::route('key') if your app uses that), keeping the same
ENT_QUOTES and UTF-8 arguments so the backlink uses the named route instead of a
hardcoded path.
app/Views/libri/index.php (1)

688-697: ⚠️ Potential issue | 🟡 Minor

Use the localized media label here, not the enum key.

The dropdown is already powered by MediaLabels::allTypes(), but the icon tooltip and the active-filter chip still show raw values like disco and audiolibro, so this UI stays partially untranslated. Reuse the same label map in both places.

Also applies to: 885-886

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/libri/index.php` around lines 688 - 697, The column render
currently uses the raw enum key (data) for the icon tooltip; replace that with
the localized label from the same mapping used for the dropdown (use
MediaLabels::allTypes() or the label map it returns) so the tooltip shows the
translated media name; update the render in the 'tipo_media' column (and the
similar code at the other occurrence around the active-filter chip) to look up
the label by key (fallback to a default like 'libro') and use that value in the
title attribute instead of the raw key.
tests/discogs-import.spec.js (4)

50-53: ⚠️ Potential issue | 🟠 Major

Teardown still misses the isbn13 persistence path.

Tests 3, 5, and 6 explicitly accept records saved through isbn13, but afterAll() only deletes by ean. A previous run can therefore leave the imported row behind and make later runs pass against stale data.

Suggested fix
-    try { dbExec("DELETE FROM libri WHERE ean = '0720642442524' AND deleted_at IS NULL"); } catch {}
+    try {
+      dbExec(
+        "DELETE FROM libri WHERE (ean = '0720642442524' OR isbn13 LIKE '%720642442524%') AND deleted_at IS NULL"
+      );
+    } catch {}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-import.spec.js` around lines 50 - 53, The teardown in
test.afterAll only deletes by ean and misses records persisted via isbn13;
update the cleanup to also remove rows where isbn13 = '0720642442524' (either
add a second dbExec call deleting where isbn13 matches or expand the SQL to
delete where ean = '0720642442524' OR isbn13 = '0720642442524'), keep the
try/catch around dbExec, and still await context?.close(); reference
test.afterAll, dbExec, isbn13, ean, and context.close when making the change.

56-67: ⚠️ Potential issue | 🟠 Major

The main import suite still turns product failures into skips.

After the env-var gate, an inactive plugin, missing import button, or empty titolo means the Discogs flow failed. Using test.skip() here lets the “full scraping flow” suite go green without actually exercising the import path.

Suggested fix
-      test.skip(parseInt(isActiveNow) === 0, 'Discogs plugin could not be activated');
+      expect(parseInt(isActiveNow)).toBeGreaterThan(0);
@@
-    if (!await importBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
-      test.skip(true, 'Import button not visible — no scraping plugin active');
-      return;
-    }
+    await expect(importBtn).toBeVisible({ timeout: 5000 });
@@
-    if (titleValue === '') {
-      // Scraping may have failed (rate limit, network). Check if any source populated data
-      test.skip(true, 'Scraping did not return data (possibly rate limited)');
-      return;
-    }
+    expect(titleValue, `Discogs scrape returned no data for barcode ${TEST_BARCODE}`).not.toBe('');

Also applies to: 77-97

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-import.spec.js` around lines 56 - 67, The current test turns
real failures into skips by calling test.skip(...) when the Discogs plugin isn't
active or import prerequisites are missing; change this to cause a test failure
instead: replace the test.skip(...) calls with an explicit failing assertion or
throw (e.g., use expect(parseInt(isActiveNow)).toBeGreaterThan(0) or throw new
Error('Discogs plugin could not be activated')) so the suite fails when the
plugin/import button/titolo check fails; apply the same change for the similar
block referenced around lines 77-97 and keep references to dbQuery, page.goto
and the surrounding test('1. Verify Discogs plugin is active', ...) block to
locate the code.

155-164: ⚠️ Potential issue | 🟠 Major

Missing persisted row should fail this suite, not skip it.

If neither lookup finds the saved album, the save step regressed. test.skip() hides that failure and lets the remaining checks continue with invalid state.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-import.spec.js` around lines 155 - 164, The test currently
calls test.skip when dbQuery returns an empty result (bookByIsbn === '') which
hides the failure; change this to actively fail the test instead—replace
test.skip(bookByIsbn === '', 'CD not found in database') with an explicit
failure (for example throw new Error('CD not found in database') or use your
test framework's fail API) so that when dbQuery (dbQuery / bookByIsbn) yields no
row the spec fails immediately and subsequent assertions (expect(...).toContain)
do not run on invalid state.

180-188: ⚠️ Potential issue | 🟠 Major

Don’t guard the only label assertion behind a formato heuristic.

A broken format mapping just bypasses the check, so this test never proves the saved record is treated as music. Assert tipo_media === 'disco' (or assert the persisted formato separately) and keep the label expectation mandatory for that branch.

Suggested fix
-    const formato = dbQuery(`SELECT formato FROM libri WHERE id = ${bookId}`);
-
-    if (formato && ['cd_audio', 'vinile', 'cd', 'vinyl'].some(f => formato.toLowerCase().includes(f))) {
-      // Music labels should be active
-      const hasMusicLabel = content.includes('Etichetta') || content.includes('Label') ||
-                            content.includes('Anno di Uscita') || content.includes('Release Year');
-      expect(hasMusicLabel).toBe(true);
-    }
+    const tipoMedia = dbQuery(
+      `SELECT tipo_media FROM libri WHERE id = ${bookId} AND deleted_at IS NULL LIMIT 1`
+    );
+    expect(tipoMedia).toBe('disco');
+
+    const hasMusicLabel = content.includes('Etichetta') || content.includes('Label') ||
+                          content.includes('Anno di Uscita') || content.includes('Release Year');
+    expect(hasMusicLabel).toBe(true);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-import.spec.js` around lines 180 - 188, The test currently
skips the music-label assertion based on a fragile formato heuristic; instead
query the persisted tipo_media for the record (use dbQuery to SELECT tipo_media
FROM libri WHERE id = ${bookId}) and assert expect(tipo_media).toBe('disco') (or
separately assert the persisted formato equals a music format) and then always
assert the label fields are present by checking content (hasMusicLabel) with
expect(hasMusicLabel).toBe(true); keep references to the existing formato
variable only if you add a separate assertion for the stored formato, and use
the same bookId and content variables already in the test.
tests/discogs-plugin.spec.js (2)

183-202: ⚠️ Potential issue | 🟠 Major

This “frontend” check still only exercises the admin template.

Requesting ${BASE}/admin/libri/${bookId} will not catch regressions in the public detail page or the Barcode-vs-ISBN label logic there. Resolve the public book URL and assert the frontend HTML directly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-plugin.spec.js` around lines 183 - 202, Test "6. Frontend:
music book shows Barcode instead of ISBN-13" is hitting the admin template
(`page.request.get(${BASE}/admin/libri/${bookId})`) so it doesn't validate the
public/front-end labeling; change the request to the public book detail URL (use
the public route pattern used elsewhere, e.g. `${BASE}/libri/${bookId}` or the
app's public resolver) and re-run the same HTML checks against that response
(inspect the returned HTML for 'Barcode' or music-specific labels) so the spec
exercises the public detail page rather than the admin page.

91-109: ⚠️ Potential issue | 🟠 Major

Scope install/activate/settings actions to Discogs and fail when its card is missing.

button:has-text("Installa").first() can act on a different plugin, and Test 3 silently passes if the Discogs settings link never appears. Root those locators in the Discogs card and add explicit assertions for card visibility, activation, and token-field visibility before continuing.

Also applies to: 121-137

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-plugin.spec.js` around lines 91 - 109, The test currently uses
global button locators that may target other plugins; update code to scope
install/activate/settings actions to the Discogs card by deriving installBtn,
activateBtn, and settings link from the discogsCard locator
(discogsCard.locator(...)) and add explicit assertions that
discogsCard.isVisible() throws/returns false -> fail the test, that activation
actually occurred (assert activateBtn becomes hidden or card shows active state)
and that the token field (e.g., the Discogs token input) is visible before
proceeding; ensure any .isVisible({timeout}) checks use assertions (not silent
.catch(() => false)) so the test fails fast if the Discogs card is missing or
actions do not succeed.
storage/plugins/discogs/DiscogsPlugin.php (2)

696-739: ⚠️ Potential issue | 🟠 Major

api_token is still split across raw SQL and the encrypted settings API.

getSetting() reads plugin_settings verbatim while the controller save path stores encrypted ENC: values, so authenticated requests can end up sending ciphertext as the Discogs token. saveSettings() then writes plaintext back into the same table, which regresses secret handling in the opposite direction. Route both reads and writes through the same PluginManager settings API.

Also applies to: 761-820

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/discogs/DiscogsPlugin.php` around lines 696 - 739,
getSetting() and saveSettings() are directly reading/writing plugin_settings
(returning/storing raw or ENC: values) causing plaintext/ciphertext mismatch for
api_token; switch both read and write paths to use the centralized PluginManager
settings API instead of direct SQL. Locate getSetting() and saveSettings() in
DiscogsPlugin and replace the direct DB SELECT/UPDATE logic that touches
plugin_settings and pluginId with calls to the PluginManager methods that
retrieve and store plugin settings (the encrypted-aware getters/setters provided
by the PluginManager), ensuring you pass the plugin identifier (this->pluginId
or resolved name) and the 'api_token' key so the API handles
encryption/decryption consistently. Ensure you remove the direct SQL access
(prepare/execute/bind_param) for those keys and rely solely on the PluginManager
for both reads and writes.

223-231: ⚠️ Potential issue | 🟠 Major

The promised title fallback still never runs.

Line 230 returns immediately on a barcode miss, so releases without Discogs barcode metadata are unreachable even though this method is documented as “barcode, then query search.” If title/artist are required for the fallback, thread them into this hook or move the fallback to a stage that has them before returning.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/discogs/DiscogsPlugin.php` around lines 223 - 231, The method
currently returns $currentResult immediately when the barcode search yields no
results (see $searchResult check), preventing the documented "barcode, then
query search" fallback from ever running; instead, remove the early return and
invoke the fallback query using title/artist (pull them from $currentResult if
available) or call a dedicated fallback helper (e.g., a new or existing method
like searchByTitleArtist) which builds a non-barcode $searchUrl and calls
apiRequest with the same $token; if title/artist are not present in this scope,
thread them into this hook (pass them into the method) or move the fallback
invocation to the earlier stage that has those fields before returning.
🧹 Nitpick comments (1)
app/Controllers/ScrapeController.php (1)

776-780: Avoid baking a scraper name into core normalization.

This branch is really about EAN-only music payloads, not Discogs specifically. Keying it off source === 'discogs' means the next non-book scraper will need another core special-case instead of using a provider-agnostic signal.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/ScrapeController.php` around lines 776 - 780, The
normalization branch is keyed to ($data['source'] ?? '') === 'discogs', which
bakes a provider name into core logic; instead detect EAN-only music payloads
generically (e.g., check for presence of a barcode/EAN and absence of
isbn13/isbn10 or a provider-agnostic flag). Update the ScrapeController.php
branch that currently returns $data for Discogs to something like: if
(isset($data['barcode']) && empty($data['isbn13']) && empty($data['isbn10']))
return $data; or use a new payload field like is_ean_only set by scrapers, and
reference the existing variables $data['source'], $data['barcode'],
$data['isbn13'], and $data['isbn10'] when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Controllers/FrontendController.php`:
- Around line 900-904: Guard the filter addition so we only add the
"l.tipo_media" condition when the column actually exists: before pushing to
conditions[], params[] and appending to types, check schema availability for the
column (e.g. use your DB schema helper like hasColumn('lotes','tipo_media') or
query information_schema) and only run the existing block when that check passes
and filters['tipo_media'] is not empty; update the code paths around
filters['tipo_media'], conditions[], params[] and types to be conditional on the
schema check to avoid "Unknown column 'l.tipo_media'" errors.

In `@app/Controllers/LibraryThingImportController.php`:
- Around line 914-915: parseLibraryThingRow() currently sets
$result['tipo_media'] via \App\Support\MediaLabels::inferTipoMedia(...)
unconditionally, causing updates to overwrite an existing tipo_media (e.g.,
disco/dvd) with a default like libro; change the logic so parseLibraryThingRow()
leaves $result['tipo_media'] null unless inferTipoMedia(...) returns a
recognized/valid value, and update the update branches (the code paths that
write back $result['tipo_media'] on matched imports) to skip writing tipo_media
when it is null; only apply the default inference (call inferTipoMedia and set
tipo_media if null) in the insert/new-record path so new imports get a default
but updates do not overwrite an existing tipo_media.

In `@app/Models/BookRepository.php`:
- Around line 303-306: The code currently sets tipo_media only from
$data['tipo_media'] (and similar for scraped_tipo_media), causing records that
only provide formato (as in the Discogs flow) to default to 'libro'; update the
logic in the blocks that call hasColumn('tipo_media') and normalizeEnumValue to
first check for a canonical tipo_media, then fall back to $data['formato'] (map
known formato values like CD/LP/cassette to their corresponding tipo_media)
before finally defaulting to 'libro'; apply the same fallback change to the
other occurrence that handles scraped_tipo_media so both paths use formato when
tipo_media is absent, using the existing normalizeEnumValue and addField
functions to validate and set the field.

In `@app/Views/frontend/book-detail.php`:
- Around line 164-166: The JSON-LD currently sets `@type` via
\App\Support\MediaLabels::schemaOrgType($resolvedTipoMedia) but always builds
$bookSchema with book-specific properties (bookEdition, isbn, numberOfPages);
change schema construction to branch on the resolved type (use the value
returned by MediaLabels::schemaOrgType($resolvedTipoMedia) or the
$resolvedTipoMedia variable) and create a media-specific schema object for
non-Book types (e.g., MusicRecording, Movie) that omits book-only fields and
adds appropriate properties for that media; update code around the $bookSchema
variable so you build and output the correct schema shape per type instead of
only switching `@type`.

In `@installer/database/migrations/migrate_0.5.4.sql`:
- Around line 27-38: The migration's UPDATEs use exact LOWER(formato) IN (...)
matching which misses values matched at runtime by
App\Support\MediaLabels::inferTipoMedia() (which uses substring checks); update
the three UPDATE statements in migrate_0.5.4.sql to use the same
substring/ILIKE/REGEXP rules as inferTipoMedia() (e.g., check LOWER(formato)
LIKE '%cd%' OR '%vinyl%' OR '%lp%' OR '%cassette%' for disco, patterns for
'audiolibro' like '%audio%' or '%audiobook%', and patterns for 'dvd' like
'%dvd%' OR '%blu%' OR '%blu-ray%'/'%blu_ray%') so existing rows get the same
tipo_media classification as the runtime MediaLabels::inferTipoMedia() logic.

In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 277-291: The current early return in DiscogsPlugin (the check if
(($data['source'] ?? '') !== 'discogs') in DiscogsPlugin.php) prevents cover
enrichment when a discogs_id exists but source isn't 'discogs'; change the
gating logic to base enrichment on the presence of discogs_id instead of source:
remove or bypass the source check and allow the subsequent code that reads
$discogsId = $data['discogs_id'] ?? null and calls
$this->getSetting('api_token'), self::API_BASE . '/releases/' . (int)$discogsId
and $this->apiRequest(...) to run whenever discogs_id is present (null check
remains), so covers are backfilled even when BookDataMerger::merge() left source
unchanged.

In `@storage/plugins/discogs/plugin.json`:
- Around line 11-12: The manifest declares requires_app and max_app_version but
PluginManager only validates requires_php, so either add app-version validation
to PluginManager or remove those fields from the manifest; to fix, update
PluginManager to read the manifest's requires_app and max_app_version, parse
semantic versions, fetch the running application's version (use the existing
app-version source in your app, e.g. an Application.version or APP_VERSION
constant), then enforce that runningVersion >= requires_app and runningVersion
<= max_app_version and fail install/activation with a clear error, or if you
prefer the simpler change, remove the requires_app and max_app_version entries
from the plugin manifest to avoid promising unsupported compatibility.

In `@tests/discogs-advanced.spec.js`:
- Around line 116-139: The test "3. Schema.org JSON-LD type is MusicAlbum for
disco" currently only checks storage via apiResp/musicBook and DB queries
(musicBook, musicBookId, bookBookId); update it to actually fetch and assert the
frontend JSON-LD `@type`: navigate to the public detail page for the found
musicBook (or construct its public URL) and parse the <script
type="application/ld+json"> payload, then assert that the parsed JSON's "@type"
equals "MusicAlbum" for the disco record and "Book" for the libro record;
alternatively, if you intend to keep it as a storage-level test, rename the test
title to reflect that it only validates tipo_media in API/DB.
- Around line 52-60: The teardown is only deleting seeded rows by their original
titolo suffix so if a test (Test 5) renames a row the cleanup can miss it;
update the teardown (afterAll) to remove fixtures by stable identifiers instead
of mutable titles—e.g., call dbExec DELETE statements targeting the seeded ean
'1234567890123' and isbn13 '9781234567897' (and any other fixed identifiers used
in the inserts) or use a robust predicate like WHERE titolo LIKE 'E2E_ADV_%'
combined with OR ean=... OR isbn13=...; apply the same change for all teardown
spots related to the inserts created by RUN_ID (the dbExec insert sites and the
afterAll reset) so stale rows cannot survive a failed reset.

---

Outside diff comments:
In `@app/Controllers/CsvImportController.php`:
- Around line 1231-1256: The UPDATE statement prepared in CsvImportController
(the $stmt prepared for table libri) must include the soft-delete guard so we
only update non-deleted rows; modify the WHERE clause from "WHERE id = ?" to
"WHERE id = ? AND deleted_at IS NULL" (or equivalent parameterized condition) so
a concurrent soft-delete won’t allow updates to archived rows; ensure the change
is applied to the prepared SQL used by the update path that complements
findExistingBook().

In `@app/Support/PluginManager.php`:
- Around line 45-53: The bundled "discogs" entry is being auto-enabled because
autoRegisterBundledPlugins() inserts entries with is_active = 1 and immediately
calls onActivate(); change the behavior so bundled optional plugins are
registered but not auto-activated: either remove 'discogs' from the
BUNDLED_PLUGINS constant and add it to a new BUNDLED_OPTIONAL_PLUGINS list, or
modify autoRegisterBundledPlugins() to insert with is_active = 0 and skip
calling onActivate() for items flagged optional (check for a new optionalPlugins
array or a lookup), keeping onActivate() calls only for truly required plugins;
update references to BUNDLED_PLUGINS, autoRegisterBundledPlugins(), is_active
and onActivate() accordingly.

In `@app/Support/Updater.php`:
- Around line 39-47: The rollback path doesn't snapshot storage/plugins so
updating bundled plugins (BUNDLED_PLUGINS used by updateBundledPlugins() called
from installUpdate()) can leave plugins upgraded when core is restored; modify
backupAppFiles() and restoreAppFiles() to include the storage/plugins directory
(or alternatively make updateBundledPlugins() write plugin updates to a
temporary location and atomically move them only after install/migrations
succeed), ensuring any plugin paths touched by BUNDLED_PLUGINS are backed up and
restored on failure.

---

Duplicate comments:
In `@app/Views/frontend/book-detail.php`:
- Around line 2192-2193: The fallback author label currently uses the parent
$isMusic flag; change it to use the related item’s own media flag by switching
the ternary to check $related's isMusic flag first (e.g. $related['isMusic'] ??
$isMusic) so the fallback text for <?= 'autori' ?> is chosen per related card
rather than the parent page.

In `@app/Views/frontend/catalog.php`:
- Around line 1302-1326: The tipo_media UI is added server-side but not fully
wired to the client: ensure the client-side active-state sync mirrors how
availability works by having updateFilter('tipo_media', ...) trigger the same JS
path that sets/unsets the .filter-option.active class and updates the
active-filter tags; use the user-facing labels from MediaLabels::allTypes() (the
$tipoFilters entries / $tmInfo['label']) when rendering active-filter tags
instead of raw enum keys so displayed tags show localized labels, and ensure any
JS that reads currentTipo (filters['tipo_media']) uses the stringified values
you json_encode in the onclick to match server/state comparison.

In `@app/Views/libri/index.php`:
- Around line 688-697: The column render currently uses the raw enum key (data)
for the icon tooltip; replace that with the localized label from the same
mapping used for the dropdown (use MediaLabels::allTypes() or the label map it
returns) so the tooltip shows the translated media name; update the render in
the 'tipo_media' column (and the similar code at the other occurrence around the
active-filter chip) to look up the label by key (fallback to a default like
'libro') and use that value in the title attribute instead of the raw key.

In `@app/Views/libri/partials/book_form.php`:
- Around line 3615-3624: The code sets the select `#tipo_media` directly from
data.tipo_media which may contain variants (e.g., "CD", "vinyl") and leaves the
select unset; instead whitelist and normalize the incoming value against the
canonical keys from MediaLabels::allTypes(): create a mapping of allowed
keys/aliases (server-side output of MediaLabels::allTypes() into a JS object or
array), normalize data.tipo_media (trim, lower/upper-case as needed), look up
the normalized value in that whitelist (or map aliases to canonical keys) and
only assign tipoMediaSelect.value when a match exists; keep the try/catch but
log or silently skip when no valid match is found to avoid inconsistent form
state.

In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 696-739: getSetting() and saveSettings() are directly
reading/writing plugin_settings (returning/storing raw or ENC: values) causing
plaintext/ciphertext mismatch for api_token; switch both read and write paths to
use the centralized PluginManager settings API instead of direct SQL. Locate
getSetting() and saveSettings() in DiscogsPlugin and replace the direct DB
SELECT/UPDATE logic that touches plugin_settings and pluginId with calls to the
PluginManager methods that retrieve and store plugin settings (the
encrypted-aware getters/setters provided by the PluginManager), ensuring you
pass the plugin identifier (this->pluginId or resolved name) and the 'api_token'
key so the API handles encryption/decryption consistently. Ensure you remove the
direct SQL access (prepare/execute/bind_param) for those keys and rely solely on
the PluginManager for both reads and writes.
- Around line 223-231: The method currently returns $currentResult immediately
when the barcode search yields no results (see $searchResult check), preventing
the documented "barcode, then query search" fallback from ever running; instead,
remove the early return and invoke the fallback query using title/artist (pull
them from $currentResult if available) or call a dedicated fallback helper
(e.g., a new or existing method like searchByTitleArtist) which builds a
non-barcode $searchUrl and calls apiRequest with the same $token; if
title/artist are not present in this scope, thread them into this hook (pass
them into the method) or move the fallback invocation to the earlier stage that
has those fields before returning.

In `@storage/plugins/discogs/views/settings.php`:
- Around line 138-142: Replace the hardcoded url('/admin/plugins') used in the
anchor href with the named-route helper and escape its result; specifically, in
the anchor where htmlspecialchars(url('/admin/plugins')...) is used, call
htmlspecialchars(route_path('key'), ENT_QUOTES, 'UTF-8') (or
RouteTranslator::route('key') if your app uses that), keeping the same
ENT_QUOTES and UTF-8 arguments so the backlink uses the named route instead of a
hardcoded path.

In `@tests/discogs-import.spec.js`:
- Around line 50-53: The teardown in test.afterAll only deletes by ean and
misses records persisted via isbn13; update the cleanup to also remove rows
where isbn13 = '0720642442524' (either add a second dbExec call deleting where
isbn13 matches or expand the SQL to delete where ean = '0720642442524' OR isbn13
= '0720642442524'), keep the try/catch around dbExec, and still await
context?.close(); reference test.afterAll, dbExec, isbn13, ean, and
context.close when making the change.
- Around line 56-67: The current test turns real failures into skips by calling
test.skip(...) when the Discogs plugin isn't active or import prerequisites are
missing; change this to cause a test failure instead: replace the test.skip(...)
calls with an explicit failing assertion or throw (e.g., use
expect(parseInt(isActiveNow)).toBeGreaterThan(0) or throw new Error('Discogs
plugin could not be activated')) so the suite fails when the plugin/import
button/titolo check fails; apply the same change for the similar block
referenced around lines 77-97 and keep references to dbQuery, page.goto and the
surrounding test('1. Verify Discogs plugin is active', ...) block to locate the
code.
- Around line 155-164: The test currently calls test.skip when dbQuery returns
an empty result (bookByIsbn === '') which hides the failure; change this to
actively fail the test instead—replace test.skip(bookByIsbn === '', 'CD not
found in database') with an explicit failure (for example throw new Error('CD
not found in database') or use your test framework's fail API) so that when
dbQuery (dbQuery / bookByIsbn) yields no row the spec fails immediately and
subsequent assertions (expect(...).toContain) do not run on invalid state.
- Around line 180-188: The test currently skips the music-label assertion based
on a fragile formato heuristic; instead query the persisted tipo_media for the
record (use dbQuery to SELECT tipo_media FROM libri WHERE id = ${bookId}) and
assert expect(tipo_media).toBe('disco') (or separately assert the persisted
formato equals a music format) and then always assert the label fields are
present by checking content (hasMusicLabel) with
expect(hasMusicLabel).toBe(true); keep references to the existing formato
variable only if you add a separate assertion for the stored formato, and use
the same bookId and content variables already in the test.

In `@tests/discogs-plugin.spec.js`:
- Around line 183-202: Test "6. Frontend: music book shows Barcode instead of
ISBN-13" is hitting the admin template
(`page.request.get(${BASE}/admin/libri/${bookId})`) so it doesn't validate the
public/front-end labeling; change the request to the public book detail URL (use
the public route pattern used elsewhere, e.g. `${BASE}/libri/${bookId}` or the
app's public resolver) and re-run the same HTML checks against that response
(inspect the returned HTML for 'Barcode' or music-specific labels) so the spec
exercises the public detail page rather than the admin page.
- Around line 91-109: The test currently uses global button locators that may
target other plugins; update code to scope install/activate/settings actions to
the Discogs card by deriving installBtn, activateBtn, and settings link from the
discogsCard locator (discogsCard.locator(...)) and add explicit assertions that
discogsCard.isVisible() throws/returns false -> fail the test, that activation
actually occurred (assert activateBtn becomes hidden or card shows active state)
and that the token field (e.g., the Discogs token input) is visible before
proceeding; ensure any .isVisible({timeout}) checks use assertions (not silent
.catch(() => false)) so the test fails fast if the Discogs card is missing or
actions do not succeed.

---

Nitpick comments:
In `@app/Controllers/ScrapeController.php`:
- Around line 776-780: The normalization branch is keyed to ($data['source'] ??
'') === 'discogs', which bakes a provider name into core logic; instead detect
EAN-only music payloads generically (e.g., check for presence of a barcode/EAN
and absence of isbn13/isbn10 or a provider-agnostic flag). Update the
ScrapeController.php branch that currently returns $data for Discogs to
something like: if (isset($data['barcode']) && empty($data['isbn13']) &&
empty($data['isbn10'])) return $data; or use a new payload field like
is_ean_only set by scrapers, and reference the existing variables
$data['source'], $data['barcode'], $data['isbn13'], and $data['isbn10'] when
making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f2a2b3b5-5827-45bc-a23d-e5cedd7a20fa

📥 Commits

Reviewing files that changed from the base of the PR and between d0cf881 and 5724f16.

📒 Files selected for processing (33)
  • .gitignore
  • app/Controllers/CsvImportController.php
  • app/Controllers/FrontendController.php
  • app/Controllers/LibraryThingImportController.php
  • app/Controllers/LibriApiController.php
  • app/Controllers/LibriController.php
  • app/Controllers/PluginController.php
  • app/Controllers/PublicApiController.php
  • app/Controllers/ScrapeController.php
  • app/Models/BookRepository.php
  • app/Support/MediaLabels.php
  • app/Support/PluginManager.php
  • app/Support/Updater.php
  • app/Views/frontend/book-detail.php
  • app/Views/frontend/catalog-grid.php
  • app/Views/frontend/catalog.php
  • app/Views/libri/index.php
  • app/Views/libri/partials/book_form.php
  • app/Views/libri/scheda_libro.php
  • installer/database/migrations/migrate_0.5.4.sql
  • installer/database/schema.sql
  • locale/de_DE.json
  • locale/en_US.json
  • locale/it_IT.json
  • pinakes-v0.5.3.zip.sha256
  • storage/plugins/discogs/DiscogsPlugin.php
  • storage/plugins/discogs/README.md
  • storage/plugins/discogs/plugin.json
  • storage/plugins/discogs/views/settings.php
  • storage/plugins/discogs/wrapper.php
  • tests/discogs-advanced.spec.js
  • tests/discogs-import.spec.js
  • tests/discogs-plugin.spec.js

Comment on lines +11 to +12
"requires_app": "0.5.0",
"max_app_version": "1.0.0",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't rely on requires_app / max_app_version as a compatibility guard yet.

PluginManager currently validates only requires_php and just persists requires_app, so these manifest entries won't actually block incompatible Pinakes versions from installing or activating this plugin. Either add the app-version checks in PluginManager or drop the unsupported compatibility promise from the manifest for now.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/discogs/plugin.json` around lines 11 - 12, The manifest
declares requires_app and max_app_version but PluginManager only validates
requires_php, so either add app-version validation to PluginManager or remove
those fields from the manifest; to fix, update PluginManager to read the
manifest's requires_app and max_app_version, parse semantic versions, fetch the
running application's version (use the existing app-version source in your app,
e.g. an Application.version or APP_VERSION constant), then enforce that
runningVersion >= requires_app and runningVersion <= max_app_version and fail
install/activation with a clear error, or if you prefer the simpler change,
remove the requires_app and max_app_version entries from the plugin manifest to
avoid promising unsupported compatibility.

@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@CodeRabbit full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 30, 2026

✅ Actions performed

Full review triggered.

Backend:
- CsvImportController: soft-delete guard on all UPDATE libri
- CSV parseCsvRow: normalize tipo_media through inferTipoMedia
- FrontendController: hasColumn guard for tipo_media filter
- LT import: tipo_media null on update (preserve existing), infer only on insert
- BookRepository createBasic: fallback from formato when tipo_media absent
- Migration: LIKE patterns instead of exact IN() for broader matching
- Discogs enrichment: gate on discogs_id, not source

Schema.org:
- MusicAlbum: byArtist, recordLabel, numTracks, EAN identifier
- Movie: director, productionCompany, EAN identifier
- Audiobook: author, publisher, readBy (narrator), isbn
- Book: full properties (author, translator, illustrator, editor, isbn, issn, pages, edition)

Tests:
- Cleanup by ean/isbn13 (stable identifiers, not mutable titles)
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 30, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/Support/Updater.php (1)

2227-2237: ⚠️ Potential issue | 🟠 Major

Guard this recursive delete and stop on the first removal failure.

removeDirectoryTree() trusts whatever path it receives and hides every unlink() / rmdir() failure. Since the caller now builds that path from raw entries in BundledPlugins::LIST (app/Support/BundledPlugins.php, Lines 1-19), a bad slug could escape storage/plugins, and even ordinary delete failures would leave a mixed old/new plugin tree while the update continues. Please validate/canonicalize the plugin path before Line 2236 and make this helper abort instead of suppressing filesystem errors.

Also applies to: 2244-2264

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Support/Updater.php` around lines 2227 - 2237, The delete step uses
removeDirectoryTree() on paths built from BundledPlugins::LIST and currently
suppresses failures; before calling removeDirectoryTree($targetPluginPath) in
Updater.php validate and canonicalize the target path (resolve realpath or
normalize and ensure it is strictly inside the expected storage/plugins
directory) to prevent path traversal, and change the flow so
removeDirectoryTree() returns/throws on first failure and the caller aborts the
update (do not continue to copyDirectoryRecursive) when removal fails; update
removeDirectoryTree() to surface unlink()/rmdir() errors (or return false) and
make the loop in the plugin update block check that result and stop the update
if it fails.
♻️ Duplicate comments (1)
app/Support/MediaLabels.php (1)

91-107: ⚠️ Potential issue | 🟠 Major

Make explicit tipo_media authoritative in isMusic().

Line 93 resolves the explicit media type, but Lines 97-105 still inspect formato and can turn audiolibro rows on cd_audio/audiocassetta back into music. That brings back the Artista/Etichetta/Tracklist mislabeling for audiobooks on physical media.

♻️ Minimal fix
 public static function isMusic(?string $formato, ?string $tipoMedia = null): bool
 {
-    if (self::resolveTipoMedia($formato, $tipoMedia) === 'disco') {
-        return true;
-    }
-
-    foreach (self::normalizedCandidates($formato) as $candidate) {
-        if (in_array($candidate, self::MUSIC_FORMATS, true)) {
-            return true;
-        }
-
-        if (preg_match('/\b(?:music|musik)\b/i', $candidate) === 1) {
-            return true;
-        }
+    $normalizedTipoMedia = self::normalizeTipoMedia($tipoMedia);
+    if ($normalizedTipoMedia !== null) {
+        return $normalizedTipoMedia === 'disco';
     }
 
-    return false;
+    return self::inferTipoMedia($formato) === 'disco';
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Support/MediaLabels.php` around lines 91 - 107, isMusic() currently falls
back to inspecting formato even when an explicit tipo_media is provided via
resolveTipoMedia(), which lets rows with tipo_media like "audiolibro" be
misclassified as music; change isMusic() so that after calling
resolveTipoMedia($formato, $tipoMedia) you treat any non-null resolved tipo as
authoritative: if it equals 'disco' return true, otherwise return false and skip
the normalizedCandidates()/MUSIC_FORMATS/preg_match checks; refer to isMusic(),
resolveTipoMedia(), normalizedCandidates(), and MUSIC_FORMATS when making the
change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Controllers/FrontendController.php`:
- Line 745: The 'tipo_media' entry currently passes the raw param through,
allowing arrays (e.g. ?tipo_media[]=music) to reach SQL bind code; change the
assignment for 'tipo_media' so it normalizes to a scalar string: if
$params['tipo_media'] is an array, take its first element, otherwise use the
value, then cast to string and trim() (falling back to empty string). Update the
array entry that currently reads 'tipo_media' => $params['tipo_media'] ?? '' to
perform this normalization so later code using l.tipo_media = ? always receives
a scalar.

In `@app/Controllers/PluginController.php`:
- Around line 219-220: Replace the 403+Location pair with a proper redirect: if
you intend to move unauthorized users to the dashboard, return a 302 (or 303)
redirect instead of 403 and build the URL with the named-route helper (e.g. use
route_path('admin.dashboard') or RouteTranslator::route('admin.dashboard'))
rather than the hardcoded '/admin/dashboard'; alternatively, if you intend to
deny access, keep the 403 but remove the Location header (i.e. return
$response->withStatus(403) only). Apply this change where the $_SESSION['user']
admin check and the response->withStatus(...)->withHeader('Location', ...) call
occur.

In `@app/Models/BookRepository.php`:
- Around line 1060-1068: The code in BookRepository that sets tipo_media
currently defaults to 'libro' even when no media-related input was provided;
update the conditional that wraps the resolve/normalize block (the section
handling tipo_media, using MediaLabels::resolveTipoMedia and normalizeEnumValue)
to first check that at least one media signal key exists in the incoming $data
(e.g., 'formato', 'scraped_formato', or 'scraped_tipo_media') before calling
resolveTipoMedia and applying the default; this ensures updateOptionals() won't
silently overwrite existing tipo_media values when the payload contains no media
fields.

In `@app/Support/Updater.php`:
- Around line 2236-2237: Currently the updater deletes the live plugin dir then
copies directly with removeDirectoryTree($targetPluginPath) followed by
copyDirectoryRecursive($sourcePluginPath, $targetPluginPath), which can leave
storage/plugins/<slug> missing if copy fails; change the flow to stage the new
plugin tree into a temp directory (e.g. $targetPluginPath . '.tmp' or use
sys_get_temp_dir()), perform copyDirectoryRecursive($sourcePluginPath,
$tempPath), verify the copy succeeded, then atomically swap by renaming/removing
the old $targetPluginPath and renaming $tempPath to $targetPluginPath (or first
back up the original into the existing rollback set and include the plugin path
in the backup/restore logic used in the restore routine), ensuring
removeDirectoryTree is only called after a successful staged copy and that
failures leave the original directory intact.

In `@app/Views/admin/plugins.php`:
- Line 300: The link is building a hardcoded settings URL with
url('/admin/plugins/' . (int) $plugin['id'] . '/settings'); replace this with
the named route helper (e.g. route_path('admin.plugins.settings', ['id' => (int)
$plugin['id']]) or RouteTranslator::route('admin.plugins.settings', ['id' =>
(int) $plugin['id']])) and keep the outer htmlspecialchars(..., ENT_QUOTES,
'UTF-8') call; ensure you pass the plugin id as a parameter to the route helper
so the generated URL matches the previous path.

In `@installer/database/migrations/migrate_0.5.4.sql`:
- Around line 15-47: Migration currently creates idx_libri_tipo_media_deleted_at
with columns (tipo_media, deleted_at) then drops idx_libri_deleted_at, which
breaks queries that filter first on deleted_at; change the new composite index
creation to use (deleted_at, tipo_media) instead of (tipo_media, deleted_at) so
it can serve WHERE deleted_at IS NULL queries before dropping
idx_libri_deleted_at, keeping the conditional CREATE logic for
idx_libri_tipo_media_deleted_at and leaving the DROP logic for
idx_libri_deleted_at and idx_libri_tipo_media unchanged.

In `@installer/database/schema.sql`:
- Line 421: The composite index KEY `idx_libri_tipo_media_deleted_at` on table
`libri` is ordered (`tipo_media`,`deleted_at`) which won't support queries that
filter only on `deleted_at IS NULL`; change the index to have `deleted_at` as
the leading column so it becomes (`deleted_at`,`tipo_media`) to preserve
soft-delete coverage while keeping the media filter. Update the index definition
for `idx_libri_tipo_media_deleted_at` accordingly (drop/recreate or alter the
index) so queries filtering on deleted_at alone use the index.

In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 112-133: Wrap the deletion and re-insertion of hooks in a DB
transaction so the hook set is registered atomically: call
$this->db->begin_transaction() before $this->deleteHooks() and the foreach that
uses $this->db->prepare(), and on any prepare or execute error call
$this->db->rollback() (logging the error) and abort activation; only call
$this->db->commit() after all bind_param/execute calls succeed; ensure you still
close statements ($stmt->close()) on both success and error paths so resources
are released.
- Around line 235-249: The code currently uses the original $isbn as the EAN
even when a title/artist fallback (searchDiscogsByTitleArtist) matched—prevent
this by not carrying the unvalidated $isbn into persistent EAN fields when a
non-barcode fallback was used; modify the branches that call mergeBookData after
searchDiscogsByTitleArtist (and the similar MusicBrainz fallback) so they either
(a) pass a null/empty EAN to mergeBookData or (b) set a flag indicating
"unvalidated identifier" and ensure the persistence logic (where EAN is written,
e.g., the code path that uses $isbn to set ean) only writes EAN when it was
obtained/validated from the release (e.g., from
fetchDiscogsReleaseFromSearchResult) rather than reusing the original $isbn;
update searchDiscogsByTitleArtist / mergeBookData call sites accordingly to
avoid persisting an unverified barcode.

In `@tests/discogs-advanced.spec.js`:
- Around line 18-19: Replace the hardcoded fixtures SEEDED_MUSIC_EAN and
SEEDED_BOOK_ISBN with unique generated identifiers to avoid cross-test
collisions: update where SEEDED_MUSIC_EAN and SEEDED_BOOK_ISBN are defined/used
in tests/discogs-advanced.spec.js to call the unique generators (e.g.,
libri.ean() for EANs and libri.isbn13() for ISBNs or a test helper that returns
a fresh value per test) and propagate those generated values into the
insert/cleanup code and assertions so each test run uses its own identifiers
instead of the fixed '1234567890123' and '9781234567897'.

In `@tests/discogs-import.spec.js`:
- Around line 50-53: The test currently relies on a shared barcode to assert and
clean up which can match unrelated rows; instead capture the created record's id
from the admin create response (the /admin/libri/<id> response at the create
step currently around Lines 137-140), store it in a variable (e.g., createdId)
and use that id for the DB assertion and for teardown cleanup (replace dbExec
DELETE WHERE ean/isbn13 checks with DELETE WHERE id = createdId and assert using
SELECT ... WHERE id = createdId). Update any other spots in the same test (Lines
~143-159) that reference the barcode to use the saved id variable so assertions
and cleanup target the exact created row.

In `@tests/multisource-scraping.spec.js`:
- Around line 114-118: Test suite is collecting created IDs (manualBookId,
importedBookId, manualDiscId, importedDisc1Id, importedDisc2Id) but never
deletes the rows in afterAll; update the afterAll block to delete the created
RUN_TAG/catalog rows using those IDs (call the existing cleanup/delete helpers
or DB methods used elsewhere for removing books/discs/tags) so the suite removes
the records it created and prevents RUN_TAG pollution (ensure the afterAll
references the same variables: manualBookId, importedBookId, manualDiscId,
importedDisc1Id, importedDisc2Id).

---

Outside diff comments:
In `@app/Support/Updater.php`:
- Around line 2227-2237: The delete step uses removeDirectoryTree() on paths
built from BundledPlugins::LIST and currently suppresses failures; before
calling removeDirectoryTree($targetPluginPath) in Updater.php validate and
canonicalize the target path (resolve realpath or normalize and ensure it is
strictly inside the expected storage/plugins directory) to prevent path
traversal, and change the flow so removeDirectoryTree() returns/throws on first
failure and the caller aborts the update (do not continue to
copyDirectoryRecursive) when removal fails; update removeDirectoryTree() to
surface unlink()/rmdir() errors (or return false) and make the loop in the
plugin update block check that result and stop the update if it fails.

---

Duplicate comments:
In `@app/Support/MediaLabels.php`:
- Around line 91-107: isMusic() currently falls back to inspecting formato even
when an explicit tipo_media is provided via resolveTipoMedia(), which lets rows
with tipo_media like "audiolibro" be misclassified as music; change isMusic() so
that after calling resolveTipoMedia($formato, $tipoMedia) you treat any non-null
resolved tipo as authoritative: if it equals 'disco' return true, otherwise
return false and skip the normalizedCandidates()/MUSIC_FORMATS/preg_match
checks; refer to isMusic(), resolveTipoMedia(), normalizedCandidates(), and
MUSIC_FORMATS when making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: acb748ff-3281-4eaf-ac9a-8c0ddd3acd74

📥 Commits

Reviewing files that changed from the base of the PR and between 475e8b5 and c7a419c.

📒 Files selected for processing (25)
  • app/Controllers/CsvImportController.php
  • app/Controllers/FrontendController.php
  • app/Controllers/LibraryThingImportController.php
  • app/Controllers/LibriApiController.php
  • app/Controllers/PluginController.php
  • app/Controllers/ScrapeController.php
  • app/Models/BookRepository.php
  • app/Routes/web.php
  • app/Support/BundledPlugins.php
  • app/Support/MediaLabels.php
  • app/Support/PluginManager.php
  • app/Support/Updater.php
  • app/Views/admin/plugins.php
  • app/Views/frontend/book-detail.php
  • app/Views/libri/scheda_libro.php
  • installer/classes/Installer.php
  • installer/database/migrations/migrate_0.5.4.sql
  • installer/database/schema.sql
  • storage/plugins/discogs/DiscogsPlugin.php
  • storage/plugins/discogs/views/settings.php
  • tests/discogs-advanced.spec.js
  • tests/discogs-import.spec.js
  • tests/discogs-plugin.spec.js
  • tests/multisource-scraping.spec.js
  • tests/playwright.config.js
✅ Files skipped from review due to trivial changes (2)
  • tests/playwright.config.js
  • app/Support/BundledPlugins.php
🚧 Files skipped from review as they are similar to previous changes (6)
  • app/Controllers/ScrapeController.php
  • app/Support/PluginManager.php
  • app/Controllers/LibriApiController.php
  • app/Controllers/CsvImportController.php
  • tests/discogs-plugin.spec.js
  • app/Controllers/LibraryThingImportController.php

Comment on lines +114 to +118
let manualBookId = 0;
let importedBookId = 0;
let manualDiscId = 0;
let importedDisc1Id = 0;
let importedDisc2Id = 0;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Delete the rows this suite creates in afterAll.

Lines 114-118 already collect the created ids, but Lines 131-133 only close the context. On any reused E2E database this leaves permanent RUN_TAG rows behind and the suite will slowly pollute the catalog.

Also applies to: 131-133

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/multisource-scraping.spec.js` around lines 114 - 118, Test suite is
collecting created IDs (manualBookId, importedBookId, manualDiscId,
importedDisc1Id, importedDisc2Id) but never deletes the rows in afterAll; update
the afterAll block to delete the created RUN_TAG/catalog rows using those IDs
(call the existing cleanup/delete helpers or DB methods used elsewhere for
removing books/discs/tags) so the suite removes the records it created and
prevents RUN_TAG pollution (ensure the afterAll references the same variables:
manualBookId, importedBookId, manualDiscId, importedDisc1Id, importedDisc2Id).

@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 31, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (6)
storage/plugins/discogs/DiscogsPlugin.php (2)

149-158: ⚠️ Potential issue | 🟠 Major

Bubble hook-registration failures up to activation.

After the rollback you only log the exception, so onInstall()/onActivate() still return normally and the plugin can appear activated while plugin_hooks is empty. This needs to abort the lifecycle call.

🛠️ Suggested change
         } catch (\Throwable $e) {
             if ($stmt instanceof \mysqli_stmt) {
                 $stmt->close();
             }
             try {
                 $this->db->rollback();
             } catch (\Throwable) {
             }
             \App\Support\SecureLogger::error($e->getMessage());
+            throw $e;
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/discogs/DiscogsPlugin.php` around lines 149 - 158, The
current catch block in DiscogsPlugin.php swallows exceptions (logs via
App\Support\SecureLogger::error) then allows onInstall()/onActivate() to return
normally, so hook-registration failures don't abort activation; modify the catch
to abort the lifecycle by rethrowing the caught exception (or throw a new
appropriate exception) after closing $stmt and attempting rollback, ensuring
onInstall()/onActivate() cannot complete successfully when the rollback path is
taken.

257-260: ⚠️ Potential issue | 🟠 Major

This Discogs title fallback still can’t run in the normal scrape path.

app/Controllers/ScrapeController.php invokes scrape.fetch.custom with null as the initial value, and app/Support/HookManager.php passes that through as the first callback argument. So on a barcode miss, $currentResult is usually null, searchDiscogsByTitleArtist() returns immediately, and the code jumps straight to MusicBrainz instead of performing the promised Discogs title/artist fallback.

Also applies to: 285-289

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/discogs/DiscogsPlugin.php` around lines 257 - 260, The
fallback path fails when $currentResult is null; update the Discogs fallback so
it still runs on a barcode miss by making searchDiscogsByTitleArtist accept a
null $currentResult (or detect null before calling) and extract title/artist
from the incoming request/token instead, e.g. have
searchDiscogsByTitleArtist($currentResult, $token) handle $currentResult===null
by reading title/artist from $token/request context, then return the Discogs
data to be passed into mergeBookData($currentResult, $discogsFallback); apply
the same null-safe change to the other fallback call around the 285-289 block.
app/Models/BookRepository.php (1)

1060-1075: ⚠️ Potential issue | 🟠 Major

Ignore empty media signals in this fallback.

array_key_exists() is still too broad here. If the payload carries formato, scraped_formato, or scraped_tipo_media as '', this block resolves back to 'libro' and silently downgrades an existing disco/dvd on an otherwise unrelated optional update.

🛠️ Suggested change
-        if ($this->hasColumn('tipo_media') && !array_key_exists('tipo_media', $cols)) {
-            $hasMediaSignal = array_key_exists('formato', $data)
-                || array_key_exists('scraped_formato', $data)
-                || array_key_exists('scraped_tipo_media', $data);
+        if ($this->hasColumn('tipo_media') && !array_key_exists('tipo_media', $cols)) {
+            $formato = trim((string) ($data['formato'] ?? ($data['scraped_formato'] ?? '')));
+            $scrapedTipoMedia = trim((string) ($data['scraped_tipo_media'] ?? ''));
+            $hasMediaSignal = $formato !== '' || $scrapedTipoMedia !== '';
 
             if ($hasMediaSignal) {
                 $val = \App\Support\MediaLabels::resolveTipoMedia(
-                    $data['formato'] ?? ($data['scraped_formato'] ?? null),
-                    $data['scraped_tipo_media'] ?? null
+                    $formato !== '' ? $formato : null,
+                    $scrapedTipoMedia !== '' ? $scrapedTipoMedia : null
                 );
                 $normalized = $this->normalizeEnumValue((string) $val, 'tipo_media', 'libro');
                 if ($normalized !== '') {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Models/BookRepository.php` around lines 1060 - 1075, The fallback
currently treats empty-string signals as present because it uses
array_key_exists for formato, scraped_formato and scraped_tipo_media; update the
guard in the BookRepository block (inside the hasColumn('tipo_media') branch
that calls \App\Support\MediaLabels::resolveTipoMedia and
$this->normalizeEnumValue) to only consider non-empty signals (e.g. use isset or
explicit !== '' checks) so that '' values are ignored; only call
resolveTipoMedia and set $cols['tipo_media'] when at least one of those inputs
is a non-empty, meaningful string to avoid silently downgrading existing
tipo_media like 'disco'/'dvd'.
app/Support/MediaLabels.php (1)

242-250: ⚠️ Potential issue | 🟠 Major

Check audiobook tokens before the generic music tokens.

Formats like Audiobook CD or audiolibro cassette satisfy the 'cd'/'cassette' branch first, so inferTipoMedia() returns disco and never reaches the audiobook branch. Any code path that infers from formato alone will mislabel audiobook media as music.

🛠️ Suggested change
         foreach (self::normalizedCandidates($formato) as $candidate) {
+            if (str_contains($candidate, 'audiolibro') || str_contains($candidate, 'audiobook')) {
+                return 'audiolibro';
+            }
+
             foreach (['cdaudio', 'compactdisc', 'vinile', 'vinyl', 'lp', 'cd', 'cassetta', 'cassette', 'audiocassetta'] as $musicToken) {
                 if (str_contains($candidate, $musicToken)) {
                     return 'disco';
                 }
             }
-
-            if (str_contains($candidate, 'audiolibro') || str_contains($candidate, 'audiobook')) {
-                return 'audiolibro';
-            }
 
             if (str_contains($candidate, 'dvd') || str_contains($candidate, 'bluray') || str_contains($candidate, 'blu_ray')) {
                 return 'dvd';
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Support/MediaLabels.php` around lines 242 - 250, The logic in
inferTipoMedia (iterating candidates from normalizedCandidates($formato)) checks
generic music tokens (e.g., 'cd', 'cassette') before audiobook tokens, causing
inputs like "Audiobook CD" to be misclassified as 'disco'; fix this by checking
audiobook tokens first for each $candidate (or moving the audiolibro/audiobook
check above the music-token loop) so that if str_contains($candidate,
'audiolibro') || str_contains($candidate, 'audiobook') returns true it
immediately returns 'audiolibro' before testing the music tokens; update any
token arrays if needed to include other audiobook synonyms and keep
normalizedCandidates($formato) usage unchanged.
tests/discogs-advanced.spec.js (1)

125-150: ⚠️ Potential issue | 🟡 Minor

The “Book” half of this JSON-LD check is still missing.

This test now validates MusicAlbum on the music page, but the book side only checks tipo_media in MySQL. A frontend regression that emits the wrong JSON-LD @type for libro would still pass.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-advanced.spec.js` around lines 125 - 150, The test only asserts
the MusicAlbum JSON-LD for the music item; add a symmetric frontend assertion
that the book page emits a Schema.org JSON-LD with "@type": "Book".
Specifically, in the same test (or a nearby test) fetch the book HTML (use
page.request.get(`${BASE}/libro/${bookBookId}`)), extract jsonLdBlocks and parse
them into schemas just like for musicHtml, then find a schema where
schema['@type'] === 'Book' and assert it is truthy with a clear message (e.g.,
'Frontend JSON-LD is missing Book for libro'); keep the existing dbQuery checks
for tipo_media.
installer/database/migrations/migrate_0.5.4.sql (1)

66-84: ⚠️ Potential issue | 🟡 Minor

Backfill the same music|musik cases that runtime inference already treats as disco.

App\Support\MediaLabels::inferTipoMedia() still maps whole-word music/musik to disco, but this migration leaves those upgraded rows as libro. New imports and migrated data will disagree for the same formato values.

🛠️ Suggested change
 UPDATE libri SET tipo_media = 'disco'
 WHERE tipo_media = 'libro'
   AND (LOWER(formato) LIKE '%cd%' OR LOWER(formato) LIKE '%compact disc%'
        OR LOWER(formato) LIKE '%vinyl%' OR LOWER(formato) LIKE '%vinile%'
        OR LOWER(formato) LIKE '%lp%' OR LOWER(formato) LIKE '%cassett%'
        OR LOWER(formato) LIKE '%audio cassetta%' OR LOWER(formato) LIKE '%audio-cassetta%'
-       OR LOWER(formato) LIKE '%audiocassetta%')
+       OR LOWER(formato) LIKE '%audiocassetta%'
+       OR LOWER(formato) REGEXP '(^|[^[:alnum:]])(music|musik)([^[:alnum:]]|$)')
   AND LOWER(formato) NOT LIKE '%audiolibro%' AND LOWER(formato) NOT LIKE '%audiobook%';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@installer/database/migrations/migrate_0.5.4.sql` around lines 66 - 84, The
migration should mirror App\Support\MediaLabels::inferTipoMedia() by also
backfilling rows where formato indicates music/musik as disco; update the first
UPDATE (the one setting tipo_media = 'disco' for tipo_media = 'libro') to
include additional LOWER(formato) LIKE patterns that match whole-word "music"
and "musik" (and common variants) so migrated rows match runtime inference in
MediaLabels::inferTipoMedia().
🧹 Nitpick comments (2)
app/Controllers/FrontendController.php (1)

917-927: Share the libri column cache.

app/Controllers/LibriApiController.php:550-570 already has the same schema-probe responsibility for libri. Extracting one helper would keep the frontend/API fallback logic from drifting the next time the migration-compatibility path changes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/FrontendController.php` around lines 917 - 927, Extract the
duplicated "does libri have this column" logic from
FrontendController::hasLibriColumn and the similar probe in LibriApiController
into a single shared helper (e.g., a new static method
SchemaHelper::hasLibriColumn(mysqli $db, string $column): bool or a trait used
by both controllers) and update both controllers to call that helper; preserve
the current caching behavior by keeping a static $columnCache inside the shared
method so both callers share the same cache and continue to use
$db->real_escape_string and "SHOW COLUMNS FROM libri LIKE ..." for the probe.
tests/discogs-import.spec.js (1)

88-96: Wait on the field state instead of sleeping 8 seconds.

This scrape path is rate-limited and can take different branches, so the fixed timeout is both slower than needed and still prone to CI flakes. Waiting until titolo becomes non-empty is much more stable.

♻️ Suggested change
-    // Wait for scraping response (Discogs needs time + rate limits)
-    // The scraping service tries multiple sources — wait up to 20s
-    await page.waitForTimeout(8000);
-
-    // Check if title was populated
     const titleField = page.locator('input[name="titolo"]');
+    await expect(titleField).toHaveValue(/\S+/, { timeout: 20000 });
     const titleValue = await titleField.inputValue();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-import.spec.js` around lines 88 - 96, Replace the fixed sleep
with waiting for the titolo input to become non-empty: remove
page.waitForTimeout(8000) and instead wait until
document.querySelector('input[name="titolo"]') has a trimmed value length > 0
(e.g. use page.waitForFunction(() =>
document.querySelector('input[name="titolo"]')?.value?.trim().length > 0) or
Playwright's expect on the locator). Keep the existing titleField / titleValue
checks but only read titleValue after the wait to avoid flakiness.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Controllers/PluginController.php`:
- Around line 433-449: In PluginController::index(), before passing
$pluginSettings to the template, redact the decrypted Discogs token the same way
existing keys are redacted: when iterating plugin settings (the code that
currently blanks out 'google_books_api_key' and 'api_key'), also blank out
'api_token' (from PluginManager::getSettings()) so the decrypted token is not
exposed to the view; update the logic that prepares $pluginSettings for the
admin view to set $pluginSettings['api_token'] = '' (or remove the key)
following the existing pattern used for api_key/google_books_api_key.
- Around line 229-235: Add a defensive is_callable() check for
getSettingsViewPath() in PluginController before invoking it: when you retrieve
$pluginInstance via getPluginInstance($pluginId) ensure you verify both
is_callable([$pluginInstance, 'hasSettingsPage']) and
is_callable([$pluginInstance, 'getSettingsViewPath']) (in addition to
$pluginInstance->hasSettingsPage()) before calling getSettingsViewPath(); if
either callable check fails, return the 404 response as the existing branch
does. This prevents a fatal undefined-method call if a plugin advertises
hasSettingsPage but doesn't implement getSettingsViewPath.

In `@app/Support/Updater.php`:
- Around line 2264-2272: In the catch (\Throwable $e) block that currently calls
rename($backupPath, $targetPluginPath), check the return value of rename and
handle failures instead of ignoring them: after attempting rename($backupPath,
$targetPluginPath) verify it returned true, and if it returned false (or threw),
log a clear recovery-failure message or throw a new exception (e.g.
RuntimeException) that includes both the original $e->getMessage() and the
backup/target paths so the recovery failure is visible; keep the existing
removeDirectoryTree($stagingPath) call and ensure any new exception includes
context about the failed rename for troubleshooting.

---

Duplicate comments:
In `@app/Models/BookRepository.php`:
- Around line 1060-1075: The fallback currently treats empty-string signals as
present because it uses array_key_exists for formato, scraped_formato and
scraped_tipo_media; update the guard in the BookRepository block (inside the
hasColumn('tipo_media') branch that calls
\App\Support\MediaLabels::resolveTipoMedia and $this->normalizeEnumValue) to
only consider non-empty signals (e.g. use isset or explicit !== '' checks) so
that '' values are ignored; only call resolveTipoMedia and set
$cols['tipo_media'] when at least one of those inputs is a non-empty, meaningful
string to avoid silently downgrading existing tipo_media like 'disco'/'dvd'.

In `@app/Support/MediaLabels.php`:
- Around line 242-250: The logic in inferTipoMedia (iterating candidates from
normalizedCandidates($formato)) checks generic music tokens (e.g., 'cd',
'cassette') before audiobook tokens, causing inputs like "Audiobook CD" to be
misclassified as 'disco'; fix this by checking audiobook tokens first for each
$candidate (or moving the audiolibro/audiobook check above the music-token loop)
so that if str_contains($candidate, 'audiolibro') || str_contains($candidate,
'audiobook') returns true it immediately returns 'audiolibro' before testing the
music tokens; update any token arrays if needed to include other audiobook
synonyms and keep normalizedCandidates($formato) usage unchanged.

In `@installer/database/migrations/migrate_0.5.4.sql`:
- Around line 66-84: The migration should mirror
App\Support\MediaLabels::inferTipoMedia() by also backfilling rows where formato
indicates music/musik as disco; update the first UPDATE (the one setting
tipo_media = 'disco' for tipo_media = 'libro') to include additional
LOWER(formato) LIKE patterns that match whole-word "music" and "musik" (and
common variants) so migrated rows match runtime inference in
MediaLabels::inferTipoMedia().

In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 149-158: The current catch block in DiscogsPlugin.php swallows
exceptions (logs via App\Support\SecureLogger::error) then allows
onInstall()/onActivate() to return normally, so hook-registration failures don't
abort activation; modify the catch to abort the lifecycle by rethrowing the
caught exception (or throw a new appropriate exception) after closing $stmt and
attempting rollback, ensuring onInstall()/onActivate() cannot complete
successfully when the rollback path is taken.
- Around line 257-260: The fallback path fails when $currentResult is null;
update the Discogs fallback so it still runs on a barcode miss by making
searchDiscogsByTitleArtist accept a null $currentResult (or detect null before
calling) and extract title/artist from the incoming request/token instead, e.g.
have searchDiscogsByTitleArtist($currentResult, $token) handle
$currentResult===null by reading title/artist from $token/request context, then
return the Discogs data to be passed into mergeBookData($currentResult,
$discogsFallback); apply the same null-safe change to the other fallback call
around the 285-289 block.

In `@tests/discogs-advanced.spec.js`:
- Around line 125-150: The test only asserts the MusicAlbum JSON-LD for the
music item; add a symmetric frontend assertion that the book page emits a
Schema.org JSON-LD with "@type": "Book". Specifically, in the same test (or a
nearby test) fetch the book HTML (use
page.request.get(`${BASE}/libro/${bookBookId}`)), extract jsonLdBlocks and parse
them into schemas just like for musicHtml, then find a schema where
schema['@type'] === 'Book' and assert it is truthy with a clear message (e.g.,
'Frontend JSON-LD is missing Book for libro'); keep the existing dbQuery checks
for tipo_media.

---

Nitpick comments:
In `@app/Controllers/FrontendController.php`:
- Around line 917-927: Extract the duplicated "does libri have this column"
logic from FrontendController::hasLibriColumn and the similar probe in
LibriApiController into a single shared helper (e.g., a new static method
SchemaHelper::hasLibriColumn(mysqli $db, string $column): bool or a trait used
by both controllers) and update both controllers to call that helper; preserve
the current caching behavior by keeping a static $columnCache inside the shared
method so both callers share the same cache and continue to use
$db->real_escape_string and "SHOW COLUMNS FROM libri LIKE ..." for the probe.

In `@tests/discogs-import.spec.js`:
- Around line 88-96: Replace the fixed sleep with waiting for the titolo input
to become non-empty: remove page.waitForTimeout(8000) and instead wait until
document.querySelector('input[name="titolo"]') has a trimmed value length > 0
(e.g. use page.waitForFunction(() =>
document.querySelector('input[name="titolo"]')?.value?.trim().length > 0) or
Playwright's expect on the locator). Keep the existing titleField / titleValue
checks but only read titleValue after the wait to avoid flakiness.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 15c8c2a9-13ea-47e4-abfe-cf057bb21191

📥 Commits

Reviewing files that changed from the base of the PR and between c7a419c and b5a247d.

📒 Files selected for processing (13)
  • app/Controllers/FrontendController.php
  • app/Controllers/PluginController.php
  • app/Models/BookRepository.php
  • app/Support/MediaLabels.php
  • app/Support/RouteTranslator.php
  • app/Support/Updater.php
  • app/Views/admin/plugins.php
  • installer/classes/Installer.php
  • installer/database/migrations/migrate_0.5.4.sql
  • installer/database/schema.sql
  • storage/plugins/discogs/DiscogsPlugin.php
  • tests/discogs-advanced.spec.js
  • tests/discogs-import.spec.js
✅ Files skipped from review due to trivial changes (1)
  • app/Support/RouteTranslator.php
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/Views/admin/plugins.php

1. LibriController: add tipo_media to store/update field defaults
   so form POST value flows through createBasic/updateBasic directly

2. CsvImportController: hasColumn guard on tipo_media in raw SQL
   prevents 'Unknown column' on pre-0.5.4 databases

3. LibraryThingImportController: same hasColumn guard on all 4 SQL blocks

4. DiscogsPlugin: Deezer enrichment gated to music sources only
   prevents attaching music album covers to books
- inferTipoMedia: check audiobook BEFORE music tokens
  (prevents "Audiobook CD" → disco misclassification)
- updateOptionals: non-empty string check instead of array_key_exists
  (prevents '' from downgrading disco→libro)
- DiscogsPlugin: rethrow on hook registration failure
- DiscogsPlugin: null guard on title fallback (no title = skip)
- PluginController: is_callable guard for getSettingsViewPath
- Updater: log rename failure in plugin recovery path
- Migration: REGEXP for whole-word music/musik → disco
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@CodeRabbit full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 31, 2026

✅ Actions performed

Full review triggered.

@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@CodeRabbit review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 31, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
app/Controllers/ScrapeController.php (1)

143-149: ⚠️ Potential issue | 🟠 Major

Resolve tipo_media before any book-only enrichment.

enrichWithSbnData() still runs before ensureTipoMedia(), so a Discogs EAN can still go through the SBN enrichment path and merge book fields before the non-book guard in normalizeIsbnFields() executes. Move ensureTipoMedia() above this block and skip SBN enrichment unless tipo_media === 'libro'.

Suggested fix
-            // Try to enrich with SBN data if Dewey is missing
-            if (empty($payload['classificazione_dewey'])) {
-                $payload = $this->enrichWithSbnData($payload, $cleanIsbn);
-            }
-
             $payload = $this->ensureTipoMedia($payload);
+            if (($payload['tipo_media'] ?? 'libro') === 'libro' && empty($payload['classificazione_dewey'])) {
+                $payload = $this->enrichWithSbnData($payload, $cleanIsbn);
+            }

Apply the same reordering in the fallback branch.

Also applies to: 213-219

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/ScrapeController.php` around lines 143 - 149, Move the call
to ensureTipoMedia() so it runs before any SBN enrichment and guard SBN
enrichment on tipo_media === 'libro'; specifically, call
$this->ensureTipoMedia($payload) before the block that invokes
$this->enrichWithSbnData($payload, $cleanIsbn) and change the logic to only call
enrichWithSbnData when $payload['tipo_media'] === 'libro' (do the same
reordering and guard in the fallback branch that currently mirrors lines
213-219) so Discogs EANs won't be merged with book-only SBN fields.
app/Support/Updater.php (1)

2211-2237: ⚠️ Potential issue | 🟠 Major

Fail the update when a declared bundled plugin is missing from the release package.

This currently degrades to a debug log and success, so users can complete an update without ever receiving a plugin that BundledPlugins::LIST says should be bundled. That leaves the upgraded core advertising or expecting plugin code that is not on disk.

💡 Proposed fix
         if (!is_dir($sourcePluginsDir)) {
-            $this->debugLog('DEBUG', 'Nessuna directory plugins nel pacchetto');
-            return;
+            if (count(BundledPlugins::LIST) > 0) {
+                throw new Exception(__('Directory plugins bundled mancante nel pacchetto di aggiornamento.'));
+            }
+            $this->debugLog('DEBUG', 'Nessuna directory plugins nel pacchetto');
+            return;
         }
@@
             $sourcePluginPath = $sourcePluginsDir . '/' . $pluginSlug;
             if (!is_dir($sourcePluginPath)) {
-                $this->debugLog('DEBUG', 'Plugin bundled non presente nel pacchetto', ['plugin' => $pluginSlug]);
-                continue;
+                throw new Exception(sprintf(__('Plugin bundled mancante nel pacchetto di aggiornamento: %s'), $pluginSlug));
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Support/Updater.php` around lines 2211 - 2237, The code currently logs
missing bundled plugins and continues; instead, when iterating
BundledPlugins::LIST and a corresponding $sourcePluginPath does not exist (after
computing $pluginSlug via normalizeBundledPluginSlug), stop the update and throw
an exception (or return a failure) so the update fails visibly; replace the
debugLog('DEBUG', 'Plugin bundled non presente nel pacchetto', ['plugin' =>
$pluginSlug]) + continue with a thrown Exception (include the plugin slug and
source path in the message) so callers cannot proceed with a core upgrade that
declares a bundled plugin absent from $sourcePluginsDir.
app/Models/BookRepository.php (1)

1003-1075: ⚠️ Potential issue | 🟠 Major

Persist formato in the optional scrape-update path.

This block now derives tipo_media from formato / scraped_formato, but updateOptionals() still never writes the format column itself. A Discogs enrichment can therefore save tipo_media='disco' while leaving formato empty or stale, so the new music-format metadata is lost.

💡 Suggested fix
-        foreach (['numero_pagine', 'ean', 'data_pubblicazione', 'anno_pubblicazione', 'traduttore', 'illustratore', 'curatore', 'collana', 'edizione', 'tipo_media'] as $c) {
+        foreach (['numero_pagine', 'ean', 'data_pubblicazione', 'anno_pubblicazione', 'traduttore', 'illustratore', 'curatore', 'collana', 'edizione', 'formato', 'tipo_media'] as $c) {

+        if ($this->hasColumn('formato') && !isset($cols['formato']) && !empty($data['scraped_formato'])) {
+            $cols['formato'] = (string) $data['scraped_formato'];
+        }

-            $formato = trim((string) ($data['formato'] ?? ($data['scraped_formato'] ?? '')));
+            $formato = trim((string) ($cols['formato'] ?? $data['formato'] ?? ($data['scraped_formato'] ?? '')));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Models/BookRepository.php` around lines 1003 - 1075, The updateOptionals
path derives tipo_media from formato/scraped_formato but never persists the
formato column, causing format metadata to be lost; inside the same method
(e.g., BookRepository::updateOptionals) where you compute $formato and
$scrapedTipoMedia and set $cols['tipo_media'], also check hasColumn('formato')
and if formato/scraped_formato is non-empty and 'formato' is not already set in
$cols, set $cols['formato'] to the trimmed value (prefer $data['formato'] if
present, otherwise scraped_formato) so the raw format is stored alongside the
normalized tipo_media. Ensure you don't overwrite an existing $cols['formato'].
app/Controllers/LibraryThingImportController.php (1)

913-938: ⚠️ Potential issue | 🟠 Major

Map music Media into formato, not just tipo_media.

parseLibraryThingRow() now infers a music tipo_media, but the Mediaformato map still only covers book-like labels. For CD/LP/cassette rows, formato stays empty and the save paths below fall back to 'cartaceo', so imported music lands with a book format.

Also applies to: 1348-1351, 1423-1426, 1536-1540, 1615-1619

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibraryThingImportController.php` around lines 913 - 938, The
Media→formato mapping only covers book-like labels, so music rows
(CD/LP/cassette) end up defaulting to 'cartaceo'; update the $mediaMap used in
parseLibraryThingRow (the block that sets $media, iterates $mediaMap and assigns
$result['formato']) to include common music labels (e.g., 'cd', 'compact disc',
'cd audio', 'lp', 'vinile', 'vinyl', 'record', 'album', 'cassette', 'tape',
etc.) mapped to the appropriate music formato value you use (e.g., 'musica',
'cd', 'vinile' as per your conventions), and mirror the same change in the other
duplicated Media→formato mappings mentioned (the blocks around the other
occurrences) so formato is correctly set for music before falling back to
'cartaceo'.
♻️ Duplicate comments (12)
storage/plugins/discogs/README.md (1)

13-19: ⚠️ Potential issue | 🟡 Minor

Clarify Discogs title fallback before MusicBrainz fallback.

The strategy lists barcode → details → MusicBrainz fallback, but PR objectives state a Discogs title/title+artist fallback after barcode. Please document that intermediate step (or explicitly state it was intentionally removed) to keep behavior/docs/objectives aligned.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/discogs/README.md` around lines 13 - 19, The documentation
currently omits the intermediate Discogs title/title+artist fallback between
barcode search (GET /database/search?barcode={ean}&type=release) and the
MusicBrainz fallback; update the "Strategia di ricerca" section to either re-add
an explicit step describing the Discogs title or title+artist fallback (e.g.,
"2a. Ricerca per titolo o titolo+artista su Discogs -- GET
/database/search?query={title}+{artist}") or add a short note stating this step
was intentionally removed, and ensure the sequence numbering adjusts (barcode →
details → [title/title+artist fallback] → MusicBrainz → Deezer) so docs match PR
objectives and implementation.
app/Views/admin/plugins.php (1)

244-244: ⚠️ Potential issue | 🟠 Major

Use a named settings route instead of concatenating path segments.

Line 244 and Line 300 still construct the settings URL by appending /{id}/settings manually. Please switch both to a dedicated named route (e.g. route_path('...settings...', ['id' => ...]) or RouteTranslator::route(...)) and keep htmlspecialchars(..., ENT_QUOTES, 'UTF-8') around the final URL.

As per coding guidelines: Never use hardcoded routes. Use route_path('key') or RouteTranslator::route('key') instead of hardcoded paths like /accedi or /login.

Also applies to: 300-301

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/admin/plugins.php` at line 244, The settings URL is being built by
concatenating route_path('plugins') with "/{id}/settings"; replace both
occurrences (the data-settings-url at the lines referenced) to call the named
settings route instead (e.g. route_path('plugins.settings', ['id' =>
(int)$plugin['id']]) or RouteTranslator::route('plugins.settings', ['id' =>
(int)$plugin['id']])) and wrap that call with htmlspecialchars(..., ENT_QUOTES,
'UTF-8') just like before so no hardcoded path segments remain.
app/Views/libri/partials/book_form.php (1)

3615-3624: ⚠️ Potential issue | 🟡 Minor

Whitelist scraped tipo_media before applying it to the select.

Line 3620 applies raw data.tipo_media. If a source returns non-canonical labels, the selected value can become inconsistent. Normalize and allow only keys from MediaLabels::allTypes() before assignment.

🔧 Suggested guard
 // Auto-set tipo_media from scraped data
 try {
     if (data.tipo_media) {
         const tipoMediaSelect = document.getElementById('tipo_media');
-        if (tipoMediaSelect) {
-            tipoMediaSelect.value = data.tipo_media;
+        const allowedTipoMedia = new Set(<?= json_encode(array_keys(\App\Support\MediaLabels::allTypes()), JSON_HEX_TAG) ?>);
+        const normalizedTipoMedia = String(data.tipo_media).trim().toLowerCase();
+        if (tipoMediaSelect && allowedTipoMedia.has(normalizedTipoMedia)) {
+            tipoMediaSelect.value = normalizedTipoMedia;
         }
     }
 } catch (err) {
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/libri/partials/book_form.php` around lines 3615 - 3624, The code
currently assigns raw data.tipo_media into the select element; instead, fetch
the canonical allowed keys from MediaLabels::allTypes() (or a client-side
equivalent whitelist), normalize/transform incoming data.tipo_media to a
canonical key (e.g., lowercase/trim or map known aliases) and only set
document.getElementById('tipo_media').value if the normalized value exists in
the whitelist; otherwise skip assignment or set a safe default. Update the
tipo_media assignment block (the try that references tipoMediaSelect and
data.tipo_media) to perform the whitelist check and normalization before
modifying the select.
app/Views/frontend/catalog.php (1)

1308-1324: ⚠️ Potential issue | 🟡 Minor

Finish wiring tipo_media through the AJAX filter state.

The server-rendered list is fine, but after updateFilter('tipo_media', ...) or removeFilter('tipo_media') the sidebar never reapplies its active class, and the active-filter chip still falls back to raw enum values like disco/dvd. Give this filter a stable JS hook, emit a localized value→label map from the same $tipoFilters source, and sync it the same way availability is synced.

Based on learnings, Applies to **/*.php : Use json_encode(..., JSON_HEX_TAG) for any PHP→JS output in views.

Also applies to: 1451-1487, 1539-1667

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/frontend/catalog.php` around lines 1308 - 1324, The sidebar isn’t
reapplying the active class or localized labels because tipo_media isn’t wired
into the client-side filter state; fix by exporting the same $tipoFilters PHP
array to JS (serialize with json_encode using JSON_HEX_TAG and JSON_HEX_APOS for
safety) into a stable JS variable/map and attach a stable DOM hook (e.g.,
data-filter="tipo_media" on the filter container or each <a>) so
updateFilter('tipo_media', ...) and removeFilter('tipo_media') can look up the
localized label and re-render the active class exactly like the availability
sync does; update the client-side code that applies active classes and chips to
consult this new tipo_media map and the DOM hook when toggling filters.
app/Views/libri/index.php (1)

688-697: ⚠️ Potential issue | 🟡 Minor

Render the localized media label here too.

The filter dropdown already uses MediaLabels::allTypes(), but the table tooltip and active-filter chip still show raw enum keys like disco and audiolibro. Reuse the same label map in JS so these surfaces stay translated and do not drift from the backend metadata.

♻️ Proposed fix
+  const mediaLabels = <?= json_encode(
+    array_map(static fn($meta) => __($meta['label']), \App\Support\MediaLabels::allTypes()),
+    JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
+  ) ?>;
...
       { // Media type icon
         data: 'tipo_media',
         orderable: false,
         searchable: false,
         width: '30px',
         className: 'text-center align-middle',
         render: function(data) {
           const icons = { libro: 'fa-book', disco: 'fa-compact-disc', audiolibro: 'fa-headphones', dvd: 'fa-film', altro: 'fa-box' };
-          return '<i class="fas ' + (icons[data] || 'fa-book') + ' text-gray-400" title="' + (data || 'libro') + '"></i>';
+          const label = mediaLabels[data] || data || <?= json_encode(__('Libro'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
+          return '<i class="fas ' + (icons[data] || 'fa-book') + ' text-gray-400" title="' + escapeHtml(label) + '"></i>';
         }
       },
...
-    if (tipoMedia) filters.push({ key: 'tipo_media_filter', label: `${<?= json_encode(__("Tipo Media"), JSON_HEX_TAG) ?>}: ${tipoMedia}`, icon: 'fa-compact-disc' });
+    if (tipoMedia) filters.push({ key: 'tipo_media_filter', label: `${<?= json_encode(__("Tipo Media"), JSON_HEX_TAG) ?>}: ${mediaLabels[tipoMedia] || tipoMedia}`, icon: 'fa-compact-disc' });

As per coding guidelines Use json_encode(..., JSON_HEX_TAG) for any PHP→JS output in views.

Also applies to: 885-886

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/libri/index.php` around lines 688 - 697, The tooltip/title in the
DataTable render (column data 'tipo_media' inside the render: function)
currently shows raw enum keys; fix this by emitting the PHP label map from
MediaLabels::allTypes() into JS (use json_encode(MediaLabels::allTypes(),
JSON_HEX_TAG)) to create a labels map variable, then change the render to use
labels[data] || labels['libro'] for the title and any places that display the
human label (also update the active-filter chip rendering referenced around the
other usages noted). Ensure you replace raw enum usage with the labels map in
the render function and the other occurrences mentioned (the similar spots at
the other referenced lines).
app/Controllers/LibriApiController.php (1)

35-35: ⚠️ Potential issue | 🟡 Minor

Normalize tipo_media before casting it to string.

An array query param like ?tipo_media[]=disco still reaches this line raw. In PHP that can degrade into 'Array' and emit an array-to-string warning, so the filter silently stops matching and can corrupt the JSON response under stricter error reporting. The frontend controller already hardens this path.

♻️ Proposed fix
-        $tipo_media = trim((string) ($q['tipo_media'] ?? ''));
+        $rawTipoMedia = $q['tipo_media'] ?? '';
+        if (is_array($rawTipoMedia)) {
+            $rawTipoMedia = $rawTipoMedia[0] ?? '';
+        }
+        $tipo_media = is_scalar($rawTipoMedia) ? trim((string) $rawTipoMedia) : '';
#!/bin/bash
# Compare `tipo_media` normalization across the two controllers.
# Expectation:
# - FrontendController shows an `is_array` guard.
# - LibriApiController still casts the raw query param directly.

rg -n -C3 "tipo_media" app/Controllers/FrontendController.php app/Controllers/LibriApiController.php
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibriApiController.php` at line 35, The line setting
$tipo_media in LibriApiController should guard against array query params before
casting: detect if ($q['tipo_media']) is an array and pull a sensible scalar
(e.g. the first element or implode) and then trim/cast, otherwise cast as now;
update the assignment that uses $q['tipo_media'] so it mirrors the
FrontendController's is_array guard to avoid array-to-string warnings and broken
filtering.
app/Support/Updater.php (1)

2244-2280: ⚠️ Potential issue | 🟠 Major

Bundled plugin swaps are still outside the updater rollback boundary.

Once one of these renames succeeds, any later failure here or later in installUpdate() only restores the directories backed up by backupAppFiles(). The already-swapped storage/plugins/<slug> tree stays on the new version, so a failed update can still leave old core code running against new plugin code. Please either include bundled plugins in backupAppFiles() / restoreAppFiles() or defer the final swap until the whole install commits.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Support/Updater.php` around lines 2244 - 2280, The bundled-plugin
directory swap is happening outside the main rollback boundary so a partial
failure can leave the plugin directory at the new version; modify the updater to
include bundled plugins in the main backup/restore flow or postpone the final
rename until the install commit: either (A) extend
backupAppFiles()/restoreAppFiles() to include storage/plugins/<slug> (ensure
backupAppFiles() records the same backupPath and restoreAppFiles() can restore
it) so copyDirectoryRecursive/rename of targetPluginPath happens inside the
rollback scope, or (B) change the flow in
installUpdate()/Updater::copyDirectoryRecursive block to copy plugin files into
a staging area and defer the rename of $stagingPath -> $targetPluginPath (and
removal of $backupPath) until after all other install steps succeed (so the
final swap is performed only once the install commits). Ensure references to
copyDirectoryRecursive, $stagingPath, $targetPluginPath, $backupPath,
backupAppFiles(), restoreAppFiles(), and installUpdate() are updated
accordingly.
app/Controllers/PluginController.php (1)

437-453: ⚠️ Potential issue | 🟠 Major

Redact the Discogs token before it reaches the plugins view.

Once this branch starts saving api_token, index() will decrypt it via PluginManager::getSettings() and pass it into $pluginSettings, because only the Google Books / API Book Scraper secrets are masked there today. Add the same masking for api_token so plaintext credentials never enter the template scope.

💡 Suggested fix in index()
+            if ($plugin['name'] === 'discogs' && array_key_exists('api_token', $settings)) {
+                $settings['api_token_exists'] = $settings['api_token'] !== '';
+                $settings['api_token'] = $settings['api_token_exists'] ? '••••••••' : '';
+            }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/PluginController.php` around lines 437 - 453, The Discogs
plaintext api_token is being saved and then exposed in the view because
PluginManager::getSettings() decrypts it and index() passes $pluginSettings into
the template; update PluginController::index() to mask/remove the 'api_token'
value before rendering (e.g., replace the 'api_token' string with a boolean like
'has_token' based on whether it's non-empty or unset the key) so the template
never receives the decrypted token. Locate the handling of $pluginSettings
(after $this->pluginManager->getSettings(...)) and add logic to detect the
'discogs' plugin or the 'api_token' key and transform it to a masked indicator.
tests/multisource-scraping.spec.js (1)

114-118: ⚠️ Potential issue | 🟠 Major

Clean up the five catalog rows this suite creates in afterAll().

The suite already tracks all created IDs, but teardown only closes the browser. On any reused E2E database this leaves the RUN_TAG books/discs behind and can contaminate later catalog/filter assertions.

Also applies to: 131-133

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/multisource-scraping.spec.js` around lines 114 - 118, The test suite
declares ID trackers (manualBookId, importedBookId, manualDiscId,
importedDisc1Id, importedDisc2Id) but afterAll() only closes the browser; update
afterAll() to remove the five created catalog rows using those IDs — call the
existing cleanup APIs or helpers (e.g., deleteBookById/deleteDiscById or the
test harness catalog cleanup function) for each non-zero ID in that list,
handling errors gracefully so tests teardown the RUN_TAG books/discs and avoid
contaminating subsequent E2E runs; ensure you reference and clear manualBookId,
importedBookId, manualDiscId, importedDisc1Id, and importedDisc2Id inside
afterAll().
storage/plugins/discogs/plugin.json (1)

10-12: ⚠️ Potential issue | 🟠 Major

Don't advertise app-version gating unless the runtime enforces it.

If PluginManager still only checks requires_php, these keys won't block incompatible installs or activations and the manifest is promising a guard that doesn't exist yet.

Confirm the runtime check in PluginManager:

#!/bin/bash
rg -n "requires_app|max_app_version|requires_php|version_compare" app/Support/PluginManager.php -C 3
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/discogs/plugin.json` around lines 10 - 12, The plugin
manifest advertises requires_app and max_app_version but PluginManager currently
only enforces requires_php; update the runtime to enforce these fields or remove
them from the manifest. Add checks in the PluginManager class (e.g., the method
that validates manifests such as validatePluginManifest/validateRequirements or
registerPlugin) to read manifest['requires_app'] and manifest['max_app_version']
and use PHP's version_compare to ensure the current app version satisfies
requires_app (>=) and is <= max_app_version before allowing install/activation;
return/log a clear error when the checks fail. Alternatively, if you don't want
runtime enforcement now, delete the requires_app and max_app_version keys from
plugin.json so the manifest doesn't promise unsupported gating.
storage/plugins/discogs/DiscogsPlugin.php (1)

369-374: ⚠️ Potential issue | 🟠 Major

Keep tipo_media authoritative when deciding Deezer enrichment.

This OR-chain can flip an explicit audiolibro back to music via format or source, so CD/cassette audiobooks can pick up album covers and genres. Use one resolved media type for this gate.

💡 Minimal fix
-        $resolvedType = \App\Support\MediaLabels::inferTipoMedia($data['format'] ?? $data['formato'] ?? '');
-        $isMusicSource = ($data['tipo_media'] ?? '') === 'disco'
-            || $resolvedType === 'disco'
-            || ($data['source'] ?? '') === 'discogs'
-            || ($data['source'] ?? '') === 'musicbrainz';
+        $resolvedType = \App\Support\MediaLabels::resolveTipoMedia(
+            $data['format'] ?? $data['formato'] ?? null,
+            $data['tipo_media'] ?? null
+        );
+        $isMusicSource = $resolvedType === 'disco';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/discogs/DiscogsPlugin.php` around lines 369 - 374, Compute a
single authoritative media type and prefer an explicit tipo_media over the
inferred format: call inferTipoMedia as you do, then set a single $resolvedMedia
= $data['tipo_media'] ?? $resolvedType; finally use $resolvedMedia in the Deezer
gate (e.g. $isMusicSource = $resolvedMedia === 'disco' || ($data['source'] ??
'') === 'discogs' || ($data['source'] ?? '') === 'musicbrainz') so an explicit
audiolibro in tipo_media is not overridden by format or source.
tests/discogs-plugin.spec.js (1)

64-80: ⚠️ Potential issue | 🟠 Major

Handle installation before asserting activation on a clean database.

Test 1 explicitly accepts plugins.name = 'discogs' being absent, but Test 2 then immediately requires a DB row and only looks for Attiva. On a fresh environment, the suite fails without ever exercising the install flow.

Also applies to: 83-97

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-plugin.spec.js` around lines 64 - 80, The test assumes the
discogs plugin is installed but may run on a clean DB; update the test to handle
installation before asserting activation: after computing pluginExists (dbQuery)
and locating discogsCard (page.locator/... getByRole heading), if
parseInt(pluginExists) === 0 then interact with discogsCard to trigger the
install flow (e.g., click the install button within that card or navigate the
install UI), wait for the install to complete (waitForLoadState /
waitForSelector for the "Attiva" or installation confirmation) and re-query the
DB (dbQuery) or pageContent to confirm the plugin row exists before asserting
the final expect; ensure the same pattern is applied for the second block (lines
83-97) as well.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Controllers/LibriController.php`:
- Line 3041: The CSV export currently synthesizes a 'libro' type when
$libro['tipo_media'] is null; locate the CSV row construction in LibriController
where $libro['tipo_media'] is used and stop defaulting to 'libro' — instead emit
the raw null/empty value (e.g. leave it null or an empty string) so the exporter
does not invent a media type; replace the expression $libro['tipo_media'] ??
'libro' with a non-defaulting alternative (e.g. $libro['tipo_media'] ?? null or
$libro['tipo_media'] ?? '') so imports can preserve or infer the proper media
type.

In `@app/Support/PluginManager.php`:
- Around line 356-364: getPluginInstance() currently calls instantiatePlugin()
which can throw on missing/broken plugin files; wrap the
instantiatePlugin($plugin) call in a try/catch that catches Throwable/Exception,
handle the error (log or suppress) and return null so the public method honors
its nullable contract instead of letting exceptions bubble up and cause a 500;
reference the methods getPluginInstance and instantiatePlugin when applying this
change.

In `@app/Views/frontend/book-detail.php`:
- Around line 2239-2240: The view currently uses
htmlspecialchars($related['autori'] ?? __($relatedIsMusic ? 'Artista
sconosciuto' : 'Autore sconosciuto'), ...) which only falls back when autori is
null but leaves empty strings; change this to explicitly treat empty/whitespace
as missing by checking trim($related['autori'] ?? '') (or empty after trim) and
using the music-aware fallback (__($relatedIsMusic ? 'Artista sconosciuto' :
'Autore sconosciuto')) when that check fails; update the expression around
htmlspecialchars in the related-book-author card so empty autori strings render
the fallback.

In `@app/Views/frontend/catalog-grid.php`:
- Around line 41-44: The media badge span (class "book-media-badge") is
currently icon-only and uses title, which isn't sufficient for accessibility;
update the span that uses MediaLabels::tipoMediaDisplayName($book['tipo_media'])
to include an accessible name (e.g., add aria-label with that display name or
add visually hidden text inside the span) and mark the <i> icon produced by
MediaLabels::icon($book['tipo_media']) as decorative (e.g., aria-hidden="true"
on the <i>) so screen readers announce the media label from the span rather than
the icon.

In `@app/Views/libri/index.php`:
- Around line 314-315: The icon-only column header using the <th> that contains
<i class="fas fa-compact-disc text-gray-400" title="<?= __("Tipo Media")
?>"></i> is inaccessible; update that <th> to provide an accessible name by
either adding an aria-label (e.g., aria-label="<?= __('Tipo Media') ?>") on the
<th> or inserting visually hidden text (a <span class="sr-only"><?= __('Tipo
Media') ?></span>) next to the icon, and mark the icon decorative with
aria-hidden="true" so screen readers read the header text only. Ensure the
second header (<?= __("Cover") ?>) remains unchanged.

In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 269-273: The MusicBrainz fallback call currently passes null for
the $fallbackBarcode, losing the validated barcode; update the call to
searchMusicBrainz(...) in this branch to pass the validated barcode (e.g.,
$isbn) as the third argument so the function receives the validated EAN (replace
the null with $isbn), then merge the result as before via
mergeBookData($currentResult, $mbResult).

In `@storage/plugins/discogs/views/settings.php`:
- Around line 23-31: Validate that $_POST['api_token'] is a string before
calling trim(): check is_string($_POST['api_token']) (or cast only when safe)
and if not, set $apiToken = '' and populate $errorMessage with a validation
error instead of calling trim on an array or other type; then proceed to build
$settings and call $plugin->saveSettings($settings) only when $apiToken is a
valid string. Ensure this check is done before the existing lines that define
$apiToken, $settings, and call $plugin->saveSettings so the page returns a
user-friendly validation error rather than throwing a TypeError.

In `@tests/discogs-import.spec.js`:
- Around line 115-120: The test currently asserts the real public barcode
(TEST_BARCODE) is present in the scraped inputs 'input[name="ean"]' or
'input[name="isbn13"]', which can persist a shared identifier; before submitting
the imported row you should overwrite the scraped identifier with a run-scoped
value: locate the fields using page.locator('input[name="ean"]') and
page.locator('input[name="isbn13"]') and set (fill) them to a generated unique
barcode/string (same pattern used by the multisource suite) so the form saves a
test-specific identifier; apply this replacement wherever the test reads/writes
ean/isbn13 in the block around the expect (also for the related checks in lines
123-148).
- Around line 51-57: The cleanup currently does a raw DELETE via dbExec on the
libri table inside test.afterAll (using createdId) which can violate FK
constraints and the empty catch hides failures; instead call the app's normal
delete/soft-delete path (e.g., use the same controller/service used by the admin
form) or explicitly delete dependent copie rows first (DELETE FROM copie WHERE
libro_id = ...) then soft-delete libri, and replace the empty catch with an
assertion or a logged/raised error so test cleanup failures are visible; locate
the cleanup in test.afterAll, the createdId variable, and the dbExec call to
apply the change.

In `@tests/discogs-plugin.spec.js`:
- Around line 214-244: The "scrape failed" branch doesn't validate anything
because the console listener is added after the scrape and no assertion is made;
move or add the console error listener setup (page.on('console', ...)) before
the scraping/import action (the code that resolves titleValue via
page.locator('input[name="titolo"]').inputValue()) and, in the else branch where
titleValue === '', assert that the collected logs array is empty (or contains
only expected messages) to fail the test if any JS errors were emitted; refer to
the existing symbols page, titleField/titleValue, logs and the
page.on('console', ...) handler to locate and update the test.

---

Outside diff comments:
In `@app/Controllers/LibraryThingImportController.php`:
- Around line 913-938: The Media→formato mapping only covers book-like labels,
so music rows (CD/LP/cassette) end up defaulting to 'cartaceo'; update the
$mediaMap used in parseLibraryThingRow (the block that sets $media, iterates
$mediaMap and assigns $result['formato']) to include common music labels (e.g.,
'cd', 'compact disc', 'cd audio', 'lp', 'vinile', 'vinyl', 'record', 'album',
'cassette', 'tape', etc.) mapped to the appropriate music formato value you use
(e.g., 'musica', 'cd', 'vinile' as per your conventions), and mirror the same
change in the other duplicated Media→formato mappings mentioned (the blocks
around the other occurrences) so formato is correctly set for music before
falling back to 'cartaceo'.

In `@app/Controllers/ScrapeController.php`:
- Around line 143-149: Move the call to ensureTipoMedia() so it runs before any
SBN enrichment and guard SBN enrichment on tipo_media === 'libro'; specifically,
call $this->ensureTipoMedia($payload) before the block that invokes
$this->enrichWithSbnData($payload, $cleanIsbn) and change the logic to only call
enrichWithSbnData when $payload['tipo_media'] === 'libro' (do the same
reordering and guard in the fallback branch that currently mirrors lines
213-219) so Discogs EANs won't be merged with book-only SBN fields.

In `@app/Models/BookRepository.php`:
- Around line 1003-1075: The updateOptionals path derives tipo_media from
formato/scraped_formato but never persists the formato column, causing format
metadata to be lost; inside the same method (e.g.,
BookRepository::updateOptionals) where you compute $formato and
$scrapedTipoMedia and set $cols['tipo_media'], also check hasColumn('formato')
and if formato/scraped_formato is non-empty and 'formato' is not already set in
$cols, set $cols['formato'] to the trimmed value (prefer $data['formato'] if
present, otherwise scraped_formato) so the raw format is stored alongside the
normalized tipo_media. Ensure you don't overwrite an existing $cols['formato'].

In `@app/Support/Updater.php`:
- Around line 2211-2237: The code currently logs missing bundled plugins and
continues; instead, when iterating BundledPlugins::LIST and a corresponding
$sourcePluginPath does not exist (after computing $pluginSlug via
normalizeBundledPluginSlug), stop the update and throw an exception (or return a
failure) so the update fails visibly; replace the debugLog('DEBUG', 'Plugin
bundled non presente nel pacchetto', ['plugin' => $pluginSlug]) + continue with
a thrown Exception (include the plugin slug and source path in the message) so
callers cannot proceed with a core upgrade that declares a bundled plugin absent
from $sourcePluginsDir.

---

Duplicate comments:
In `@app/Controllers/LibriApiController.php`:
- Line 35: The line setting $tipo_media in LibriApiController should guard
against array query params before casting: detect if ($q['tipo_media']) is an
array and pull a sensible scalar (e.g. the first element or implode) and then
trim/cast, otherwise cast as now; update the assignment that uses
$q['tipo_media'] so it mirrors the FrontendController's is_array guard to avoid
array-to-string warnings and broken filtering.

In `@app/Controllers/PluginController.php`:
- Around line 437-453: The Discogs plaintext api_token is being saved and then
exposed in the view because PluginManager::getSettings() decrypts it and index()
passes $pluginSettings into the template; update PluginController::index() to
mask/remove the 'api_token' value before rendering (e.g., replace the
'api_token' string with a boolean like 'has_token' based on whether it's
non-empty or unset the key) so the template never receives the decrypted token.
Locate the handling of $pluginSettings (after
$this->pluginManager->getSettings(...)) and add logic to detect the 'discogs'
plugin or the 'api_token' key and transform it to a masked indicator.

In `@app/Support/Updater.php`:
- Around line 2244-2280: The bundled-plugin directory swap is happening outside
the main rollback boundary so a partial failure can leave the plugin directory
at the new version; modify the updater to include bundled plugins in the main
backup/restore flow or postpone the final rename until the install commit:
either (A) extend backupAppFiles()/restoreAppFiles() to include
storage/plugins/<slug> (ensure backupAppFiles() records the same backupPath and
restoreAppFiles() can restore it) so copyDirectoryRecursive/rename of
targetPluginPath happens inside the rollback scope, or (B) change the flow in
installUpdate()/Updater::copyDirectoryRecursive block to copy plugin files into
a staging area and defer the rename of $stagingPath -> $targetPluginPath (and
removal of $backupPath) until after all other install steps succeed (so the
final swap is performed only once the install commits). Ensure references to
copyDirectoryRecursive, $stagingPath, $targetPluginPath, $backupPath,
backupAppFiles(), restoreAppFiles(), and installUpdate() are updated
accordingly.

In `@app/Views/admin/plugins.php`:
- Line 244: The settings URL is being built by concatenating
route_path('plugins') with "/{id}/settings"; replace both occurrences (the
data-settings-url at the lines referenced) to call the named settings route
instead (e.g. route_path('plugins.settings', ['id' => (int)$plugin['id']]) or
RouteTranslator::route('plugins.settings', ['id' => (int)$plugin['id']])) and
wrap that call with htmlspecialchars(..., ENT_QUOTES, 'UTF-8') just like before
so no hardcoded path segments remain.

In `@app/Views/frontend/catalog.php`:
- Around line 1308-1324: The sidebar isn’t reapplying the active class or
localized labels because tipo_media isn’t wired into the client-side filter
state; fix by exporting the same $tipoFilters PHP array to JS (serialize with
json_encode using JSON_HEX_TAG and JSON_HEX_APOS for safety) into a stable JS
variable/map and attach a stable DOM hook (e.g., data-filter="tipo_media" on the
filter container or each <a>) so updateFilter('tipo_media', ...) and
removeFilter('tipo_media') can look up the localized label and re-render the
active class exactly like the availability sync does; update the client-side
code that applies active classes and chips to consult this new tipo_media map
and the DOM hook when toggling filters.

In `@app/Views/libri/index.php`:
- Around line 688-697: The tooltip/title in the DataTable render (column data
'tipo_media' inside the render: function) currently shows raw enum keys; fix
this by emitting the PHP label map from MediaLabels::allTypes() into JS (use
json_encode(MediaLabels::allTypes(), JSON_HEX_TAG)) to create a labels map
variable, then change the render to use labels[data] || labels['libro'] for the
title and any places that display the human label (also update the active-filter
chip rendering referenced around the other usages noted). Ensure you replace raw
enum usage with the labels map in the render function and the other occurrences
mentioned (the similar spots at the other referenced lines).

In `@app/Views/libri/partials/book_form.php`:
- Around line 3615-3624: The code currently assigns raw data.tipo_media into the
select element; instead, fetch the canonical allowed keys from
MediaLabels::allTypes() (or a client-side equivalent whitelist),
normalize/transform incoming data.tipo_media to a canonical key (e.g.,
lowercase/trim or map known aliases) and only set
document.getElementById('tipo_media').value if the normalized value exists in
the whitelist; otherwise skip assignment or set a safe default. Update the
tipo_media assignment block (the try that references tipoMediaSelect and
data.tipo_media) to perform the whitelist check and normalization before
modifying the select.

In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 369-374: Compute a single authoritative media type and prefer an
explicit tipo_media over the inferred format: call inferTipoMedia as you do,
then set a single $resolvedMedia = $data['tipo_media'] ?? $resolvedType; finally
use $resolvedMedia in the Deezer gate (e.g. $isMusicSource = $resolvedMedia ===
'disco' || ($data['source'] ?? '') === 'discogs' || ($data['source'] ?? '') ===
'musicbrainz') so an explicit audiolibro in tipo_media is not overridden by
format or source.

In `@storage/plugins/discogs/plugin.json`:
- Around line 10-12: The plugin manifest advertises requires_app and
max_app_version but PluginManager currently only enforces requires_php; update
the runtime to enforce these fields or remove them from the manifest. Add checks
in the PluginManager class (e.g., the method that validates manifests such as
validatePluginManifest/validateRequirements or registerPlugin) to read
manifest['requires_app'] and manifest['max_app_version'] and use PHP's
version_compare to ensure the current app version satisfies requires_app (>=)
and is <= max_app_version before allowing install/activation; return/log a clear
error when the checks fail. Alternatively, if you don't want runtime enforcement
now, delete the requires_app and max_app_version keys from plugin.json so the
manifest doesn't promise unsupported gating.

In `@storage/plugins/discogs/README.md`:
- Around line 13-19: The documentation currently omits the intermediate Discogs
title/title+artist fallback between barcode search (GET
/database/search?barcode={ean}&type=release) and the MusicBrainz fallback;
update the "Strategia di ricerca" section to either re-add an explicit step
describing the Discogs title or title+artist fallback (e.g., "2a. Ricerca per
titolo o titolo+artista su Discogs -- GET
/database/search?query={title}+{artist}") or add a short note stating this step
was intentionally removed, and ensure the sequence numbering adjusts (barcode →
details → [title/title+artist fallback] → MusicBrainz → Deezer) so docs match PR
objectives and implementation.

In `@tests/discogs-plugin.spec.js`:
- Around line 64-80: The test assumes the discogs plugin is installed but may
run on a clean DB; update the test to handle installation before asserting
activation: after computing pluginExists (dbQuery) and locating discogsCard
(page.locator/... getByRole heading), if parseInt(pluginExists) === 0 then
interact with discogsCard to trigger the install flow (e.g., click the install
button within that card or navigate the install UI), wait for the install to
complete (waitForLoadState / waitForSelector for the "Attiva" or installation
confirmation) and re-query the DB (dbQuery) or pageContent to confirm the plugin
row exists before asserting the final expect; ensure the same pattern is applied
for the second block (lines 83-97) as well.

In `@tests/multisource-scraping.spec.js`:
- Around line 114-118: The test suite declares ID trackers (manualBookId,
importedBookId, manualDiscId, importedDisc1Id, importedDisc2Id) but afterAll()
only closes the browser; update afterAll() to remove the five created catalog
rows using those IDs — call the existing cleanup APIs or helpers (e.g.,
deleteBookById/deleteDiscById or the test harness catalog cleanup function) for
each non-zero ID in that list, handling errors gracefully so tests teardown the
RUN_TAG books/discs and avoid contaminating subsequent E2E runs; ensure you
reference and clear manualBookId, importedBookId, manualDiscId, importedDisc1Id,
and importedDisc2Id inside afterAll().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 54d5292c-03a8-40a6-bfb3-16b5ee5bde74

📥 Commits

Reviewing files that changed from the base of the PR and between d0cf881 and 610c29c.

📒 Files selected for processing (40)
  • .gitignore
  • app/Controllers/CsvImportController.php
  • app/Controllers/FrontendController.php
  • app/Controllers/LibraryThingImportController.php
  • app/Controllers/LibriApiController.php
  • app/Controllers/LibriController.php
  • app/Controllers/PluginController.php
  • app/Controllers/PublicApiController.php
  • app/Controllers/ScrapeController.php
  • app/Models/BookRepository.php
  • app/Routes/web.php
  • app/Support/BundledPlugins.php
  • app/Support/MediaLabels.php
  • app/Support/PluginManager.php
  • app/Support/RouteTranslator.php
  • app/Support/Updater.php
  • app/Views/admin/plugins.php
  • app/Views/frontend/book-detail.php
  • app/Views/frontend/catalog-grid.php
  • app/Views/frontend/catalog.php
  • app/Views/libri/index.php
  • app/Views/libri/partials/book_form.php
  • app/Views/libri/scheda_libro.php
  • installer/classes/Installer.php
  • installer/database/migrations/migrate_0.5.4.sql
  • installer/database/schema.sql
  • locale/de_DE.json
  • locale/en_US.json
  • locale/it_IT.json
  • storage/plugins/discogs/DiscogsPlugin.php
  • storage/plugins/discogs/README.md
  • storage/plugins/discogs/plugin.json
  • storage/plugins/discogs/views/settings.php
  • storage/plugins/discogs/wrapper.php
  • tests/discogs-advanced.spec.js
  • tests/discogs-import.spec.js
  • tests/discogs-plugin.spec.js
  • tests/multisource-scraping.spec.js
  • tests/playwright.config.js
  • version.json

Comment on lines 2239 to +2240
<p class="related-book-author">
<?= htmlspecialchars($related['autori'] ?? __('Autore sconosciuto'), ENT_QUOTES, 'UTF-8') ?>
<?= htmlspecialchars($related['autori'] ?? __($relatedIsMusic ? 'Artista sconosciuto' : 'Autore sconosciuto'), ENT_QUOTES, 'UTF-8') ?>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use the unknown-author fallback when autori is blank, not only when it is null.

?? leaves '' untouched, so related cards can still render an empty author line instead of the new music-aware fallback.

💡 Minimal fix
-                            <?= htmlspecialchars($related['autori'] ?? __($relatedIsMusic ? 'Artista sconosciuto' : 'Autore sconosciuto'), ENT_QUOTES, 'UTF-8') ?>
+                            <?php $relatedAuthorsLabel = trim((string) ($related['autori'] ?? '')); ?>
+                            <?= htmlspecialchars(
+                                $relatedAuthorsLabel !== '' ? $relatedAuthorsLabel : __($relatedIsMusic ? 'Artista sconosciuto' : 'Autore sconosciuto'),
+                                ENT_QUOTES,
+                                'UTF-8'
+                            ) ?>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/frontend/book-detail.php` around lines 2239 - 2240, The view
currently uses htmlspecialchars($related['autori'] ?? __($relatedIsMusic ?
'Artista sconosciuto' : 'Autore sconosciuto'), ...) which only falls back when
autori is null but leaves empty strings; change this to explicitly treat
empty/whitespace as missing by checking trim($related['autori'] ?? '') (or empty
after trim) and using the music-aware fallback (__($relatedIsMusic ? 'Artista
sconosciuto' : 'Autore sconosciuto')) when that check fails; update the
expression around htmlspecialchars in the related-book-author card so empty
autori strings render the fallback.

Comment on lines +41 to +44
<?php if (($book['tipo_media'] ?? 'libro') !== 'libro'): ?>
<span class="book-media-badge" title="<?= htmlspecialchars(\App\Support\MediaLabels::tipoMediaDisplayName($book['tipo_media']), ENT_QUOTES, 'UTF-8') ?>">
<i class="fas <?= htmlspecialchars(\App\Support\MediaLabels::icon($book['tipo_media']), ENT_QUOTES, 'UTF-8') ?>"></i>
</span>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Give the media badge an accessible name.

This is icon-only UI, and title is not a reliable accessible label. Add aria-label or visually hidden text on the wrapper and mark the <i> as decorative so assistive tech can distinguish non-book media in the grid.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/frontend/catalog-grid.php` around lines 41 - 44, The media badge
span (class "book-media-badge") is currently icon-only and uses title, which
isn't sufficient for accessibility; update the span that uses
MediaLabels::tipoMediaDisplayName($book['tipo_media']) to include an accessible
name (e.g., add aria-label with that display name or add visually hidden text
inside the span) and mark the <i> icon produced by
MediaLabels::icon($book['tipo_media']) as decorative (e.g., aria-hidden="true"
on the <i>) so screen readers announce the media label from the span rather than
the icon.

Comment on lines +23 to +31
$apiToken = trim($_POST['api_token'] ?? '');
if ($apiToken !== '') {
$settings = ['api_token' => $apiToken];
if ($plugin->saveSettings($settings)) {
$successMessage = __('Impostazioni Discogs salvate correttamente.');
} else {
$errorMessage = __('Errore nel salvataggio delle impostazioni.');
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n storage/plugins/discogs/views/settings.php

Repository: fabiodalez-dev/Pinakes

Length of output: 9898


🏁 Script executed:

# Search for similar form handling patterns with POST data
rg '\$_POST\[' --type php -B 2 -A 2 | head -100

Repository: fabiodalez-dev/Pinakes

Length of output: 7451


🏁 Script executed:

# Look for validation patterns in the codebase
rg 'is_string.*\$_POST' --type php

Repository: fabiodalez-dev/Pinakes

Length of output: 48


Add type validation for api_token before calling trim().

$_POST['api_token'] can be submitted as an array (e.g., api_token[]=x), causing trim() to throw a TypeError and crash the settings page with a 500 error instead of a validation error.

Suggested fix
-            $apiToken = trim($_POST['api_token'] ?? '');
-            if ($apiToken !== '') {
-                $settings = ['api_token' => $apiToken];
-                if ($plugin->saveSettings($settings)) {
-                    $successMessage = __('Impostazioni Discogs salvate correttamente.');
-                } else {
-                    $errorMessage = __('Errore nel salvataggio delle impostazioni.');
-                }
-            }
+            $rawApiToken = $_POST['api_token'] ?? '';
+            if (!is_string($rawApiToken)) {
+                $errorMessage = __('Token Discogs non valido.');
+            } else {
+                $apiToken = trim($rawApiToken);
+                if ($apiToken !== '') {
+                    $settings = ['api_token' => $apiToken];
+                    if ($plugin->saveSettings($settings)) {
+                        $successMessage = __('Impostazioni Discogs salvate correttamente.');
+                    } else {
+                        $errorMessage = __('Errore nel salvataggio delle impostazioni.');
+                    }
+                }
+            }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/discogs/views/settings.php` around lines 23 - 31, Validate
that $_POST['api_token'] is a string before calling trim(): check
is_string($_POST['api_token']) (or cast only when safe) and if not, set
$apiToken = '' and populate $errorMessage with a validation error instead of
calling trim on an array or other type; then proceed to build $settings and call
$plugin->saveSettings($settings) only when $apiToken is a valid string. Ensure
this check is done before the existing lines that define $apiToken, $settings,
and call $plugin->saveSettings so the page returns a user-friendly validation
error rather than throwing a TypeError.

Comment on lines +51 to +57
test.afterAll(async () => {
// Cleanup test data
try {
if (createdId !== '') {
dbExec(`DELETE FROM libri WHERE id = ${Number(createdId)} AND deleted_at IS NULL`);
}
} catch {}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't hard-delete libri here without cleaning related data.

Saving through the admin form also creates copy rows. A raw DELETE FROM libri can fail on FK constraints or orphan copie, and the empty catch {} hides when cleanup did nothing. Use the app's normal delete/soft-delete path, or remove dependent rows first and assert cleanup succeeded.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-import.spec.js` around lines 51 - 57, The cleanup currently
does a raw DELETE via dbExec on the libri table inside test.afterAll (using
createdId) which can violate FK constraints and the empty catch hides failures;
instead call the app's normal delete/soft-delete path (e.g., use the same
controller/service used by the admin form) or explicitly delete dependent copie
rows first (DELETE FROM copie WHERE libro_id = ...) then soft-delete libri, and
replace the empty catch with an assertion or a logged/raised error so test
cleanup failures are visible; locate the cleanup in test.afterAll, the createdId
variable, and the dbExec call to apply the change.

Comment on lines +115 to +120
// Check EAN field has the barcode
const eanValue = await page.locator('input[name="ean"]').inputValue();
// The barcode might be in isbn13 or ean depending on the scraper
const isbn13Value = await page.locator('input[name="isbn13"]').inputValue();
expect(eanValue === TEST_BARCODE || isbn13Value === TEST_BARCODE ||
eanValue.includes('720642442524') || isbn13Value.includes('720642442524')).toBe(true);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Replace the scraped public barcode before saving the test row.

TEST_BARCODE is a real release identifier and ean / isbn13 are unique. Persisting it unchanged makes this create flow fail or mutate shared E2E data if that barcode already exists. Rewrite the imported identifier to a run-scoped value before submit, like the multisource suite does.

Also applies to: 123-148

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-import.spec.js` around lines 115 - 120, The test currently
asserts the real public barcode (TEST_BARCODE) is present in the scraped inputs
'input[name="ean"]' or 'input[name="isbn13"]', which can persist a shared
identifier; before submitting the imported row you should overwrite the scraped
identifier with a run-scoped value: locate the fields using
page.locator('input[name="ean"]') and page.locator('input[name="isbn13"]') and
set (fill) them to a generated unique barcode/string (same pattern used by the
multisource suite) so the form saves a test-specific identifier; apply this
replacement wherever the test reads/writes ean/isbn13 in the block around the
expect (also for the related checks in lines 123-148).

Comment on lines +214 to +244
// Wait for response (up to 15s — Discogs can be slow)
await page.waitForTimeout(5000);

// Check if any fields were populated
const titleField = page.locator('input[name="titolo"]');
const titleValue = await titleField.inputValue().catch(() => '');

if (titleValue !== '') {
// Scraping succeeded — verify some data
expect(titleValue.length).toBeGreaterThan(0);

// Check if format was set to a music type
const formatField = page.locator('input[name="formato"]');
const formatValue = await formatField.inputValue().catch(() => '');
// Format might be populated from Discogs

// Check description (should contain tracklist)
const descFrame = page.frameLocator('.tox-edit-area__iframe').first();
if (await descFrame.locator('body').isVisible({ timeout: 2000 }).catch(() => false)) {
const descText = await descFrame.locator('body').textContent().catch(() => '');
// If Discogs returned tracklist, description should have content
if (descText) {
expect(descText.length).toBeGreaterThan(0);
}
}
} else {
// Scraping might have failed (rate limit, network) — that's OK for CI
// Just verify no JS errors occurred
const logs = [];
page.on('console', msg => { if (msg.type() === 'error') logs.push(msg.text()); });
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The “scrape failed” branch currently proves nothing.

The console listener is attached after the import attempt, and logs is never asserted. A JS error or a slow Discogs/MusicBrainz response can therefore still pass green without validating either success or the expected failure state.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-plugin.spec.js` around lines 214 - 244, The "scrape failed"
branch doesn't validate anything because the console listener is added after the
scrape and no assertion is made; move or add the console error listener setup
(page.on('console', ...)) before the scraping/import action (the code that
resolves titleValue via page.locator('input[name="titolo"]').inputValue()) and,
in the else branch where titleValue === '', assert that the collected logs array
is empty (or contains only expected messages) to fail the test if any JS errors
were emitted; refer to the existing symbols page, titleField/titleValue, logs
and the page.on('console', ...) handler to locate and update the test.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

♻️ Duplicate comments (5)
storage/plugins/discogs/DiscogsPlugin.php (1)

269-273: ⚠️ Potential issue | 🟠 Major

Pass the validated barcode into the MusicBrainz fallback.

This branch is reached from a barcode lookup, but null drops the only validated EAN if the MusicBrainz detail payload does not echo it back.

Minimal fix
-                $mbResult = $this->searchMusicBrainz($isbn, $token, null);
+                $mbResult = $this->searchMusicBrainz($isbn, $token, $isbn);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/discogs/DiscogsPlugin.php` around lines 269 - 273, The
MusicBrainz fallback call uses null for the barcode param which drops the
validated EAN; change the third argument of searchMusicBrainz in this branch to
pass the validated barcode variable (e.g. $isbn or the validatedBarcode variable
used earlier) instead of null so the validated EAN is preserved when calling
searchMusicBrainz before merging via mergeBookData($currentResult, $mbResult).
app/Controllers/PluginController.php (1)

441-453: ⚠️ Potential issue | 🟠 Major

Persisting api_token here still needs the admin-list redaction path.

index() still only strips google_books_api_key and api_key before $pluginSettings is handed to app/Views/admin/plugins.php, so adding Discogs token persistence here leaves the new secret available to the template scope.

Minimal follow-up in index()
             if ($plugin['name'] === 'api-book-scraper' && array_key_exists('api_key', $settings)) {
                 $settings['api_key_exists'] = $settings['api_key'] !== '' && $settings['api_key'] !== '••••••••';
                 $settings['api_key'] = $settings['api_key_exists'] ? '••••••••' : '';
             }
+
+            if ($plugin['name'] === 'discogs' && array_key_exists('api_token', $settings)) {
+                $settings['api_token_exists'] = $settings['api_token'] !== '';
+                $settings['api_token'] = $settings['api_token_exists'] ? '••••••••' : '';
+            }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/PluginController.php` around lines 441 - 453, The code
persists the Discogs secret under the 'api_token' key via
PluginController::setSetting but PluginController::index() still only redacts
'google_books_api_key' and 'api_key' before passing $pluginSettings to the
admin/plugins.php view, leaving the newly saved Discogs token exposed; update
index() to also redact the Discogs token key used by this plugin (the
'api_token' entry saved via $this->pluginManager->setSetting($pluginId,
'api_token', ...)) or replace it with a boolean like 'has_token' before
rendering so templates never receive the raw secret (ensure the same identifying
symbol names: PluginController::index(), $pluginSettings, 'api_token',
admin/plugins.php).
app/Controllers/LibriController.php (1)

3041-3041: ⚠️ Potential issue | 🟠 Major

Don’t fabricate libro in exported CSV rows.

If tipo_media is absent in the exported row, this writes an explicit libro and prevents a later CSV import from preserving or inferring the real media type from formato. Emit an empty field instead.

♻️ Proposed fix
-                    $libro['tipo_media'] ?? 'libro',
+                    $libro['tipo_media'] ?? '',
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibriController.php` at line 3041, In the CSV export in
LibriController where the $libro array is serialized (look for the CSV export
block referencing $libro and the 'tipo_media' key), stop defaulting missing
tipo_media to the literal 'libro' and emit an empty field instead; change the
expression that uses $libro['tipo_media'] ?? 'libro' so it returns an empty
string when tipo_media is absent (preserving downstream import/format inference
from formato).
installer/database/migrations/migrate_0.5.4.sql (1)

67-86: ⚠️ Potential issue | 🟠 Major

Keep the backfill rules aligned with runtime media normalization.

This still misses aliases that runtime already accepts as non-books (album, record, disc, movie, film) and separator variants like audio-book / audio_book. Rows using those values will stay libro or be misclassified during upgrade, so migrated catalogs can disagree with App\Support\MediaLabels::inferTipoMedia() and with new imports.

♻️ Proposed fix
 UPDATE libri SET tipo_media = 'disco'
 WHERE tipo_media = 'libro'
-  AND (LOWER(formato) LIKE '%cd%' OR LOWER(formato) LIKE '%compact disc%'
-       OR LOWER(formato) LIKE '%vinyl%' OR LOWER(formato) LIKE '%vinile%'
-       OR LOWER(formato) LIKE '%lp%' OR LOWER(formato) LIKE '%cassett%'
-       OR LOWER(formato) LIKE '%audio cassetta%' OR LOWER(formato) LIKE '%audio-cassetta%'
-       OR LOWER(formato) LIKE '%audiocassetta%'
-       OR LOWER(formato) REGEXP '[[:<:]]music[[:>:]]'
-       OR LOWER(formato) REGEXP '[[:<:]]musik[[:>:]]')
-  AND LOWER(formato) NOT LIKE '%audiolibro%' AND LOWER(formato) NOT LIKE '%audiobook%';
+  AND LOWER(formato) REGEXP '(^|[^[:alnum:]_])(disc|record|album|cd|lp|music|musik)([^[:alnum:]_]|$)|compact[ _-]?disc|vinyl|vinile|cassett'
+  AND LOWER(formato) NOT REGEXP 'audiolibro|audio[ _-]?book';
 
 UPDATE libri SET tipo_media = 'audiolibro'
 WHERE tipo_media = 'libro'
-  AND (LOWER(formato) LIKE '%audiolibro%' OR LOWER(formato) LIKE '%audiobook%' OR LOWER(formato) LIKE '%audio book%');
+  AND LOWER(formato) REGEXP 'audiolibro|audio[ _-]?book';
 
 UPDATE libri SET tipo_media = 'dvd'
 WHERE tipo_media = 'libro'
-  AND (LOWER(formato) LIKE '%dvd%' OR LOWER(formato) LIKE '%blu-ray%'
-       OR LOWER(formato) LIKE '%blu_ray%' OR LOWER(formato) LIKE '%blu ray%'
-       OR LOWER(formato) LIKE '%bluray%');
+  AND LOWER(formato) REGEXP 'dvd|blu[ _-]?ray|movie|film';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@installer/database/migrations/migrate_0.5.4.sql` around lines 67 - 86, The
UPDATE backfill queries on table libri that set tipo_media based on
LOWER(formato) need to be expanded to match the same aliases and separators used
by App\Support\MediaLabels::inferTipoMedia(): add alternative terms 'album',
'record', 'disc' to the disco/music rule and 'movie', 'film' to the dvd rule,
and support separator variants like 'audio-book' and 'audio_book' (and 'blu-ray'
/ 'blu_ray') in the LIKE/REGEXP checks so that rows where formato contains those
terms are reclassified (keep the existing exclusions for audiolibro/audiobook
when classifying disco/music). Ensure you update the same three UPDATE
statements that operate on libri.tipo_media when tipo_media = 'libro' so the
migration aligns with runtime normalization.
app/Controllers/LibraryThingImportController.php (1)

936-939: ⚠️ Potential issue | 🟡 Minor

Unrecognized Media values can still overwrite existing tipo_media.

The past review concern is only partially addressed. When Media is empty, tipo_media is correctly set to null. However, when Media contains an unrecognized value (e.g., "Magazine", "Comic"), inferTipoMedia() defaults to 'libro', which then passes through normalizeTipoMedia() in updateBook() and overwrites existing tipo_media values like 'disco' or 'dvd'.

For consistency with CsvImportController (which uses normalizeTipoMedia() for parsing), consider using normalizeTipoMedia() here so unrecognized values return null and the COALESCE(?, tipo_media) in updates preserves existing values.

💡 Suggested fix
-        $result['tipo_media'] = $mediaRaw !== '' ? \App\Support\MediaLabels::inferTipoMedia($mediaRaw) : null;
+        $result['tipo_media'] = $mediaRaw !== '' ? \App\Support\MediaLabels::normalizeTipoMedia($mediaRaw) : null;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibraryThingImportController.php` around lines 936 - 939, The
current code in LibraryThingImportController sets tipo_media using
\App\Support\MediaLabels::inferTipoMedia($mediaRaw), but inferTipoMedia maps
unrecognized values to 'libro' and will overwrite existing tipo_media; change
this to use \App\Support\MediaLabels::normalizeTipoMedia($mediaRaw) (the same
normalization CsvImportController uses) so unknown Media strings return null and
the subsequent updateBook() COALESCE(?, tipo_media) preserves existing values;
update the assignment of $result['tipo_media'] to call normalizeTipoMedia when
$mediaRaw !== '' and keep null for empty strings.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Controllers/LibriController.php`:
- Line 1220: The code is always seeding 'tipo_media' => '' into the update
payload which causes BookRepository::update (which checks for key existence) to
reset stored enum values; instead, stop adding the 'tipo_media' key unless the
incoming request actually provided it. Locate the payload-building code in
LibriController (where 'tipo_media' => '' is set) and change it to conditionally
add 'tipo_media' only when the Request contains that field (e.g., use
$request->has('tipo_media') or similar) so updates that omit the field do not
include the key; no change needed to BookRepository other than ensuring it
continues to treat key presence as intent to update.

In `@app/Support/Updater.php`:
- Around line 2246-2278: The code currently throws if removing the old backup
after a successful swap fails; make deletion of the backup a best-effort
operation so a post-swap cleanup error cannot abort an otherwise-successful
plugin activation. Specifically, in Updater.php where rename($stagingPath,
$targetPluginPath) succeeds and you call removeDirectoryTree($backupPath), wrap
removeDirectoryTree($backupPath) in its own try/catch (or check returns) and on
failure call $this->debugLog(...) instead of throwing; keep the existing restore
logic in the outer catch that attempts to rename($backupPath, $targetPluginPath)
only when needed. This ensures copyDirectoryRecursive, rename($targetPluginPath,
$backupPath), rename($stagingPath, $targetPluginPath) remain unchanged while
removeDirectoryTree($backupPath) is non-fatal.
- Around line 2296-2325: The cleanup fails on symlinks; update
removeDirectoryTree to explicitly detect and unlink symlinks before treating
entries as files/dirs: inside the foreach over the
RecursiveIteratorIterator(RecursiveDirectoryIterator(...)) check $item->isLink()
first and call unlink($item->getPathname()) (and continue) so symlinked entries
aren’t passed to isDir()/rmdir; also handle the root $path by checking
is_link($path) and calling unlink($path) instead of rmdir when appropriate.
Ensure you reference removeDirectoryTree,
RecursiveDirectoryIterator/RecursiveIteratorIterator, isLink/is_link, unlink and
rmdir when applying the fix.

In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 310-316: The fallback artist extraction in DiscogsPlugin uses
$currentResult['authors'] directly, which leads to the string "Array" when
entries are structured (e.g. ['name'=>'...']). Update the logic in the block
handling $currentResult['authors'] so that when authors is an array you inspect
the first element: if it's a scalar use it, if it's an array look for a 'name'
key (or any scalar field) and use that, falling back to a string cast only if
necessary; always trim the chosen value and assign it to $artist before building
the Discogs fallback query so the query uses a real artist name instead of
"Array".
- Around line 934-939: mergeBookData currently hardcodes 'discogs' as the source
when calling \App\Support\BookDataMerger::merge, which misattributes upstream
data; instead determine the real source from the payload (prefer $new's source,
then $existing's source) and pass that source string as the third argument to
BookDataMerger::merge (fall back to 'discogs' only if neither payload supplies a
source). Update the mergeBookData method to compute the source variable from
$new and $existing and pass it into BookDataMerger::merge.

---

Duplicate comments:
In `@app/Controllers/LibraryThingImportController.php`:
- Around line 936-939: The current code in LibraryThingImportController sets
tipo_media using \App\Support\MediaLabels::inferTipoMedia($mediaRaw), but
inferTipoMedia maps unrecognized values to 'libro' and will overwrite existing
tipo_media; change this to use
\App\Support\MediaLabels::normalizeTipoMedia($mediaRaw) (the same normalization
CsvImportController uses) so unknown Media strings return null and the
subsequent updateBook() COALESCE(?, tipo_media) preserves existing values;
update the assignment of $result['tipo_media'] to call normalizeTipoMedia when
$mediaRaw !== '' and keep null for empty strings.

In `@app/Controllers/LibriController.php`:
- Line 3041: In the CSV export in LibriController where the $libro array is
serialized (look for the CSV export block referencing $libro and the
'tipo_media' key), stop defaulting missing tipo_media to the literal 'libro' and
emit an empty field instead; change the expression that uses
$libro['tipo_media'] ?? 'libro' so it returns an empty string when tipo_media is
absent (preserving downstream import/format inference from formato).

In `@app/Controllers/PluginController.php`:
- Around line 441-453: The code persists the Discogs secret under the
'api_token' key via PluginController::setSetting but PluginController::index()
still only redacts 'google_books_api_key' and 'api_key' before passing
$pluginSettings to the admin/plugins.php view, leaving the newly saved Discogs
token exposed; update index() to also redact the Discogs token key used by this
plugin (the 'api_token' entry saved via
$this->pluginManager->setSetting($pluginId, 'api_token', ...)) or replace it
with a boolean like 'has_token' before rendering so templates never receive the
raw secret (ensure the same identifying symbol names: PluginController::index(),
$pluginSettings, 'api_token', admin/plugins.php).

In `@installer/database/migrations/migrate_0.5.4.sql`:
- Around line 67-86: The UPDATE backfill queries on table libri that set
tipo_media based on LOWER(formato) need to be expanded to match the same aliases
and separators used by App\Support\MediaLabels::inferTipoMedia(): add
alternative terms 'album', 'record', 'disc' to the disco/music rule and 'movie',
'film' to the dvd rule, and support separator variants like 'audio-book' and
'audio_book' (and 'blu-ray' / 'blu_ray') in the LIKE/REGEXP checks so that rows
where formato contains those terms are reclassified (keep the existing
exclusions for audiolibro/audiobook when classifying disco/music). Ensure you
update the same three UPDATE statements that operate on libri.tipo_media when
tipo_media = 'libro' so the migration aligns with runtime normalization.

In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 269-273: The MusicBrainz fallback call uses null for the barcode
param which drops the validated EAN; change the third argument of
searchMusicBrainz in this branch to pass the validated barcode variable (e.g.
$isbn or the validatedBarcode variable used earlier) instead of null so the
validated EAN is preserved when calling searchMusicBrainz before merging via
mergeBookData($currentResult, $mbResult).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: acbd1d93-aea8-4b15-ad5c-048b2a915b09

📥 Commits

Reviewing files that changed from the base of the PR and between b5a247d and 610c29c.

📒 Files selected for processing (10)
  • app/Controllers/CsvImportController.php
  • app/Controllers/LibraryThingImportController.php
  • app/Controllers/LibriController.php
  • app/Controllers/PluginController.php
  • app/Models/BookRepository.php
  • app/Support/MediaLabels.php
  • app/Support/Updater.php
  • installer/database/migrations/migrate_0.5.4.sql
  • storage/plugins/discogs/DiscogsPlugin.php
  • version.json
✅ Files skipped from review due to trivial changes (1)
  • version.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/Models/BookRepository.php

Comment on lines +2296 to +2325
private function removeDirectoryTree(string $path): void
{
if (!file_exists($path)) {
return;
}
if (!is_dir($path)) {
throw new Exception(sprintf(__('Percorso plugin non valido: %s'), $path));
}

$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);

foreach ($iterator as $item) {
if ($item->isDir()) {
if (!rmdir($item->getPathname())) {
throw new Exception(sprintf(__('Impossibile rimuovere directory: %s'), $item->getPathname()));
}
} else {
if (!unlink($item->getPathname())) {
throw new Exception(sprintf(__('Impossibile rimuovere file: %s'), $item->getPathname()));
}
}
}

if (!rmdir($path)) {
throw new Exception(sprintf(__('Impossibile rimuovere directory: %s'), $path));
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

In PHP, when RecursiveDirectoryIterator is created with a symlinked directory path and no FOLLOW_SYMLINKS flag, does it enumerate the target directory contents, and does SplFileInfo::isDir() return true for symlinks to directories?

💡 Result:

No, when RecursiveDirectoryIterator is created with a symlinked directory path without the FilesystemIterator::FOLLOW_SYMLINKS flag, it does not enumerate the target directory contents—it iterates over the symlink as if it were a non-directory file (a leaf node). Yes, SplFileInfo::isDir returns true for symlinks to directories.

Citations:


🏁 Script executed:

#!/bin/bash
# Search for PHP symlink handling documentation and verify behavior
python3 << 'EOF'
import urllib.request
import json

# Check if SplFileInfo has isLink() method by examining PHP docs
queries = [
    "SplFileInfo isLink method PHP",
    "PHP rmdir symlink behavior",
    "PHP unlink symlink directory"
]

for query in queries:
    print(f"Query: {query}")
    print("-" * 50)
    # We'll rely on documented PHP behavior
    print()
EOF

Repository: fabiodalez-dev/Pinakes

Length of output: 328


🌐 Web query:

Does SplFileInfo in PHP have an isLink() method, and what is the correct way to detect symlinks when iterating with RecursiveDirectoryIterator?

💡 Result:

Yes, SplFileInfo in PHP has an isLink method that returns true if the file is a symbolic link. The correct way to detect symlinks when iterating with RecursiveDirectoryIterator is to use $file->isLink on each SplFileInfo object returned by the iterator. Example: $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator('/path')); foreach ($iterator as $file) { if ($file->isLink) { echo $file->getPathname . ' is a symlink'; } } Note: RecursiveDirectoryIterator returns SplFileInfo objects by default (CURRENT_AS_FILEINFO), which support isLink. Use FilesystemIterator::FOLLOW_SYMLINKS flag if you want to follow symlinks during recursion, but isLink detects the symlink itself regardless.

Citations:


🌐 Web query:

What happens when you call rmdir() on a PHP symlink to a directory versus using unlink()?

💡 Result:

When calling rmdir or unlink on a PHP symlink pointing to a directory, the behavior differs by operating system, as PHP's filesystem functions follow underlying platform semantics. On Unix/Linux systems: - unlink($symlink) deletes the symlink itself successfully. - rmdir($symlink) fails, typically because the symlink appears as a non-empty directory (it points to a directory, which PHP's is_dir detects as true). On Windows: - unlink($symlink) fails with a "Permission denied" warning. - rmdir($symlink) deletes the symlink itself successfully. In both cases, neither function affects the target directory—only the symlink is removed. This platform difference is explicitly noted in the official PHP unlink documentation: "If the file is a symlink, the symlink will be deleted. On Windows, to delete a symlink to a directory, rmdir has to be used instead." For cross-platform code, detect the OS (e.g., via PHP_OS_FAMILY) and choose the appropriate function, or use is_link combined with platform checks.

Citations:


Explicit symlink handling required to prevent cleanup failures.

The RecursiveDirectoryIterator without FOLLOW_SYMLINKS doesn't traverse into symlinked directories, but it will encounter symlinks in the iteration. On Unix/Linux systems, calling rmdir() on a symlink to a directory fails because the symlink appears as a non-directory entity, causing an exception. The fix correctly detects symlinks using isLink() and removes them explicitly with unlink() before attempting directory removal operations, preventing cleanup from failing when symlinks exist in the plugin directory.

Proposed fix
 private function removeDirectoryTree(string $path): void
 {
-        if (!file_exists($path)) {
+        if (!file_exists($path) && !is_link($path)) {
             return;
         }
+        if (is_link($path)) {
+            if (!unlink($path)) {
+                throw new Exception(sprintf(__('Impossibile rimuovere link simbolico: %s'), $path));
+            }
+            return;
+        }
         if (!is_dir($path)) {
             throw new Exception(sprintf(__('Percorso plugin non valido: %s'), $path));
         }
@@
         foreach ($iterator as $item) {
+            if ($item->isLink()) {
+                if (!unlink($item->getPathname())) {
+                    throw new Exception(sprintf(__('Impossibile rimuovere link simbolico: %s'), $item->getPathname()));
+                }
+                continue;
+            }
             if ($item->isDir()) {
                 if (!rmdir($item->getPathname())) {
                     throw new Exception(sprintf(__('Impossibile rimuovere directory: %s'), $item->getPathname()));
                 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private function removeDirectoryTree(string $path): void
{
if (!file_exists($path)) {
return;
}
if (!is_dir($path)) {
throw new Exception(sprintf(__('Percorso plugin non valido: %s'), $path));
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $item) {
if ($item->isDir()) {
if (!rmdir($item->getPathname())) {
throw new Exception(sprintf(__('Impossibile rimuovere directory: %s'), $item->getPathname()));
}
} else {
if (!unlink($item->getPathname())) {
throw new Exception(sprintf(__('Impossibile rimuovere file: %s'), $item->getPathname()));
}
}
}
if (!rmdir($path)) {
throw new Exception(sprintf(__('Impossibile rimuovere directory: %s'), $path));
}
}
private function removeDirectoryTree(string $path): void
{
if (!file_exists($path) && !is_link($path)) {
return;
}
if (is_link($path)) {
if (!unlink($path)) {
throw new Exception(sprintf(__('Impossibile rimuovere link simbolico: %s'), $path));
}
return;
}
if (!is_dir($path)) {
throw new Exception(sprintf(__('Percorso plugin non valido: %s'), $path));
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $item) {
if ($item->isLink()) {
if (!unlink($item->getPathname())) {
throw new Exception(sprintf(__('Impossibile rimuovere link simbolico: %s'), $item->getPathname()));
}
continue;
}
if ($item->isDir()) {
if (!rmdir($item->getPathname())) {
throw new Exception(sprintf(__('Impossibile rimuovere directory: %s'), $item->getPathname()));
}
} else {
if (!unlink($item->getPathname())) {
throw new Exception(sprintf(__('Impossibile rimuovere file: %s'), $item->getPathname()));
}
}
}
if (!rmdir($path)) {
throw new Exception(sprintf(__('Impossibile rimuovere directory: %s'), $path));
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Support/Updater.php` around lines 2296 - 2325, The cleanup fails on
symlinks; update removeDirectoryTree to explicitly detect and unlink symlinks
before treating entries as files/dirs: inside the foreach over the
RecursiveIteratorIterator(RecursiveDirectoryIterator(...)) check $item->isLink()
first and call unlink($item->getPathname()) (and continue) so symlinked entries
aren’t passed to isDir()/rmdir; also handle the root $path by checking
is_link($path) and calling unlink($path) instead of rmdir when appropriate.
Ensure you reference removeDirectoryTree,
RecursiveDirectoryIterator/RecursiveIteratorIterator, isLink/is_link, unlink and
rmdir when applying the fix.

- MusicBrainz fallback: pass $isbn (validated barcode) instead of null
- PluginController index: redact Discogs api_token before template
- CSV export: don't fabricate 'libro' for empty tipo_media
Tests:
1. tipo_media ENUM column exists with all values
2. Book form has tipo_media dropdown
3. Admin list has media type icon column
4. API filters by tipo_media=disco
5. CD shows music-specific labels
6. Book shows standard labels
7. Edit CD preserves tipo_media=disco
8. CSV export includes tipo_media
9. Format display name (cd_audio → CD Audio)
10. Discogs plugin registered as bundled

PHPStan level 5 fixes:
- instantiatePlugin() returns object (not ?object)
- Remove redundant is_object() and null checks
- Discogs plugin: merge genres + styles into parole_chiave
  (was: only styles, now: "Rock, Alternative Rock, Grunge")
- Form JS: handle data.parole_chiave alias alongside data.keywords
- BookRepository updateOptionals: add parole_chiave to optional columns
  (was missing — scraped keywords never persisted via enrichment path)
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@CodeRabbit review

@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@CodeRabbit full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 31, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/Support/PluginManager.php (1)

47-52: ⚠️ Potential issue | 🟠 Major

Remove optional plugins from auto-registration or gate activation with metadata.

BundledPlugins::LIST includes discogs, a network-backed music scraper that should remain opt-in. The autoRegisterBundledPlugins() method inserts all bundled plugins with is_active = 1 hardcoded (line 114) and immediately calls onActivate() hooks, bypassing any opt-in check. On first load or DB rebuild, Discogs will be auto-activated without user consent, enabling external API calls to Discogs, MusicBrainz, and Deezer.

Either remove optional plugins from BundledPlugins::LIST or add a metadata flag (e.g., "optional": true in plugin.json) and check it before auto-activation:

$isOptional = $pluginMeta['metadata']['optional'] ?? false;
if (!$isOptional) {
    // Insert with is_active = 1 and run onActivate
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Support/PluginManager.php` around lines 47 - 52,
autoRegisterBundledPlugins currently iterates BundledPlugins::LIST and
force-activates every plugin (inserting with is_active = 1 and calling
onActivate()), which will auto-enable optional network-backed plugins like
discogs; change the logic in autoRegisterBundledPlugins to read each plugin's
plugin.json (or existing metadata loader) and check a metadata flag such as
"optional" (e.g., $pluginMeta['metadata']['optional'] ?? false) and skip
automatic activation for optional plugins (insert them with is_active = 0 or
don't call onActivate()), only auto-activate those without the optional flag;
reference BundledPlugins::LIST, autoRegisterBundledPlugins(), and onActivate()
when locating the change.
♻️ Duplicate comments (16)
app/Controllers/LibriController.php (1)

679-680: ⚠️ Potential issue | 🟠 Major

Avoid seeding tipo_media with an empty default in create/update payloads.

Line 679 and Line 1220 force tipo_media into payloads even when the request did not provide it, which can silently override inferred/scraped media type handling downstream.

♻️ Suggested fix
-            'tipo_media' => '',
         foreach ($fields as $k => $v) {
             if (array_key_exists($k, $data))
                 $fields[$k] = $data[$k];
         }
+        if (!array_key_exists('tipo_media', $data) || trim((string) $data['tipo_media']) === '') {
+            unset($fields['tipo_media']);
+        }

Also applies to: 1220-1221

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibriController.php` around lines 679 - 680, The payload
builder in LibriController is forcibly setting 'tipo_media' => '' into
create/update payloads which can override inferred media types; update the
payload construction in the methods that build these arrays (where 'tipo_media'
is currently assigned) to only include 'tipo_media' when the incoming request
explicitly provides it (e.g., check $request->has('tipo_media') or isset on the
input) or remove the default assignment entirely so the key is omitted when
empty, ensuring downstream logic that infers/scrapes media type is not silently
overridden.
app/Views/frontend/catalog-grid.php (1)

41-44: ⚠️ Potential issue | 🟡 Minor

Add an explicit accessible name for the media badge.

The badge is still icon-only and title is not a reliable accessible label. Add aria-label on the badge and mark the icon decorative.

♿ Suggested fix
-                  <span class="book-media-badge" title="<?= htmlspecialchars(\App\Support\MediaLabels::tipoMediaDisplayName($book['tipo_media']), ENT_QUOTES, 'UTF-8') ?>">
-                    <i class="fas <?= htmlspecialchars(\App\Support\MediaLabels::icon($book['tipo_media']), ENT_QUOTES, 'UTF-8') ?>"></i>
+                  <span
+                    class="book-media-badge"
+                    title="<?= htmlspecialchars(\App\Support\MediaLabels::tipoMediaDisplayName($book['tipo_media']), ENT_QUOTES, 'UTF-8') ?>"
+                    aria-label="<?= htmlspecialchars(\App\Support\MediaLabels::tipoMediaDisplayName($book['tipo_media']), ENT_QUOTES, 'UTF-8') ?>">
+                    <i class="fas <?= htmlspecialchars(\App\Support\MediaLabels::icon($book['tipo_media']), ENT_QUOTES, 'UTF-8') ?>" aria-hidden="true"></i>
                   </span>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/frontend/catalog-grid.php` around lines 41 - 44, The media badge is
currently icon-only and uses title (unreliable for accessibility); update the
span.book-media-badge to include an explicit aria-label using
htmlspecialchars(\App\Support\MediaLabels::tipoMediaDisplayName($book['tipo_media']),
ENT_QUOTES, 'UTF-8') and keep the existing title if desired, and mark the inner
<i> element decorative by adding aria-hidden="true" (or role="presentation") so
screen readers read only the span label; locate the badge markup in
catalog-grid.php using the span with class "book-media-badge" and the helper
calls MediaLabels::tipoMediaDisplayName(...) and MediaLabels::icon(...) to apply
these attributes.
app/Views/frontend/catalog.php (1)

1302-1326: ⚠️ Potential issue | 🟡 Minor

Keep the tipo_media UI in sync after AJAX refreshes.

This still has the same client-side gap: updateFilter('tipo_media', ...) never reapplies .active in the sidebar, and updateActiveFiltersDisplay() still has no value→label map, so the chip falls back to raw enum keys. Mirror the availability sync path for media types and expose the labels to JS.

Also applies to: 1451-1490, 1539-1556, 1603-1667

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/frontend/catalog.php` around lines 1302 - 1326, The tipo_media UI
isn't kept in sync after AJAX updates because the client never gets the
media-type labels or reapplies the active class; update the server output (from
MediaLabels::allTypes) to expose a JS map of tipo_media value→label (and icon)
alongside the existing availability data, then modify the client functions
updateFilter('tipo_media', ...) and updateActiveFiltersDisplay() to use that
map: when updateFilter is called for tipo_media ensure it toggles the
.filter-option.active class for the matching .filter-option element (by matching
its data/value) and updateActiveFiltersDisplay should translate enum keys into
the exposed labels instead of showing raw keys; reference MediaLabels::allTypes,
tipo_media, .filter-option, updateFilter, and updateActiveFiltersDisplay when
making these changes.
app/Support/PluginManager.php (1)

356-364: ⚠️ Potential issue | 🟠 Major

Catch instantiation failures inside getPluginInstance().

instantiatePlugin() can throw for missing files, wrapper/class resolution, or constructor/setup errors. Letting that escape from a nullable helper turns a broken plugin into a 500 instead of null for the caller.

🛠️ Minimal fix
 public function getPluginInstance(int $pluginId): ?object
 {
     $plugin = $this->getPlugin($pluginId);
     if ($plugin === null) {
         return null;
     }

-    return $this->instantiatePlugin($plugin);
+    try {
+        return $this->instantiatePlugin($plugin);
+    } catch (\Throwable $e) {
+        SecureLogger::warning("[PluginManager] Failed to instantiate plugin {$plugin['name']}", [
+            'plugin_id' => $pluginId,
+            'error' => $e->getMessage(),
+        ]);
+        return null;
+    }
 }
As per coding guidelines, `Use \Throwable not \Exception for exception handling (strict_types TypeError extends \Error)`.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Support/PluginManager.php` around lines 356 - 364, getPluginInstance
currently lets instantiatePlugin exceptions bubble up, causing a 500; wrap the
call to instantiatePlugin($plugin) in a try-catch that catches \Throwable (not
\Exception) and returns null on failure so callers get null instead of an
exception; locate getPluginInstance and modify the return path to catch any
\Throwable from instantiatePlugin, optionally logging the error via the existing
logger if available, then return null.
app/Views/libri/partials/book_form.php (1)

424-428: ⚠️ Potential issue | 🟡 Minor

Normalize tipo_media before rendering or applying scraper results.

This select only accepts MediaLabels::allTypes() keys, but both $book['tipo_media'] and data.tipo_media are used raw. If the payload carries labels like CD, LP, or vinyl, the control no longer reflects the stored/imported media type and it becomes easy to overwrite it on save. Whitelist/map to the canonical keys first and fall back to a safe default when there is no match.

Also applies to: 3615-3620

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/libri/partials/book_form.php` around lines 424 - 428, Normalize and
whitelist the media type before rendering and before applying scraper/imported
values: map incoming values (from $book['tipo_media'] and data.tipo_media) to
the canonical keys returned by MediaLabels::allTypes(), using a lookup that
converts common labels like "CD", "LP", "vinyl" to their canonical key, and fall
back to 'libro' when no match is found; update the select rendering (the block
using MediaLabels::allTypes() and the option selected check) to use the
normalized key and ensure any code that assigns data.tipo_media uses the same
normalization function so the saved value always matches the allowed keys.
storage/plugins/discogs/README.md (1)

15-18: ⚠️ Potential issue | 🟡 Minor

Document the Discogs title fallback before MusicBrainz.

The flow here jumps from barcode lookup straight to MusicBrainz, so the README no longer matches the PR's barcode → title fallback behavior.

📝 Suggested text
 1. Ricerca per barcode (EAN/UPC) su Discogs -- `GET /database/search?barcode={ean}&type=release`
 2. Recupero dettagli completi della release Discogs -- `GET /releases/{id}`
-3. **Fallback MusicBrainz** -- se Discogs non trova risultati, cerca su MusicBrainz per barcode
-4. **Arricchimento Deezer** -- se manca la copertina o il genere, cerca su Deezer per titolo+artista
+3. **Fallback Discogs per titolo/artista** -- se il barcode non restituisce risultati, prova `GET /database/search?q={title}+{artist}&type=release`
+4. **Fallback MusicBrainz** -- se anche la ricerca Discogs per titolo non trova risultati, cerca su MusicBrainz per barcode
+5. **Arricchimento Deezer** -- se manca la copertina o il genere, cerca su Deezer per titolo+artista
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/discogs/README.md` around lines 15 - 18, Update the README's
barcode resolution flow to reflect the actual PR behavior by inserting the
Discogs title-based fallback step before the MusicBrainz fallback: keep the
existing "Ricerca per barcode" and "Recupero dettagli completi della release
Discogs" entries, then add a new bullet describing "Fallback titolo su Discogs
(ricerca per titolo+artista quando il barcode non restituisce release)" before
the existing "Fallback MusicBrainz" line, and ensure the Deezer enrichment line
remains last so the sequence reads barcode → Discogs details → Discogs title
fallback → MusicBrainz fallback → Deezer enrichment.
app/Support/Updater.php (2)

2296-2325: ⚠️ Potential issue | 🟡 Minor

Unlink symlinks before treating them as files/directories.

removeDirectoryTree() still assumes every entry can be removed via unlink() or rmdir(). A symlink-to-directory can hit the isDir()/rmdir() branch on Unix and make cleanup fail, which is especially risky because this helper is used in the swap recovery path.

🧹 Suggested change
-        if (!file_exists($path)) {
+        if (!file_exists($path) && !is_link($path)) {
             return;
         }
+        if (is_link($path)) {
+            if (!unlink($path)) {
+                throw new Exception(sprintf(__('Impossibile rimuovere link simbolico: %s'), $path));
+            }
+            return;
+        }
         if (!is_dir($path)) {
             throw new Exception(sprintf(__('Percorso plugin non valido: %s'), $path));
         }
@@
         foreach ($iterator as $item) {
+            if ($item->isLink()) {
+                if (!unlink($item->getPathname())) {
+                    throw new Exception(sprintf(__('Impossibile rimuovere link simbolico: %s'), $item->getPathname()));
+                }
+                continue;
+            }
             if ($item->isDir()) {
                 if (!rmdir($item->getPathname())) {
                     throw new Exception(sprintf(__('Impossibile rimuovere directory: %s'), $item->getPathname()));
                 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Support/Updater.php` around lines 2296 - 2325, removeDirectoryTree
currently treats symlinks as normal files/directories which can cause rmdir
failures for symlinked directories; update the loop in removeDirectoryTree to
check $item->isLink() first and call unlink($item->getPathname()) for symlinks
(throw the same Exception on failure), only then branch into $item->isDir() =>
rmdir(...) and else => unlink(...), so symlink-to-directory is removed via
unlink instead of rmdir while preserving the existing error messages and
behavior.

2246-2263: ⚠️ Potential issue | 🟠 Major

Make backup cleanup non-fatal after a successful swap.

Once Line 2254 has activated the staged plugin, an exception from Line 2262 aborts the whole update even though restoreAppFiles() never rolls back storage/plugins. Cleanup failure here should be logged, not rethrown.

♻️ Suggested change
-                if (is_dir($backupPath)) {
-                    $this->removeDirectoryTree($backupPath);
-                }
+                if (is_dir($backupPath)) {
+                    try {
+                        $this->removeDirectoryTree($backupPath);
+                    } catch (\Throwable $cleanupError) {
+                        $this->debugLog('WARNING', 'Impossibile rimuovere il backup del plugin aggiornato', [
+                            'plugin' => $pluginSlug,
+                            'backup' => $backupPath,
+                            'error' => $cleanupError->getMessage(),
+                        ]);
+                    }
+                }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Support/Updater.php` around lines 2246 - 2263, The backup cleanup after
activating the staged plugin should not be fatal: wrap the final backup removal
(the call to $this->removeDirectoryTree($backupPath) in the activation path
after rename($stagingPath, $targetPluginPath)) in a try/catch and, on failure,
log the error instead of rethrowing so the update proceeds; use the class's
existing logging facility (e.g. $this->logger->error or the same logger used
elsewhere in Updater.php) and include context (plugin slug and $e->getMessage())
while ensuring no exception from removeDirectoryTree aborts the successful swap
or interferes with restoreAppFiles() or storage/plugins state.
tests/multisource-scraping.spec.js (1)

114-118: ⚠️ Potential issue | 🟠 Major

Delete the created libri rows in teardown.

The suite captures every created id but afterAll() only closes the browser context. Re-running this spec against a shared E2E database will keep accumulating RUN_TAG rows and eventually contaminate the list/search assertions.

🧹 Suggested change
 test.afterAll(async () => {
+  const ids = [manualBookId, importedBookId, manualDiscId, importedDisc1Id, importedDisc2Id]
+    .map(Number)
+    .filter((id) => id > 0);
+  const filters = [`titolo LIKE '${sqlEscape(RUN_TAG)}%'`];
+  if (ids.length > 0) {
+    filters.unshift(`id IN (${ids.join(',')})`);
+  }
+  try {
+    dbQuery(`DELETE FROM libri WHERE ${filters.join(' OR ')}`);
+  } catch {}
   await context?.close();
 });

Also applies to: 131-133

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/multisource-scraping.spec.js` around lines 114 - 118, The test suite
currently captures created record IDs (manualBookId, importedBookId,
manualDiscId, importedDisc1Id, importedDisc2Id and the similar vars at the other
block) but never deletes those rows in afterAll; update the afterAll teardown to
delete the created libri rows (or rows tagged with RUN_TAG) by using the
captured IDs to remove the specific records (or a RUN_TAG-scoped delete) so
re-running the spec against a shared E2E DB doesn't accumulate data; locate the
afterAll function and add cleanup logic that issues deletes for the recorded IDs
(or a single delete filtered by RUN_TAG) and ensure it runs after closing the
browser context.
storage/plugins/discogs/DiscogsPlugin.php (3)

369-374: ⚠️ Potential issue | 🟠 Major

Honor explicit tipo_media before inferring a music source.

inferTipoMedia('cd_audio') still returns disco even when $data['tipo_media'] is audiolibro, so audiobook-on-CD payloads can be pushed through Discogs/Deezer enrichment and pick up album art. Resolve the media type with the explicit value included, then test that resolved value.

💡 Minimal fix
-        $resolvedType = \App\Support\MediaLabels::inferTipoMedia($data['format'] ?? $data['formato'] ?? '');
-        $isMusicSource = ($data['tipo_media'] ?? '') === 'disco'
-            || $resolvedType === 'disco'
+        $resolvedType = \App\Support\MediaLabels::resolveTipoMedia(
+            $data['format'] ?? $data['formato'] ?? null,
+            $data['tipo_media'] ?? null
+        );
+        $isMusicSource = $resolvedType === 'disco'
             || ($data['source'] ?? '') === 'discogs'
             || ($data['source'] ?? '') === 'musicbrainz';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/discogs/DiscogsPlugin.php` around lines 369 - 374, The code
currently calls \App\Support\MediaLabels::inferTipoMedia(...) and then compares
$data['tipo_media'] separately, which lets inferred values override an explicit
tipo_media; change the resolution logic so the explicit $data['tipo_media'] (if
present and non-empty) is used first and only fall back to inferTipoMedia(...)
otherwise (update the $resolvedType assignment), then keep the $isMusicSource
check using that $resolvedType along with the existing source checks; reference:
inferTipoMedia, $resolvedType, $isMusicSource, and $data['tipo_media'].

937-942: ⚠️ Potential issue | 🟠 Major

Pass the real payload source into BookDataMerger.

mapMusicBrainzToPinakes() already returns source => 'musicbrainz', but this call always tells the merger the new data came from Discogs. That mislabels fallback hits and can skew _primary_source and source-weighted selection.

💡 Minimal fix
     private function mergeBookData(?array $existing, ?array $new): ?array
     {
         // Use BookDataMerger if available
         if (class_exists('\\App\\Support\\BookDataMerger')) {
-            return \App\Support\BookDataMerger::merge($existing, $new, 'discogs');
+            $mergeSource = is_string($new['source'] ?? null) && $new['source'] !== ''
+                ? $new['source']
+                : (is_string($existing['source'] ?? null) && $existing['source'] !== '' ? $existing['source'] : 'discogs');
+            return \App\Support\BookDataMerger::merge($existing, $new, $mergeSource);
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/discogs/DiscogsPlugin.php` around lines 937 - 942, The
mergeBookData method currently calls
\App\Support\BookDataMerger::merge($existing, $new, 'discogs') which hardcodes
the payload source; change it to derive the source from the incoming payload
(e.g. $new['source'] if set) and pass that value into BookDataMerger::merge
instead of the literal 'discogs' so fallback hits retain their real source;
update mergeBookData (and the \App\Support\BookDataMerger::merge call site) to
default to 'discogs' only when the new payload has no source field.

310-316: ⚠️ Potential issue | 🟠 Major

Normalize structured authors entries before building the fallback query.

If authors arrives as [['name' => '...']], (string) ($currentResult['authors'][0] ?? '') becomes "Array", so the Discogs fallback sends an invalid artist= filter and misses valid releases.

💡 Minimal fix
         $artist = trim((string) ($currentResult['author'] ?? ''));
         if ($artist === '' && !empty($currentResult['authors'])) {
             if (is_array($currentResult['authors'])) {
-                $artist = trim((string) ($currentResult['authors'][0] ?? ''));
+                $firstAuthor = $currentResult['authors'][0] ?? '';
+                if (is_array($firstAuthor)) {
+                    $artist = trim((string) ($firstAuthor['name'] ?? ''));
+                } else {
+                    $artist = trim((string) $firstAuthor);
+                }
             } else {
                 $artist = trim((string) $currentResult['authors']);
             }
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/discogs/DiscogsPlugin.php` around lines 310 - 316, The code
that sets $artist from $currentResult['authors'] can cast an array to the string
"Array" when entries are structured (e.g. ['name' => '...']); update the
fallback logic in DiscogsPlugin.php where $artist and $currentResult['authors']
are handled to normalize authors before casting—if $currentResult['authors'] is
an array and its first element is an array with a 'name' key, use that 'name'
value (or if it's a list of such arrays, extract the first non-empty 'name'),
otherwise fall back to the existing string handling; ensure you still apply
trim((string) ...) to the final value so the Discogs artist= filter receives a
proper string.
tests/discogs-import.spec.js (2)

115-120: ⚠️ Potential issue | 🟠 Major

Replace the imported public barcode before submitting the form.

This flow imports a real Discogs barcode and then persists it unchanged into ean/isbn13, which are unique fields. Reruns can collide with existing/shared data and turn the save step into a flaky uniqueness failure.

🧪 Minimal fix
     const isbn13Value = await page.locator('input[name="isbn13"]').inputValue();
     expect(eanValue === TEST_BARCODE || isbn13Value === TEST_BARCODE ||
            eanValue.includes('720642442524') || isbn13Value.includes('720642442524')).toBe(true);
+
+    const uniqueBarcode = `${Date.now()}${Math.floor(Math.random() * 10)}`.slice(-13);
+    const eanField = page.locator('input[name="ean"]');
+    const isbn13Field = page.locator('input[name="isbn13"]');
+    if (await eanField.isVisible().catch(() => false)) {
+      await eanField.fill(uniqueBarcode);
+    }
+    if (await isbn13Field.isVisible().catch(() => false)) {
+      await isbn13Field.fill(uniqueBarcode);
+    }

Also applies to: 123-149

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-import.spec.js` around lines 115 - 120, The test currently
persists a real public Discogs barcode into the form (checked via
input[name="ean"] and input[name="isbn13"]) which can cause uniqueness
collisions on re-runs; before submitting the form in the discogs-import.spec.js
flow, detect if the imported value equals the public barcode (e.g., TEST_BARCODE
or '720642442524') and programmatically replace it with a unique value (for
example build one using a timestamp or random suffix) by updating the same
locators ('input[name="ean"]' and 'input[name="isbn13"]') so the save step uses
a non-colliding barcode. Ensure both fields are set consistently and then
proceed to submit.

51-57: ⚠️ Potential issue | 🟠 Major

Avoid raw DELETE FROM libri in teardown.

Saving through the admin form can create dependent copie rows, so this hard delete can fail on FK constraints, and the empty catch {} makes the leak invisible. Use the normal delete/soft-delete path, or remove dependents first and surface cleanup failures.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-import.spec.js` around lines 51 - 57, The teardown uses a raw
hard DELETE via dbExec when createdId is set, which can fail silently due to FK
constraints on dependent copie rows and the empty catch hides failures; update
test.afterAll to call the application's normal delete/soft-delete path (e.g.,
invoke the same controller/service method used for admin deletes) or explicitly
remove dependent copie rows first before deleting libri, and remove the empty
catch so any cleanup errors surface (look for createdId, dbExec, and
test.afterAll to modify).
tests/discogs-plugin.spec.js (1)

204-208: ⚠️ Potential issue | 🟠 Major

This scrape smoke test can still pass without proving anything.

After activation, a missing import button is a failure, not a skip. And in the empty-title branch the console listener is attached after the import attempt and never asserted, so JS errors or a broken scrape path still go green.

🧪 Minimal fix
+    const logs = [];
+    page.on('console', msg => {
+      if (msg.type() === 'error') logs.push(msg.text());
+    });
+
     const importBtn = page.locator('#btnImportIsbn');
-    if (!await importBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
-      test.skip(true, 'Import button not visible');
-      return;
-    }
+    await expect(importBtn, 'Import button not visible after Discogs activation').toBeVisible({ timeout: 3000 });
@@
-    } else {
-      // Scraping might have failed (rate limit, network) — that's OK for CI
-      // Just verify no JS errors occurred
-      const logs = [];
-      page.on('console', msg => { if (msg.type() === 'error') logs.push(msg.text()); });
-    }
+    } else {
+      expect(logs, 'Scrape returned no data and emitted console errors').toEqual([]);
+    }

Also applies to: 239-243

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/discogs-plugin.spec.js` around lines 204 - 208, The test currently
skips when the import button is missing and attaches the page console listener
after triggering import, which hides real failures; change the behavior so that
the absence of the import button (identified via importBtn =
page.locator('#btnImportIsbn') and importBtn.isVisible(...)) throws or fails the
test instead of calling test.skip, move the console listener attachment (the
page.on('console', ...) handler used in the empty-title branch) to before any
import attempt, and add explicit assertions on captured console events/errors
after the import action to ensure JS errors or scrape failures are asserted;
apply the same fixes to the duplicate block referenced around lines 239-243
(where the same importBtn/console logic is duplicated).
app/Views/frontend/book-detail.php (1)

2239-2240: ⚠️ Potential issue | 🟡 Minor

Treat blank autori as missing here.

?? only falls back on null, so '' still renders an empty author line instead of the music-aware unknown-author label.

💡 Minimal fix
-                            <?= htmlspecialchars($related['autori'] ?? __($relatedIsMusic ? 'Artista sconosciuto' : 'Autore sconosciuto'), ENT_QUOTES, 'UTF-8') ?>
+                            <?php $relatedAuthorsLabel = trim((string) ($related['autori'] ?? '')); ?>
+                            <?= htmlspecialchars(
+                                $relatedAuthorsLabel !== '' ? $relatedAuthorsLabel : __($relatedIsMusic ? 'Artista sconosciuto' : 'Autore sconosciuto'),
+                                ENT_QUOTES,
+                                'UTF-8'
+                            ) ?>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/frontend/book-detail.php` around lines 2239 - 2240, The template
currently only falls back when $related['autori'] is null, so blank strings
still render an empty author; update the logic around $related['autori'] (used
inside the related-book-author paragraph and passed to htmlspecialchars) to
treat empty or whitespace-only strings as missing by checking
trim($related['autori']) (or casting to string and testing length) and, when
empty, use the music-aware label determined by $relatedIsMusic (__( 'Artista
sconosciuto' ) vs __( 'Autore sconosciuto' )), otherwise pass the actual author
through htmlspecialchars as before.
🧹 Nitpick comments (2)
locale/en_US.json (2)

2838-2838: Prefer a broader English label for Cartaceo.

Line 2838 maps Cartaceo to Paperback, which is narrower than the likely media-type meaning (print/physical). This can mislabel non-paperback printed items.

Suggested wording update
-  "Cartaceo": "Paperback",
+  "Cartaceo": "Print",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@locale/en_US.json` at line 2838, The translation key "Cartaceo" in
locale/en_US.json is mapped too narrowly to "Paperback"; change the value to a
broader English label that covers all printed/physical media (e.g., "Print
(Physical)", "Physical copy", or "Printed material") so non-paperback printed
items aren't misclassified—update the value for the "Cartaceo" key accordingly.

4194-4194: DiscoRecord may be too vinyl-specific.

Line 4194 can read as “vinyl record” in English. If this key is the generic media type bucket, Disc is safer and broader.

Suggested wording update
-  "Disco": "Record",
+  "Disco": "Disc",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@locale/en_US.json` at line 4194, The i18n key "Disco" with value "Record" is
vinyl-specific; rename the JSON key "Disco" to "Disc" (keep or adjust the value
as needed) and update all code references that use the "Disco" translation key
(e.g., any calls to t('Disco') or i18n lookup) to use "Disc" instead so runtime
lookups remain correct; ensure the change is applied in the same locale file and
mirrored in other locale files if they exist.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Controllers/CsvImportController.php`:
- Around line 794-797: The code in CsvImportController that sets 'formato' =>
!empty($row['formato']) ? trim($row['formato']) : 'cartaceo' incorrectly
defaults missing formato to 'cartaceo' even when tipo_media is present; change
this so missing formato is set to null (not 'cartaceo'), preserve the existing
DB value when performing updates, and only derive a sensible fallback for
formato during inserts (e.g., derive from tipo_media or default to 'cartaceo' at
insert time). Update the handling where tipo_media is normalized via
\App\Support\MediaLabels::normalizeTipoMedia to not rely on a forced formato
fallback, and apply the same fix to the other similar occurrences noted (the
other CsvImportController blocks that set 'formato' with a 'cartaceo' fallback).

In `@app/Controllers/FrontendController.php`:
- Around line 904-908: The current check in FrontendController (around the block
using hasLibriColumn and filters['tipo_media']) silently skips applying the
tipo_media filter when the libri table lacks that column; update the controller
to detect this case and communicate it back to the view/API instead of silently
ignoring it: when filters['tipo_media'] is present but hasLibriColumn($db,
'tipo_media') returns false, set a flag or clear the incoming
filters['tipo_media'] value (e.g., add a response field like
'unsupported_filters' or remove the key from the filters array) so the view can
hide/disable the tipo_media UI or show a warning, and apply the same behavior to
the similar block at the other location (the section referenced at 917-927) so
both places consistently surface unsupported filters rather than becoming
no-ops.

In `@app/Controllers/LibraryThingImportController.php`:
- Around line 1348-1351: The code unconditionally defaults missing formato to
'cartaceo' even when tipo_media indicates a non-book item; change the logic so
when $hasTipoMedia is true you do not coerce formato to 'cartaceo' — instead
preserve an empty/null formato (e.g. use $data['formato'] ?? null or an empty
string) and only apply the 'cartaceo' default when $hasTipoMedia is false;
update the same pattern wherever formato is set (the blocks using $params[] with
$hasTipoMedia and MediaLabels::normalizeTipoMedia) so tipo_media='disco' (or
other non-book) cannot be paired with formato='cartaceo'.

In `@app/Controllers/ScrapeController.php`:
- Around line 148-151: The Discogs plugin needs to register the
scrape.isbn.validate hook so barcode-only EAN/UPC values bypass the ISBN-only
gate; update the Discogs plugin to add a scrape.isbn.validate handler that
returns true for valid EAN/UPC formats (e.g., 12/13-digit numeric barcodes) so
requests pass the validation in ScrapeController (which calls ensureTipoMedia
and normalizeIsbnFields and currently rejects non-ISBN barcodes before
scrape.fetch.custom/fetchFromDiscogs runs); implement the handler in the
plugin's bootstrap/registration code and ensure it only returns true for
legitimate EAN/UPC patterns to avoid weakening ISBN validation globally.

In `@app/Views/frontend/book-detail.php`:
- Around line 1758-1761: Replace the hardcoded "ISBN-13" meta-label with the
dynamic label generator so music rows that carry a barcode in isbn13 show the
correct label: call MediaLabels::label('isbn13', $book['isbn13']) (while still
escaping the value for output) instead of printing the literal "ISBN-13"; do the
same change for the repeated block that appears later (the similar block at the
other occurrence). Ensure you still render the escaped value via
htmlspecialchars($book['isbn13'], ENT_QUOTES, 'UTF-8') but use
MediaLabels::label('isbn13', $book['isbn13']) for the meta-label so music
identifiers map to "Barcode".

In `@app/Views/libri/scheda_libro.php`:
- Around line 603-606: The tracklist formatting collapses lines because
strip_tags($libro['descrizione']) removes <br> and similar separators; before
calling strip_tags() in the isMusic branch, normalize HTML line separators into
newline characters (e.g. replace <br>, <br/>, <br /> and block tags like </p>
with "\n" using a case-insensitive str_ireplace or preg_replace) and then call
strip_tags() so App\Support\MediaLabels::formatTracklist receives a
newline-separated string and can parse individual tracks correctly.

In `@installer/database/migrations/migrate_0.5.4.sql`:
- Around line 4-10: The ALTER should not assume the existence of libri.formato;
modify the migration logic that builds `@sql` (which currently checks `@col_exists`
for tipo_media) to also check for the existence of the formato column and choose
between two ALTER variants: one that appends "AFTER formato" if formato exists
and another that adds tipo_media without an AFTER clause if formato is absent,
ensuring the table 'libri' and column 'tipo_media' are still created even when
formato was never added.

In `@storage/plugins/discogs/views/settings.php`:
- Around line 112-116: The token visibility toggle button has no accessible
name; update the button element used with
onclick="togglePasswordVisibility('api_token')" (the button containing <i
id="api_token_icon">) to include an aria-label (e.g., "Show API token" / "Hide
API token") and modify the togglePasswordVisibility function to update that
aria-label each time it flips state so the label always reflects the current
action and stays in sync with the icon state.

In `@tests/pr100-media-types.spec.js`:
- Line 17: The test currently reuses fixed seeded identifiers (the RUN_ID
constant and hardcoded ean/isbn13 values) across runs which can collide in a
shared E2E DB; change the seed generation in tests/pr100-media-types.spec.js to
create unique per-run identifiers (e.g., append the RUN_ID or a UUID/random
suffix to the ean/isbn13 strings when inserting fixtures) and update the
teardown/cleanup queries to target those exact generated identifiers (reference
RUN_ID and the ean/isbn13 variables in the insert and delete logic so the
teardown deletes only the rows created by this run).

---

Outside diff comments:
In `@app/Support/PluginManager.php`:
- Around line 47-52: autoRegisterBundledPlugins currently iterates
BundledPlugins::LIST and force-activates every plugin (inserting with is_active
= 1 and calling onActivate()), which will auto-enable optional network-backed
plugins like discogs; change the logic in autoRegisterBundledPlugins to read
each plugin's plugin.json (or existing metadata loader) and check a metadata
flag such as "optional" (e.g., $pluginMeta['metadata']['optional'] ?? false) and
skip automatic activation for optional plugins (insert them with is_active = 0
or don't call onActivate()), only auto-activate those without the optional flag;
reference BundledPlugins::LIST, autoRegisterBundledPlugins(), and onActivate()
when locating the change.

---

Duplicate comments:
In `@app/Controllers/LibriController.php`:
- Around line 679-680: The payload builder in LibriController is forcibly
setting 'tipo_media' => '' into create/update payloads which can override
inferred media types; update the payload construction in the methods that build
these arrays (where 'tipo_media' is currently assigned) to only include
'tipo_media' when the incoming request explicitly provides it (e.g., check
$request->has('tipo_media') or isset on the input) or remove the default
assignment entirely so the key is omitted when empty, ensuring downstream logic
that infers/scrapes media type is not silently overridden.

In `@app/Support/PluginManager.php`:
- Around line 356-364: getPluginInstance currently lets instantiatePlugin
exceptions bubble up, causing a 500; wrap the call to instantiatePlugin($plugin)
in a try-catch that catches \Throwable (not \Exception) and returns null on
failure so callers get null instead of an exception; locate getPluginInstance
and modify the return path to catch any \Throwable from instantiatePlugin,
optionally logging the error via the existing logger if available, then return
null.

In `@app/Support/Updater.php`:
- Around line 2296-2325: removeDirectoryTree currently treats symlinks as normal
files/directories which can cause rmdir failures for symlinked directories;
update the loop in removeDirectoryTree to check $item->isLink() first and call
unlink($item->getPathname()) for symlinks (throw the same Exception on failure),
only then branch into $item->isDir() => rmdir(...) and else => unlink(...), so
symlink-to-directory is removed via unlink instead of rmdir while preserving the
existing error messages and behavior.
- Around line 2246-2263: The backup cleanup after activating the staged plugin
should not be fatal: wrap the final backup removal (the call to
$this->removeDirectoryTree($backupPath) in the activation path after
rename($stagingPath, $targetPluginPath)) in a try/catch and, on failure, log the
error instead of rethrowing so the update proceeds; use the class's existing
logging facility (e.g. $this->logger->error or the same logger used elsewhere in
Updater.php) and include context (plugin slug and $e->getMessage()) while
ensuring no exception from removeDirectoryTree aborts the successful swap or
interferes with restoreAppFiles() or storage/plugins state.

In `@app/Views/frontend/book-detail.php`:
- Around line 2239-2240: The template currently only falls back when
$related['autori'] is null, so blank strings still render an empty author;
update the logic around $related['autori'] (used inside the related-book-author
paragraph and passed to htmlspecialchars) to treat empty or whitespace-only
strings as missing by checking trim($related['autori']) (or casting to string
and testing length) and, when empty, use the music-aware label determined by
$relatedIsMusic (__( 'Artista sconosciuto' ) vs __( 'Autore sconosciuto' )),
otherwise pass the actual author through htmlspecialchars as before.

In `@app/Views/frontend/catalog-grid.php`:
- Around line 41-44: The media badge is currently icon-only and uses title
(unreliable for accessibility); update the span.book-media-badge to include an
explicit aria-label using
htmlspecialchars(\App\Support\MediaLabels::tipoMediaDisplayName($book['tipo_media']),
ENT_QUOTES, 'UTF-8') and keep the existing title if desired, and mark the inner
<i> element decorative by adding aria-hidden="true" (or role="presentation") so
screen readers read only the span label; locate the badge markup in
catalog-grid.php using the span with class "book-media-badge" and the helper
calls MediaLabels::tipoMediaDisplayName(...) and MediaLabels::icon(...) to apply
these attributes.

In `@app/Views/frontend/catalog.php`:
- Around line 1302-1326: The tipo_media UI isn't kept in sync after AJAX updates
because the client never gets the media-type labels or reapplies the active
class; update the server output (from MediaLabels::allTypes) to expose a JS map
of tipo_media value→label (and icon) alongside the existing availability data,
then modify the client functions updateFilter('tipo_media', ...) and
updateActiveFiltersDisplay() to use that map: when updateFilter is called for
tipo_media ensure it toggles the .filter-option.active class for the matching
.filter-option element (by matching its data/value) and
updateActiveFiltersDisplay should translate enum keys into the exposed labels
instead of showing raw keys; reference MediaLabels::allTypes, tipo_media,
.filter-option, updateFilter, and updateActiveFiltersDisplay when making these
changes.

In `@app/Views/libri/partials/book_form.php`:
- Around line 424-428: Normalize and whitelist the media type before rendering
and before applying scraper/imported values: map incoming values (from
$book['tipo_media'] and data.tipo_media) to the canonical keys returned by
MediaLabels::allTypes(), using a lookup that converts common labels like "CD",
"LP", "vinyl" to their canonical key, and fall back to 'libro' when no match is
found; update the select rendering (the block using MediaLabels::allTypes() and
the option selected check) to use the normalized key and ensure any code that
assigns data.tipo_media uses the same normalization function so the saved value
always matches the allowed keys.

In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 369-374: The code currently calls
\App\Support\MediaLabels::inferTipoMedia(...) and then compares
$data['tipo_media'] separately, which lets inferred values override an explicit
tipo_media; change the resolution logic so the explicit $data['tipo_media'] (if
present and non-empty) is used first and only fall back to inferTipoMedia(...)
otherwise (update the $resolvedType assignment), then keep the $isMusicSource
check using that $resolvedType along with the existing source checks; reference:
inferTipoMedia, $resolvedType, $isMusicSource, and $data['tipo_media'].
- Around line 937-942: The mergeBookData method currently calls
\App\Support\BookDataMerger::merge($existing, $new, 'discogs') which hardcodes
the payload source; change it to derive the source from the incoming payload
(e.g. $new['source'] if set) and pass that value into BookDataMerger::merge
instead of the literal 'discogs' so fallback hits retain their real source;
update mergeBookData (and the \App\Support\BookDataMerger::merge call site) to
default to 'discogs' only when the new payload has no source field.
- Around line 310-316: The code that sets $artist from $currentResult['authors']
can cast an array to the string "Array" when entries are structured (e.g.
['name' => '...']); update the fallback logic in DiscogsPlugin.php where $artist
and $currentResult['authors'] are handled to normalize authors before casting—if
$currentResult['authors'] is an array and its first element is an array with a
'name' key, use that 'name' value (or if it's a list of such arrays, extract the
first non-empty 'name'), otherwise fall back to the existing string handling;
ensure you still apply trim((string) ...) to the final value so the Discogs
artist= filter receives a proper string.

In `@storage/plugins/discogs/README.md`:
- Around line 15-18: Update the README's barcode resolution flow to reflect the
actual PR behavior by inserting the Discogs title-based fallback step before the
MusicBrainz fallback: keep the existing "Ricerca per barcode" and "Recupero
dettagli completi della release Discogs" entries, then add a new bullet
describing "Fallback titolo su Discogs (ricerca per titolo+artista quando il
barcode non restituisce release)" before the existing "Fallback MusicBrainz"
line, and ensure the Deezer enrichment line remains last so the sequence reads
barcode → Discogs details → Discogs title fallback → MusicBrainz fallback →
Deezer enrichment.

In `@tests/discogs-import.spec.js`:
- Around line 115-120: The test currently persists a real public Discogs barcode
into the form (checked via input[name="ean"] and input[name="isbn13"]) which can
cause uniqueness collisions on re-runs; before submitting the form in the
discogs-import.spec.js flow, detect if the imported value equals the public
barcode (e.g., TEST_BARCODE or '720642442524') and programmatically replace it
with a unique value (for example build one using a timestamp or random suffix)
by updating the same locators ('input[name="ean"]' and 'input[name="isbn13"]')
so the save step uses a non-colliding barcode. Ensure both fields are set
consistently and then proceed to submit.
- Around line 51-57: The teardown uses a raw hard DELETE via dbExec when
createdId is set, which can fail silently due to FK constraints on dependent
copie rows and the empty catch hides failures; update test.afterAll to call the
application's normal delete/soft-delete path (e.g., invoke the same
controller/service method used for admin deletes) or explicitly remove dependent
copie rows first before deleting libri, and remove the empty catch so any
cleanup errors surface (look for createdId, dbExec, and test.afterAll to
modify).

In `@tests/discogs-plugin.spec.js`:
- Around line 204-208: The test currently skips when the import button is
missing and attaches the page console listener after triggering import, which
hides real failures; change the behavior so that the absence of the import
button (identified via importBtn = page.locator('#btnImportIsbn') and
importBtn.isVisible(...)) throws or fails the test instead of calling test.skip,
move the console listener attachment (the page.on('console', ...) handler used
in the empty-title branch) to before any import attempt, and add explicit
assertions on captured console events/errors after the import action to ensure
JS errors or scrape failures are asserted; apply the same fixes to the duplicate
block referenced around lines 239-243 (where the same importBtn/console logic is
duplicated).

In `@tests/multisource-scraping.spec.js`:
- Around line 114-118: The test suite currently captures created record IDs
(manualBookId, importedBookId, manualDiscId, importedDisc1Id, importedDisc2Id
and the similar vars at the other block) but never deletes those rows in
afterAll; update the afterAll teardown to delete the created libri rows (or rows
tagged with RUN_TAG) by using the captured IDs to remove the specific records
(or a RUN_TAG-scoped delete) so re-running the spec against a shared E2E DB
doesn't accumulate data; locate the afterAll function and add cleanup logic that
issues deletes for the recorded IDs (or a single delete filtered by RUN_TAG) and
ensure it runs after closing the browser context.

---

Nitpick comments:
In `@locale/en_US.json`:
- Line 2838: The translation key "Cartaceo" in locale/en_US.json is mapped too
narrowly to "Paperback"; change the value to a broader English label that covers
all printed/physical media (e.g., "Print (Physical)", "Physical copy", or
"Printed material") so non-paperback printed items aren't misclassified—update
the value for the "Cartaceo" key accordingly.
- Line 4194: The i18n key "Disco" with value "Record" is vinyl-specific; rename
the JSON key "Disco" to "Disc" (keep or adjust the value as needed) and update
all code references that use the "Disco" translation key (e.g., any calls to
t('Disco') or i18n lookup) to use "Disc" instead so runtime lookups remain
correct; ensure the change is applied in the same locale file and mirrored in
other locale files if they exist.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ca4cb812-3685-4e4b-9ef0-58c2a12fb6b9

📥 Commits

Reviewing files that changed from the base of the PR and between d0cf881 and 5c0d069.

📒 Files selected for processing (44)
  • .gitignore
  • app/Controllers/CsvImportController.php
  • app/Controllers/FrontendController.php
  • app/Controllers/LibraryThingImportController.php
  • app/Controllers/LibriApiController.php
  • app/Controllers/LibriController.php
  • app/Controllers/PluginController.php
  • app/Controllers/PublicApiController.php
  • app/Controllers/ScrapeController.php
  • app/Models/BookRepository.php
  • app/Routes/web.php
  • app/Support/BundledPlugins.php
  • app/Support/MediaLabels.php
  • app/Support/PluginManager.php
  • app/Support/RouteTranslator.php
  • app/Support/Updater.php
  • app/Views/admin/plugins.php
  • app/Views/frontend/book-detail.php
  • app/Views/frontend/catalog-grid.php
  • app/Views/frontend/catalog.php
  • app/Views/libri/index.php
  • app/Views/libri/partials/book_form.php
  • app/Views/libri/scheda_libro.php
  • installer/classes/Installer.php
  • installer/database/migrations/migrate_0.5.4.sql
  • installer/database/schema.sql
  • locale/de_DE.json
  • locale/en_US.json
  • locale/it_IT.json
  • storage/plugins/discogs/DiscogsPlugin.php
  • storage/plugins/discogs/README.md
  • storage/plugins/discogs/plugin.json
  • storage/plugins/discogs/views/settings.php
  • storage/plugins/discogs/wrapper.php
  • tests/discogs-advanced.spec.js
  • tests/discogs-import.spec.js
  • tests/discogs-plugin.spec.js
  • tests/multisource-scraping.spec.js
  • tests/playwright.config.js
  • tests/pr100-media-types.spec.js
  • vendor/composer/autoload_classmap.php
  • vendor/composer/autoload_static.php
  • vendor/composer/installed.php
  • version.json

Comment on lines 794 to +797
'formato' => !empty($row['formato']) ? trim($row['formato']) : 'cartaceo',
'tipo_media' => array_key_exists('tipo_media', $row) && trim((string) ($row['tipo_media'] ?? '')) !== ''
? \App\Support\MediaLabels::normalizeTipoMedia(trim((string) $row['tipo_media']))
: null,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t default missing formato to cartaceo when tipo_media drives the import.

Rows that only provide tipo_media still parse with formato = 'cartaceo', and both write paths persist that fallback. Inserts then store a music item with the wrong format, while updates can silently reset an existing CD/LP/vinyl row back to cartaceo. Keep missing formato nullable, preserve the current DB value on updates, and only derive a fallback on insert.

Also applies to: 1253-1255, 1269-1270, 1355-1358, 1363-1369

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/CsvImportController.php` around lines 794 - 797, The code in
CsvImportController that sets 'formato' => !empty($row['formato']) ?
trim($row['formato']) : 'cartaceo' incorrectly defaults missing formato to
'cartaceo' even when tipo_media is present; change this so missing formato is
set to null (not 'cartaceo'), preserve the existing DB value when performing
updates, and only derive a sensible fallback for formato during inserts (e.g.,
derive from tipo_media or default to 'cartaceo' at insert time). Update the
handling where tipo_media is normalized via
\App\Support\MediaLabels::normalizeTipoMedia to not rely on a forced formato
fallback, and apply the same fix to the other similar occurrences noted (the
other CsvImportController blocks that set 'formato' with a 'cartaceo' fallback).

Comment on lines +904 to +908
if (!empty($filters['tipo_media']) && $this->hasLibriColumn($db, 'tipo_media')) {
$conditions[] = "l.tipo_media = ?";
$params[] = $filters['tipo_media'];
$types .= 's';
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Don't turn unsupported tipo_media into a silent no-op.

This guard avoids the SQL error, but pre-migration installs can now keep tipo_media active in the URL/UI while the query is unfiltered. Please surface this capability to the view/API and clear or hide the filter when hasLibriColumn() is false.

Also applies to: 917-927

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/FrontendController.php` around lines 904 - 908, The current
check in FrontendController (around the block using hasLibriColumn and
filters['tipo_media']) silently skips applying the tipo_media filter when the
libri table lacks that column; update the controller to detect this case and
communicate it back to the view/API instead of silently ignoring it: when
filters['tipo_media'] is present but hasLibriColumn($db, 'tipo_media') returns
false, set a flag or clear the incoming filters['tipo_media'] value (e.g., add a
response field like 'unsupported_filters' or remove the key from the filters
array) so the view can hide/disable the tipo_media UI or show a warning, and
apply the same behavior to the similar block at the other location (the section
referenced at 917-927) so both places consistently surface unsupported filters
rather than becoming no-ops.

Comment on lines +1348 to +1351
$params[] = !empty($data['formato']) ? $data['formato'] : 'cartaceo';
if ($hasTipoMedia) {
$params[] = \App\Support\MediaLabels::normalizeTipoMedia($data['tipo_media'] ?? null);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't coerce non-book imports back to cartaceo.

Lines 1348-1351, 1423-1426, 1536-1540, and 1615-1619 still default a missing formato to 'cartaceo' even when the new tipo_media flow resolved a non-book value. With the current parser, media strings like CD/LP/Vinyl can set tipo_media = disco while leaving formato empty, so inserts/updates persist inconsistent metadata (tipo_media = disco, formato = cartaceo).

Also applies to: 1423-1426, 1536-1540, 1615-1619

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/LibraryThingImportController.php` around lines 1348 - 1351,
The code unconditionally defaults missing formato to 'cartaceo' even when
tipo_media indicates a non-book item; change the logic so when $hasTipoMedia is
true you do not coerce formato to 'cartaceo' — instead preserve an empty/null
formato (e.g. use $data['formato'] ?? null or an empty string) and only apply
the 'cartaceo' default when $hasTipoMedia is false; update the same pattern
wherever formato is set (the blocks using $params[] with $hasTipoMedia and
MediaLabels::normalizeTipoMedia) so tipo_media='disco' (or other non-book)
cannot be paired with formato='cartaceo'.

Comment on lines +148 to 151
$payload = $this->ensureTipoMedia($payload);

// Normalize ISBN fields (auto-calculate missing isbn10/isbn13)
$payload = $this->normalizeIsbnFields($payload, $cleanIsbn);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Discogs hook registrations:"
rg -n -C2 "scrape\.(isbn\.validate|sources|fetch\.custom|data\.modify)" storage/plugins/discogs app/Controllers/ScrapeController.php

echo
echo "Barcode/EAN references in the Discogs plugin:"
rg -n -C2 "barcode|ean|upc" storage/plugins/discogs

echo
echo "Current validation gate in ScrapeController:"
rg -n -C4 "isValidIsbn|scrape\.isbn\.validate|scrape\.fetch\.custom" app/Controllers/ScrapeController.php

Repository: fabiodalez-dev/Pinakes

Length of output: 17204


Discogs plugin cannot receive EAN/UPC inputs—the Discogs plugin does not register the scrape.isbn.validate hook.

The validation at lines 89–94 blocks any non-ISBN barcode (EAN/UPC) before scrape.fetch.custom runs. The Discogs plugin registers scrape.sources, scrape.fetch.custom, and scrape.data.modify, but not scrape.isbn.validate. Without this hook registration, barcode searches for music media never reach fetchFromDiscogs() because the request is rejected with a 400 error at line 94.

To support barcode-first lookups for CDs/LPs, add a scrape.isbn.validate hook registration in the Discogs plugin that returns true for valid EAN/UPC formats, allowing non-ISBN barcodes to bypass the ISBN-only validation gate.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Controllers/ScrapeController.php` around lines 148 - 151, The Discogs
plugin needs to register the scrape.isbn.validate hook so barcode-only EAN/UPC
values bypass the ISBN-only gate; update the Discogs plugin to add a
scrape.isbn.validate handler that returns true for valid EAN/UPC formats (e.g.,
12/13-digit numeric barcodes) so requests pass the validation in
ScrapeController (which calls ensureTipoMedia and normalizeIsbnFields and
currently rejects non-ISBN barcodes before scrape.fetch.custom/fetchFromDiscogs
runs); implement the handler in the plugin's bootstrap/registration code and
ensure it only returns true for legitimate EAN/UPC patterns to avoid weakening
ISBN validation globally.

Comment on lines +1758 to 1761
<?php if (!empty($book['isbn13']) && !($isMusic && !empty($book['ean']))): ?>
<div class="meta-item">
<div class="meta-label">ISBN-13</div>
<div class="meta-value"><?= htmlspecialchars($book['isbn13'], ENT_QUOTES, 'UTF-8') ?></div>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Music rows with only isbn13 still render the book label.

MediaLabels::label('isbn13', ...) already maps music identifiers to Barcode, but this branch hardcodes ISBN-13. If an import leaves the barcode in isbn13 while ean is empty, the detail page still shows the wrong label.

💡 Minimal fix
-                                <div class="meta-label">ISBN-13</div>
+                                <div class="meta-label"><?= \App\Support\MediaLabels::label('isbn13', $book['formato'] ?? null, $book['tipo_media'] ?? null) ?></div>

Also applies to: 1772-1775

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Views/frontend/book-detail.php` around lines 1758 - 1761, Replace the
hardcoded "ISBN-13" meta-label with the dynamic label generator so music rows
that carry a barcode in isbn13 show the correct label: call
MediaLabels::label('isbn13', $book['isbn13']) (while still escaping the value
for output) instead of printing the literal "ISBN-13"; do the same change for
the repeated block that appears later (the similar block at the other
occurrence). Ensure you still render the escaped value via
htmlspecialchars($book['isbn13'], ENT_QUOTES, 'UTF-8') but use
MediaLabels::label('isbn13', $book['isbn13']) for the meta-label so music
identifiers map to "Barcode".

Comment on lines +112 to +116
<button type="button"
onclick="togglePasswordVisibility('api_token')"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700">
<i class="fas fa-eye" id="api_token_icon"></i>
</button>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Label the token visibility toggle.

This new icon-only button has no accessible name, so assistive tech just announces “button”. Add an aria-label and keep it in sync with the visible/hidden state.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storage/plugins/discogs/views/settings.php` around lines 112 - 116, The token
visibility toggle button has no accessible name; update the button element used
with onclick="togglePasswordVisibility('api_token')" (the button containing <i
id="api_token_icon">) to include an aria-label (e.g., "Show API token" / "Hide
API token") and modify the togglePasswordVisibility function to update that
aria-label each time it flips state so the label always reflects the current
action and stays in sync with the icon state.

1. Discogs: register scrape.isbn.validate for EAN/UPC barcodes
2. mergeBookData: dynamic source from payload, not hardcoded 'discogs'
3. enrichWithDiscogsData: resolveTipoMedia (honors explicit tipo_media)
4. Authors array normalize (handle structured ['name' => '...'])
5. strip_tags: br→newline before stripping in tracklist rendering
6. PluginManager getPluginInstance: try/catch \Throwable → null
7. Updater: backup cleanup non-fatal after successful swap
8. Migration: AFTER formato guard (check column exists)
Security:
- Cast releaseId to int (SSRF prevention)
- Add CURLOPT_PROTOCOLS, CURLOPT_MAXREDIRS, CURLOPT_CONNECTTIMEOUT,
  CURLOPT_SSL_VERIFYPEER to all 4 curl call sites
- Add curl_error() logging on CoverArt, MusicBrainz, Deezer calls

Bug fixes:
- inferTipoMedia: exact match for short tokens 'cd'/'lp' to prevent
  false positives on 'CD-ROM' and words containing 'lp'
- formatTracklist: detect pre-formatted <ol> HTML and return as-is
- Schema.org: separate CreativeWork branch (no isbn/numberOfPages)
- Migration SQL: replace %cd%/%lp% LIKE with specific patterns

Code quality:
- Remove dead $discogsToken parameter from searchMusicBrainz()
- Make Discogs rate limiter static (consistent with MusicBrainz)
- Make Deezer rate limit elapsed-based (not fixed usleep)
- Add missing translation key to en_US and de_DE

Config:
- Full .coderabbit.yaml with 9 path_instructions sections
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 1, 2026

✅ Actions performed

Full review triggered.

- Optional plugins (discogs) now start inactive on first registration
  via metadata.optional flag in plugin.json + PluginManager check
- Add aria-label/role=img on media type icons in DataTable (a11y)
- Add aria-label on token visibility toggle button
- Use MediaLabels::label() for ISBN-13 label in frontend (was hardcoded)
- Fix test identifiers: EAN/ISBN now use RUN_ID to avoid collisions
- Fix test teardown: soft-delete instead of hard DELETE
- Translation fixes: Disco→Disc, Cartaceo→Print (en_US)
- Add Mostra/nascondi token translation key (en_US, de_DE)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Proposal: Music audio CD/LP

1 participant