@@ -3602,6 +3612,17 @@ function initializeIsbnImport() {
} catch (err) {
}
+ // 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;
+ }
+ }
+ } catch (err) {
+ }
+
// Handle series (collana)
try {
if (data.series) {
@@ -3729,12 +3750,13 @@ function initializeIsbnImport() {
} catch (err) {
}
- // Handle keywords (parole_chiave) - categories from Google Books
+ // Handle keywords (parole_chiave) - from Google Books, Discogs, MusicBrainz
try {
- if (data.keywords) {
+ const kw = data.keywords || data.parole_chiave;
+ if (kw) {
const keywordsInput = document.querySelector('input[name="parole_chiave"]');
if (keywordsInput) {
- keywordsInput.value = data.keywords;
+ keywordsInput.value = kw;
}
}
} catch (err) {
diff --git a/app/Views/libri/scheda_libro.php b/app/Views/libri/scheda_libro.php
index 0dd4960e..d4288544 100644
--- a/app/Views/libri/scheda_libro.php
+++ b/app/Views/libri/scheda_libro.php
@@ -5,6 +5,10 @@
$libro = $libro ?? [];
$isCatalogueMode = ConfigStore::isCatalogueMode();
+// Resolve tipo_media once for badge display and dynamic labels
+$resolvedTipoMedia = \App\Support\MediaLabels::resolveTipoMedia($libro['formato'] ?? null, $libro['tipo_media'] ?? null);
+$isMusic = $resolvedTipoMedia === 'disco';
+
$status = strtolower((string)($libro['stato'] ?? ''));
$statusClasses = [
'disponibile' => 'inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-semibold bg-green-500 text-white',
@@ -66,6 +70,10 @@
@@ -141,7 +149,7 @@ class="max-h-80 object-contain rounded-lg shadow" />
@@ -331,7 +339,7 @@ class="text-gray-900 hover:text-gray-600 hover:underline font-semibold">= App\
-
= __("Anno pubblicazione") ?>
+ = \App\Support\MediaLabels::label('anno_pubblicazione', $libro['formato'] ?? null, $libro['tipo_media'] ?? null) ?>
@@ -360,7 +368,7 @@ class="text-gray-700 hover:text-gray-900 hover:underline transition-colors">
-
= __("Pagine") ?>
+ = \App\Support\MediaLabels::label('numero_pagine', $libro['formato'] ?? null, $libro['tipo_media'] ?? null) ?>
@@ -373,7 +381,7 @@ class="text-gray-700 hover:text-gray-900 hover:underline transition-colors">
= __("Formato") ?>
-
+
@@ -586,13 +594,22 @@ class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full
-
+
+ /i', "\n", $libro['descrizione']);
+ $descText = preg_replace('/<\/(?:p|div|li|h[1-6])>/i', "\n", (string) $descText);
+ $descText = strip_tags((string) $descText);
+ ?>
+ = \App\Support\MediaLabels::formatTracklist($descText) ?>
+
+
+
diff --git a/data/dewey/dewey_completo_it.json b/data/dewey/dewey_completo_it.json
index 95ba3790..e352890e 100644
--- a/data/dewey/dewey_completo_it.json
+++ b/data/dewey/dewey_completo_it.json
@@ -6822,6 +6822,12 @@
"name": "Narrativa inglese",
"level": 3,
"children": [
+ {
+ "code": "823.7",
+ "name": "Narrativa Inglese, 1800-1837",
+ "level": 4,
+ "children": []
+ },
{
"code": "823.9",
"name": "Narrativa Inglese",
diff --git a/installer/classes/Installer.php b/installer/classes/Installer.php
index 85d32788..69c6eebb 100755
--- a/installer/classes/Installer.php
+++ b/installer/classes/Installer.php
@@ -646,7 +646,7 @@ public function importOptimizationIndexes(): bool
'idx_isbn10' => 'isbn10',
'idx_genere_scaffale' => 'genere_id, scaffale_id',
'idx_sottogenere_scaffale' => 'sottogenere_id, scaffale_id',
- 'idx_libri_deleted_at' => 'deleted_at',
+ 'idx_libri_tipo_media_deleted_at' => 'deleted_at, tipo_media',
],
'libri_autori' => [
'idx_libro_autore' => 'libro_id, autore_id',
diff --git a/installer/database/migrations/migrate_0.5.4.sql b/installer/database/migrations/migrate_0.5.4.sql
new file mode 100644
index 00000000..97b19a5c
--- /dev/null
+++ b/installer/database/migrations/migrate_0.5.4.sql
@@ -0,0 +1,96 @@
+-- Add tipo_media column to libri table
+-- FULLY IDEMPOTENT
+
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'libri'
+ AND COLUMN_NAME = 'tipo_media');
+SET @formato_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'libri'
+ AND COLUMN_NAME = 'formato');
+SET @sql = IF(@col_exists = 0,
+ IF(@formato_exists > 0,
+ "ALTER TABLE libri ADD COLUMN tipo_media ENUM('libro','disco','audiolibro','dvd','altro') NOT NULL DEFAULT 'libro' AFTER formato",
+ "ALTER TABLE libri ADD COLUMN tipo_media ENUM('libro','disco','audiolibro','dvd','altro') NOT NULL DEFAULT 'libro'"),
+ 'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- Composite index for media filtering + soft delete
+SET @idx_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'libri'
+ AND INDEX_NAME = 'idx_libri_tipo_media_deleted_at');
+SET @idx_order_ok = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'libri'
+ AND INDEX_NAME = 'idx_libri_tipo_media_deleted_at'
+ AND ((SEQ_IN_INDEX = 1 AND COLUMN_NAME = 'deleted_at')
+ OR (SEQ_IN_INDEX = 2 AND COLUMN_NAME = 'tipo_media')));
+SET @sql = IF(@idx_exists > 0 AND @idx_order_ok <> 2,
+ 'ALTER TABLE libri DROP INDEX idx_libri_tipo_media_deleted_at',
+ 'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @idx_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'libri'
+ AND INDEX_NAME = 'idx_libri_tipo_media_deleted_at');
+SET @sql = IF(@idx_exists = 0,
+ 'ALTER TABLE libri ADD INDEX idx_libri_tipo_media_deleted_at (deleted_at, tipo_media)',
+ 'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @old_tipo_idx_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'libri'
+ AND INDEX_NAME = 'idx_libri_tipo_media');
+SET @sql = IF(@old_tipo_idx_exists > 0,
+ 'ALTER TABLE libri DROP INDEX idx_libri_tipo_media',
+ 'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @old_deleted_idx_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'libri'
+ AND INDEX_NAME = 'idx_libri_deleted_at');
+SET @sql = IF(@old_deleted_idx_exists > 0,
+ 'ALTER TABLE libri DROP INDEX idx_libri_deleted_at',
+ 'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- Auto-populate from existing formato values
+-- Use specific patterns to avoid false positives (%cd% matches CD-ROM, %lp% matches 'help')
+UPDATE libri SET tipo_media = 'disco'
+WHERE tipo_media = 'libro'
+ AND (LOWER(formato) LIKE '%cd audio%' OR LOWER(formato) LIKE '%cd_audio%'
+ OR LOWER(formato) LIKE '%cd-audio%' OR LOWER(formato) = 'cd'
+ OR LOWER(formato) LIKE '%compact disc%'
+ OR LOWER(formato) LIKE '%vinyl%' OR LOWER(formato) LIKE '%vinile%'
+ OR LOWER(formato) = 'lp' OR LOWER(formato) LIKE '%lp %' 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%';
+
+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%');
+
+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%');
diff --git a/installer/database/schema.sql b/installer/database/schema.sql
index 465d4948..a47e21f6 100755
--- a/installer/database/schema.sql
+++ b/installer/database/schema.sql
@@ -368,6 +368,7 @@ CREATE TABLE `libri` (
`private_comment` text COLLATE utf8mb4_unicode_ci COMMENT 'Private comment (LibraryThing)',
`parole_chiave` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
`formato` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT 'cartaceo',
+ `tipo_media` enum('libro','disco','audiolibro','dvd','altro') NOT NULL DEFAULT 'libro',
`peso` float DEFAULT NULL,
`dimensioni` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`physical_description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Physical description (LibraryThing)',
@@ -417,10 +418,10 @@ CREATE TABLE `libri` (
KEY `idx_libri_titolo_sottotitolo` (`titolo`,`sottotitolo`),
KEY `editore_id` (`editore_id`),
KEY `idx_libri_stato` (`stato`),
+ KEY `idx_libri_tipo_media_deleted_at` (`deleted_at`,`tipo_media`),
KEY `fk_libri_mensola` (`mensola_id`),
KEY `idx_libri_scaffale_mensola` (`scaffale_id`,`mensola_id`),
KEY `idx_libri_posizione_progressiva` (`posizione_progressiva`),
- KEY `idx_libri_deleted_at` (`deleted_at`),
KEY `idx_collana` (`collana`),
KEY `idx_lt_rating` (`rating`),
KEY `idx_lt_date_read` (`date_read`),
diff --git a/locale/de_DE.json b/locale/de_DE.json
index bc79bf69..b4160913 100644
--- a/locale/de_DE.json
+++ b/locale/de_DE.json
@@ -214,6 +214,7 @@
"Anno da": "Jahr von",
"Anno di Pubblicazione": "Erscheinungsjahr",
"Anno di pubblicazione": "Erscheinungsjahr",
+ "Anno di Uscita": "Erscheinungsjahr",
"Anno max": "Jahr max.",
"Anno min": "Jahr min.",
"Anno numerico (usato per filtri e ordinamento)": "Numerisches Jahr (wird für Filter und Sortierung verwendet)",
@@ -271,6 +272,9 @@
"Arricchisci dati con scraping web (copertine, descrizioni, etc.)": "Daten mit Web-Scraping anreichern (Cover, Beschreibungen usw.)",
"Article (Articolo/Blog)": "Article (Artikel/Blog)",
"Articolo": "Artikel",
+ "Artista": "Künstler",
+ "Artista sconosciuto": "Unbekannter Künstler",
+ "Artisti": "Künstler",
"Ascolta Audiobook": "Hörbuch anhören",
"Ascolta l'audiobook": "Hörbuch anhören",
"Assicurati che corrisponda al tipo di carta per etichette che utilizzi.": "Stellen Sie sicher, dass es mit dem von Ihnen verwendeten Etikettenpapier übereinstimmt.",
@@ -369,6 +373,7 @@
"Backup non trovato.": "Sicherung nicht gefunden.",
"Backup ripristinato con successo.": "Sicherung erfolgreich wiederhergestellt.",
"Backup tabella": "Tabelle wird gesichert",
+ "Barcode": "Barcode",
"Barra laterale": "Seitenleiste",
"Benvenuto": "Willkommen",
"Benvenuto nell'Installer": "Willkommen beim Installer",
@@ -924,6 +929,7 @@
"Disattiva": "Deaktivieren",
"Disattiva modalità manutenzione": "Wartungsmodus deaktivieren",
"Disattivata": "Inaktiv",
+ "Discografia": "Diskografie",
"Disconnesso": "Getrennt",
"Disconnetti": "Abmelden",
"Disconnetti tutti": "Alle abmelden",
@@ -1850,6 +1856,7 @@
"Impostazioni API Book Scraper salvate correttamente.": "Book Scraper API-Einstellungen erfolgreich gespeichert.",
"Impostazioni Applicazione": "Anwendungseinstellungen",
"Impostazioni Date": "Datumseinstellungen",
+ "Impostazioni Discogs salvate correttamente.": "Discogs-Einstellungen erfolgreich gespeichert.",
"Impostazioni SEO": "SEO-Einstellungen",
"Impostazioni Z39.50 salvate correttamente.": "Z39.50-Einstellungen erfolgreich gespeichert.",
"Impostazioni avanzate aggiornate correttamente.": "Erweiterte Einstellungen erfolgreich aktualisiert.",
@@ -2291,6 +2298,7 @@
"Mondadori": "Mondadori",
"Monitora tentativi di login e eventi di sicurezza": "Anmeldeversuche und Sicherheitsereignisse überwachen",
"Mostra": "Anzeigen",
+ "Mostra/nascondi token": "Token anzeigen/verbergen",
"Mostra API Key": "API-Schlüssel anzeigen",
"Mostra Cookie Analitici": "Analyse-Cookies anzeigen",
"Mostra Cookie di Marketing": "Marketing-Cookies anzeigen",
@@ -3507,6 +3515,16 @@
"Opera \"%s\" creata con %d volumi": "Werk \"%s\" erstellt mit %d Bänden",
"Errore nella creazione dell'opera": "Fehler beim Erstellen des Werks",
"Aggiungi volume": "Band hinzufügen",
+ "Cartaceo": "Druckausgabe",
+ "eBook": "eBook",
+ "Audiolibro": "Hörbuch",
+ "CD Audio": "Audio-CD",
+ "Vinile": "Schallplatte",
+ "LP": "LP",
+ "Cassetta": "Kassette",
+ "DVD": "DVD",
+ "Blu-ray": "Blu-ray",
+ "Digitale": "Digital",
"Cerca libro": "Buch suchen",
"Titolo o ISBN...": "Titel oder ISBN...",
"Numero volume": "Bandnummer",
@@ -3676,7 +3694,10 @@
"Toggle menu": "Menü umschalten",
"Toggle search": "Suche umschalten",
"Token CSRF non valido": "Ungültiges CSRF-Token.",
+ "Token CSRF non valido.": "Ungültiges CSRF-Token.",
"Token CSRF non valido. Riprova.": "Ungültiges CSRF-Token. Bitte versuchen Sie es erneut.",
+ "Token configurato": "Token konfiguriert",
+ "Token configurato — lascia vuoto per mantenere": "Token konfiguriert — leer lassen zum Beibehalten",
"Token di sicurezza non valido. Riprova.": "Ungültiges Sicherheitstoken. Bitte versuchen Sie es erneut.",
"Top 10 Lettori Più Attivi": "Top 10 der aktivsten Leser",
"Top 10 Libri Più Prestati": "Top 10 der am häufigsten ausgeliehenen Bücher",
@@ -3712,6 +3733,8 @@
"Totale utenti:": "Benutzer gesamt:",
"Totale: %s righe": "Gesamt: %s Zeilen",
"Totali": "Gesamt",
+ "Tracce": "Titel",
+ "Tracklist": "Titelliste",
"Traduttore": "Übersetzer",
"Traduzioni e localizzazione": "Übersetzungen und Lokalisierung",
"Trascina le sezioni per riordinarle. L'ordine sarà salvato automaticamente e rispecchiato nella homepage.": "Ziehen Sie die Bereiche zum Neuordnen. Die Reihenfolge wird automatisch gespeichert und auf der Startseite übernommen.",
@@ -4168,5 +4191,9 @@
"È uno standard emergente (
llmstxt.org) che fornisce ai motori AI un sommario strutturato del sito in formato Markdown. Quando attivo, il file viene generato dinamicamente con le statistiche della biblioteca, le pagine pubbliche e le informazioni API.": "Ein aufkommender Standard (
llmstxt.org), der KI-Systemen eine strukturierte Markdown-Zusammenfassung der Website bereitstellt. Wenn aktiviert, wird die Datei dynamisch mit Bibliotheksstatistiken, öffentlichen Seiten und API-Informationen generiert.",
"Abilita llms.txt": "llms.txt aktivieren",
"Rende disponibile /llms.txt e lo aggiunge a robots.txt": "Macht /llms.txt verfügbar und fügt es zur robots.txt hinzu",
- "Disattivato": "Deaktiviert"
-}
+ "Disattivato": "Deaktiviert",
+ "Tipo Media": "Medientyp",
+ "Disco": "Schallplatte",
+ "Tutti i media": "Alle Medien",
+ "Impossibile scaricare (senza ISBN/barcode):": "Download nicht möglich (kein ISBN/Barcode):"
+}
\ No newline at end of file
diff --git a/locale/en_US.json b/locale/en_US.json
index 37d825d9..eb971365 100644
--- a/locale/en_US.json
+++ b/locale/en_US.json
@@ -206,6 +206,7 @@
"Anno (YYYY)": "Year (YYYY)",
"Anno di Pubblicazione": "Publication Year",
"Anno di pubblicazione": "Publication year",
+ "Anno di Uscita": "Release Year",
"Anno max": "Year max",
"Anno min": "Year min",
"Anno numerico (usato per filtri e ordinamento)": "Numeric year (used for filters and sorting)",
@@ -253,6 +254,9 @@
"Archiviato": "Archived",
"Archivio": "Archive",
"Arricchimento automatico dati": "Automatic Data Enrichment",
+ "Artista": "Artist",
+ "Artista sconosciuto": "Unknown artist",
+ "Artisti": "Artists",
"Articolo": "Article",
"Ascolta Audiobook": "Listen to Audiobook",
"Ascolta l'audiobook": "Listen to the audiobook",
@@ -344,6 +348,7 @@
"Nome File": "File Name",
"Nome backup non specificato": "Backup name not specified",
"Nome backup non valido": "Invalid backup name",
+ "Barcode": "Barcode",
"Barra laterale": "Sidebar",
"Benvenuto": "Welcome",
"Benvenuto nell'Installer": "Welcome to the Installer",
@@ -788,6 +793,7 @@
"Disattiva": "Deactivate",
"Disattiva modalità manutenzione": "Disable maintenance mode",
"Disattivata": "Inactive",
+ "Discografia": "Discography",
"Disconnesso": "Disconnected",
"Disinstalla": "Uninstall",
"Divisioni": "Divisions",
@@ -1459,6 +1465,7 @@
"Impostazioni": "Settings",
"Impostazioni Applicazione": "Application Settings",
"Impostazioni Date": "Date Settings",
+ "Impostazioni Discogs salvate correttamente.": "Discogs settings saved successfully.",
"Impostazioni SEO": "SEO Settings",
"Impostazioni avanzate aggiornate correttamente.": "Advanced settings updated successfully.",
"Impostazioni contatti aggiornate correttamente.": "Contact settings updated successfully.",
@@ -1805,6 +1812,7 @@
"Mondadori": "Mondadori",
"Monitora tentativi di login e eventi di sicurezza": "Monitor login attempts and security events",
"Mostra": "Show",
+ "Mostra/nascondi token": "Show/hide token",
"Mostra API Key": "Show API Key",
"Mostra Cookie Analitici": "Show Analytics Cookies",
"Mostra Cookie di Marketing": "Show Marketing Cookies",
@@ -2828,6 +2836,16 @@
"Opera \"%s\" creata con %d volumi": "Work \"%s\" created with %d volumes",
"Errore nella creazione dell'opera": "Error creating the work",
"Aggiungi volume": "Add volume",
+ "Cartaceo": "Print",
+ "eBook": "eBook",
+ "Audiolibro": "Audiobook",
+ "CD Audio": "Audio CD",
+ "Vinile": "Vinyl",
+ "LP": "LP",
+ "Cassetta": "Cassette",
+ "DVD": "DVD",
+ "Blu-ray": "Blu-ray",
+ "Digitale": "Digital",
"Cerca libro": "Search for a book",
"Titolo o ISBN...": "Title or ISBN...",
"Numero volume": "Volume number",
@@ -2975,7 +2993,10 @@
"Titolo, sottotitolo, descrizione...": "Title, subtitle, description...",
"Titolo...": "Title...",
"Token CSRF non valido": "Invalid CSRF token.",
+ "Token CSRF non valido.": "Invalid CSRF token.",
"Token CSRF non valido. Riprova.": "Invalid CSRF token. Please try again.",
+ "Token configurato": "Token configured",
+ "Token configurato — lascia vuoto per mantenere": "Token configured — leave empty to keep",
"Token di sicurezza non valido. Riprova.": "Invalid security token. Please try again.",
"Top 10 Lettori Più Attivi": "Top 10 Most Active Readers",
"Top 10 Libri Più Prestati": "Top 10 Most Loaned Books",
@@ -3009,6 +3030,8 @@
"Totale libri presenti": "Total books in collection",
"Totale utenti:": "Total users:",
"Totale: %s righe": "Total: %s rows",
+ "Tracce": "Tracks",
+ "Tracklist": "Tracklist",
"Traduzioni e localizzazione": "Translations and localization",
"Trascina le sezioni per riordinarle. L'ordine sarà salvato automaticamente e rispecchiato nella homepage.": "Drag sections to reorder them. The order will be saved automatically and reflected on the homepage.",
"Trascina per riordinare • Il codice deve essere univoco": "Drag to reorder • Code must be unique",
@@ -4168,5 +4191,9 @@
"È uno standard emergente (
llmstxt.org) che fornisce ai motori AI un sommario strutturato del sito in formato Markdown. Quando attivo, il file viene generato dinamicamente con le statistiche della biblioteca, le pagine pubbliche e le informazioni API.": "An emerging standard (
llmstxt.org) that provides AI engines with a structured Markdown summary of the site. When enabled, the file is dynamically generated with library statistics, public pages, and API information.",
"Abilita llms.txt": "Enable llms.txt",
"Rende disponibile /llms.txt e lo aggiunge a robots.txt": "Makes /llms.txt available and adds it to robots.txt",
- "Disattivato": "Disabled"
-}
+ "Disattivato": "Disabled",
+ "Tipo Media": "Media Type",
+ "Disco": "Disc",
+ "Tutti i media": "All media",
+ "Impossibile scaricare (senza ISBN/barcode):": "Cannot download (no ISBN/barcode):"
+}
\ No newline at end of file
diff --git a/locale/it_IT.json b/locale/it_IT.json
index 0993a1ac..5d32ef37 100644
--- a/locale/it_IT.json
+++ b/locale/it_IT.json
@@ -32,6 +32,8 @@
"Errore durante l'installazione: %s": "Errore durante l'installazione: %s",
"Non autorizzato.": "Non autorizzato.",
"Token CSRF non valido.": "Token CSRF non valido.",
+ "Token configurato": "Token configurato",
+ "Token configurato — lascia vuoto per mantenere": "Token configurato — lascia vuoto per mantenere",
"File non trovato nell'upload.": "File non trovato nell'upload.",
"Errore durante il caricamento del file (code: %s).": "Errore durante il caricamento del file (code: %s).",
"Solo file ZIP sono accettati.": "Solo file ZIP sono accettati.",
@@ -136,5 +138,14 @@
"Invia per email": "Invia per email",
"Copia link": "Copia link",
"Link copiato!": "Link copiato!",
- "Condividi": "Condividi"
-}
+ "Condividi": "Condividi",
+ "Tipo Media": "Tipo Media",
+ "Libro": "Libro",
+ "Disco": "Disco",
+ "Tutti i media": "Tutti i media",
+ "Filtra per tipo": "Filtra per tipo",
+ "Audiolibro": "Audiolibro",
+ "DVD": "DVD",
+ "Altro": "Altro",
+ "Impossibile scaricare (senza ISBN/barcode):": "Impossibile scaricare (senza ISBN/barcode):"
+}
\ No newline at end of file
diff --git a/storage/plugins/discogs/DiscogsPlugin.php b/storage/plugins/discogs/DiscogsPlugin.php
new file mode 100644
index 00000000..ca18a7da
--- /dev/null
+++ b/storage/plugins/discogs/DiscogsPlugin.php
@@ -0,0 +1,1379 @@
+db = $db;
+ $this->hookManager = $hookManager;
+ }
+
+ /**
+ * Activate the plugin and register all hooks
+ */
+ public function activate(): void
+ {
+ Hooks::add('scrape.sources', [$this, 'addDiscogsSource'], 8);
+ Hooks::add('scrape.fetch.custom', [$this, 'fetchFromDiscogs'], 8);
+ Hooks::add('scrape.data.modify', [$this, 'enrichWithDiscogsData'], 15);
+ Hooks::add('scrape.isbn.validate', [$this, 'validateBarcode'], 5);
+ }
+
+ /**
+ * Validate barcode: accept ISBN, EAN-13, and UPC-A codes
+ */
+ public function validateBarcode(bool $isValid, string $isbn): bool
+ {
+ // If already valid (ISBN), keep it
+ if ($isValid) {
+ return true;
+ }
+ // Accept EAN-13 (13 digits) and UPC-A (12 digits)
+ $clean = preg_replace('/[^0-9]/', '', $isbn);
+ return strlen((string) $clean) === 13 || strlen((string) $clean) === 12;
+ }
+
+ /**
+ * Called when plugin is installed via PluginManager
+ */
+ public function onInstall(): void
+ {
+ \App\Support\SecureLogger::debug('[Discogs] Plugin installed');
+ $this->registerHooks();
+ }
+
+ /**
+ * Called when plugin is activated via PluginManager
+ */
+ public function onActivate(): void
+ {
+ $this->registerHooks();
+ \App\Support\SecureLogger::debug('[Discogs] Plugin activated');
+ }
+
+ /**
+ * Called when plugin is deactivated via PluginManager
+ */
+ public function onDeactivate(): void
+ {
+ $this->deleteHooks();
+ \App\Support\SecureLogger::debug('[Discogs] Plugin deactivated');
+ }
+
+ /**
+ * Called when plugin is uninstalled via PluginManager
+ */
+ public function onUninstall(): void
+ {
+ $this->deleteHooks();
+ \App\Support\SecureLogger::debug('[Discogs] Plugin uninstalled');
+ }
+
+ /**
+ * Set the plugin ID (called by PluginManager after installation)
+ */
+ public function setPluginId(int $pluginId): void
+ {
+ $this->pluginId = $pluginId;
+ $this->ensureHooksRegistered();
+ }
+
+ /**
+ * Register hooks in the database for persistence
+ */
+ private function registerHooks(): void
+ {
+ if ($this->db === null || $this->pluginId === null) {
+ \App\Support\SecureLogger::warning('[Discogs] Cannot register hooks: missing DB or plugin ID');
+ return;
+ }
+
+ $hooks = [
+ ['scrape.sources', 'addDiscogsSource', 8],
+ ['scrape.fetch.custom', 'fetchFromDiscogs', 8],
+ ['scrape.data.modify', 'enrichWithDiscogsData', 15],
+ ['scrape.isbn.validate', 'validateBarcode', 5],
+ ];
+
+ $stmt = null;
+ try {
+ $this->db->begin_transaction();
+
+ $deleteStmt = $this->db->prepare("DELETE FROM plugin_hooks WHERE plugin_id = ?");
+ if ($deleteStmt === false) {
+ throw new \RuntimeException('[Discogs] Failed to prepare hook cleanup: ' . $this->db->error);
+ }
+ $deleteStmt->bind_param('i', $this->pluginId);
+ if (!$deleteStmt->execute()) {
+ throw new \RuntimeException('[Discogs] Failed to delete existing hooks: ' . $deleteStmt->error);
+ }
+ $deleteStmt->close();
+
+ foreach ($hooks as [$hookName, $method, $priority]) {
+ $stmt = $this->db->prepare(
+ "INSERT INTO plugin_hooks (plugin_id, hook_name, callback_class, callback_method, priority, is_active, created_at)
+ VALUES (?, ?, ?, ?, ?, 1, NOW())"
+ );
+
+ if ($stmt === false) {
+ throw new \RuntimeException('[Discogs] Failed to prepare statement: ' . $this->db->error);
+ }
+
+ $callbackClass = 'DiscogsPlugin';
+ $stmt->bind_param('isssi', $this->pluginId, $hookName, $callbackClass, $method, $priority);
+
+ if (!$stmt->execute()) {
+ throw new \RuntimeException("[Discogs] Failed to register hook {$hookName}: " . $stmt->error);
+ }
+
+ $stmt->close();
+ $stmt = null;
+ }
+
+ $this->db->commit();
+ \App\Support\SecureLogger::debug('[Discogs] Hooks registered');
+ } catch (\Throwable $e) {
+ if ($stmt instanceof \mysqli_stmt) {
+ $stmt->close();
+ }
+ try {
+ $this->db->rollback();
+ } catch (\Throwable) {
+ }
+ \App\Support\SecureLogger::error($e->getMessage());
+ throw $e;
+ }
+ }
+
+ /**
+ * Ensure hooks are registered in the database
+ */
+ private function ensureHooksRegistered(): void
+ {
+ if ($this->db === null || $this->pluginId === null) {
+ return;
+ }
+
+ $stmt = $this->db->prepare("SELECT COUNT(*) AS total FROM plugin_hooks WHERE plugin_id = ?");
+ if ($stmt === false) {
+ return;
+ }
+
+ $stmt->bind_param('i', $this->pluginId);
+
+ if ($stmt->execute()) {
+ $result = $stmt->get_result();
+ $row = $result ? $result->fetch_assoc() : null;
+ if ((int)($row['total'] ?? 0) === 0) {
+ $this->registerHooks();
+ }
+ }
+
+ $stmt->close();
+ }
+
+ /**
+ * Delete all hooks for this plugin
+ */
+ private function deleteHooks(): void
+ {
+ if ($this->db === null || $this->pluginId === null) {
+ return;
+ }
+
+ $stmt = $this->db->prepare("DELETE FROM plugin_hooks WHERE plugin_id = ?");
+ if ($stmt) {
+ $stmt->bind_param('i', $this->pluginId);
+ $stmt->execute();
+ $stmt->close();
+ }
+ }
+
+ // ─── Scraping Hooks ─────────────────────────────────────────────────
+
+ /**
+ * Add Discogs as a scraping source
+ *
+ * @param array $sources Existing sources
+ * @param string $isbn ISBN/EAN being scraped
+ * @return array Modified sources
+ */
+ public function addDiscogsSource(array $sources, string $isbn): array
+ {
+ $sources['discogs'] = [
+ 'name' => 'Discogs',
+ 'url_pattern' => self::API_BASE . '/database/search?barcode={isbn}&type=release',
+ 'enabled' => true,
+ 'priority' => 8,
+ 'fields' => ['title', 'authors', 'publisher', 'year', 'description', 'image', 'format'],
+ ];
+
+ return $sources;
+ }
+
+ /**
+ * Fetch music metadata from Discogs API
+ *
+ * Search strategy:
+ * 1. Barcode search (EAN/UPC)
+ * 2. Query search as fallback
+ * 3. Fetch full release details
+ *
+ * @param mixed $currentResult Previous accumulated result from other plugins
+ * @param array $sources Available sources
+ * @param string $isbn ISBN/EAN/barcode to search
+ * @return array|null Merged data or previous result
+ */
+ public function fetchFromDiscogs($currentResult, array $sources, string $isbn): ?array
+ {
+ // Only proceed if Discogs source is enabled
+ if (!isset($sources['discogs']) || !$sources['discogs']['enabled']) {
+ return $currentResult;
+ }
+
+ // Don't skip — always try to merge Discogs data for additional fields
+ // BookDataMerger::merge() only fills missing fields, so it's safe
+
+ try {
+ $token = $this->getSetting('api_token');
+
+ // Search by barcode only (no generic fallback — too unreliable)
+ $searchUrl = self::API_BASE . '/database/search?barcode=' . urlencode($isbn) . '&type=release';
+ $searchResult = $this->apiRequest($searchUrl, $token);
+
+ if (empty($searchResult['results'][0])) {
+ // Title/artist fallback only works when a previous scraper already
+ // provided partial data (title + artist). When $currentResult is
+ // null (first scraper in the chain), there is nothing to search for.
+ if ($currentResult !== null && is_array($currentResult) && !empty($currentResult['title'])) {
+ $discogsFallback = $this->searchDiscogsByTitleArtist($currentResult, $token);
+ if ($discogsFallback !== null) {
+ return $this->mergeBookData($currentResult, $discogsFallback);
+ }
+ }
+
+ // Discogs found nothing — try MusicBrainz as fallback
+ $mbResult = $this->searchMusicBrainz($isbn, $isbn);
+ if ($mbResult !== null) {
+ return $this->mergeBookData($currentResult, $mbResult);
+ }
+ return $currentResult;
+ }
+
+ $discogsData = $this->fetchDiscogsReleaseFromSearchResult($searchResult['results'][0], $token, $isbn);
+ if ($discogsData === null) {
+ return $currentResult;
+ }
+
+ // Merge with existing data
+ return $this->mergeBookData($currentResult, $discogsData);
+
+ } catch (\Throwable $e) {
+ \App\Support\SecureLogger::error('[Discogs] Plugin Error: ' . $e->getMessage());
+ return $currentResult;
+ }
+ }
+
+ private function searchDiscogsByTitleArtist($currentResult, ?string $token): ?array
+ {
+ if (!is_array($currentResult)) {
+ return null;
+ }
+
+ $resolvedTipoMedia = \App\Support\MediaLabels::resolveTipoMedia(
+ $currentResult['format'] ?? $currentResult['formato'] ?? null,
+ $currentResult['tipo_media'] ?? null
+ );
+ if ($resolvedTipoMedia !== 'disco') {
+ return null;
+ }
+
+ $title = trim((string) ($currentResult['title'] ?? ''));
+ if ($title === '') {
+ return null;
+ }
+
+ $artist = trim((string) ($currentResult['author'] ?? ''));
+ if ($artist === '' && !empty($currentResult['authors'])) {
+ if (is_array($currentResult['authors'])) {
+ $firstAuthor = $currentResult['authors'][0] ?? '';
+ if (is_array($firstAuthor)) {
+ $artist = trim((string) ($firstAuthor['name'] ?? ''));
+ } else {
+ $artist = trim((string) $firstAuthor);
+ }
+ } else {
+ $artist = trim((string) $currentResult['authors']);
+ }
+ }
+
+ $query = [
+ 'type=release',
+ 'release_title=' . urlencode($title),
+ ];
+ if ($artist !== '') {
+ $query[] = 'artist=' . urlencode($artist);
+ }
+
+ $searchUrl = self::API_BASE . '/database/search?' . implode('&', $query);
+ $searchResult = $this->apiRequest($searchUrl, $token);
+ if (empty($searchResult['results'][0])) {
+ return null;
+ }
+
+ return $this->fetchDiscogsReleaseFromSearchResult($searchResult['results'][0], $token, null);
+ }
+
+ private function fetchDiscogsReleaseFromSearchResult(array $searchResult, ?string $token, ?string $fallbackBarcode): ?array
+ {
+ $releaseId = $searchResult['id'] ?? null;
+ if ($releaseId === null) {
+ return null;
+ }
+
+ $releaseUrl = self::API_BASE . '/releases/' . (int) $releaseId;
+ $release = $this->apiRequest($releaseUrl, $token);
+
+ if (empty($release) || empty($release['title'])) {
+ return null;
+ }
+
+ return $this->mapReleaseToPinakes($release, $searchResult, $fallbackBarcode);
+ }
+
+ /**
+ * Enrich existing data with Discogs cover if missing
+ *
+ * @param array $data Current payload
+ * @param string $isbn ISBN/EAN
+ * @param array $source Source information
+ * @param array $originalPayload Original payload before modifications
+ * @return array Enriched payload
+ */
+ public function enrichWithDiscogsData(array $data, string $isbn, array $source, array $originalPayload): array
+ {
+ // If data already has an image, skip
+ if (!empty($data['image'])) {
+ return $data;
+ }
+
+ // Only enrich from Deezer for music sources (avoid attaching music covers to books)
+ $resolvedType = \App\Support\MediaLabels::resolveTipoMedia(
+ $data['format'] ?? $data['formato'] ?? null,
+ $data['tipo_media'] ?? null
+ );
+ $isMusicSource = $resolvedType === 'disco'
+ || ($data['source'] ?? '') === 'discogs'
+ || ($data['source'] ?? '') === 'musicbrainz';
+
+ // Try to fetch cover from Discogs using discogs_id (regardless of source)
+ $discogsId = $data['discogs_id'] ?? null;
+ if ($discogsId === null) {
+ // No discogs_id — skip to Deezer enrichment below (only for music)
+ if ($isMusicSource && (empty($data['image']) || empty($data['genres'])) && !empty($data['title'])) {
+ $data = $this->enrichFromDeezer($data);
+ }
+ return $data;
+ }
+
+ try {
+ $token = $this->getSetting('api_token');
+ $releaseUrl = self::API_BASE . '/releases/' . (int)$discogsId;
+ $release = $this->apiRequest($releaseUrl, $token);
+
+ if (!empty($release['images'][0]['uri'])) {
+ $data['image'] = $release['images'][0]['uri'];
+ $data['cover_url'] = $release['images'][0]['uri'];
+ } elseif (!empty($release['thumb'])) {
+ $data['image'] = $release['thumb'];
+ $data['cover_url'] = $release['thumb'];
+ }
+ } catch (\Throwable $e) {
+ \App\Support\SecureLogger::warning('[Discogs] Cover enrichment error: ' . $e->getMessage());
+ }
+
+ // If still missing cover or genre, try Deezer enrichment (only for music)
+ if ($isMusicSource && (empty($data['image']) || empty($data['genres'])) && !empty($data['title'])) {
+ $data = $this->enrichFromDeezer($data);
+ }
+
+ return $data;
+ }
+
+ // ─── Data Mapping ───────────────────────────────────────────────────
+
+ /**
+ * Map a Discogs release to Pinakes book data format
+ *
+ * @param array $release Full release data from /releases/{id}
+ * @param array $searchResult Search result entry (has thumb/cover_image)
+ * @param string|null $fallbackBarcode Original barcode/EAN used for a validated barcode search
+ * @return array Pinakes-formatted data
+ */
+ private function mapReleaseToPinakes(array $release, array $searchResult, ?string $fallbackBarcode): array
+ {
+ // Extract album title — Discogs format is "Artist - Album Title"
+ $title = $this->extractAlbumTitle($release['title'] ?? '');
+
+ // Extract artists
+ $artists = $this->extractArtists($release['artists'] ?? []);
+ $firstArtist = $artists[0] ?? '';
+
+ // Build tracklist description as HTML
+ $description = $this->buildTracklistDescription($release['tracklist'] ?? []);
+
+ // Get cover image: prefer full images (requires auth), fallback to search thumbnails
+ $coverUrl = null;
+ if (!empty($release['images'][0]['uri'])) {
+ $coverUrl = $release['images'][0]['uri'];
+ } elseif (!empty($searchResult['cover_image'])) {
+ $coverUrl = $searchResult['cover_image'];
+ } elseif (!empty($searchResult['thumb'])) {
+ $coverUrl = $searchResult['thumb'];
+ }
+
+ // Extract publisher (label + catalog number)
+ $publisher = '';
+ $catalogNumber = '';
+ if (!empty($release['labels'][0]['name'])) {
+ $publisher = trim($release['labels'][0]['name']);
+ $catalogNumber = trim($release['labels'][0]['catno'] ?? '');
+ }
+
+ // Extract series
+ $series = null;
+ if (!empty($release['series'][0]['name'])) {
+ $series = trim($release['series'][0]['name']);
+ }
+
+ // Map Discogs format to Pinakes format
+ $format = $this->mapDiscogsFormat($release['formats'] ?? []);
+
+ // Extract all genres + styles as keywords
+ $allGenres = [];
+ foreach ($release['genres'] ?? [] as $g) {
+ $g = trim((string) $g);
+ if ($g !== '') {
+ $allGenres[] = $g;
+ }
+ }
+ foreach ($release['styles'] ?? [] as $style) {
+ $s = trim((string) $style);
+ if ($s !== '' && !in_array($s, $allGenres, true)) {
+ $allGenres[] = $s;
+ }
+ }
+ $genre = $allGenres[0] ?? '';
+ $keywords = implode(', ', $allGenres);
+
+ // Year
+ $year = isset($release['year']) && $release['year'] > 0
+ ? (string) $release['year']
+ : null;
+
+ // Weight in kg (Discogs gives grams)
+ $weightKg = null;
+ if (!empty($release['estimated_weight']) && is_numeric($release['estimated_weight'])) {
+ $weightKg = round((float) $release['estimated_weight'] / 1000, 3);
+ }
+
+ // Price
+ $price = null;
+ if (!empty($release['lowest_price']) && is_numeric($release['lowest_price'])) {
+ $price = (string) $release['lowest_price'];
+ }
+
+ // Number of tracks
+ $trackCount = 0;
+ foreach ($release['tracklist'] ?? [] as $track) {
+ if (($track['type_'] ?? 'track') === 'track' && trim($track['title'] ?? '') !== '') {
+ $trackCount++;
+ }
+ }
+
+ // Format quantity (number of discs)
+ $formatQty = (int) ($release['format_quantity'] ?? 1);
+
+ // Notes from Discogs
+ $discogsNotes = trim($release['notes'] ?? '');
+
+ // Build note_varie with extra metadata
+ $noteParts = [];
+ if ($catalogNumber !== '') {
+ $noteParts[] = 'Cat#: ' . $catalogNumber;
+ }
+ if (!empty($release['country'])) {
+ $noteParts[] = 'Country: ' . $release['country'];
+ }
+ if ($formatQty > 1) {
+ $noteParts[] = $formatQty . ' discs';
+ }
+ // Extra artists (producers, engineers, etc.)
+ $credits = $this->extractCredits($release['extraartists'] ?? []);
+ if ($credits !== '') {
+ $noteParts[] = $credits;
+ }
+ if ($discogsNotes !== '') {
+ $noteParts[] = $discogsNotes;
+ }
+ $noteVarie = implode("\n", $noteParts);
+
+ // Discogs URL for sameAs
+ $discogsUrl = $release['uri'] ?? null;
+
+ // Physical description (format details)
+ $physicalDesc = '';
+ if (!empty($release['formats'][0])) {
+ $fmt = $release['formats'][0];
+ $parts = [$fmt['name'] ?? ''];
+ foreach ($fmt['descriptions'] ?? [] as $desc) {
+ $parts[] = $desc;
+ }
+ $physicalDesc = implode(', ', array_filter($parts));
+ }
+
+ $releaseBarcode = $fallbackBarcode ?? $this->extractBarcodeFromRelease($release, $searchResult);
+
+ return [
+ 'title' => $title,
+ 'author' => $firstArtist,
+ 'authors' => $artists,
+ 'description' => $description,
+ 'image' => $coverUrl,
+ 'cover_url' => $coverUrl,
+ 'year' => $year,
+ 'publisher' => $publisher,
+ 'series' => $series ?? '',
+ 'format' => $format,
+ 'genres' => $genre,
+ 'parole_chiave' => $keywords,
+ 'isbn10' => null,
+ 'isbn13' => null,
+ 'ean' => $releaseBarcode,
+ 'country' => $release['country'] ?? null,
+ 'tipo_media' => 'disco',
+ 'source' => 'discogs',
+ 'discogs_id' => $release['id'] ?? null,
+ 'peso' => $weightKg,
+ 'prezzo' => $price,
+ 'numero_pagine' => $trackCount > 0 ? (string) $trackCount : null,
+ 'note_varie' => $noteVarie !== '' ? $noteVarie : null,
+ 'physical_description' => $physicalDesc !== '' ? $physicalDesc : null,
+ 'numero_inventario' => $catalogNumber !== '' ? $catalogNumber : null,
+ 'discogs_url' => $discogsUrl,
+ ];
+ }
+
+ /**
+ * Extract album title from Discogs "Artist - Album" format
+ *
+ * Discogs returns titles like "Pink Floyd - The Dark Side Of The Moon".
+ * We want just the album part: "The Dark Side Of The Moon".
+ *
+ * @param string $fullTitle Full Discogs title
+ * @return string Album title only
+ */
+ private function extractAlbumTitle(string $fullTitle): string
+ {
+ $fullTitle = trim($fullTitle);
+ if ($fullTitle === '') {
+ return '';
+ }
+
+ // Split on " - " (with spaces) to separate artist from album
+ $parts = explode(' - ', $fullTitle, 2);
+ if (count($parts) === 2) {
+ $albumPart = trim($parts[1]);
+ if ($albumPart !== '') {
+ return $albumPart;
+ }
+ }
+
+ // If no separator found or album part is empty, return full title
+ return $fullTitle;
+ }
+
+ /**
+ * Extract artist names from Discogs artists array
+ *
+ * @param array $artists Discogs artists array
+ * @return array Artist name strings
+ */
+ private function extractArtists(array $artists): array
+ {
+ $names = [];
+ foreach ($artists as $artist) {
+ $name = trim($artist['name'] ?? '');
+ if ($name !== '') {
+ // Discogs appends " (2)" etc. for disambiguation — remove it
+ $name = (string)preg_replace('/\s*\(\d+\)$/', '', $name);
+ $names[] = $name;
+ }
+ }
+ return $names;
+ }
+
+ /**
+ * Extract credits from Discogs extraartists (producers, engineers, etc.)
+ */
+ private function extractCredits(array $extraartists): string
+ {
+ if (empty($extraartists)) {
+ return '';
+ }
+ $credits = [];
+ foreach ($extraartists as $person) {
+ $name = trim($person['name'] ?? '');
+ $role = trim($person['role'] ?? '');
+ if ($name === '' || $role === '') {
+ continue;
+ }
+ // Clean disambiguation suffix
+ $name = (string) preg_replace('/\s*\(\d+\)$/', '', $name);
+ $credits[] = $role . ': ' . $name;
+ }
+ if (empty($credits)) {
+ return '';
+ }
+ return 'Credits: ' . implode(', ', $credits);
+ }
+
+ /**
+ * Build a tracklist description from Discogs tracklist data
+ *
+ * Produces text like:
+ * Tracklist:
+ * 1. Speak to Me (1:30)
+ * 2. Breathe (2:43)
+ *
+ * @param array $tracklist Discogs tracklist array
+ * @return string Formatted tracklist
+ */
+ private function buildTracklistDescription(array $tracklist): string
+ {
+ if (empty($tracklist)) {
+ return '';
+ }
+
+ $items = [];
+ foreach ($tracklist as $track) {
+ $trackTitle = trim($track['title'] ?? '');
+ if ($trackTitle === '') {
+ continue;
+ }
+ $duration = trim($track['duration'] ?? '');
+ $text = htmlspecialchars($trackTitle, ENT_QUOTES, 'UTF-8');
+ if ($duration !== '') {
+ $text .= ' (' . htmlspecialchars($duration, ENT_QUOTES, 'UTF-8') . ')';
+ }
+ $items[] = $text;
+ }
+
+ if (empty($items)) {
+ return '';
+ }
+
+ return '' . implode('', array_map(static fn(string $item): string => '- ' . $item . '
', $items)) . '
';
+ }
+
+ /**
+ * Map Discogs format names to Pinakes format identifiers
+ *
+ * @param array $formats Discogs formats array
+ * @return string Pinakes format string
+ */
+ private function mapDiscogsFormat(array $formats): string
+ {
+ if (empty($formats[0]['name'])) {
+ return 'altro';
+ }
+
+ $discogsFormat = strtolower(trim($formats[0]['name']));
+
+ $formatMap = [
+ 'cd' => 'cd_audio',
+ 'cdr' => 'cd_audio',
+ 'cds' => 'cd_audio',
+ 'sacd' => 'cd_audio',
+ 'vinyl' => 'vinile',
+ 'lp' => 'vinile',
+ 'cassette' => 'audiocassetta',
+ 'dvd' => 'dvd',
+ 'blu-ray' => 'blu_ray',
+ 'file' => 'digitale',
+ 'all media' => 'altro',
+ ];
+
+ foreach ($formatMap as $discogsKey => $pinakesValue) {
+ if (str_contains($discogsFormat, $discogsKey)) {
+ return $pinakesValue;
+ }
+ }
+
+ return 'altro';
+ }
+
+ // ─── API Communication ──────────────────────────────────────────────
+
+ /**
+ * Make an authenticated request to the Discogs API
+ *
+ * Discogs requires:
+ * - A descriptive User-Agent header (mandatory)
+ * - Optional: Authorization token for higher rate limits (60/min vs 25/min)
+ *
+ * @param string $url Full API URL
+ * @param string|null $token Optional Discogs personal access token
+ * @return array|null Decoded JSON response or null on failure
+ */
+ /** @var float Timestamp of last API request for rate limiting */
+ private static float $lastRequestTime = 0.0;
+ private static float $lastDeezerRequestTime = 0.0;
+
+ private function apiRequest(string $url, ?string $token = null): ?array
+ {
+ // Centralized rate limiting: 1s with token (60 req/min), 2.5s without (25 req/min)
+ $minInterval = ($token !== null && $token !== '') ? 1.0 : 2.5;
+ $elapsed = microtime(true) - self::$lastRequestTime;
+ if (self::$lastRequestTime > 0 && $elapsed < $minInterval) {
+ usleep((int) (($minInterval - $elapsed) * 1_000_000));
+ }
+ self::$lastRequestTime = microtime(true);
+
+ $headers = [
+ 'Accept: application/vnd.discogs.v2.discogs+json',
+ ];
+
+ if ($token !== null && $token !== '') {
+ $headers[] = 'Authorization: Discogs token=' . $token;
+ }
+
+ $ch = curl_init();
+ curl_setopt_array($ch, [
+ CURLOPT_URL => $url,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_TIMEOUT => self::TIMEOUT,
+ CURLOPT_CONNECTTIMEOUT => 5,
+ CURLOPT_MAXREDIRS => 5,
+ CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
+ CURLOPT_USERAGENT => self::USER_AGENT,
+ CURLOPT_HTTPHEADER => $headers,
+ CURLOPT_SSL_VERIFYPEER => true,
+ ]);
+
+ $response = curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $curlError = curl_error($ch);
+ curl_close($ch);
+
+ if ($curlError !== '') {
+ \App\Support\SecureLogger::warning('[Discogs] cURL error: ' . $curlError);
+ return null;
+ }
+
+ if ($httpCode !== 200 || !is_string($response) || $response === '') {
+ if ($httpCode === 429) {
+ \App\Support\SecureLogger::warning('[Discogs] Rate limit exceeded (HTTP 429)');
+ }
+ return null;
+ }
+
+ $data = json_decode($response, true);
+ if (!is_array($data)) {
+ return null;
+ }
+
+ return $data;
+ }
+
+ // ─── Settings ───────────────────────────────────────────────────────
+
+ /**
+ * Read a plugin setting from plugin_settings table
+ *
+ * Settings are stored with the plugin's ID in the plugin_settings table,
+ * following the same pattern as OpenLibraryPlugin.
+ *
+ * @param string $key Setting key (e.g. 'api_token')
+ * @return string|null Setting value or null
+ */
+ private function getSetting(string $key): ?string
+ {
+ $pluginId = $this->resolvePluginId();
+ $manager = $this->getPluginManager();
+
+ if ($pluginId === null || $manager === null) {
+ return null;
+ }
+
+ $value = $manager->getSetting($pluginId, $key);
+ return is_string($value) ? $value : null;
+ }
+
+ /**
+ * Get public settings info (for admin UI)
+ *
+ * @return array Settings map
+ */
+ public function getSettings(): array
+ {
+ $token = $this->getSetting('api_token');
+ return [
+ 'api_token' => $token !== null && $token !== '' ? '********' : '',
+ ];
+ }
+
+ /**
+ * Save plugin settings to plugin_settings table
+ *
+ * @param array $settings Settings key-value pairs
+ * @return bool True if all settings were saved successfully
+ */
+ public function saveSettings(array $settings): bool
+ {
+ $pluginId = $this->resolvePluginId();
+ $manager = $this->getPluginManager();
+
+ if ($pluginId === null || $manager === null) {
+ return false;
+ }
+
+ foreach ($settings as $key => $value) {
+ if (!$manager->setSetting($pluginId, (string) $key, $value, true)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private function resolvePluginId(): ?int
+ {
+ if ($this->pluginId !== null || $this->db === null) {
+ return $this->pluginId;
+ }
+
+ $stmt = $this->db->prepare("SELECT id FROM plugins WHERE name = ? LIMIT 1");
+ if ($stmt === false) {
+ return null;
+ }
+
+ $pluginName = 'discogs';
+ $stmt->bind_param('s', $pluginName);
+ $stmt->execute();
+ $result = $stmt->get_result();
+ $row = $result ? $result->fetch_assoc() : null;
+ if ($result) {
+ $result->free();
+ }
+ $stmt->close();
+
+ $this->pluginId = isset($row['id']) ? (int) $row['id'] : null;
+ return $this->pluginId;
+ }
+
+ private function getPluginManager(): ?\App\Support\PluginManager
+ {
+ if ($this->pluginManager !== null) {
+ return $this->pluginManager;
+ }
+
+ if ($this->db === null) {
+ return null;
+ }
+
+ $hookManager = $this->hookManager instanceof \App\Support\HookManager
+ ? $this->hookManager
+ : new \App\Support\HookManager($this->db);
+
+ $this->pluginManager = new \App\Support\PluginManager($this->db, $hookManager);
+ return $this->pluginManager;
+ }
+
+ /**
+ * Whether this plugin has a dedicated settings page
+ */
+ public function hasSettingsPage(): bool
+ {
+ return true;
+ }
+
+ /**
+ * Get the path to the settings view file
+ */
+ public function getSettingsViewPath(): string
+ {
+ return __DIR__ . '/views/settings.php';
+ }
+
+ /**
+ * Get plugin info
+ *
+ * @return array Plugin metadata
+ */
+ public function getInfo(): array
+ {
+ return [
+ 'name' => 'discogs',
+ 'display_name' => 'Music Scraper (Discogs, MusicBrainz, Deezer)',
+ 'version' => '1.1.0',
+ 'description' => 'Scraping multi-sorgente di metadati musicali: Discogs, MusicBrainz + Cover Art Archive, Deezer.',
+ ];
+ }
+
+ // ─── Data Merge ─────────────────────────────────────────────────────
+
+ /**
+ * Merge book data from a new source into existing data
+ *
+ * @param array|null $existing Existing accumulated data
+ * @param array|null $new New data from current source
+ * @return array|null Merged data
+ */
+ private function mergeBookData(?array $existing, ?array $new): ?array
+ {
+ // Use BookDataMerger if available
+ if (class_exists('\\App\\Support\\BookDataMerger')) {
+ $mergeSource = $new['source'] ?? ($existing['source'] ?? 'discogs');
+ return \App\Support\BookDataMerger::merge($existing, $new, $mergeSource);
+ }
+
+ // Fallback: simple merge
+ if ($new === null || empty($new)) {
+ return $existing;
+ }
+
+ if ($existing === null || empty($existing)) {
+ return $new;
+ }
+
+ // Fill empty fields in existing data with new data
+ foreach ($new as $key => $value) {
+ if (str_starts_with($key, '_')) {
+ continue;
+ }
+ if (!isset($existing[$key]) || $existing[$key] === '' ||
+ (is_array($existing[$key]) && empty($existing[$key]))) {
+ $existing[$key] = $value;
+ }
+ }
+
+ return $existing;
+ }
+
+ // ─── MusicBrainz Integration ────────────────────────────────────────
+
+ /**
+ * Search MusicBrainz by barcode as fallback when Discogs finds nothing
+ *
+ * @param string $barcode EAN/UPC barcode
+ * @param string|null $fallbackBarcode Persist this barcode only when it was validated by the search path
+ * @return array|null Pinakes-formatted data or null if not found
+ */
+ private function searchMusicBrainz(string $barcode, ?string $fallbackBarcode): ?array
+ {
+ // Search by barcode
+ $url = 'https://musicbrainz.org/ws/2/release?query=barcode:' . urlencode($barcode) . '&fmt=json&limit=1';
+ $result = $this->musicBrainzRequest($url);
+
+ if (empty($result['releases'][0])) {
+ return null;
+ }
+
+ $release = $result['releases'][0];
+ $mbid = $release['id'] ?? null;
+ if ($mbid === null || !is_string($mbid)) {
+ return null;
+ }
+
+ // Fetch full release details
+ $detailUrl = 'https://musicbrainz.org/ws/2/release/' . $mbid . '?inc=artists+labels+recordings+release-groups&fmt=json';
+ $detail = $this->musicBrainzRequest($detailUrl);
+ if (empty($detail)) {
+ return null;
+ }
+
+ // Get cover from Cover Art Archive
+ $coverUrl = $this->fetchCoverArtArchive($mbid);
+
+ return $this->mapMusicBrainzToPinakes($detail, $fallbackBarcode, $coverUrl);
+ }
+
+ /**
+ * Map MusicBrainz release data to Pinakes book data format
+ *
+ * @param array $release Full release data from MusicBrainz
+ * @param string|null $fallbackBarcode Original barcode used for search
+ * @param string|null $coverUrl Cover URL from Cover Art Archive
+ * @return array Pinakes-formatted data
+ */
+ private function mapMusicBrainzToPinakes(array $release, ?string $fallbackBarcode, ?string $coverUrl): array
+ {
+ $title = trim($release['title'] ?? '');
+
+ // Extract artists from artist-credit array
+ $artists = [];
+ $firstArtist = '';
+ if (!empty($release['artist-credit']) && is_array($release['artist-credit'])) {
+ foreach ($release['artist-credit'] as $credit) {
+ $name = trim($credit['name'] ?? '');
+ if ($name !== '') {
+ $artists[] = $name;
+ }
+ }
+ $firstArtist = $artists[0] ?? '';
+ }
+
+ // Build tracklist HTML from media/tracks
+ $description = '';
+ if (!empty($release['media'][0]['tracks']) && is_array($release['media'][0]['tracks'])) {
+ $items = [];
+ foreach ($release['media'][0]['tracks'] as $track) {
+ $trackTitle = trim($track['title'] ?? '');
+ if ($trackTitle === '') {
+ continue;
+ }
+ $text = htmlspecialchars($trackTitle, ENT_QUOTES, 'UTF-8');
+ // Length is in milliseconds
+ $lengthMs = $track['length'] ?? null;
+ if ($lengthMs !== null && is_numeric($lengthMs) && (int)$lengthMs > 0) {
+ $totalSeconds = (int)round((int)$lengthMs / 1000);
+ $minutes = intdiv($totalSeconds, 60);
+ $seconds = $totalSeconds % 60;
+ $duration = $minutes . ':' . str_pad((string)$seconds, 2, '0', STR_PAD_LEFT);
+ $text .= ' (' . $duration . ')';
+ }
+ $items[] = $text;
+ }
+ if (!empty($items)) {
+ $description = '' . implode('', array_map(
+ static fn(string $item): string => '- ' . $item . '
',
+ $items
+ )) . '
';
+ }
+ }
+
+ // Year: first 4 chars of date
+ $year = null;
+ $date = $release['date'] ?? '';
+ if (is_string($date) && strlen($date) >= 4) {
+ $year = substr($date, 0, 4);
+ }
+
+ // Publisher: first label
+ $publisher = '';
+ if (!empty($release['label-info'][0]['label']['name'])) {
+ $publisher = trim((string)$release['label-info'][0]['label']['name']);
+ }
+
+ // Format mapping
+ $format = 'altro';
+ if (!empty($release['media'][0]['format'])) {
+ $mbFormat = strtolower(trim((string)$release['media'][0]['format']));
+ $formatMap = [
+ 'cd' => 'cd_audio',
+ 'vinyl' => 'vinile',
+ 'cassette' => 'audiocassetta',
+ 'digital media' => 'digitale',
+ 'dvd' => 'dvd',
+ 'blu-ray' => 'blu_ray',
+ ];
+ foreach ($formatMap as $key => $value) {
+ if (str_contains($mbFormat, $key)) {
+ $format = $value;
+ break;
+ }
+ }
+ }
+
+ // Track count
+ $trackCount = 0;
+ if (!empty($release['media'][0]['tracks']) && is_array($release['media'][0]['tracks'])) {
+ $trackCount = count($release['media'][0]['tracks']);
+ }
+
+ $releaseBarcode = $fallbackBarcode ?? $this->extractBarcodeFromRelease($release);
+
+ return [
+ 'title' => $title,
+ 'author' => $firstArtist,
+ 'authors' => $artists,
+ 'description' => $description,
+ 'image' => $coverUrl,
+ 'cover_url' => $coverUrl,
+ 'year' => $year,
+ 'publisher' => $publisher,
+ 'series' => '',
+ 'format' => $format,
+ 'genres' => '',
+ 'parole_chiave' => '',
+ 'isbn10' => null,
+ 'isbn13' => null,
+ 'ean' => $releaseBarcode,
+ 'country' => $release['country'] ?? null,
+ 'tipo_media' => 'disco',
+ 'source' => 'musicbrainz',
+ 'musicbrainz_id' => $release['id'] ?? null,
+ 'numero_pagine' => $trackCount > 0 ? (string)$trackCount : null,
+ ];
+ }
+
+ private function extractBarcodeFromRelease(array $release, array $searchResult = []): ?string
+ {
+ $candidates = [];
+
+ $this->appendBarcodeCandidates($candidates, $release['barcode'] ?? null);
+ $this->appendBarcodeCandidates($candidates, $searchResult['barcode'] ?? null);
+ foreach ($release['identifiers'] ?? [] as $identifier) {
+ if (!is_array($identifier)) {
+ continue;
+ }
+ $type = strtolower(trim((string) ($identifier['type'] ?? '')));
+ if ($type !== '' && !str_contains($type, 'barcode')) {
+ continue;
+ }
+ $candidates[] = (string) ($identifier['value'] ?? '');
+ }
+
+ foreach ($candidates as $candidate) {
+ $normalized = preg_replace('/\D+/', '', $candidate) ?? '';
+ if ($normalized !== '' && (strlen($normalized) === 12 || strlen($normalized) === 13)) {
+ return $normalized;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @param array $candidates
+ * @param mixed $value
+ */
+ private function appendBarcodeCandidates(array &$candidates, $value): void
+ {
+ if (is_array($value)) {
+ foreach ($value as $nestedValue) {
+ $this->appendBarcodeCandidates($candidates, $nestedValue);
+ }
+ return;
+ }
+
+ if ($value === null || $value === '') {
+ return;
+ }
+
+ $candidates[] = (string) $value;
+ }
+
+ /**
+ * Fetch cover art URL from the Cover Art Archive
+ *
+ * @param string $mbid MusicBrainz release ID
+ * @return string|null URL of the cover image or null if unavailable
+ */
+ private function fetchCoverArtArchive(string $mbid): ?string
+ {
+ // Cover Art Archive — no rate limit, but may 404
+ $url = 'https://coverartarchive.org/release/' . urlencode($mbid);
+ $ch = curl_init();
+ curl_setopt_array($ch, [
+ CURLOPT_URL => $url,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_TIMEOUT => 10,
+ CURLOPT_CONNECTTIMEOUT => 5,
+ CURLOPT_MAXREDIRS => 5,
+ CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
+ CURLOPT_USERAGENT => self::USER_AGENT,
+ CURLOPT_SSL_VERIFYPEER => true,
+ ]);
+ $resp = curl_exec($ch);
+ $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $curlErr = curl_error($ch);
+ curl_close($ch);
+
+ if ($curlErr !== '') {
+ \App\Support\SecureLogger::warning('[CoverArt] cURL error: ' . $curlErr);
+ return null;
+ }
+
+ if ($code !== 200 || !is_string($resp)) {
+ return null;
+ }
+
+ $data = json_decode($resp, true);
+ if (!is_array($data) || empty($data['images']) || !is_array($data['images'])) {
+ return null;
+ }
+
+ // Prefer front cover, then first image
+ foreach ($data['images'] as $img) {
+ if (!is_array($img)) {
+ continue;
+ }
+ if (($img['front'] ?? false) === true) {
+ return $img['thumbnails']['large'] ?? $img['image'] ?? null;
+ }
+ }
+
+ $firstImg = $data['images'][0];
+ if (is_array($firstImg)) {
+ return $firstImg['thumbnails']['large'] ?? $firstImg['image'] ?? null;
+ }
+
+ return null;
+ }
+
+ /**
+ * Make a rate-limited request to the MusicBrainz API
+ *
+ * MusicBrainz enforces a strict 1 request/second limit.
+ * We use 1.1s between requests for safety margin.
+ *
+ * @param string $url Full MusicBrainz API URL
+ * @return array|null Decoded JSON response or null on failure
+ */
+ private function musicBrainzRequest(string $url): ?array
+ {
+ // MusicBrainz requires 1 req/s strictly
+ $elapsed = microtime(true) - self::$lastMbRequestTime;
+ if (self::$lastMbRequestTime > 0 && $elapsed < 1.1) {
+ usleep((int)((1.1 - $elapsed) * 1_000_000));
+ }
+ self::$lastMbRequestTime = microtime(true);
+
+ $ch = curl_init();
+ curl_setopt_array($ch, [
+ CURLOPT_URL => $url,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_TIMEOUT => self::TIMEOUT,
+ CURLOPT_CONNECTTIMEOUT => 5,
+ CURLOPT_MAXREDIRS => 5,
+ CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
+ CURLOPT_USERAGENT => self::USER_AGENT,
+ CURLOPT_HTTPHEADER => ['Accept: application/json'],
+ CURLOPT_SSL_VERIFYPEER => true,
+ ]);
+ $resp = curl_exec($ch);
+ $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $curlErr = curl_error($ch);
+ curl_close($ch);
+
+ if ($curlErr !== '') {
+ \App\Support\SecureLogger::warning('[MusicBrainz] cURL error: ' . $curlErr);
+ return null;
+ }
+
+ if ($code !== 200 || !is_string($resp)) {
+ return null;
+ }
+
+ $data = json_decode($resp, true);
+ return is_array($data) ? $data : null;
+ }
+
+ // ─── Deezer Integration ─────────────────────────────────────────────
+
+ /**
+ * Enrich data with Deezer album cover and metadata
+ *
+ * Searches Deezer by title+artist to find a matching album,
+ * then fills in missing cover image.
+ *
+ * @param array $data Current Pinakes data (must have 'title')
+ * @return array Enriched data
+ */
+ private function enrichFromDeezer(array $data): array
+ {
+ $title = trim($data['title'] ?? '');
+ $artist = trim($data['author'] ?? '');
+ if ($title === '') {
+ return $data;
+ }
+
+ $query = $artist !== '' ? $artist . ' ' . $title : $title;
+ $url = 'https://api.deezer.com/search/album?q=' . urlencode($query) . '&limit=1';
+
+ // Elapsed-based rate limit — 1 second between Deezer requests
+ $elapsed = microtime(true) - self::$lastDeezerRequestTime;
+ if (self::$lastDeezerRequestTime > 0 && $elapsed < 1.0) {
+ usleep((int) ((1.0 - $elapsed) * 1_000_000));
+ }
+ self::$lastDeezerRequestTime = microtime(true);
+
+ $ch = curl_init();
+ curl_setopt_array($ch, [
+ CURLOPT_URL => $url,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 10,
+ CURLOPT_CONNECTTIMEOUT => 5,
+ CURLOPT_MAXREDIRS => 5,
+ CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
+ CURLOPT_USERAGENT => self::USER_AGENT,
+ CURLOPT_SSL_VERIFYPEER => true,
+ ]);
+ $resp = curl_exec($ch);
+ $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $curlErr = curl_error($ch);
+ curl_close($ch);
+
+ if ($curlErr !== '') {
+ \App\Support\SecureLogger::warning('[Deezer] cURL error: ' . $curlErr);
+ return $data;
+ }
+
+ if ($code !== 200 || !is_string($resp)) {
+ return $data;
+ }
+
+ $result = json_decode($resp, true);
+ if (!is_array($result) || empty($result['data'][0])) {
+ return $data;
+ }
+
+ $album = $result['data'][0];
+ if (!is_array($album)) {
+ return $data;
+ }
+
+ // Fill missing cover with Deezer's high-quality image
+ if (empty($data['image']) && !empty($album['cover_xl'])) {
+ $data['image'] = $album['cover_xl'];
+ $data['cover_url'] = $album['cover_xl'];
+ }
+
+ return $data;
+ }
+}
diff --git a/storage/plugins/discogs/README.md b/storage/plugins/discogs/README.md
new file mode 100644
index 00000000..d8e0b9b0
--- /dev/null
+++ b/storage/plugins/discogs/README.md
@@ -0,0 +1,129 @@
+# Music Scraper Plugin (Discogs, MusicBrainz, Deezer)
+
+Plugin multi-sorgente per lo scraping di metadati musicali in Pinakes, pensato per catalogare supporti musicali (CD, LP, vinili, cassette). Interroga Discogs, MusicBrainz + Cover Art Archive e Deezer per massimizzare la copertura dei dati.
+
+## Funzionamento
+
+Il plugin si aggancia al sistema di scraping tramite tre hook:
+
+- **scrape.sources** (priorita 8) -- Registra il plugin come fonte di scraping
+- **scrape.fetch.custom** (priorita 8) -- Esegue la ricerca e il recupero dei metadati
+- **scrape.data.modify** (priorita 15) -- Arricchisce i dati con copertine mancanti
+
+### Strategia di ricerca
+
+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
+
+## Mappatura dati Discogs -> Pinakes
+
+| Discogs | Pinakes | Note |
+|---------|---------|------|
+| Artist | Autore | Rimossa disambiguazione "(2)" ecc. |
+| Album title | Titolo | Estratto da formato "Artist - Album" |
+| Label | Editore | Prima etichetta |
+| Barcode | EAN | Il codice usato per la ricerca |
+| Year | Anno | Anno di uscita della release |
+| Tracklist | Descrizione | Formattata come "1. Titolo (3:45)" |
+| Cover image | Copertina | Immagine primaria o thumbnail |
+| Format | Formato | CD -> cd_audio, Vinyl -> vinile, ecc. |
+| Genre | Genere | Primo genere della release |
+| Series | Serie | Se presente nella release |
+| Country | Paese | Paese di pubblicazione |
+
+### Formati supportati
+
+| Discogs | Pinakes |
+|---------|---------|
+| CD, CDr, CDs, SACD | cd_audio |
+| Vinyl, LP | vinile |
+| Cassette | audiocassetta |
+| DVD | dvd |
+| Blu-ray | blu_ray |
+| File | digitale |
+| Altro | altro |
+
+## Token API (opzionale)
+
+Senza token le API di Discogs permettono 25 richieste al minuto. Con un token personale il limite sale a 60 richieste al minuto.
+
+### Come ottenere un token
+
+1. Accedi a https://www.discogs.com/settings/developers
+2. Clicca "Generate new token"
+3. Copia il token generato
+4. Inseriscilo nelle impostazioni del plugin in Pinakes (chiave: `api_token`)
+
+**Nota:** le immagini ad alta risoluzione (`images[].uri`) richiedono autenticazione. Senza token il plugin usa le thumbnail dalla ricerca.
+
+## Installazione
+
+1. Crea un file ZIP con tutti i file del plugin
+2. Vai su **Admin -> Plugin**
+3. Clicca **"Carica Plugin"**
+4. Seleziona il file ZIP e clicca **"Installa"**
+5. Attiva il plugin dalla lista
+
+## MusicBrainz (fallback barcode)
+
+Quando Discogs non trova risultati per un barcode, il plugin cerca automaticamente su [MusicBrainz](https://musicbrainz.org/), un database musicale open data.
+
+- **Ricerca per barcode** -- `GET /ws/2/release?query=barcode:{ean}&fmt=json`
+- **Dettagli release** -- `GET /ws/2/release/{mbid}?inc=artists+labels+recordings+release-groups&fmt=json`
+- **Cover Art Archive** -- le copertine vengono recuperate da [Cover Art Archive](https://coverartarchive.org/), un archivio gratuito collegato a MusicBrainz
+
+### Mappatura MusicBrainz -> Pinakes
+
+| MusicBrainz | Pinakes | Note |
+|-------------|---------|------|
+| title | Titolo | Titolo della release |
+| artist-credit | Autore/i | Array di crediti artista con joinphrase |
+| media.tracks | Descrizione | Tracklist HTML con durate (ms -> mm:ss) |
+| date | Anno | Primi 4 caratteri della data |
+| label-info.label.name | Editore | Prima etichetta |
+| media.format | Formato | CD -> cd_audio, Vinyl -> vinile, ecc. |
+| Cover Art Archive | Copertina | Preferisce front cover, poi prima immagine |
+
+Non e richiesta autenticazione. Rate limit: 1 richiesta/secondo (rispettato automaticamente).
+
+## Deezer (copertine HD)
+
+Se dopo Discogs e MusicBrainz la copertina o il genere sono ancora mancanti, il plugin cerca su [Deezer](https://developers.deezer.com/) per titolo+artista.
+
+- **Ricerca album** -- `GET /search/album?q={artista}+{titolo}&limit=1`
+- **Copertina HD** -- usa `cover_xl` (1000x1000px) per la massima qualita
+- Non richiede autenticazione ne API key
+- Rate limit: 1 secondo tra le richieste
+
+## Rate Limiting
+
+Il plugin rispetta i limiti di ciascuna API con throttling adattivo:
+
+| Sorgente | Rate limit | Intervallo |
+|----------|-----------|------------|
+| Discogs (con token) | 60 req/min | 1s tra le chiamate |
+| Discogs (senza token) | 25 req/min | 2.5s tra le chiamate |
+| MusicBrainz | 1 req/s | 1.1s tra le chiamate |
+| Cover Art Archive | Nessun limite | Nessun ritardo aggiuntivo |
+| Deezer | 50 req/5s | 1s tra le chiamate |
+
+In caso di errore 429 (rate limit exceeded) la risposta viene registrata nei log.
+
+## Link utili
+
+- [Discogs API Documentation](https://www.discogs.com/developers)
+- [Discogs Database Search](https://www.discogs.com/developers#page:database,header:database-search)
+- [Discogs Release](https://www.discogs.com/developers#page:database,header:database-release)
+- [MusicBrainz API Documentation](https://musicbrainz.org/doc/MusicBrainz_API)
+- [Cover Art Archive API](https://wiki.musicbrainz.org/Cover_Art_Archive/API)
+- [Deezer API Documentation](https://developers.deezer.com/api)
+
+## Licenza
+
+Questo plugin e parte del progetto Pinakes ed e rilasciato sotto la stessa licenza del progetto principale.
+
+I dati di Discogs sono soggetti ai [termini di utilizzo delle API Discogs](https://www.discogs.com/developers/#page:home,header:home-general-information).
+I dati di MusicBrainz sono disponibili sotto [licenza CC0](https://creativecommons.org/publicdomain/zero/1.0/) (dominio pubblico).
+Le copertine di Cover Art Archive seguono le rispettive licenze indicate per ciascuna immagine.
diff --git a/storage/plugins/discogs/plugin.json b/storage/plugins/discogs/plugin.json
new file mode 100644
index 00000000..dda83454
--- /dev/null
+++ b/storage/plugins/discogs/plugin.json
@@ -0,0 +1,65 @@
+{
+ "name": "discogs",
+ "display_name": "Music Scraper (Discogs, MusicBrainz, Deezer)",
+ "description": "Scraping multi-sorgente di metadati musicali: Discogs (barcode/titolo), MusicBrainz + Cover Art Archive (fallback barcode), Deezer (copertine HD). Supporta CD, LP, vinili, cassette.",
+ "version": "1.0.0",
+ "author": "Fabiodalez",
+ "author_url": "",
+ "plugin_url": "https://www.discogs.com",
+ "main_file": "wrapper.php",
+ "requires_php": "8.0",
+ "requires_app": "0.5.0",
+ "max_app_version": "1.0.0",
+ "metadata": {
+ "optional": true,
+ "category": "scraping",
+ "tags": [
+ "api",
+ "discogs",
+ "musicbrainz",
+ "deezer",
+ "scraping",
+ "music",
+ "vinyl",
+ "cd"
+ ],
+ "priority": 8,
+ "api_endpoints": [
+ "https://api.discogs.com/database/search",
+ "https://api.discogs.com/releases/{id}",
+ "https://musicbrainz.org/ws/2/release",
+ "https://coverartarchive.org/release/{mbid}",
+ "https://api.deezer.com/search/album"
+ ],
+ "hooks": [
+ {
+ "name": "scrape.sources",
+ "callback_method": "addDiscogsSource",
+ "description": "Adds Discogs as a scraping source for music media",
+ "priority": 8
+ },
+ {
+ "name": "scrape.fetch.custom",
+ "callback_method": "fetchFromDiscogs",
+ "description": "Fetches metadata from Discogs API",
+ "priority": 8
+ },
+ {
+ "name": "scrape.data.modify",
+ "callback_method": "enrichWithDiscogsData",
+ "description": "Enriches data with Discogs covers and tracklists",
+ "priority": 15
+ }
+ ],
+ "features": [
+ "Ricerca per barcode (EAN/UPC)",
+ "Ricerca per titolo/artista",
+ "Copertine album ad alta risoluzione",
+ "Tracklist completa nella descrizione",
+ "Mappatura etichetta su editore",
+ "Supporto CD, vinile, cassette",
+ "Fallback MusicBrainz + Cover Art Archive per barcode",
+ "Arricchimento copertine HD da Deezer"
+ ]
+ }
+}
diff --git a/storage/plugins/discogs/views/settings.php b/storage/plugins/discogs/views/settings.php
new file mode 100644
index 00000000..25072950
--- /dev/null
+++ b/storage/plugins/discogs/views/settings.php
@@ -0,0 +1,181 @@
+Errore: Plugin non caricato correttamente.
';
+ return;
+}
+
+// Gestione salvataggio impostazioni
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && (isset($_POST['save_discogs_settings']) || isset($_POST['clear_discogs_settings']))) {
+ if (\App\Support\Csrf::validate($_POST['csrf_token'] ?? null)) {
+ if (isset($_POST['clear_discogs_settings'])) {
+ if ($plugin->saveSettings(['api_token' => ''])) {
+ $successMessage = __('Token Discogs rimosso correttamente.');
+ } else {
+ $errorMessage = __('Errore nel salvataggio delle impostazioni.');
+ }
+ } else {
+ $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.');
+ }
+ }
+ }
+ } else {
+ $errorMessage = __('Token CSRF non valido.');
+ }
+}
+
+$currentSettings = $plugin->getSettings();
+$hasToken = !empty($currentSettings['api_token']);
+$csrfToken = \App\Support\Csrf::ensureToken();
+$pluginsRoute = htmlspecialchars(route_path('plugins'), ENT_QUOTES, 'UTF-8');
+?>
+
+
+
+
diff --git a/storage/plugins/discogs/wrapper.php b/storage/plugins/discogs/wrapper.php
new file mode 100644
index 00000000..f8e7d9d2
--- /dev/null
+++ b/storage/plugins/discogs/wrapper.php
@@ -0,0 +1,105 @@
+instance = new \App\Plugins\Discogs\DiscogsPlugin($db, $hookManager);
+ }
+
+ /**
+ * Activate the plugin
+ */
+ public function activate(): void
+ {
+ if (method_exists($this->instance, 'activate')) {
+ $this->instance->activate();
+ }
+ }
+
+ /**
+ * Deactivate the plugin (called by PluginManager)
+ */
+ public function onDeactivate(): void
+ {
+ if (method_exists($this->instance, 'onDeactivate')) {
+ $this->instance->onDeactivate();
+ }
+ \App\Support\SecureLogger::debug('[Discogs] Plugin deactivated');
+ }
+
+ /**
+ * Called when plugin is installed (by PluginManager)
+ */
+ public function onInstall(): void
+ {
+ if (method_exists($this->instance, 'onInstall')) {
+ $this->instance->onInstall();
+ }
+ \App\Support\SecureLogger::debug('[Discogs] Plugin installed');
+ }
+
+ /**
+ * Called when plugin is activated (by PluginManager)
+ */
+ public function onActivate(): void
+ {
+ if (method_exists($this->instance, 'onActivate')) {
+ $this->instance->onActivate();
+ } elseif (method_exists($this->instance, 'activate')) {
+ $this->instance->activate();
+ }
+ \App\Support\SecureLogger::debug('[Discogs] Plugin activated');
+ }
+
+ /**
+ * Called when plugin is uninstalled (by PluginManager)
+ */
+ public function onUninstall(): void
+ {
+ if (method_exists($this->instance, 'onUninstall')) {
+ $this->instance->onUninstall();
+ }
+ \App\Support\SecureLogger::debug('[Discogs] Plugin uninstalled');
+ }
+
+ /**
+ * Set the plugin ID (called by PluginManager after installation)
+ */
+ public function setPluginId(int $pluginId): void
+ {
+ $this->instance->setPluginId($pluginId);
+ }
+
+ /**
+ * Forward all method calls to the namespaced instance
+ */
+ public function __call($method, $args)
+ {
+ if (method_exists($this->instance, $method)) {
+ return call_user_func_array([$this->instance, $method], $args);
+ }
+
+ throw new \BadMethodCallException("Method {$method} does not exist");
+ }
+ }
+}
diff --git a/tests/discogs-advanced.spec.js b/tests/discogs-advanced.spec.js
new file mode 100644
index 00000000..1d787afa
--- /dev/null
+++ b/tests/discogs-advanced.spec.js
@@ -0,0 +1,230 @@
+// @ts-check
+/**
+ * Advanced Discogs tests: tipo_media filtering, CSV export, Schema.org,
+ * tracklist rendering, and edit persistence.
+ * Requires: app installed, admin user, Discogs plugin active, music records in DB.
+ */
+const { test, expect } = require('@playwright/test');
+const { execFileSync } = require('child_process');
+
+const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081';
+const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || '';
+const ADMIN_PASS = process.env.E2E_ADMIN_PASS || '';
+const DB_USER = process.env.E2E_DB_USER || '';
+const DB_PASS = process.env.E2E_DB_PASS || '';
+const DB_NAME = process.env.E2E_DB_NAME || '';
+const DB_SOCKET = process.env.E2E_DB_SOCKET || '';
+const RUN_ID = Date.now();
+const SEEDED_MUSIC_EAN = `2${String(RUN_ID).slice(-12)}`;
+const SEEDED_BOOK_ISBN = `978${String(RUN_ID).slice(-10)}`;
+
+function dbQuery(sql) {
+ const args = ['-u', DB_USER, `-p${DB_PASS}`, DB_NAME, '-N', '-B', '-e', sql];
+ if (DB_SOCKET) args.splice(3, 0, '-S', DB_SOCKET);
+ return execFileSync('mysql', args, { encoding: 'utf-8', timeout: 10000 }).trim();
+}
+
+function dbExec(sql) {
+ const args = ['-u', DB_USER, `-p${DB_PASS}`, DB_NAME, '-e', sql];
+ if (DB_SOCKET) args.splice(3, 0, '-S', DB_SOCKET);
+ execFileSync('mysql', args, { encoding: 'utf-8', timeout: 10000 });
+}
+
+test.describe.serial('Discogs Advanced Tests', () => {
+ /** @type {import('@playwright/test').Page} */
+ let page;
+ /** @type {import('@playwright/test').BrowserContext} */
+ let context;
+ let musicBookId = '';
+ let bookBookId = '';
+
+ test.beforeAll(async ({ browser }) => {
+ test.skip(!ADMIN_EMAIL || !ADMIN_PASS || !DB_USER || !DB_PASS || !DB_NAME, 'Missing E2E env vars');
+ context = await browser.newContext();
+ page = await context.newPage();
+
+ // Login
+ await page.goto(`${BASE}/accedi`);
+ await page.fill('input[name="email"]', ADMIN_EMAIL);
+ await page.fill('input[name="password"]', ADMIN_PASS);
+ await page.click('button[type="submit"]');
+ await page.waitForURL(/\/admin\//, { timeout: 15000 });
+
+ // Seed a music record and a book for comparison
+ dbExec(
+ "INSERT INTO libri (titolo, formato, tipo_media, ean, copie_totali, copie_disponibili, descrizione, note_varie, created_at, updated_at) " +
+ "VALUES ('E2E_ADV_CD_" + RUN_ID + "', 'cd_audio', 'disco', '" + SEEDED_MUSIC_EAN + "', 1, 1, " +
+ "'Track One - Track Two', 'Cat: TEST-001', NOW(), NOW())"
+ );
+ dbExec(
+ "INSERT INTO libri (titolo, formato, tipo_media, isbn13, copie_totali, copie_disponibili, descrizione, created_at, updated_at) " +
+ "VALUES ('E2E_ADV_Book_" + RUN_ID + "', 'cartaceo', 'libro', '" + SEEDED_BOOK_ISBN + "', 1, 1, " +
+ "'A test book description', NOW(), NOW())"
+ );
+
+ musicBookId = dbQuery(`SELECT id FROM libri WHERE titolo = 'E2E_ADV_CD_${RUN_ID}' AND deleted_at IS NULL LIMIT 1`);
+ bookBookId = dbQuery(`SELECT id FROM libri WHERE titolo = 'E2E_ADV_Book_${RUN_ID}' AND deleted_at IS NULL LIMIT 1`);
+ });
+
+ test.afterAll(async () => {
+ try {
+ dbExec(
+ `DELETE FROM libri
+ WHERE id IN (${Number(musicBookId) || 0}, ${Number(bookBookId) || 0})
+ OR ean = '${SEEDED_MUSIC_EAN}'
+ OR isbn13 = '${SEEDED_BOOK_ISBN}'`
+ );
+ } catch {}
+ await context?.close();
+ });
+
+ // ═══════════════════════════════════════════════════════════════════
+ // Test 1: tipo_media filter in admin book list
+ // ═══════════════════════════════════════════════════════════════════
+ test('1. Admin list filters by tipo_media=disco', async () => {
+ // Fetch the DataTable API with tipo_media filter
+ const resp = await page.request.get(`${BASE}/api/libri?tipo_media=disco&start=0&length=100&search_text=E2E_ADV`);
+ expect(resp.status()).toBe(200);
+ const data = await resp.json();
+
+ // Should find the CD but not the book
+ const titles = (data.data || []).map((r) => r.titolo || r.info || '');
+ const flatTitles = titles.join(' ');
+
+ expect(flatTitles).toContain(`E2E_ADV_CD_${RUN_ID}`);
+ expect(flatTitles).not.toContain(`E2E_ADV_Book_${RUN_ID}`);
+ });
+
+ // ═══════════════════════════════════════════════════════════════════
+ // Test 2: CSV export includes tipo_media column
+ // ═══════════════════════════════════════════════════════════════════
+ test('2. CSV export includes tipo_media for music records', async () => {
+ const resp = await page.request.get(`${BASE}/admin/libri/export/csv?ids=${musicBookId}`);
+ expect(resp.status()).toBe(200);
+ const body = await resp.text();
+
+ // Parse header
+ const lines = body.split('\n');
+ const header = lines[0].replace(/^\uFEFF/, '');
+ expect(header).toContain('tipo_media');
+
+ // Parse the data row
+ const headerFields = header.split(';');
+ const tipoMediaIdx = headerFields.indexOf('tipo_media');
+ expect(tipoMediaIdx).toBeGreaterThan(-1);
+
+ if (lines.length > 1 && lines[1].trim()) {
+ const dataFields = lines[1].split(';');
+ expect(dataFields[tipoMediaIdx]).toBe('disco');
+ }
+ });
+
+ // ═══════════════════════════════════════════════════════════════════
+ // Test 3: Schema.org uses MusicAlbum for disco, Book for libro
+ // ═══════════════════════════════════════════════════════════════════
+ test('3. Schema.org JSON-LD type is MusicAlbum for disco', async () => {
+ const musicResp = await page.request.get(`${BASE}/libro/${musicBookId}`);
+ expect(musicResp.status()).toBe(200);
+ const musicHtml = await musicResp.text();
+
+ const jsonLdBlocks = Array.from(
+ musicHtml.matchAll(/