diff --git a/client/package.json b/client/package.json index cc785926..21cae124 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,7 @@ { "name": "audiobookshelf-client", "version": "2.4.4", + "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", "scripts": { diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index 1721a379..12ce7b1e 100644 --- a/client/pages/config/index.vue +++ b/client/pages/config/index.vue @@ -47,10 +47,6 @@

{{ $strings.LabelSettingsChromecastSupport }}

-
- -
-

{{ $strings.HeaderSettingsScanner }}

@@ -237,17 +233,7 @@ export default { hasPrefixesChanged: false, newServerSettings: {}, showConfirmPurgeCache: false, - savingPrefixes: false, - metadataFileFormats: [ - { - text: '.json', - value: 'json' - }, - { - text: '.abs (deprecated)', - value: 'abs' - } - ] + savingPrefixes: false } }, watch: { @@ -329,10 +315,6 @@ export default { updateServerLanguage(val) { this.updateSettingsKey('language', val) }, - updateMetadataFileFormat(val) { - if (this.serverSettings.metadataFileFormat === val) return - this.updateSettingsKey('metadataFileFormat', val) - }, updateSettingsKey(key, val) { if (key === 'scannerDisableWatcher') { this.newServerSettings.scannerDisableWatcher = val diff --git a/client/strings/da.json b/client/strings/da.json index aa1b66ed..adf138a1 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Gem omslag med element", "LabelSettingsStoreCoversWithItemHelp": "Som standard gemmes omslag i /metadata/items, aktivering af denne indstilling vil gemme omslag i mappen for dit bibliotekselement. Kun én fil med navnet \"cover\" vil blive bevaret", "LabelSettingsStoreMetadataWithItem": "Gem metadata med element", - "LabelSettingsStoreMetadataWithItemHelp": "Som standard gemmes metadatafiler i /metadata/items, aktivering af denne indstilling vil gemme metadatafiler i dine bibliotekselementmapper. Bruger .abs-filudvidelsen", + "LabelSettingsStoreMetadataWithItemHelp": "Som standard gemmes metadatafiler i /metadata/items, aktivering af denne indstilling vil gemme metadatafiler i dine bibliotekselementmapper", "LabelSettingsTimeFormat": "Tidsformat", "LabelShowAll": "Vis alle", "LabelSize": "Størrelse", diff --git a/client/strings/de.json b/client/strings/de.json index fe1df71c..a072a549 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Titelbilder im Medienordner speichern", "LabelSettingsStoreCoversWithItemHelp": "Standardmäßig werden die Titelbilder in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Titelbilder als jpg Datei in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet. Es wird immer nur eine Datei mit dem Namen \"cover.jpg\" gespeichert.", "LabelSettingsStoreMetadataWithItem": "Metadaten als OPF-Datei im Medienordner speichern", - "LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet. Es wird immer nur eine Datei mit dem Namen \"matadata.abs\" gespeichert.", + "LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet", "LabelSettingsTimeFormat": "Zeitformat", "LabelShowAll": "Alles anzeigen", "LabelSize": "Größe", diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 43f44821..24d07726 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Store covers with item", "LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept", "LabelSettingsStoreMetadataWithItem": "Store metadata with item", - "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension", + "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders", "LabelSettingsTimeFormat": "Time Format", "LabelShowAll": "Show All", "LabelSize": "Size", diff --git a/client/strings/es.json b/client/strings/es.json index 3a6012b6..4b37139d 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Guardar portadas con elementos", "LabelSettingsStoreCoversWithItemHelp": "Por defecto, las portadas se almacenan en /metadata/items. Si habilita esta opción, las portadas se almacenarán en la carpeta de elementos de su biblioteca. Se guardará un solo archivo llamado \"cover\".", "LabelSettingsStoreMetadataWithItem": "Guardar metadatos con elementos", - "LabelSettingsStoreMetadataWithItemHelp": "Por defecto, los archivos de metadatos se almacenan en /metadata/items. Si habilita esta opción, los archivos de metadatos se guardarán en la carpeta de elementos de su biblioteca. Usa la extensión .abs", + "LabelSettingsStoreMetadataWithItemHelp": "Por defecto, los archivos de metadatos se almacenan en /metadata/items. Si habilita esta opción, los archivos de metadatos se guardarán en la carpeta de elementos de su biblioteca", "LabelSettingsTimeFormat": "Formato de Tiempo", "LabelShowAll": "Mostrar Todos", "LabelSize": "Tamaño", diff --git a/client/strings/fr.json b/client/strings/fr.json index a78f7d66..28bdf743 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Enregistrer la couverture avec les articles", "LabelSettingsStoreCoversWithItemHelp": "Par défaut, les couvertures sont enregistrées dans /metadata/items. Activer ce paramètre enregistrera les couvertures dans le dossier avec les fichiers de l’article. Seul un fichier nommé « cover » sera conservé.", "LabelSettingsStoreMetadataWithItem": "Enregistrer les Métadonnées avec les articles", - "LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les métadonnées sont enregistrées dans /metadata/items. Activer ce paramètre enregistrera les métadonnées dans le dossier de l’article avec une extension « .abs ».", + "LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les métadonnées sont enregistrées dans /metadata/items", "LabelSettingsTimeFormat": "Format d’heure", "LabelShowAll": "Afficher Tout", "LabelSize": "Taille", diff --git a/client/strings/gu.json b/client/strings/gu.json index 8b6a963f..8593a95d 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Store covers with item", "LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept", "LabelSettingsStoreMetadataWithItem": "Store metadata with item", - "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension", + "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders", "LabelSettingsTimeFormat": "Time Format", "LabelShowAll": "Show All", "LabelSize": "Size", diff --git a/client/strings/hi.json b/client/strings/hi.json index 7c8651e3..82d25986 100644 --- a/client/strings/hi.json +++ b/client/strings/hi.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Store covers with item", "LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept", "LabelSettingsStoreMetadataWithItem": "Store metadata with item", - "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension", + "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders", "LabelSettingsTimeFormat": "Time Format", "LabelShowAll": "Show All", "LabelSize": "Size", diff --git a/client/strings/hr.json b/client/strings/hr.json index 8e3946a5..e9a323ee 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Spremi cover uz stakvu", "LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept", "LabelSettingsStoreMetadataWithItem": "Spremi metapodatke uz stavku", - "LabelSettingsStoreMetadataWithItemHelp": "Po defaultu metapodatci su spremljeni u /metadata/items, uključujućite li ovu postavku, metapodatci će biti spremljeni u folderima od biblioteke. Koristi .abs ekstenziju.", + "LabelSettingsStoreMetadataWithItemHelp": "Po defaultu metapodatci su spremljeni u /metadata/items, uključujućite li ovu postavku, metapodatci će biti spremljeni u folderima od biblioteke", "LabelSettingsTimeFormat": "Time Format", "LabelShowAll": "Prikaži sve", "LabelSize": "Veličina", diff --git a/client/strings/it.json b/client/strings/it.json index e384ea59..f73b3ffc 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Archivia le copertine con il file", "LabelSettingsStoreCoversWithItemHelp": "Di default, le immagini di copertina sono salvate dentro /metadata/items, abilitando questa opzione le copertine saranno archiviate nella cartella della libreria corrispondente. Verrà conservato solo un file denominato \"cover\"", "LabelSettingsStoreMetadataWithItem": "Archivia i metadata con il file", - "LabelSettingsStoreMetadataWithItemHelp": "Di default, i metadati sono salvati dentro /metadata/items, abilitando questa opzione si memorizzeranno i metadata nella cartella della libreria. I file avranno estensione .abs", + "LabelSettingsStoreMetadataWithItemHelp": "Di default, i metadati sono salvati dentro /metadata/items, abilitando questa opzione si memorizzeranno i metadata nella cartella della libreria", "LabelSettingsTimeFormat": "Formato Ora", "LabelShowAll": "Mostra Tutto", "LabelSize": "Dimensione", diff --git a/client/strings/lt.json b/client/strings/lt.json index c5c937ba..dee54e12 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Saugoti viršelius su elementu", "LabelSettingsStoreCoversWithItemHelp": "Pagal nutylėjimą viršeliai saugomi /metadata/items aplanke, įjungus šią parinktį viršeliai bus saugomi jūsų bibliotekos elemento aplanke. Bus išsaugotas tik vienas „cover“ pavadinimo failas.", "LabelSettingsStoreMetadataWithItem": "Saugoti metaduomenis su elementu", - "LabelSettingsStoreMetadataWithItemHelp": "Pagal nutylėjimą metaduomenų failai saugomi /metadata/items aplanke, įjungus šią parinktį metaduomenų failai bus saugomi jūsų bibliotekos elemento aplanke. Naudojamas .abs plėtinys.", + "LabelSettingsStoreMetadataWithItemHelp": "Pagal nutylėjimą metaduomenų failai saugomi /metadata/items aplanke, įjungus šią parinktį metaduomenų failai bus saugomi jūsų bibliotekos elemento aplanke", "LabelSettingsTimeFormat": "Laiko formatas", "LabelShowAll": "Rodyti viską", "LabelSize": "Dydis", diff --git a/client/strings/nl.json b/client/strings/nl.json index a2046d57..62696dce 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Bewaar covers bij onderdeel", "LabelSettingsStoreCoversWithItemHelp": "Standaard worden covers bewaard in /metadata/items, door deze instelling in te schakelen zullen covers in de map van je bibliotheekonderdeel bewaard worden. Slechts een bestand genaamd \"cover\" zal worden bewaard", "LabelSettingsStoreMetadataWithItem": "Bewaar metadata bij onderdeel", - "LabelSettingsStoreMetadataWithItemHelp": "Standaard worden metadata-bestanden bewaard in /metadata/items, door deze instelling in te schakelen zullen metadata bestanden in de map van je bibliotheekonderdeel bewaard worden. Gebruikt .abs-extensie", + "LabelSettingsStoreMetadataWithItemHelp": "Standaard worden metadata-bestanden bewaard in /metadata/items, door deze instelling in te schakelen zullen metadata bestanden in de map van je bibliotheekonderdeel bewaard worden", "LabelSettingsTimeFormat": "Tijdformat", "LabelShowAll": "Toon alle", "LabelSize": "Grootte", diff --git a/client/strings/no.json b/client/strings/no.json index 26a282af..dc7685ee 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Lagre bokomslag med gjenstand", "LabelSettingsStoreCoversWithItemHelp": "Som standard vil bokomslag bli lagret under /metadata/items, aktiveres dette valget vil bokomslag bli lagret i samme mappe som gjenstanden. Kun en fil med navn \"cover\" vil bli beholdt", "LabelSettingsStoreMetadataWithItem": "Lagre metadata med gjenstand", - "LabelSettingsStoreMetadataWithItemHelp": "Som standard vil metadata bli lagret under /metadata/items, aktiveres dette valget vil metadata bli lagret i samme mappe som gjenstanden. Bruker .abs filetternavn", + "LabelSettingsStoreMetadataWithItemHelp": "Som standard vil metadata bli lagret under /metadata/items, aktiveres dette valget vil metadata bli lagret i samme mappe som gjenstanden", "LabelSettingsTimeFormat": "Tid format", "LabelShowAll": "Vis alt", "LabelSize": "Størrelse", diff --git a/client/strings/pl.json b/client/strings/pl.json index b38406ba..c4fb50f8 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Przechowuj okładkę w folderze książki", "LabelSettingsStoreCoversWithItemHelp": "Domyślnie okładki są przechowywane w folderze /metadata/items, włączenie tej opcji spowoduje, że okładka będzie przechowywana w folderze ksiązki. Tylko jedna okładka o nazwie pliku \"cover\" będzie przechowywana.", "LabelSettingsStoreMetadataWithItem": "Przechowuj metadane w folderze książki", - "LabelSettingsStoreMetadataWithItemHelp": "Domyślnie metadane są przechowywane w folderze /metadata/items, włączenie tej opcji spowoduje, że okładka będzie przechowywana w folderze ksiązki. Tylko jedna okładka o nazwie pliku \"cover\" będzie przechowywana. Rozszerzenie pliku metadanych: .abs", + "LabelSettingsStoreMetadataWithItemHelp": "Domyślnie metadane są przechowywane w folderze /metadata/items, włączenie tej opcji spowoduje, że okładka będzie przechowywana w folderze ksiązki. Tylko jedna okładka o nazwie pliku \"cover\" będzie przechowywana", "LabelSettingsTimeFormat": "Time Format", "LabelShowAll": "Pokaż wszystko", "LabelSize": "Rozmiar", diff --git a/client/strings/ru.json b/client/strings/ru.json index 94a8bc63..69868bca 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Хранить обложки с элементом", "LabelSettingsStoreCoversWithItemHelp": "По умолчанию обложки сохраняются в папке /metadata/items, при включении этой настройки обложка будет храниться в папке элемента. Будет сохраняться только один файл с именем \"cover\"", "LabelSettingsStoreMetadataWithItem": "Хранить метаинформацию с элементом", - "LabelSettingsStoreMetadataWithItemHelp": "По умолчанию метаинформация сохраняется в папке /metadata/items, при включении этой настройки метаинформация будет храниться в папке элемента. Используется расширение файла .abs", + "LabelSettingsStoreMetadataWithItemHelp": "По умолчанию метаинформация сохраняется в папке /metadata/items, при включении этой настройки метаинформация будет храниться в папке элемента", "LabelSettingsTimeFormat": "Формат времени", "LabelShowAll": "Показать все", "LabelSize": "Размер", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 0ea26727..219e861a 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "存储项目封面", "LabelSettingsStoreCoversWithItemHelp": "默认情况下封面存储在/metadata/items文件夹中, 启用此设置将存储封面在你媒体项目文件夹中. 只保留一个名为 \"cover\" 的文件", "LabelSettingsStoreMetadataWithItem": "存储项目元数据", - "LabelSettingsStoreMetadataWithItemHelp": "默认情况下元数据文件存储在/metadata/items文件夹中, 启用此设置将存储元数据在你媒体项目文件夹中. 使 .abs 文件护展名", + "LabelSettingsStoreMetadataWithItemHelp": "默认情况下元数据文件存储在/metadata/items文件夹中, 启用此设置将存储元数据在你媒体项目文件夹中", "LabelSettingsTimeFormat": "时间格式", "LabelShowAll": "全部显示", "LabelSize": "文件大小", diff --git a/package.json b/package.json index e76147d8..f8ea7dee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "audiobookshelf", "version": "2.4.4", + "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", "scripts": { @@ -45,4 +46,4 @@ "devDependencies": { "nodemon": "^2.0.20" } -} +} \ No newline at end of file diff --git a/server/Database.js b/server/Database.js index 521e016d..5721ac27 100644 --- a/server/Database.js +++ b/server/Database.js @@ -276,11 +276,17 @@ class Database { global.ServerSettings = this.serverSettings.toJSON() // Version specific migrations - if (this.serverSettings.version === '2.3.0' && this.compareVersions(packageJson.version, '2.3.0') == 1) { - await dbMigration.migrationPatch(this) + if (packageJson.version !== this.serverSettings.version) { + if (this.serverSettings.version === '2.3.0' && this.compareVersions(packageJson.version, '2.3.0') == 1) { + await dbMigration.migrationPatch(this) + } + if (['2.3.0', '2.3.1', '2.3.2', '2.3.3'].includes(this.serverSettings.version) && this.compareVersions(packageJson.version, '2.3.3') >= 0) { + await dbMigration.migrationPatch2(this) + } } - if (['2.3.0', '2.3.1', '2.3.2', '2.3.3'].includes(this.serverSettings.version) && this.compareVersions(packageJson.version, '2.3.3') >= 0) { - await dbMigration.migrationPatch2(this) + // Build migrations + if (this.serverSettings.buildNumber <= 0) { + await require('./utils/migrations/absMetadataMigration').migrate(this) } await this.cleanDatabase() @@ -288,9 +294,19 @@ class Database { // Set if root user has been created this.hasRootUser = await this.models.user.getHasRootUser() + // Update server settings with version/build + let updateServerSettings = false if (packageJson.version !== this.serverSettings.version) { Logger.info(`[Database] Server upgrade detected from ${this.serverSettings.version} to ${packageJson.version}`) this.serverSettings.version = packageJson.version + this.serverSettings.buildNumber = packageJson.buildNumber + updateServerSettings = true + } else if (packageJson.buildNumber !== this.serverSettings.buildNumber) { + Logger.info(`[Database] Server v${packageJson.version} build upgraded from ${this.serverSettings.buildNumber} to ${packageJson.buildNumber}`) + this.serverSettings.buildNumber = packageJson.buildNumber + updateServerSettings = true + } + if (updateServerSettings) { await this.updateServerSettings() } } diff --git a/server/models/Book.js b/server/models/Book.js index 31bcfa3c..9537d7b3 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -211,6 +211,32 @@ class Book extends Model { } } + getAbsMetadataJson() { + return { + tags: this.tags || [], + chapters: this.chapters?.map(c => ({ ...c })) || [], + title: this.title, + subtitle: this.subtitle, + authors: this.authors.map(a => a.name), + narrators: this.narrators, + series: this.series.map(se => { + const sequence = se.bookSeries?.sequence || '' + if (!sequence) return se.name + return `${se.name} #${sequence}` + }), + genres: this.genres || [], + publishedYear: this.publishedYear, + publishedDate: this.publishedDate, + publisher: this.publisher, + description: this.description, + isbn: this.isbn, + asin: this.asin, + language: this.language, + explicit: !!this.explicit, + abridged: !!this.abridged + } + } + /** * Initialize model * @param {import('../Database').sequelize} sequelize diff --git a/server/models/Podcast.js b/server/models/Podcast.js index 60311bfd..82ae8fe2 100644 --- a/server/models/Podcast.js +++ b/server/models/Podcast.js @@ -112,6 +112,25 @@ class Podcast extends Model { } } + getAbsMetadataJson() { + return { + tags: this.tags || [], + title: this.title, + author: this.author, + description: this.description, + releaseDate: this.releaseDate, + genres: this.genres || [], + feedURL: this.feedURL, + imageURL: this.imageURL, + itunesPageURL: this.itunesPageURL, + itunesId: this.itunesId, + itunesArtistId: this.itunesArtistId, + language: this.language, + explicit: !!this.explicit, + podcastType: this.podcastType + } + } + /** * Initialize model * @param {import('../Database').sequelize} sequelize diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index bb91e2d6..3b92bdcc 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -2,7 +2,6 @@ const uuidv4 = require("uuid").v4 const fs = require('../libs/fsExtra') const Path = require('path') const Logger = require('../Logger') -const abmetadataGenerator = require('../utils/generators/abmetadataGenerator') const LibraryFile = require('./files/LibraryFile') const Book = require('./mediaTypes/Book') const Podcast = require('./mediaTypes/Podcast') @@ -263,7 +262,7 @@ class LibraryItem { } /** - * Save metadata.json/metadata.abs file + * Save metadata.json file * TODO: Move to new LibraryItem model * @returns {Promise} null if not saved */ @@ -282,91 +281,41 @@ class LibraryItem { await fs.ensureDir(metadataPath) } - const metadataFileFormat = global.ServerSettings.metadataFileFormat - const metadataFilePath = Path.join(metadataPath, `metadata.${metadataFileFormat}`) - if (metadataFileFormat === 'json') { - // Remove metadata.abs if it exists - if (await fs.pathExists(Path.join(metadataPath, `metadata.abs`))) { - Logger.debug(`[LibraryItem] Removing metadata.abs for item "${this.media.metadata.title}"`) - await fs.remove(Path.join(metadataPath, `metadata.abs`)) - this.libraryFiles = this.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.abs`))) + const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`) + + return fs.writeFile(metadataFilePath, JSON.stringify(this.media.toJSONForMetadataFile(), null, 2)).then(async () => { + // Add metadata.json to libraryFiles array if it is new + let metadataLibraryFile = this.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) + if (storeMetadataWithItem) { + if (!metadataLibraryFile) { + metadataLibraryFile = new LibraryFile() + await metadataLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) + this.libraryFiles.push(metadataLibraryFile) + } else { + const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) + if (fileTimestamps) { + metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs + metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs + metadataLibraryFile.metadata.size = fileTimestamps.size + metadataLibraryFile.ino = fileTimestamps.ino + } + } + const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path) + if (libraryItemDirTimestamps) { + this.mtimeMs = libraryItemDirTimestamps.mtimeMs + this.ctimeMs = libraryItemDirTimestamps.ctimeMs + } } - return fs.writeFile(metadataFilePath, JSON.stringify(this.media.toJSONForMetadataFile(), null, 2)).then(async () => { - // Add metadata.json to libraryFiles array if it is new - let metadataLibraryFile = this.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) - if (storeMetadataWithItem) { - if (!metadataLibraryFile) { - metadataLibraryFile = new LibraryFile() - await metadataLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) - this.libraryFiles.push(metadataLibraryFile) - } else { - const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) - if (fileTimestamps) { - metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs - metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs - metadataLibraryFile.metadata.size = fileTimestamps.size - metadataLibraryFile.ino = fileTimestamps.ino - } - } - const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path) - if (libraryItemDirTimestamps) { - this.mtimeMs = libraryItemDirTimestamps.mtimeMs - this.ctimeMs = libraryItemDirTimestamps.ctimeMs - } - } + Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`) - Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`) - - return metadataLibraryFile - }).catch((error) => { - Logger.error(`[LibraryItem] Failed to save json file at "${metadataFilePath}"`, error) - return null - }).finally(() => { - this.isSavingMetadata = false - }) - } else { - // Remove metadata.json if it exists - if (await fs.pathExists(Path.join(metadataPath, `metadata.json`))) { - Logger.debug(`[LibraryItem] Removing metadata.json for item "${this.media.metadata.title}"`) - await fs.remove(Path.join(metadataPath, `metadata.json`)) - this.libraryFiles = this.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.json`))) - } - - return abmetadataGenerator.generate(this, metadataFilePath).then(async (success) => { - if (!success) { - Logger.error(`[LibraryItem] Failed saving abmetadata to "${metadataFilePath}"`) - return null - } - // Add metadata.abs to libraryFiles array if it is new - let metadataLibraryFile = this.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) - if (storeMetadataWithItem) { - if (!metadataLibraryFile) { - metadataLibraryFile = new LibraryFile() - await metadataLibraryFile.setDataFromPath(metadataFilePath, `metadata.abs`) - this.libraryFiles.push(metadataLibraryFile) - } else { - const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) - if (fileTimestamps) { - metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs - metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs - metadataLibraryFile.metadata.size = fileTimestamps.size - metadataLibraryFile.ino = fileTimestamps.ino - } - } - const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path) - if (libraryItemDirTimestamps) { - this.mtimeMs = libraryItemDirTimestamps.mtimeMs - this.ctimeMs = libraryItemDirTimestamps.ctimeMs - } - } - - Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`) - return metadataLibraryFile - }).finally(() => { - this.isSavingMetadata = false - }) - } + return metadataLibraryFile + }).catch((error) => { + Logger.error(`[LibraryItem] Failed to save json file at "${metadataFilePath}"`, error) + return null + }).finally(() => { + this.isSavingMetadata = false + }) } removeLibraryFile(ino) { diff --git a/server/objects/mediaTypes/Book.js b/server/objects/mediaTypes/Book.js index afbf1622..d53a53a7 100644 --- a/server/objects/mediaTypes/Book.js +++ b/server/objects/mediaTypes/Book.js @@ -94,7 +94,7 @@ class Book { return { tags: [...this.tags], chapters: this.chapters.map(c => ({ ...c })), - metadata: this.metadata.toJSONForMetadataFile() + ...this.metadata.toJSONForMetadataFile() } } diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js index 969e2548..a0e5de04 100644 --- a/server/objects/mediaTypes/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -97,7 +97,19 @@ class Podcast { toJSONForMetadataFile() { return { tags: [...this.tags], - metadata: this.metadata.toJSON() + title: this.metadata.title, + author: this.metadata.author, + description: this.metadata.description, + releaseDate: this.metadata.releaseDate, + genres: [...this.metadata.genres], + feedURL: this.metadata.feedUrl, + imageURL: this.metadata.imageUrl, + itunesPageURL: this.metadata.itunesPageUrl, + itunesId: this.metadata.itunesId, + itunesArtistId: this.metadata.itunesArtistId, + explicit: this.metadata.explicit, + language: this.metadata.language, + podcastType: this.metadata.type } } diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index 5c0d9dad..f31aaf6b 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -1,3 +1,4 @@ +const packageJson = require('../../../package.json') const { BookshelfView } = require('../../utils/constants') const Logger = require('../../Logger') @@ -50,7 +51,8 @@ class ServerSettings { this.logLevel = Logger.logLevel - this.version = null + this.version = packageJson.version + this.buildNumber = packageJson.buildNumber if (settings) { this.construct(settings) @@ -90,6 +92,7 @@ class ServerSettings { this.language = settings.language || 'en-us' this.logLevel = settings.logLevel || Logger.logLevel this.version = settings.version || null + this.buildNumber = settings.buildNumber || 0 // Added v2.4.5 // Migrations if (settings.storeCoverWithBook != undefined) { // storeCoverWithBook was renamed to storeCoverWithItem in 2.0.0 @@ -106,9 +109,9 @@ class ServerSettings { this.metadataFileFormat = 'abs' } - // Validation - if (!['abs', 'json'].includes(this.metadataFileFormat)) { - Logger.error(`[ServerSettings] construct: Invalid metadataFileFormat ${this.metadataFileFormat}`) + // As of v2.4.5 only json is supported + if (this.metadataFileFormat !== 'json') { + Logger.warn(`[ServerSettings] Invalid metadataFileFormat ${this.metadataFileFormat} (as of v2.4.5 only json is supported)`) this.metadataFileFormat = 'json' } @@ -146,7 +149,8 @@ class ServerSettings { timeFormat: this.timeFormat, language: this.language, logLevel: this.logLevel, - version: this.version + version: this.version, + buildNumber: this.buildNumber } } diff --git a/server/scanner/AbsMetadataFileScanner.js b/server/scanner/AbsMetadataFileScanner.js index 037726f6..1f9d2823 100644 --- a/server/scanner/AbsMetadataFileScanner.js +++ b/server/scanner/AbsMetadataFileScanner.js @@ -8,7 +8,7 @@ class AbsMetadataFileScanner { constructor() { } /** - * Check for metadata.json or metadata.abs file and set book metadata + * Check for metadata.json file and set book metadata * * @param {import('./LibraryScan')} libraryScan * @param {import('./LibraryItemScanData')} libraryItemData @@ -16,54 +16,36 @@ class AbsMetadataFileScanner { * @param {string} [existingLibraryItemId] */ async scanBookMetadataFile(libraryScan, libraryItemData, bookMetadata, existingLibraryItemId = null) { - const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile || libraryItemData.metadataAbsLibraryFile + const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile let metadataText = metadataLibraryFile ? await readTextFile(metadataLibraryFile.metadata.path) : null let metadataFilePath = metadataLibraryFile?.metadata.path - let metadataFileFormat = libraryItemData.metadataJsonLibraryFile ? 'json' : 'abs' // When metadata file is not stored with library item then check in the /metadata/items folder for it if (!metadataText && existingLibraryItemId) { let metadataPath = Path.join(global.MetadataPath, 'items', existingLibraryItemId) - let altFormat = global.ServerSettings.metadataFileFormat === 'json' ? 'abs' : 'json' - // First check the metadata format set in server settings, fallback to the alternate - metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`) - metadataFileFormat = global.ServerSettings.metadataFileFormat + metadataFilePath = Path.join(metadataPath, 'metadata.json') if (await fsExtra.pathExists(metadataFilePath)) { metadataText = await readTextFile(metadataFilePath) - } else if (await fsExtra.pathExists(Path.join(metadataPath, `metadata.${altFormat}`))) { - metadataFilePath = Path.join(metadataPath, `metadata.${altFormat}`) - metadataFileFormat = altFormat - metadataText = await readTextFile(metadataFilePath) } } if (metadataText) { - libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}" - preferring`) - let abMetadata = null - if (metadataFileFormat === 'json') { - abMetadata = abmetadataGenerator.parseJson(metadataText) - } else { - abMetadata = abmetadataGenerator.parse(metadataText, 'book') - } + libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`) + const abMetadata = abmetadataGenerator.parseJson(metadataText) || {} + for (const key in abMetadata) { + // TODO: When to override with null or empty arrays? + if (abMetadata[key] === undefined || abMetadata[key] === null) continue + if (key === 'tags' && !abMetadata.tags?.length) continue + if (key === 'chapters' && !abMetadata.chapters?.length) continue - if (abMetadata) { - if (abMetadata.tags?.length) { - bookMetadata.tags = abMetadata.tags - } - if (abMetadata.chapters?.length) { - bookMetadata.chapters = abMetadata.chapters - } - for (const key in abMetadata.metadata) { - if (abMetadata.metadata[key] === undefined || abMetadata.metadata[key] === null) continue - bookMetadata[key] = abMetadata.metadata[key] - } + bookMetadata[key] = abMetadata[key] } } } /** - * Check for metadata.json or metadata.abs file and set podcast metadata + * Check for metadata.json file and set podcast metadata * * @param {import('./LibraryScan')} libraryScan * @param {import('./LibraryItemScanData')} libraryItemData @@ -71,53 +53,28 @@ class AbsMetadataFileScanner { * @param {string} [existingLibraryItemId] */ async scanPodcastMetadataFile(libraryScan, libraryItemData, podcastMetadata, existingLibraryItemId = null) { - const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile || libraryItemData.metadataAbsLibraryFile + const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile let metadataText = metadataLibraryFile ? await readTextFile(metadataLibraryFile.metadata.path) : null let metadataFilePath = metadataLibraryFile?.metadata.path - let metadataFileFormat = libraryItemData.metadataJsonLibraryFile ? 'json' : 'abs' // When metadata file is not stored with library item then check in the /metadata/items folder for it if (!metadataText && existingLibraryItemId) { let metadataPath = Path.join(global.MetadataPath, 'items', existingLibraryItemId) - let altFormat = global.ServerSettings.metadataFileFormat === 'json' ? 'abs' : 'json' - // First check the metadata format set in server settings, fallback to the alternate - metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`) - metadataFileFormat = global.ServerSettings.metadataFileFormat + metadataFilePath = Path.join(metadataPath, 'metadata.json') if (await fsExtra.pathExists(metadataFilePath)) { metadataText = await readTextFile(metadataFilePath) - } else if (await fsExtra.pathExists(Path.join(metadataPath, `metadata.${altFormat}`))) { - metadataFilePath = Path.join(metadataPath, `metadata.${altFormat}`) - metadataFileFormat = altFormat - metadataText = await readTextFile(metadataFilePath) } } if (metadataText) { - libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}" - preferring`) - let abMetadata = null - if (metadataFileFormat === 'json') { - abMetadata = abmetadataGenerator.parseJson(metadataText) - } else { - abMetadata = abmetadataGenerator.parse(metadataText, 'podcast') - } + libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`) + const abMetadata = abmetadataGenerator.parseJson(metadataText) || {} + for (const key in abMetadata) { + if (abMetadata[key] === undefined || abMetadata[key] === null) continue + if (key === 'tags' && !abMetadata.tags?.length) continue - if (abMetadata) { - if (abMetadata.tags?.length) { - podcastMetadata.tags = abMetadata.tags - } - for (const key in abMetadata.metadata) { - if (abMetadata.metadata[key] === undefined) continue - - // TODO: New podcast model changed some keys, need to update the abmetadataGenerator - let newModelKey = key - if (key === 'feedUrl') newModelKey = 'feedURL' - else if (key === 'imageUrl') newModelKey = 'imageURL' - else if (key === 'itunesPageUrl') newModelKey = 'itunesPageURL' - else if (key === 'type') newModelKey = 'podcastType' - - podcastMetadata[newModelKey] = abMetadata.metadata[key] - } + podcastMetadata[key] = abMetadata[key] } } } diff --git a/server/scanner/BookScanner.js b/server/scanner/BookScanner.js index f752417c..282155f2 100644 --- a/server/scanner/BookScanner.js +++ b/server/scanner/BookScanner.js @@ -678,10 +678,10 @@ class BookScanner { } /** - * Metadata from metadata.json or metadata.abs + * Metadata from metadata.json */ async absMetadata() { - // If metadata.json or metadata.abs use this for metadata + // If metadata.json use this for metadata await AbsMetadataFileScanner.scanBookMetadataFile(this.libraryScan, this.libraryItemData, this.bookMetadata, this.existingLibraryItemId) } } @@ -703,121 +703,66 @@ class BookScanner { await fsExtra.ensureDir(metadataPath) } - const metadataFileFormat = global.ServerSettings.metadataFileFormat - const metadataFilePath = Path.join(metadataPath, `metadata.${metadataFileFormat}`) - if (metadataFileFormat === 'json') { - // Remove metadata.abs if it exists - if (await fsExtra.pathExists(Path.join(metadataPath, `metadata.abs`))) { - libraryScan.addLog(LogLevel.DEBUG, `Removing metadata.abs for item "${libraryItem.media.title}"`) - await fsExtra.remove(Path.join(metadataPath, `metadata.abs`)) - libraryItem.libraryFiles = libraryItem.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.abs`))) - } + const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`) - // TODO: Update to not use `metadata` so it fits the updated model - const jsonObject = { - tags: libraryItem.media.tags || [], - chapters: libraryItem.media.chapters?.map(c => ({ ...c })) || [], - metadata: { - title: libraryItem.media.title, - subtitle: libraryItem.media.subtitle, - authors: libraryItem.media.authors.map(a => a.name), - narrators: libraryItem.media.narrators, - series: libraryItem.media.series.map(se => { - const sequence = se.bookSeries?.sequence || '' - if (!sequence) return se.name - return `${se.name} #${sequence}` - }), - genres: libraryItem.media.genres || [], - publishedYear: libraryItem.media.publishedYear, - publishedDate: libraryItem.media.publishedDate, - publisher: libraryItem.media.publisher, - description: libraryItem.media.description, - isbn: libraryItem.media.isbn, - asin: libraryItem.media.asin, - language: libraryItem.media.language, - explicit: !!libraryItem.media.explicit, - abridged: !!libraryItem.media.abridged - } - } - return fsExtra.writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2)).then(async () => { - // Add metadata.json to libraryFiles array if it is new - let metadataLibraryFile = libraryItem.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) - if (storeMetadataWithItem) { - if (!metadataLibraryFile) { - const newLibraryFile = new LibraryFile() - await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) - metadataLibraryFile = newLibraryFile.toJSON() - libraryItem.libraryFiles.push(metadataLibraryFile) - } else { - const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) - if (fileTimestamps) { - metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs - metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs - metadataLibraryFile.metadata.size = fileTimestamps.size - metadataLibraryFile.ino = fileTimestamps.ino - } - } - const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path) - if (libraryItemDirTimestamps) { - libraryItem.mtime = libraryItemDirTimestamps.mtimeMs - libraryItem.ctime = libraryItemDirTimestamps.ctimeMs - let size = 0 - libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) - libraryItem.size = size - } - } - - libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`) - - return metadataLibraryFile - }).catch((error) => { - libraryScan.addLog(LogLevel.ERROR, `Failed to save json file at "${metadataFilePath}"`, error) - return null - }) - } else { - // Remove metadata.json if it exists - if (await fsExtra.pathExists(Path.join(metadataPath, `metadata.json`))) { - libraryScan.addLog(LogLevel.DEBUG, `Removing metadata.json for item "${libraryItem.media.title}"`) - await fsExtra.remove(Path.join(metadataPath, `metadata.json`)) - libraryItem.libraryFiles = libraryItem.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.json`))) - } - - return abmetadataGenerator.generateFromNewModel(libraryItem, metadataFilePath).then(async (success) => { - if (!success) { - libraryScan.addLog(LogLevel.ERROR, `Failed saving abmetadata to "${metadataFilePath}"`) - return null - } - // Add metadata.abs to libraryFiles array if it is new - let metadataLibraryFile = libraryItem.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) - if (storeMetadataWithItem) { - if (!metadataLibraryFile) { - const newLibraryFile = new LibraryFile() - await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.abs`) - metadataLibraryFile = newLibraryFile.toJSON() - libraryItem.libraryFiles.push(metadataLibraryFile) - } else { - const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) - if (fileTimestamps) { - metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs - metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs - metadataLibraryFile.metadata.size = fileTimestamps.size - metadataLibraryFile.ino = fileTimestamps.ino - } - } - const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path) - if (libraryItemDirTimestamps) { - libraryItem.mtime = libraryItemDirTimestamps.mtimeMs - libraryItem.ctime = libraryItemDirTimestamps.ctimeMs - let size = 0 - libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) - libraryItem.size = size - } - } - - libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`) - return metadataLibraryFile - }) + const jsonObject = { + tags: libraryItem.media.tags || [], + chapters: libraryItem.media.chapters?.map(c => ({ ...c })) || [], + title: libraryItem.media.title, + subtitle: libraryItem.media.subtitle, + authors: libraryItem.media.authors.map(a => a.name), + narrators: libraryItem.media.narrators, + series: libraryItem.media.series.map(se => { + const sequence = se.bookSeries?.sequence || '' + if (!sequence) return se.name + return `${se.name} #${sequence}` + }), + genres: libraryItem.media.genres || [], + publishedYear: libraryItem.media.publishedYear, + publishedDate: libraryItem.media.publishedDate, + publisher: libraryItem.media.publisher, + description: libraryItem.media.description, + isbn: libraryItem.media.isbn, + asin: libraryItem.media.asin, + language: libraryItem.media.language, + explicit: !!libraryItem.media.explicit, + abridged: !!libraryItem.media.abridged } + return fsExtra.writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2)).then(async () => { + // Add metadata.json to libraryFiles array if it is new + let metadataLibraryFile = libraryItem.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) + if (storeMetadataWithItem) { + if (!metadataLibraryFile) { + const newLibraryFile = new LibraryFile() + await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) + metadataLibraryFile = newLibraryFile.toJSON() + libraryItem.libraryFiles.push(metadataLibraryFile) + } else { + const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) + if (fileTimestamps) { + metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs + metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs + metadataLibraryFile.metadata.size = fileTimestamps.size + metadataLibraryFile.ino = fileTimestamps.ino + } + } + const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path) + if (libraryItemDirTimestamps) { + libraryItem.mtime = libraryItemDirTimestamps.mtimeMs + libraryItem.ctime = libraryItemDirTimestamps.ctimeMs + let size = 0 + libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) + libraryItem.size = size + } + } + + libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`) + + return metadataLibraryFile + }).catch((error) => { + libraryScan.addLog(LogLevel.ERROR, `Failed to save json file at "${metadataFilePath}"`, error) + return null + }) } /** diff --git a/server/scanner/PodcastScanner.js b/server/scanner/PodcastScanner.js index 53d4ad1f..b56c4db6 100644 --- a/server/scanner/PodcastScanner.js +++ b/server/scanner/PodcastScanner.js @@ -342,7 +342,7 @@ class PodcastScanner { AudioFileScanner.setPodcastMetadataFromAudioMetaTags(podcastEpisodes[0].audioFile, podcastMetadata, libraryScan) } - // Use metadata.json or metadata.abs file + // Use metadata.json file await AbsMetadataFileScanner.scanPodcastMetadataFile(libraryScan, libraryItemData, podcastMetadata, existingLibraryItemId) podcastMetadata.titleIgnorePrefix = getTitleIgnorePrefix(podcastMetadata.title) @@ -367,115 +367,60 @@ class PodcastScanner { await fsExtra.ensureDir(metadataPath) } - const metadataFileFormat = global.ServerSettings.metadataFileFormat - const metadataFilePath = Path.join(metadataPath, `metadata.${metadataFileFormat}`) - if (metadataFileFormat === 'json') { - // Remove metadata.abs if it exists - if (await fsExtra.pathExists(Path.join(metadataPath, `metadata.abs`))) { - libraryScan.addLog(LogLevel.DEBUG, `Removing metadata.abs for item "${libraryItem.media.title}"`) - await fsExtra.remove(Path.join(metadataPath, `metadata.abs`)) - libraryItem.libraryFiles = libraryItem.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.abs`))) - } + const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`) - // TODO: Update to not use `metadata` so it fits the updated model - const jsonObject = { - tags: libraryItem.media.tags || [], - metadata: { - title: libraryItem.media.title, - author: libraryItem.media.author, - description: libraryItem.media.description, - releaseDate: libraryItem.media.releaseDate, - genres: libraryItem.media.genres || [], - feedUrl: libraryItem.media.feedURL, - imageUrl: libraryItem.media.imageURL, - itunesPageUrl: libraryItem.media.itunesPageURL, - itunesId: libraryItem.media.itunesId, - itunesArtistId: libraryItem.media.itunesArtistId, - asin: libraryItem.media.asin, - language: libraryItem.media.language, - explicit: !!libraryItem.media.explicit, - type: libraryItem.media.podcastType - } - } - return fsExtra.writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2)).then(async () => { - // Add metadata.json to libraryFiles array if it is new - let metadataLibraryFile = libraryItem.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) - if (storeMetadataWithItem) { - if (!metadataLibraryFile) { - const newLibraryFile = new LibraryFile() - await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) - metadataLibraryFile = newLibraryFile.toJSON() - libraryItem.libraryFiles.push(metadataLibraryFile) - } else { - const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) - if (fileTimestamps) { - metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs - metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs - metadataLibraryFile.metadata.size = fileTimestamps.size - metadataLibraryFile.ino = fileTimestamps.ino - } - } - const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path) - if (libraryItemDirTimestamps) { - libraryItem.mtime = libraryItemDirTimestamps.mtimeMs - libraryItem.ctime = libraryItemDirTimestamps.ctimeMs - let size = 0 - libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) - libraryItem.size = size - } - } - - libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`) - - return metadataLibraryFile - }).catch((error) => { - libraryScan.addLog(LogLevel.ERROR, `Failed to save json file at "${metadataFilePath}"`, error) - return null - }) - } else { - // Remove metadata.json if it exists - if (await fsExtra.pathExists(Path.join(metadataPath, `metadata.json`))) { - libraryScan.addLog(LogLevel.DEBUG, `Removing metadata.json for item "${libraryItem.media.title}"`) - await fsExtra.remove(Path.join(metadataPath, `metadata.json`)) - libraryItem.libraryFiles = libraryItem.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.json`))) - } - - return abmetadataGenerator.generateFromNewModel(libraryItem, metadataFilePath).then(async (success) => { - if (!success) { - libraryScan.addLog(LogLevel.ERROR, `Failed saving abmetadata to "${metadataFilePath}"`) - return null - } - // Add metadata.abs to libraryFiles array if it is new - let metadataLibraryFile = libraryItem.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) - if (storeMetadataWithItem) { - if (!metadataLibraryFile) { - const newLibraryFile = new LibraryFile() - await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.abs`) - metadataLibraryFile = newLibraryFile.toJSON() - libraryItem.libraryFiles.push(metadataLibraryFile) - } else { - const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) - if (fileTimestamps) { - metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs - metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs - metadataLibraryFile.metadata.size = fileTimestamps.size - metadataLibraryFile.ino = fileTimestamps.ino - } - } - const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path) - if (libraryItemDirTimestamps) { - libraryItem.mtime = libraryItemDirTimestamps.mtimeMs - libraryItem.ctime = libraryItemDirTimestamps.ctimeMs - let size = 0 - libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) - libraryItem.size = size - } - } - - libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`) - return metadataLibraryFile - }) + const jsonObject = { + tags: libraryItem.media.tags || [], + title: libraryItem.media.title, + author: libraryItem.media.author, + description: libraryItem.media.description, + releaseDate: libraryItem.media.releaseDate, + genres: libraryItem.media.genres || [], + feedURL: libraryItem.media.feedURL, + imageURL: libraryItem.media.imageURL, + itunesPageURL: libraryItem.media.itunesPageURL, + itunesId: libraryItem.media.itunesId, + itunesArtistId: libraryItem.media.itunesArtistId, + asin: libraryItem.media.asin, + language: libraryItem.media.language, + explicit: !!libraryItem.media.explicit, + podcastType: libraryItem.media.podcastType } + return fsExtra.writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2)).then(async () => { + // Add metadata.json to libraryFiles array if it is new + let metadataLibraryFile = libraryItem.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) + if (storeMetadataWithItem) { + if (!metadataLibraryFile) { + const newLibraryFile = new LibraryFile() + await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) + metadataLibraryFile = newLibraryFile.toJSON() + libraryItem.libraryFiles.push(metadataLibraryFile) + } else { + const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) + if (fileTimestamps) { + metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs + metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs + metadataLibraryFile.metadata.size = fileTimestamps.size + metadataLibraryFile.ino = fileTimestamps.ino + } + } + const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path) + if (libraryItemDirTimestamps) { + libraryItem.mtime = libraryItemDirTimestamps.mtimeMs + libraryItem.ctime = libraryItemDirTimestamps.ctimeMs + let size = 0 + libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) + libraryItem.size = size + } + } + + libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`) + + return metadataLibraryFile + }).catch((error) => { + libraryScan.addLog(LogLevel.ERROR, `Failed to save json file at "${metadataFilePath}"`, error) + return null + }) } } module.exports = new PodcastScanner() \ No newline at end of file diff --git a/server/utils/generators/abmetadataGenerator.js b/server/utils/generators/abmetadataGenerator.js index ff82ac33..e0b78d2e 100644 --- a/server/utils/generators/abmetadataGenerator.js +++ b/server/utils/generators/abmetadataGenerator.js @@ -1,461 +1,26 @@ -const fs = require('../../libs/fsExtra') -const package = require('../../../package.json') const Logger = require('../../Logger') -const { getId } = require('../index') -const areEquivalent = require('../areEquivalent') - - -const CurrentAbMetadataVersion = 2 -// abmetadata v1 key map -// const bookKeyMap = { -// title: 'title', -// subtitle: 'subtitle', -// author: 'authorFL', -// narrator: 'narratorFL', -// publishedYear: 'publishedYear', -// publisher: 'publisher', -// description: 'description', -// isbn: 'isbn', -// asin: 'asin', -// language: 'language', -// genres: 'genresCommaSeparated' -// } - -const commaSeparatedToArray = (v) => { - if (!v) return [] - return [...new Set(v.split(',').map(_v => _v.trim()).filter(_v => _v))] -} - -const podcastMetadataMapper = { - title: { - to: (m) => m.title || '', - from: (v) => v || '' - }, - author: { - to: (m) => m.author || '', - from: (v) => v || null - }, - language: { - to: (m) => m.language || '', - from: (v) => v || null - }, - genres: { - to: (m) => m.genres?.join(', ') || '', - from: (v) => commaSeparatedToArray(v) - }, - feedUrl: { - to: (m) => m.feedUrl || '', - from: (v) => v || null - }, - itunesId: { - to: (m) => m.itunesId || '', - from: (v) => v || null - }, - explicit: { - to: (m) => m.explicit ? 'Y' : 'N', - from: (v) => v && v.toLowerCase() == 'y' - } -} - -const bookMetadataMapper = { - title: { - to: (m) => m.title || '', - from: (v) => v || '' - }, - subtitle: { - to: (m) => m.subtitle || '', - from: (v) => v || null - }, - authors: { - to: (m) => { - if (m.authorName !== undefined) return m.authorName - if (!m.authors?.length) return '' - return m.authors.map(au => au.name).join(', ') - }, - from: (v) => commaSeparatedToArray(v) - }, - narrators: { - to: (m) => m.narrators?.join(', ') || '', - from: (v) => commaSeparatedToArray(v) - }, - publishedYear: { - to: (m) => m.publishedYear || '', - from: (v) => v || null - }, - publisher: { - to: (m) => m.publisher || '', - from: (v) => v || null - }, - isbn: { - to: (m) => m.isbn || '', - from: (v) => v || null - }, - asin: { - to: (m) => m.asin || '', - from: (v) => v || null - }, - language: { - to: (m) => m.language || '', - from: (v) => v || null - }, - genres: { - to: (m) => m.genres?.join(', ') || '', - from: (v) => commaSeparatedToArray(v) - }, - series: { - to: (m) => { - if (m.seriesName !== undefined) return m.seriesName - if (!m.series?.length) return '' - return m.series.map((se) => { - const sequence = se.bookSeries?.sequence || '' - if (!sequence) return se.name - return `${se.name} #${sequence}` - }).join(', ') - }, - from: (v) => { - return commaSeparatedToArray(v).map(series => { // Return array of { name, sequence } - let sequence = null - let name = series - // Series sequence match any characters after " #" other than whitespace and another # - // e.g. "Name #1a" is valid. "Name #1#a" or "Name #1 a" is not valid. - const matchResults = series.match(/ #([^#\s]+)$/) // Pull out sequence # - if (matchResults && matchResults.length && matchResults.length > 1) { - sequence = matchResults[1] // Group 1 - name = series.replace(matchResults[0], '') - } - return { - name, - sequence - } - }) - } - }, - explicit: { - to: (m) => m.explicit ? 'Y' : 'N', - from: (v) => v && v.toLowerCase() == 'y' - }, - abridged: { - to: (m) => m.abridged ? 'Y' : 'N', - from: (v) => v && v.toLowerCase() == 'y' - } -} - -const metadataMappers = { - book: bookMetadataMapper, - podcast: podcastMetadataMapper -} - -function generate(libraryItem, outputPath) { - let fileString = `;ABMETADATA${CurrentAbMetadataVersion}\n` - fileString += `#audiobookshelf v${package.version}\n\n` - - const mediaType = libraryItem.mediaType - - fileString += `media=${mediaType}\n` - fileString += `tags=${JSON.stringify(libraryItem.media.tags)}\n` - - const metadataMapper = metadataMappers[mediaType] - var mediaMetadata = libraryItem.media.metadata - for (const key in metadataMapper) { - fileString += `${key}=${metadataMapper[key].to(mediaMetadata)}\n` - } - - // Description block - if (mediaMetadata.description) { - fileString += '\n[DESCRIPTION]\n' - fileString += mediaMetadata.description + '\n' - } - - // Book chapters - if (libraryItem.mediaType == 'book' && libraryItem.media.chapters.length) { - fileString += '\n' - libraryItem.media.chapters.forEach((chapter) => { - fileString += `[CHAPTER]\n` - fileString += `start=${chapter.start}\n` - fileString += `end=${chapter.end}\n` - fileString += `title=${chapter.title}\n` - }) - } - return fs.writeFile(outputPath, fileString).then(() => true).catch((error) => { - Logger.error(`[absMetaFileGenerator] Failed to save abs file`, error) - return false - }) -} -module.exports.generate = generate - -function generateFromNewModel(libraryItem, outputPath) { - let fileString = `;ABMETADATA${CurrentAbMetadataVersion}\n` - fileString += `#audiobookshelf v${package.version}\n\n` - - const mediaType = libraryItem.mediaType - - fileString += `media=${mediaType}\n` - fileString += `tags=${JSON.stringify(libraryItem.media.tags || '')}\n` - - const metadataMapper = metadataMappers[mediaType] - for (const key in metadataMapper) { - fileString += `${key}=${metadataMapper[key].to(libraryItem.media)}\n` - } - - // Description block - if (libraryItem.media.description) { - fileString += '\n[DESCRIPTION]\n' - fileString += libraryItem.media.description + '\n' - } - - // Book chapters - if (mediaType == 'book' && libraryItem.media.chapters?.length) { - fileString += '\n' - libraryItem.media.chapters.forEach((chapter) => { - fileString += `[CHAPTER]\n` - fileString += `start=${chapter.start}\n` - fileString += `end=${chapter.end}\n` - fileString += `title=${chapter.title}\n` - }) - } - return fs.writeFile(outputPath, fileString).then(() => true).catch((error) => { - Logger.error(`[absMetaFileGenerator] Failed to save abs file`, error) - return false - }) -} -module.exports.generateFromNewModel = generateFromNewModel - -function parseSections(lines) { - if (!lines || !lines.length || !lines[0].startsWith('[')) { // First line must be section start - return [] - } - - var sections = [] - var currentSection = [] - lines.forEach(line => { - if (!line || !line.trim()) return - - if (line.startsWith('[') && currentSection.length) { // current section ended - sections.push(currentSection) - currentSection = [] - } - - currentSection.push(line) - }) - if (currentSection.length) sections.push(currentSection) - return sections -} - -// lines inside chapter section -function parseChapterLines(lines) { - var chapter = { - start: null, - end: null, - title: null - } - - lines.forEach((line) => { - var keyValue = line.split('=') - if (keyValue.length > 1) { - var key = keyValue[0].trim() - var value = keyValue[1].trim() - - if (key === 'start' || key === 'end') { - if (!isNaN(value)) { - chapter[key] = Number(value) - } else { - Logger.warn(`[abmetadataGenerator] Invalid chapter value for ${key}: ${value}`) - } - } else if (key === 'title') { - chapter[key] = value - } - } - }) - - if (chapter.start === null || chapter.end === null || chapter.end < chapter.start) { - Logger.warn(`[abmetadataGenerator] Invalid chapter`) - return null - } - return chapter -} - -function parseTags(value) { - if (!value) return null - try { - const parsedTags = [] - JSON.parse(value).forEach((loadedTag) => { - if (loadedTag.trim()) parsedTags.push(loadedTag) // Only push tags that are non-empty - }) - return parsedTags - } catch (err) { - Logger.error(`[abmetadataGenerator] Error parsing TAGS "${value}":`, err.message) - return null - } -} - -function parseAbMetadataText(text, mediaType) { - if (!text) return null - let lines = text.split(/\r?\n/) - - // Check first line and get abmetadata version number - const firstLine = lines.shift().toLowerCase() - if (!firstLine.startsWith(';abmetadata')) { - Logger.error(`Invalid abmetadata file first line is not ;abmetadata "${firstLine}"`) - return null - } - const abmetadataVersion = Number(firstLine.replace(';abmetadata', '').trim()) - if (isNaN(abmetadataVersion) || abmetadataVersion != CurrentAbMetadataVersion) { - Logger.warn(`Invalid abmetadata version ${abmetadataVersion} - must use version ${CurrentAbMetadataVersion}`) - return null - } - - // Remove comments and empty lines - const ignoreFirstChars = [' ', '#', ';'] // Ignore any line starting with the following - lines = lines.filter(line => !!line.trim() && !ignoreFirstChars.includes(line[0])) - - // Get lines that map to book details (all lines before the first chapter or description section) - const firstSectionLine = lines.findIndex(l => l.startsWith('[')) - const detailLines = firstSectionLine > 0 ? lines.slice(0, firstSectionLine) : lines - const remainingLines = firstSectionLine > 0 ? lines.slice(firstSectionLine) : [] - - if (!detailLines.length) { - Logger.error(`Invalid abmetadata file no detail lines`) - return null - } - - // Check the media type saved for this abmetadata file show warning if not matching expected - if (detailLines[0].toLowerCase().startsWith('media=')) { - const mediaLine = detailLines.shift() // Remove media line - const abMediaType = mediaLine.toLowerCase().split('=')[1].trim() - if (abMediaType != mediaType) { - Logger.warn(`Invalid media type in abmetadata file ${abMediaType} expecting ${mediaType}`) - } - } else { - Logger.warn(`No media type found in abmetadata file - expecting ${mediaType}`) - } - - const metadataMapper = metadataMappers[mediaType] - // Put valid book detail values into map - const mediaDetails = { - metadata: {}, - chapters: [], - tags: null // When tags are null it will not be used - } - - for (let i = 0; i < detailLines.length; i++) { - const line = detailLines[i] - const keyValue = line.split('=') - if (keyValue.length < 2) { - Logger.warn('abmetadata invalid line has no =', line) - } else if (keyValue[0].trim() === 'tags') { // Parse tags - const value = keyValue.slice(1).join('=').trim() // Everything after "tags=" - mediaDetails.tags = parseTags(value) - } else if (!metadataMapper[keyValue[0].trim()]) { // Ensure valid media metadata key - Logger.warn(`abmetadata key "${keyValue[0].trim()}" is not a valid ${mediaType} metadata key`) - } else { - const key = keyValue.shift().trim() - const value = keyValue.join('=').trim() - mediaDetails.metadata[key] = metadataMapper[key].from(value) - } - } - - // Parse sections for description and chapters - const sections = parseSections(remainingLines) - sections.forEach((section) => { - const sectionHeader = section.shift() - if (sectionHeader.toLowerCase().startsWith('[description]')) { - mediaDetails.metadata.description = section.join('\n') - } else if (sectionHeader.toLowerCase().startsWith('[chapter]')) { - const chapter = parseChapterLines(section) - if (chapter) { - mediaDetails.chapters.push(chapter) - } - } - }) - - mediaDetails.chapters.sort((a, b) => a.start - b.start) - - if (mediaDetails.chapters.length) { - mediaDetails.chapters = cleanChaptersArray(mediaDetails.chapters, mediaDetails.metadata.title) || [] - } - - return mediaDetails -} -module.exports.parse = parseAbMetadataText - -function checkUpdatedBookAuthors(abmetadataAuthors, authors) { - const finalAuthors = [] - let hasUpdates = false - - abmetadataAuthors.forEach((authorName) => { - const findAuthor = authors.find(au => au.name.toLowerCase() == authorName.toLowerCase()) - if (!findAuthor) { - hasUpdates = true - finalAuthors.push({ - id: getId('new'), // New author gets created in Scanner.js after library scan - name: authorName - }) - } else { - finalAuthors.push(findAuthor) - } - }) - - var authorsRemoved = authors.filter(au => !abmetadataAuthors.some(auname => auname.toLowerCase() == au.name.toLowerCase())) - if (authorsRemoved.length) { - hasUpdates = true - } - - return { - authors: finalAuthors, - hasUpdates - } -} - -function checkUpdatedBookSeries(abmetadataSeries, series) { - var finalSeries = [] - var hasUpdates = false - - abmetadataSeries.forEach((seriesObj) => { - var findSeries = series.find(se => se.name.toLowerCase() == seriesObj.name.toLowerCase()) - if (!findSeries) { - hasUpdates = true - finalSeries.push({ - id: getId('new'), // New series gets created in Scanner.js after library scan - name: seriesObj.name, - sequence: seriesObj.sequence - }) - } else if (findSeries.sequence != seriesObj.sequence) { // Sequence was updated - hasUpdates = true - finalSeries.push({ - id: findSeries.id, - name: findSeries.name, - sequence: seriesObj.sequence - }) - } else { - finalSeries.push(findSeries) - } - }) - - var seriesRemoved = series.filter(se => !abmetadataSeries.some(_se => _se.name.toLowerCase() == se.name.toLowerCase())) - if (seriesRemoved.length) { - hasUpdates = true - } - - return { - series: finalSeries, - hasUpdates - } -} - -function checkArraysChanged(abmetadataArray, mediaArray) { - if (!Array.isArray(abmetadataArray)) return false - if (!Array.isArray(mediaArray)) return true - return abmetadataArray.join(',') != mediaArray.join(',') -} function parseJsonMetadataText(text) { try { const abmetadataData = JSON.parse(text) - if (!abmetadataData.metadata) abmetadataData.metadata = {} - if (abmetadataData.metadata.series?.length) { - abmetadataData.metadata.series = [...new Set(abmetadataData.metadata.series.map(t => t?.trim()).filter(t => t))] - abmetadataData.metadata.series = abmetadataData.metadata.series.map(series => { + // Old metadata.json used nested "metadata" + if (abmetadataData.metadata) { + for (const key in abmetadataData.metadata) { + if (abmetadataData.metadata[key] === undefined) continue + let newModelKey = key + if (key === 'feedUrl') newModelKey = 'feedURL' + else if (key === 'imageUrl') newModelKey = 'imageURL' + else if (key === 'itunesPageUrl') newModelKey = 'itunesPageURL' + else if (key === 'type') newModelKey = 'podcastType' + abmetadataData[newModelKey] = abmetadataData.metadata[key] + } + } + delete abmetadataData.metadata + + if (abmetadataData.series?.length) { + abmetadataData.series = [...new Set(abmetadataData.series.map(t => t?.trim()).filter(t => t))] + abmetadataData.series = abmetadataData.series.map(series => { let sequence = null let name = series // Series sequence match any characters after " #" other than whitespace and another # @@ -476,17 +41,17 @@ function parseJsonMetadataText(text) { abmetadataData.tags = [...new Set(abmetadataData.tags.map(t => t?.trim()).filter(t => t))] } if (abmetadataData.chapters?.length) { - abmetadataData.chapters = cleanChaptersArray(abmetadataData.chapters, abmetadataData.metadata.title) + abmetadataData.chapters = cleanChaptersArray(abmetadataData.chapters, abmetadataData.title) } // clean remove dupes - if (abmetadataData.metadata.authors?.length) { - abmetadataData.metadata.authors = [...new Set(abmetadataData.metadata.authors.map(t => t?.trim()).filter(t => t))] + if (abmetadataData.authors?.length) { + abmetadataData.authors = [...new Set(abmetadataData.authors.map(t => t?.trim()).filter(t => t))] } - if (abmetadataData.metadata.narrators?.length) { - abmetadataData.metadata.narrators = [...new Set(abmetadataData.metadata.narrators.map(t => t?.trim()).filter(t => t))] + if (abmetadataData.narrators?.length) { + abmetadataData.narrators = [...new Set(abmetadataData.narrators.map(t => t?.trim()).filter(t => t))] } - if (abmetadataData.metadata.genres?.length) { - abmetadataData.metadata.genres = [...new Set(abmetadataData.metadata.genres.map(t => t?.trim()).filter(t => t))] + if (abmetadataData.genres?.length) { + abmetadataData.genres = [...new Set(abmetadataData.genres.map(t => t?.trim()).filter(t => t))] } return abmetadataData } catch (error) { @@ -522,73 +87,3 @@ function cleanChaptersArray(chaptersArray, mediaTitle) { } return chapters } - -// Input text from abmetadata file and return object of media changes -// only returns object of changes. empty object means no changes -function parseAndCheckForUpdates(text, media, mediaType, isJSON) { - if (!text || !media || !media.metadata || !mediaType) { - Logger.error(`Invalid inputs to parseAndCheckForUpdates`) - return null - } - - const mediaMetadata = media.metadata - const metadataUpdatePayload = {} // Only updated key/values - - let abmetadataData = null - - if (isJSON) { - abmetadataData = parseJsonMetadataText(text) - } else { - abmetadataData = parseAbMetadataText(text, mediaType) - } - - if (!abmetadataData || !abmetadataData.metadata) { - Logger.error(`[abmetadataGenerator] Invalid metadata file`) - return null - } - - const abMetadata = abmetadataData.metadata // Metadata from abmetadata file - for (const key in abMetadata) { - if (mediaMetadata[key] !== undefined) { - if (key === 'authors') { - const authorUpdatePayload = checkUpdatedBookAuthors(abMetadata[key], mediaMetadata[key]) - if (authorUpdatePayload.hasUpdates) metadataUpdatePayload.authors = authorUpdatePayload.authors - } else if (key === 'series') { - const seriesUpdatePayload = checkUpdatedBookSeries(abMetadata[key], mediaMetadata[key]) - if (seriesUpdatePayload.hasUpdates) metadataUpdatePayload.series = seriesUpdatePayload.series - } else if (key === 'genres' || key === 'narrators') { // Compare array differences - if (checkArraysChanged(abMetadata[key], mediaMetadata[key])) { - metadataUpdatePayload[key] = abMetadata[key] - } - } else if (abMetadata[key] !== mediaMetadata[key]) { - metadataUpdatePayload[key] = abMetadata[key] - } - } else { - Logger.warn('[abmetadataGenerator] Invalid key', key) - } - } - - const updatePayload = {} // Only updated key/values - // Check update tags - if (abmetadataData.tags) { - if (checkArraysChanged(abmetadataData.tags, media.tags)) { - updatePayload.tags = abmetadataData.tags - } - } - - if (abmetadataData.chapters && mediaType === 'book') { - const abmetadataChaptersCleaned = cleanChaptersArray(abmetadataData.chapters) - if (abmetadataChaptersCleaned) { - if (!areEquivalent(abmetadataChaptersCleaned, media.chapters)) { - updatePayload.chapters = abmetadataChaptersCleaned - } - } - } - - if (Object.keys(metadataUpdatePayload).length) { - updatePayload.metadata = metadataUpdatePayload - } - - return updatePayload -} -module.exports.parseAndCheckForUpdates = parseAndCheckForUpdates diff --git a/server/utils/migrations/absMetadataMigration.js b/server/utils/migrations/absMetadataMigration.js new file mode 100644 index 00000000..0d9f909a --- /dev/null +++ b/server/utils/migrations/absMetadataMigration.js @@ -0,0 +1,93 @@ +const Path = require('path') +const Logger = require('../../Logger') +const fsExtra = require('../../libs/fsExtra') +const fileUtils = require('../fileUtils') +const LibraryFile = require('../../objects/files/LibraryFile') + +/** + * + * @param {import('../../models/LibraryItem')} libraryItem + * @returns {Promise} false if failed + */ +async function writeMetadataFileForItem(libraryItem) { + const storeMetadataWithItem = global.ServerSettings.storeMetadataWithItem && !libraryItem.isFile + const metadataPath = storeMetadataWithItem ? libraryItem.path : Path.join(global.MetadataPath, 'items', libraryItem.id) + const metadataFilepath = fileUtils.filePathToPOSIX(Path.join(metadataPath, 'metadata.json')) + if ((await fsExtra.pathExists(metadataFilepath))) { + // Metadata file already exists do nothing + return null + } + Logger.info(`[absMetadataMigration] metadata file not found at "${metadataFilepath}" - creating`) + + if (!storeMetadataWithItem) { + // Ensure /metadata/items/ dir + await fsExtra.ensureDir(metadataPath) + } + + const metadataJson = libraryItem.media.getAbsMetadataJson() + + // Save to file + const success = await fsExtra.writeFile(metadataFilepath, JSON.stringify(metadataJson, null, 2)).then(() => true).catch((error) => { + Logger.error(`[absMetadataMigration] failed to save metadata file at "${metadataFilepath}"`, error.message || error) + return false + }) + + if (!success) return false + if (!storeMetadataWithItem) return true // No need to do anything else + + // Safety check to make sure library file with the same path isnt already there + libraryItem.libraryFiles = libraryItem.libraryFiles.filter(lf => lf.metadata.path !== metadataFilepath) + + // Put new library file in library item + const newLibraryFile = new LibraryFile() + await newLibraryFile.setDataFromPath(metadataFilepath, 'metadata.json') + libraryItem.libraryFiles.push(newLibraryFile.toJSON()) + + // Update library item timestamps and total size + const libraryItemDirTimestamps = await fileUtils.getFileTimestampsWithIno(libraryItem.path) + if (libraryItemDirTimestamps) { + libraryItem.mtime = libraryItemDirTimestamps.mtimeMs + libraryItem.ctime = libraryItemDirTimestamps.ctimeMs + let size = 0 + libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) + libraryItem.size = size + } + + libraryItem.changed('libraryFiles', true) + return libraryItem.save().then(() => true).catch((error) => { + Logger.error(`[absMetadataMigration] failed to save libraryItem "${libraryItem.id}"`, error.message || error) + return false + }) +} + +/** + * + * @param {import('../../Database')} Database + * @param {number} [offset=0] + * @param {number} [totalCreated=0] + */ +async function runMigration(Database, offset = 0, totalCreated = 0) { + const libraryItems = await Database.libraryItemModel.getLibraryItemsIncrement(offset, 500, { isMissing: false }) + if (!libraryItems.length) return totalCreated + + let numCreated = 0 + for (const libraryItem of libraryItems) { + const success = await writeMetadataFileForItem(libraryItem) + if (success) numCreated++ + } + + if (libraryItems.length < 500) { + return totalCreated + numCreated + } + return runMigration(Database, offset + libraryItems.length, totalCreated + numCreated) +} + +/** + * + * @param {import('../../Database')} Database + */ +module.exports.migrate = async (Database) => { + Logger.info(`[absMetadataMigration] Starting metadata.json migration`) + const totalCreated = await runMigration(Database) + Logger.info(`[absMetadataMigration] Finished metadata.json migration (${totalCreated} files created)`) +} \ No newline at end of file