mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-11-07 16:44:16 +01:00
Update:Remove scanner settings, add library scanner settings tab, add order of precedence
This commit is contained in:
parent
5ad9f507ba
commit
347b49f564
@ -54,6 +54,9 @@ export default {
|
||||
buttonText() {
|
||||
return this.library ? this.$strings.ButtonSave : this.$strings.ButtonCreate
|
||||
},
|
||||
mediaType() {
|
||||
return this.libraryCopy?.mediaType
|
||||
},
|
||||
tabs() {
|
||||
return [
|
||||
{
|
||||
@ -66,12 +69,19 @@ export default {
|
||||
title: this.$strings.HeaderSettings,
|
||||
component: 'modals-libraries-library-settings'
|
||||
},
|
||||
{
|
||||
id: 'scanner',
|
||||
title: this.$strings.HeaderSettingsScanner,
|
||||
component: 'modals-libraries-library-scanner-settings'
|
||||
},
|
||||
{
|
||||
id: 'schedule',
|
||||
title: this.$strings.HeaderSchedule,
|
||||
component: 'modals-libraries-schedule-scan'
|
||||
}
|
||||
]
|
||||
].filter((tab) => {
|
||||
return tab.id !== 'scanner' || this.mediaType === 'book'
|
||||
})
|
||||
},
|
||||
tabName() {
|
||||
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||
@ -105,7 +115,9 @@ export default {
|
||||
disableWatcher: false,
|
||||
skipMatchingMediaWithAsin: false,
|
||||
skipMatchingMediaWithIsbn: false,
|
||||
autoScanCronExpression: null
|
||||
autoScanCronExpression: null,
|
||||
hideSingleBookSeries: false,
|
||||
metadataPrecedence: ['folderStructure', 'audioMetatags', 'txtFiles', 'opfFile', 'absMetadata']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
129
client/components/modals/libraries/LibraryScannerSettings.vue
Normal file
129
client/components/modals/libraries/LibraryScannerSettings.vue
Normal file
@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="w-full h-full px-1 md:px-4 py-1 mb-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg text-gray-200">Metadata order of precedence</h2>
|
||||
<ui-btn small @click="resetToDefault">Reset to default</ui-btn>
|
||||
</div>
|
||||
|
||||
<draggable v-model="metadataSourceMapped" v-bind="dragOptions" class="list-group" draggable=".item" handle=".drag-handle" tag="ul" @start="drag = true" @end="drag = false" @update="draggableUpdate">
|
||||
<transition-group type="transition" :name="!drag ? 'flip-list' : null">
|
||||
<li v-for="(source, index) in metadataSourceMapped" :key="source.id" :class="source.include ? 'item' : 'opacity-50'" class="w-full px-2 flex items-center relative border border-white/10">
|
||||
<span class="material-icons drag-handle text-xl text-gray-400 hover:text-gray-50 mr-2 md:mr-4">reorder</span>
|
||||
<div class="text-center py-1 w-8 min-w-8">
|
||||
{{ source.include ? index + 1 : '' }}
|
||||
</div>
|
||||
<div class="flex-grow px-4 py-3">{{ source.name }}</div>
|
||||
<div class="px-2 opacity-100">
|
||||
<ui-toggle-switch v-model="source.include" :off-color="'error'" @input="includeToggled(source)" />
|
||||
</div>
|
||||
</li>
|
||||
</transition-group>
|
||||
</draggable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import draggable from 'vuedraggable'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
draggable
|
||||
},
|
||||
props: {
|
||||
library: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
processing: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
drag: false,
|
||||
dragOptions: {
|
||||
animation: 200,
|
||||
group: 'description',
|
||||
ghostClass: 'ghost'
|
||||
},
|
||||
metadataSourceData: {
|
||||
folderStructure: {
|
||||
id: 'folderStructure',
|
||||
name: 'Folder structure',
|
||||
include: true
|
||||
},
|
||||
audioMetatags: {
|
||||
id: 'audioMetatags',
|
||||
name: 'Audio file meta tags',
|
||||
include: true
|
||||
},
|
||||
txtFiles: {
|
||||
id: 'txtFiles',
|
||||
name: 'desc.txt & reader.txt files',
|
||||
include: true
|
||||
},
|
||||
opfFile: {
|
||||
id: 'opfFile',
|
||||
name: 'OPF file',
|
||||
include: true
|
||||
},
|
||||
absMetadata: {
|
||||
id: 'absMetadata',
|
||||
name: 'Audiobookshelf metadata file',
|
||||
include: true
|
||||
}
|
||||
},
|
||||
metadataSourceMapped: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
librarySettings() {
|
||||
return this.library.settings || {}
|
||||
},
|
||||
mediaType() {
|
||||
return this.library.mediaType
|
||||
},
|
||||
isBookLibrary() {
|
||||
return this.mediaType === 'book'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
resetToDefault() {
|
||||
this.metadataSourceMapped = []
|
||||
for (const key in this.metadataSourceData) {
|
||||
this.metadataSourceMapped.push({ ...this.metadataSourceData[key] })
|
||||
}
|
||||
this.$emit('update', this.getLibraryData())
|
||||
},
|
||||
getLibraryData() {
|
||||
return {
|
||||
settings: {
|
||||
metadataPrecedence: this.metadataSourceMapped.map((source) => (source.include ? source.id : null)).filter((s) => s)
|
||||
}
|
||||
}
|
||||
},
|
||||
includeToggled(source) {
|
||||
this.updated()
|
||||
},
|
||||
draggableUpdate() {
|
||||
this.updated()
|
||||
},
|
||||
updated() {
|
||||
this.$emit('update', this.getLibraryData())
|
||||
},
|
||||
init() {
|
||||
const metadataPrecedence = this.librarySettings.metadataPrecedence || []
|
||||
|
||||
this.metadataSourceMapped = metadataPrecedence.map((source) => this.metadataSourceData[source]).filter((s) => s)
|
||||
|
||||
for (const sourceKey in this.metadataSourceData) {
|
||||
if (!metadataPrecedence.includes(sourceKey)) {
|
||||
const unusedSourceData = { ...this.metadataSourceData[sourceKey], include: false }
|
||||
this.metadataSourceMapped.push(unusedSourceData)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
@ -74,6 +74,11 @@ export default {
|
||||
}
|
||||
]
|
||||
if (this.isBookLibrary) {
|
||||
items.push({
|
||||
text: this.$strings.ButtonForceReScan,
|
||||
action: 'force-rescan',
|
||||
value: 'force-rescan'
|
||||
})
|
||||
items.push({
|
||||
text: this.$strings.ButtonMatchBooks,
|
||||
action: 'match-books',
|
||||
@ -95,8 +100,8 @@ export default {
|
||||
this.editClick()
|
||||
} else if (action === 'scan') {
|
||||
this.scan()
|
||||
} else if (action === 'force-scan') {
|
||||
this.forceScan()
|
||||
} else if (action === 'force-rescan') {
|
||||
this.scan(true)
|
||||
} else if (action === 'match-books') {
|
||||
this.matchAll()
|
||||
} else if (action === 'delete') {
|
||||
@ -121,9 +126,9 @@ export default {
|
||||
editClick() {
|
||||
this.$emit('edit', this.library)
|
||||
},
|
||||
scan() {
|
||||
scan(force = false) {
|
||||
this.$store
|
||||
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id })
|
||||
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force })
|
||||
.then(() => {
|
||||
this.$toast.success(this.$strings.ToastLibraryScanStarted)
|
||||
})
|
||||
|
@ -51,6 +51,56 @@
|
||||
<ui-dropdown v-model="newServerSettings.metadataFileFormat" small :items="metadataFileFormats" label="Metadata File Format" @input="updateMetadataFileFormat" :disabled="updatingServerSettings" />
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsScanner }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-parse-subtitles" v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsParseSubtitlesHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-parse-subtitles">{{ $strings.LabelSettingsParseSubtitles }}</span>
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-find-covers" v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsFindCoversHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-find-covers">{{ $strings.LabelSettingsFindCovers }}</span>
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
<div v-if="newServerSettings.scannerFindCovers" class="w-44 ml-14 mb-2">
|
||||
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-prefer-matched-metadata" v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsPreferMatchedMetadataHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-prefer-matched-metadata">{{ $strings.LabelSettingsPreferMatchedMetadata }}</span>
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-disable-watcher" v-model="scannerEnableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', !val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsEnableWatcherHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-disable-watcher">{{ $strings.LabelSettingsEnableWatcher }}</span>
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsDisplay }}</h2>
|
||||
</div>
|
||||
@ -88,86 +138,6 @@
|
||||
<div class="py-2">
|
||||
<ui-dropdown :label="$strings.LabelLanguageDefaultServer" ref="langDropdown" v-model="newServerSettings.language" :items="$languageCodeOptions" small class="max-w-52" @input="updateServerLanguage" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsScanner }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-parse-subtitles" v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsParseSubtitlesHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-parse-subtitles">{{ $strings.LabelSettingsParseSubtitles }}</span>
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-find-covers" v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsFindCoversHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-find-covers">{{ $strings.LabelSettingsFindCovers }}</span>
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
<div v-if="newServerSettings.scannerFindCovers" class="w-44 ml-14 mb-2">
|
||||
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-overdrive-media-markers" v-model="newServerSettings.scannerPreferOverdriveMediaMarker" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOverdriveMediaMarker', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsOverdriveMediaMarkersHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-overdrive-media-markers">{{ $strings.LabelSettingsOverdriveMediaMarkers }}</span>
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-prefer-audio-metadata" v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferAudioMetadata', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsPreferAudioMetadataHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-prefer-audio-metadata">{{ $strings.LabelSettingsPreferAudioMetadata }}</span>
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-prefer-opf-metadata" v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOpfMetadata', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsPreferOPFMetadataHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-prefer-opf-metadata">{{ $strings.LabelSettingsPreferOPFMetadata }}</span>
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-prefer-matched-metadata" v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsPreferMatchedMetadataHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-prefer-matched-metadata">{{ $strings.LabelSettingsPreferMatchedMetadata }}</span>
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-disable-watcher" v-model="scannerEnableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', !val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsEnableWatcherHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-disable-watcher">{{ $strings.LabelSettingsEnableWatcher }}</span>
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- old experimental features -->
|
||||
<!-- <div class="pt-4">
|
||||
|
@ -410,16 +410,10 @@
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Serien, die ein einzelnes Buch enthalten, werden in den Regalen der Serienseite und der Startseite ausgeblendet.",
|
||||
"LabelSettingsHomePageBookshelfView": "Startseite verwendet die Bücherregalansicht",
|
||||
"LabelSettingsLibraryBookshelfView": "Bibliothek verwendet die Bücherregalansicht",
|
||||
"LabelSettingsOverdriveMediaMarkers": "Verwende Overdrive Media Marker für Kapitel",
|
||||
"LabelSettingsOverdriveMediaMarkersHelp": "MP3-Dateien von Overdrive werden mit eingebetteten Kapitel-Timings als benutzerdefinierte Metadaten geliefert. Wenn Sie dies aktivieren, werden diese Markierungen automatisch für die Kapiteltaktung verwendet",
|
||||
"LabelSettingsParseSubtitles": "Analysiere Untertitel",
|
||||
"LabelSettingsParseSubtitlesHelp": "Extrahiere den Untertitel von Medium-Ordnernamen.<br>Untertitel müssen vom eigentlichem Titel durch ein \" - \" getrennt sein. <br>Beispiel: \"Titel - Untertitel\"",
|
||||
"LabelSettingsPreferAudioMetadata": "Bevorzuge lokale ID3-Audiometadaten",
|
||||
"LabelSettingsPreferAudioMetadataHelp": "In den Audiodateien eingebettete ID3 Tags werden anstelle der Ordnernamen für die Bereitstellung der Metadaten verwendet. Wenn keine ID3 Tags zur Verfügung stehen, werden die Ordnernamen verwendet.",
|
||||
"LabelSettingsPreferMatchedMetadata": "Bevorzuge online abgestimmte Metadaten",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Bei einem Schnellabgleich überschreiben online neu abgestimmte Metadaten alle schon vorhandenen Metadaten eines Mediums. Standardmäßig werden bei einem Schnellabgleich nur fehlende Metadaten ersetzt.",
|
||||
"LabelSettingsPreferOPFMetadata": "Bevorzuge OPF-Metadaten",
|
||||
"LabelSettingsPreferOPFMetadataHelp": "In OPF-Dateien gespeicherte Metadaten werden anstelle der Ordnernamen für die Bereitstellung der Metadaten verwendet. OPF-Datein sind seperate \"Textdateien\" mit der Endung \".abs\" welche in dem gleichen Ordner liegen wie das Medium selber. In dieser sind verschiedene Metadaten (z.B. Titel, Autor, Jahr, Erzähler, Handlung, ISBN, ...) gespeichert. Wenn keine OPF Datei zur Verfügung steht, wird der Ordnername verwendet.",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Überspringe beim Online-Abgleich alle Bücher die bereits eine ASIN haben",
|
||||
"LabelSettingsSkipMatchingBooksWithISBN": "Überspringe beim Online-Abgleich alle Bücher die bereits eine ISBN haben",
|
||||
"LabelSettingsSortingIgnorePrefixes": "Vorwort/Artikel beim Sortieren ignorieren",
|
||||
|
@ -410,16 +410,10 @@
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
|
||||
"LabelSettingsHomePageBookshelfView": "Home page use bookshelf view",
|
||||
"LabelSettingsLibraryBookshelfView": "Library use bookshelf view",
|
||||
"LabelSettingsOverdriveMediaMarkers": "Use Overdrive Media Markers for chapters",
|
||||
"LabelSettingsOverdriveMediaMarkersHelp": "MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically",
|
||||
"LabelSettingsParseSubtitles": "Parse subtitles",
|
||||
"LabelSettingsParseSubtitlesHelp": "Extract subtitles from audiobook folder names.<br>Subtitle must be seperated by \" - \"<br>i.e. \"Book Title - A Subtitle Here\" has the subtitle \"A Subtitle Here\"",
|
||||
"LabelSettingsPreferAudioMetadata": "Prefer audio metadata",
|
||||
"LabelSettingsPreferAudioMetadataHelp": "Audio file ID3 meta tags will be used for book details over folder names",
|
||||
"LabelSettingsPreferMatchedMetadata": "Prefer matched metadata",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Matched data will override item details when using Quick Match. By default Quick Match will only fill in missing details.",
|
||||
"LabelSettingsPreferOPFMetadata": "Prefer OPF metadata",
|
||||
"LabelSettingsPreferOPFMetadataHelp": "OPF file metadata will be used for book details over folder names",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Skip matching books that already have an ASIN",
|
||||
"LabelSettingsSkipMatchingBooksWithISBN": "Skip matching books that already have an ISBN",
|
||||
"LabelSettingsSortingIgnorePrefixes": "Ignore prefixes when sorting",
|
||||
@ -713,4 +707,4 @@
|
||||
"ToastSocketFailedToConnect": "Socket failed to connect",
|
||||
"ToastUserDeleteFailed": "Failed to delete user",
|
||||
"ToastUserDeleteSuccess": "User deleted"
|
||||
}
|
||||
}
|
@ -410,16 +410,10 @@
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Las series con un solo libro no aparecerán en la página de series ni la repisa para series de la página principal.",
|
||||
"LabelSettingsHomePageBookshelfView": "Usar la vista de librero en la página principal",
|
||||
"LabelSettingsLibraryBookshelfView": "Usar la vista de librero en la biblioteca",
|
||||
"LabelSettingsOverdriveMediaMarkers": "Usar Overdrive Media Markers para estos capítulos",
|
||||
"LabelSettingsOverdriveMediaMarkersHelp": "Los archivos MP3 de Overdrive vienen con capítulos con tiempos incrustados como metadatos personalizados. Habilitar esta opción utilizará automáticamente estas etiquetas para los tiempos de los capítulos.",
|
||||
"LabelSettingsParseSubtitles": "Extraer Subtítulos",
|
||||
"LabelSettingsParseSubtitlesHelp": "Extraer subtítulos de los nombres de las carpetas de los audiolibros.<br>Los subtítulos deben estar separados por \" - \"<br>Por ejemplo: \"Ejemplo de Título - Subtítulo Aquí\" tiene el subtítulo \"Subtítulo Aquí\"",
|
||||
"LabelSettingsPreferAudioMetadata": "Preferir metadatos del archivo de audio",
|
||||
"LabelSettingsPreferAudioMetadataHelp": "Preferir los metadatos ID3 del archivo de audio en vez de los nombres de carpetas para los detalles de libros",
|
||||
"LabelSettingsPreferMatchedMetadata": "Preferir metadatos encontrados",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Los datos encontrados sobreescribirán los detalles del elemento cuando se use \"Encontrar Rápido\". Por defecto, \"Encontrar Rápido\" sólo completará los detalles faltantes.",
|
||||
"LabelSettingsPreferOPFMetadata": "Preferir Metadatos OPF",
|
||||
"LabelSettingsPreferOPFMetadataHelp": "Preferir los archivos de metadatos OPF en vez de los nombres de carpetas para los detalles de los libros.",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Omitir libros coincidentes que ya tengan un ASIN",
|
||||
"LabelSettingsSkipMatchingBooksWithISBN": "Omitir libros coincidentes que ya tengan un ISBN",
|
||||
"LabelSettingsSortingIgnorePrefixes": "Ignorar prefijos al ordenar",
|
||||
@ -713,4 +707,4 @@
|
||||
"ToastSocketFailedToConnect": "Error al conectar al Socket",
|
||||
"ToastUserDeleteFailed": "Error al eliminar el usuario",
|
||||
"ToastUserDeleteSuccess": "Usuario eliminado"
|
||||
}
|
||||
}
|
@ -410,16 +410,10 @@
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Les séries qui ne comportent qu’un seul livre seront masquées sur la page de la série et sur les étagères de la page d’accueil.",
|
||||
"LabelSettingsHomePageBookshelfView": "La page d’accueil utilise la vue étagère",
|
||||
"LabelSettingsLibraryBookshelfView": "La bibliothèque utilise la vue étagère",
|
||||
"LabelSettingsOverdriveMediaMarkers": "Utiliser Overdrive Media Marker pour les chapitres",
|
||||
"LabelSettingsOverdriveMediaMarkersHelp": "Les fichiers MP3 d’Overdrive viennent avec les minutages des chapitres intégrés en métadonnées. Activer ce paramètre utilisera ces minutages pour les chapitres automatiquement.",
|
||||
"LabelSettingsParseSubtitles": "Analyser les sous-titres",
|
||||
"LabelSettingsParseSubtitlesHelp": "Extrait les sous-titres depuis le dossier du Livre Audio.<br>Les sous-titres doivent être séparés par « - »<br>i.e. « Titre du Livre - Ceci est un sous-titre » aura le sous-titre « Ceci est un sous-titre »",
|
||||
"LabelSettingsPreferAudioMetadata": "Préférer les métadonnées audio",
|
||||
"LabelSettingsPreferAudioMetadataHelp": "Les méta étiquettes ID3 des fichiers audios seront utilisés à la place des noms de dossier pour les détails du livre audio",
|
||||
"LabelSettingsPreferMatchedMetadata": "Préférer les métadonnées par correspondance",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Les métadonnées par correspondance écrase les détails de l’article lors d’une recherche par correspondance rapide. Par défaut, la recherche par correspondance rapide ne comblera que les éléments manquant.",
|
||||
"LabelSettingsPreferOPFMetadata": "Préférer les métadonnées OPF",
|
||||
"LabelSettingsPreferOPFMetadataHelp": "Les fichiers de métadonnées OPF seront utilisés à la place des noms de dossier pour les détails du Livre Audio",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Ignorer la recherche par correspondance sur les livres ayant déjà un ASIN",
|
||||
"LabelSettingsSkipMatchingBooksWithISBN": "Ignorer la recherche par correspondance sur les livres ayant déjà un ISBN",
|
||||
"LabelSettingsSortingIgnorePrefixes": "Ignorer les préfixes lors du tri",
|
||||
|
@ -410,16 +410,10 @@
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
|
||||
"LabelSettingsHomePageBookshelfView": "Home page use bookshelf view",
|
||||
"LabelSettingsLibraryBookshelfView": "Library use bookshelf view",
|
||||
"LabelSettingsOverdriveMediaMarkers": "Use Overdrive Media Markers for chapters",
|
||||
"LabelSettingsOverdriveMediaMarkersHelp": "MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically",
|
||||
"LabelSettingsParseSubtitles": "Parse subtitles",
|
||||
"LabelSettingsParseSubtitlesHelp": "Extract subtitles from audiobook folder names.<br>Subtitle must be seperated by \" - \"<br>i.e. \"Book Title - A Subtitle Here\" has the subtitle \"A Subtitle Here\"",
|
||||
"LabelSettingsPreferAudioMetadata": "Prefer audio metadata",
|
||||
"LabelSettingsPreferAudioMetadataHelp": "Audio file ID3 meta tags will be used for book details over folder names",
|
||||
"LabelSettingsPreferMatchedMetadata": "Prefer matched metadata",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Matched data will overide item details when using Quick Match. By default Quick Match will only fill in missing details.",
|
||||
"LabelSettingsPreferOPFMetadata": "Prefer OPF metadata",
|
||||
"LabelSettingsPreferOPFMetadataHelp": "OPF file metadata will be used for book details over folder names",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Skip matching books that already have an ASIN",
|
||||
"LabelSettingsSkipMatchingBooksWithISBN": "Skip matching books that already have an ISBN",
|
||||
"LabelSettingsSortingIgnorePrefixes": "Ignore prefixes when sorting",
|
||||
|
@ -410,16 +410,10 @@
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
|
||||
"LabelSettingsHomePageBookshelfView": "Home page use bookshelf view",
|
||||
"LabelSettingsLibraryBookshelfView": "Library use bookshelf view",
|
||||
"LabelSettingsOverdriveMediaMarkers": "Use Overdrive Media Markers for chapters",
|
||||
"LabelSettingsOverdriveMediaMarkersHelp": "MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically",
|
||||
"LabelSettingsParseSubtitles": "Parse subtitles",
|
||||
"LabelSettingsParseSubtitlesHelp": "Extract subtitles from audiobook folder names.<br>Subtitle must be seperated by \" - \"<br>i.e. \"Book Title - A Subtitle Here\" has the subtitle \"A Subtitle Here\"",
|
||||
"LabelSettingsPreferAudioMetadata": "Prefer audio metadata",
|
||||
"LabelSettingsPreferAudioMetadataHelp": "Audio file ID3 meta tags will be used for book details over folder names",
|
||||
"LabelSettingsPreferMatchedMetadata": "Prefer matched metadata",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Matched data will overide item details when using Quick Match. By default Quick Match will only fill in missing details.",
|
||||
"LabelSettingsPreferOPFMetadata": "Prefer OPF metadata",
|
||||
"LabelSettingsPreferOPFMetadataHelp": "OPF file metadata will be used for book details over folder names",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Skip matching books that already have an ASIN",
|
||||
"LabelSettingsSkipMatchingBooksWithISBN": "Skip matching books that already have an ISBN",
|
||||
"LabelSettingsSortingIgnorePrefixes": "Ignore prefixes when sorting",
|
||||
|
@ -410,16 +410,10 @@
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
|
||||
"LabelSettingsHomePageBookshelfView": "Koristi bookshelf pogled za početnu stranicu",
|
||||
"LabelSettingsLibraryBookshelfView": "Koristi bookshelf pogled za biblioteku",
|
||||
"LabelSettingsOverdriveMediaMarkers": "Koristi Overdrive Media Markers za poglavlja",
|
||||
"LabelSettingsOverdriveMediaMarkersHelp": "MP3 datoteke iz Overdriva dolaze sa vremenima od poglavlja embedani kao posebni metapodatci. Ova postavka će koristiti tagove za vremena od poglavlja automatski.",
|
||||
"LabelSettingsParseSubtitles": "Parsaj podnapise",
|
||||
"LabelSettingsParseSubtitlesHelp": "Izvadi podnapise iz imena od audiobook foldera.<br>Podnapis mora biti odvojen sa \" - \"<br>npr. \"Ime knjige - Podnapis ovdje\" ima podnapis \"Podnapis ovdje\"",
|
||||
"LabelSettingsPreferAudioMetadata": "Preferiraj audio metapodatke",
|
||||
"LabelSettingsPreferAudioMetadataHelp": "Audio file ID3 meta tagovi u audio datoteci će biti korišteni za detalje knjige.",
|
||||
"LabelSettingsPreferMatchedMetadata": "Preferiraj matchane metapodatke",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Matchani podatci će biti korišteni kada se koristi Quick Match. Po defaultu Quick Match će ispuniti samo prazne detalje.",
|
||||
"LabelSettingsPreferOPFMetadata": "Preferiraj OPF metapodatke",
|
||||
"LabelSettingsPreferOPFMetadataHelp": "OPF metapodatci datoteke će biti korišteni za detalje knjige.",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Preskoči matchanje knjiga koje već imaju ASIN",
|
||||
"LabelSettingsSkipMatchingBooksWithISBN": "SPreskoči matchanje knjiga koje već imaju ISBN",
|
||||
"LabelSettingsSortingIgnorePrefixes": "Zanemari prefikse tokom sortiranja",
|
||||
|
@ -410,16 +410,10 @@
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Le serie che hanno un solo libro saranno nascoste dalla pagina della serie e dagli scaffali della home page.",
|
||||
"LabelSettingsHomePageBookshelfView": "Home page con sfondo legno",
|
||||
"LabelSettingsLibraryBookshelfView": "Libreria con sfondo legno",
|
||||
"LabelSettingsOverdriveMediaMarkers": "Usa Overdrive Media Markers per i capitoli",
|
||||
"LabelSettingsOverdriveMediaMarkersHelp": "I file MP3 di Overdrive vengono forniti con i tempi dei capitoli incorporati come metadati personalizzati. Abilitando questa funzione verranno utilizzati automaticamente questi tag per i tempi dei capitoli",
|
||||
"LabelSettingsParseSubtitles": "Analizza sottotitoli",
|
||||
"LabelSettingsParseSubtitlesHelp": "Estrai i sottotitoli dai nomi delle cartelle degli audiolibri. <br> I sottotitoli devono essere separati da \" - \"<br> Per esempio \"Il signore degli anelli - Le due Torri \" avrà il sottotitolo \"Le due Torri\"",
|
||||
"LabelSettingsPreferAudioMetadata": "Preferisci i metadati audio",
|
||||
"LabelSettingsPreferAudioMetadataHelp": "I meta tag ID3 del file audio verrano preferiti rispetto al nome della cartella",
|
||||
"LabelSettingsPreferMatchedMetadata": "Preferisci i metadata trovati",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "I dati trovati in internet sovrascriveranno i dettagli del libro quando si utilizza quick Match. Per impostazione predefinita, Quick Match riempirà solo i dettagli mancanti.",
|
||||
"LabelSettingsPreferOPFMetadata": "Preferisci OPF metadata",
|
||||
"LabelSettingsPreferOPFMetadataHelp": "I metadati del file OPF verranno utilizzati per i dettagli del libro e non il nome della cartella",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Salta la ricerca dati in internet se è già presente un codice ASIN",
|
||||
"LabelSettingsSkipMatchingBooksWithISBN": "Salta la ricerca dati in internet se è già presente un codice ISBN",
|
||||
"LabelSettingsSortingIgnorePrefixes": "Ignora i prefissi nei titoli durante l'aggiunta",
|
||||
|
@ -410,16 +410,10 @@
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Serijos, turinčios tik vieną knygą, bus paslėptos nuo serijų puslapio ir pagrindinio puslapio lentynų.",
|
||||
"LabelSettingsHomePageBookshelfView": "Naudoti pagrindinio puslapio knygų lentynų vaizdą",
|
||||
"LabelSettingsLibraryBookshelfView": "Naudoti bibliotekos knygų lentynų vaizdą",
|
||||
"LabelSettingsOverdriveMediaMarkers": "Naudoti Overdrive žymeklius skyriams",
|
||||
"LabelSettingsOverdriveMediaMarkersHelp": "MP3 failai iš Overdrive turi įterptus skyrių laikus kaip papildomą metaduomenį. Įjungus šią funkciją, skyrių laikai bus automatiškai naudojami.",
|
||||
"LabelSettingsParseSubtitles": "Analizuoti subtitrus",
|
||||
"LabelSettingsParseSubtitlesHelp": "Išskleisti subtitrus iš audioknygos aplanko pavadinimų.<br>Subtitrai turi būti atskirti brūkšniu \"-\"<br>pavyzdžiui, \"Knygos pavadinimas - Čia yra subtitrai\" turi subtitrą \"Čia yra subtitrai\"",
|
||||
"LabelSettingsPreferAudioMetadata": "Pirmenybė failo metaduomenis",
|
||||
"LabelSettingsPreferAudioMetadataHelp": "Garso failo ID3 metaduomenys bus naudojami knygos informacijai (vietoj aplankų pavadinimų)",
|
||||
"LabelSettingsPreferMatchedMetadata": "Pirmenybė atitaikytiems metaduomenis",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Atitaikyti duomenys pakeis elementų informaciją naudojant Greitą atitikimą. Pagal nutylėjimą Greitas atitaikymas užpildys tik trūkstamas detales.",
|
||||
"LabelSettingsPreferOPFMetadata": "Pirmenybė OPF metaduomenis",
|
||||
"LabelSettingsPreferOPFMetadataHelp": "OPF failo metaduomenys bus naudojami knygos informacijai (vietoj aplankų pavadinimų)",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Praleisti knygas, kurios jau turi ASIN",
|
||||
"LabelSettingsSkipMatchingBooksWithISBN": "Praleisti knygas, kurios jau turi ISBN",
|
||||
"LabelSettingsSortingIgnorePrefixes": "Ignoruoti priešdėlius rūšiuojant",
|
||||
|
@ -410,16 +410,10 @@
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Series die slechts een enkel boek bevatten worden verborgen op de seriespagina en de homepagina-planken.",
|
||||
"LabelSettingsHomePageBookshelfView": "Boekenplank-view voor homepagina",
|
||||
"LabelSettingsLibraryBookshelfView": "Boekenplank-view voor bibliotheek",
|
||||
"LabelSettingsOverdriveMediaMarkers": "Gebruik Overdrive media markers voor hoofdstukken",
|
||||
"LabelSettingsOverdriveMediaMarkersHelp": "MP3-bestanden van Overdrive hebben hoofdstuktiming ingesloten als custom ingesloten metadata. Door dit in te schakelen worden deze tags voor hoofdstuktiming automatisch gebruikt.",
|
||||
"LabelSettingsParseSubtitles": "Parseer subtitel",
|
||||
"LabelSettingsParseSubtitlesHelp": "Haal subtitels uit mapnaam van audioboek.<br>Subtitel moet gescheiden zijn met \" - \"<br>b.v. \"Boektitel - Een Subtitel Hier\" heeft als subtitel \"Een Subtitel Hier\"",
|
||||
"LabelSettingsPreferAudioMetadata": "Prefereer audio-metadata",
|
||||
"LabelSettingsPreferAudioMetadataHelp": "Audiobestand ID3 metatags zullen worden gebruikt voor boekdetails in plaats van mapnamen",
|
||||
"LabelSettingsPreferMatchedMetadata": "Prefereer gematchte metadata",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Gematchte data zal onderdeeldetails overschrijven bij gebruik van Quick Match. Standaard vult Quick Match uitsluitend ontbrekende details aan.",
|
||||
"LabelSettingsPreferOPFMetadata": "Prefereer OPF-metadata",
|
||||
"LabelSettingsPreferOPFMetadataHelp": "OPF-bestand metadata zal worden gebruik in plaats van mapnamen",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Sla matchen van boeken over die al over een ASIN beschikken",
|
||||
"LabelSettingsSkipMatchingBooksWithISBN": "Sla matchen van boeken over die al over een ISBN beschikken",
|
||||
"LabelSettingsSortingIgnorePrefixes": "Negeer voorvoegsels bij sorteren",
|
||||
@ -713,4 +707,4 @@
|
||||
"ToastSocketFailedToConnect": "Verbinding Socket mislukt",
|
||||
"ToastUserDeleteFailed": "Verwijderen gebruiker mislukt",
|
||||
"ToastUserDeleteSuccess": "Gebruiker verwijderd"
|
||||
}
|
||||
}
|
@ -410,16 +410,10 @@
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Serier som har kun en bok vil bli gjemt på serie- og hjemmeside hyllen.",
|
||||
"LabelSettingsHomePageBookshelfView": "Hjemmeside bruk bokhyllevisning",
|
||||
"LabelSettingsLibraryBookshelfView": "Bibliotek bruk bokhyllevisning",
|
||||
"LabelSettingsOverdriveMediaMarkers": "Bruk Overdrive mediemerker for kapittel",
|
||||
"LabelSettingsOverdriveMediaMarkersHelp": "MP3 filer fra Overdrive kommer med kapittel tider bakt inn so egendefinert metadata. Aktiveres dette vil disse taggene bli brukt som kapittel tider automatisk",
|
||||
"LabelSettingsParseSubtitles": "Analyser undertekster",
|
||||
"LabelSettingsParseSubtitlesHelp": "Trekk ut undertekster fra lydbok mappenavn.<br>undertekster må være separert med \" - \"<br>f.eks. \"Boktittel - Undertekst her\" har Undertekst \"Undertekst her\"",
|
||||
"LabelSettingsPreferAudioMetadata": "Foretrekk lyd metadata",
|
||||
"LabelSettingsPreferAudioMetadataHelp": "Lydfil ID3 meta tagger vil bli brukt som bokdetaljer i stedet fro mappenavn",
|
||||
"LabelSettingsPreferMatchedMetadata": "Foretrekk funnet metadata",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Funnet data vil overskrive enhetens detaljene når man bruker Kjapt søk. Som standard vil Kjapt søk kun fylle inn manglende detaljer.",
|
||||
"LabelSettingsPreferOPFMetadata": "Foretrekk OPF metadata",
|
||||
"LabelSettingsPreferOPFMetadataHelp": "OPF fil metadata vil bli brukt som bokdetaljer i stedet fro mappenavn",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Hopp over bøker som allerede har ASIN",
|
||||
"LabelSettingsSkipMatchingBooksWithISBN": "Hopp over bøker som allerede har ISBN",
|
||||
"LabelSettingsSortingIgnorePrefixes": "Ignorer prefiks når under sortering",
|
||||
|
@ -410,16 +410,10 @@
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
|
||||
"LabelSettingsHomePageBookshelfView": "Widok półki z książkami na stronie głównej",
|
||||
"LabelSettingsLibraryBookshelfView": "Widok półki z książkami na stronie biblioteki",
|
||||
"LabelSettingsOverdriveMediaMarkers": "Użyj markerów Overdrive Media Markers dla rozdziałów",
|
||||
"LabelSettingsOverdriveMediaMarkersHelp": "Pliki MP3 z serwisu Overdrive mają wbudowane znaczniki czasu rozdziałów jako niestandardowe metadane. Włączenie tej funkcji spowoduje automatyczne użycie tych znaczników do oznaczania czasu rozdziałów.",
|
||||
"LabelSettingsParseSubtitles": "Przetwarzaj podtytuły",
|
||||
"LabelSettingsParseSubtitlesHelp": "Opcja pozwala na pobranie podtytułu z nazwy folderu z audiobookiem. <br>Podtytuł musi być rozdzielony za pomocą separatora \" - \"<br>Przykład: \"Book Title - A Subtitle Here\" podtytuł \"A Subtitle Here\"",
|
||||
"LabelSettingsPreferAudioMetadata": "Preferuj metadane audio",
|
||||
"LabelSettingsPreferAudioMetadataHelp": "Znaczniki meta ID3 plików audio będą używane dla szczegółów książki zamiast nazw folderów",
|
||||
"LabelSettingsPreferMatchedMetadata": "Preferowanie dopasowanych metadanych",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Dopasowane dane będą miały pierwszeństwo nad szczegółami pozycji podczas używania Szybkiego dopasowania. Domyślnie Szybkie dopasowanie uzupełnia tylko brakujące szczegóły.",
|
||||
"LabelSettingsPreferOPFMetadata": "Preferowanie metadanych OPF",
|
||||
"LabelSettingsPreferOPFMetadataHelp": "Metadane pliku OPF będą używane dla szczegółów książki zamiast nazw folderów",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Pomiń dopasowanie książek, które już mają ASIN",
|
||||
"LabelSettingsSkipMatchingBooksWithISBN": "Pomiń dopasowanie książek, które już mają ISBN",
|
||||
"LabelSettingsSortingIgnorePrefixes": "Ignoruj prefiksy podczas sortowania",
|
||||
|
@ -410,16 +410,10 @@
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Серии, в которых всего одна книга, будут скрыты со страницы серий и полок домашней страницы.",
|
||||
"LabelSettingsHomePageBookshelfView": "Вид книжной полки на Домашней странице",
|
||||
"LabelSettingsLibraryBookshelfView": "Вид книжной полки в Библиотеке",
|
||||
"LabelSettingsOverdriveMediaMarkers": "Overdrive Media Markers для глав",
|
||||
"LabelSettingsOverdriveMediaMarkersHelp": "MP3 файлы из Overdrive поставляется с таймингами глав, встроенными в виде пользовательских метаданных. При включении этого параметра эти теги будут автоматически использоваться для таймингов глав",
|
||||
"LabelSettingsParseSubtitles": "Разбор подзаголовков",
|
||||
"LabelSettingsParseSubtitlesHelp": "Извлечение подзаголовков из имен папок аудиокниг.<br>Подзаголовок должны быть отделен \" - \"<br>например \"Название Книги - Тут Подзаголовок\" подзаголовок будет \"Тут Подзаголовок\"",
|
||||
"LabelSettingsPreferAudioMetadata": "Предпочитать аудио метаданные",
|
||||
"LabelSettingsPreferAudioMetadataHelp": "ID3 мета теги будут использоваться для данных книг вместо имен папок",
|
||||
"LabelSettingsPreferMatchedMetadata": "Предпочитать метаданные поиска",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Данные поиска будут перезаписывать данные книг при использовании Быстрого Поиска. По умолчанию Быстрый Поиск будет использоваться только при отсутствии данных",
|
||||
"LabelSettingsPreferOPFMetadata": "Предпочитать OPF метаданные",
|
||||
"LabelSettingsPreferOPFMetadataHelp": "Метаданные из файла OPF будут использованы для данных книги вместо имен папок",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Пропускать Поиск книг у которых уже заполнен ASIN",
|
||||
"LabelSettingsSkipMatchingBooksWithISBN": "Пропускать Поиск книг у которых уже заполнен ISBN",
|
||||
"LabelSettingsSortingIgnorePrefixes": "Игнорировать префиксы при сортировке",
|
||||
|
@ -410,16 +410,10 @@
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
|
||||
"LabelSettingsHomePageBookshelfView": "首页使用书架视图",
|
||||
"LabelSettingsLibraryBookshelfView": "媒体库使用书架视图",
|
||||
"LabelSettingsOverdriveMediaMarkers": "对章节使用 Overdrive 媒体标记",
|
||||
"LabelSettingsOverdriveMediaMarkersHelp": "Overdrive 的 MP3 文件带有作为自定义元数据嵌入的章节时间. 启用此功能将自动将这些标签用于章节计时",
|
||||
"LabelSettingsParseSubtitles": "解析副标题",
|
||||
"LabelSettingsParseSubtitlesHelp": "从有声读物文件夹中提取副标题.<br>副标题必须用 \" - \" 分隔.<br>例: \"书名 - 这里是副标题\" 则显示副标题 \"这里是副标题\"",
|
||||
"LabelSettingsPreferAudioMetadata": "首选音频元数据",
|
||||
"LabelSettingsPreferAudioMetadataHelp": "音频文件 ID3 元标记将用于文件夹名称上媒体的详细信息",
|
||||
"LabelSettingsPreferMatchedMetadata": "首选匹配的元数据",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "使用快速匹配时, 匹配的数据将覆盖项目详细信息. 默认情况下, 快速匹配将只填充缺少的详细信息.",
|
||||
"LabelSettingsPreferOPFMetadata": "首选 OPF 元数据",
|
||||
"LabelSettingsPreferOPFMetadataHelp": "OPF 文件元数据将用于文件夹名称上媒体的详细信息",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "跳过匹配已有 ASIN 的图书",
|
||||
"LabelSettingsSkipMatchingBooksWithISBN": "跳过匹配已有 ISBN 的图书",
|
||||
"LabelSettingsSortingIgnorePrefixes": "排序时忽略前缀",
|
||||
|
@ -784,7 +784,14 @@ class LibraryController {
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// POST: api/libraries/:id/scan
|
||||
/**
|
||||
* POST: /api/libraries/:id/scan
|
||||
* Optional query:
|
||||
* ?force=1
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async scan(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryController] Non-root user attempted to scan library`, req.user)
|
||||
@ -792,7 +799,8 @@ class LibraryController {
|
||||
}
|
||||
res.sendStatus(200)
|
||||
|
||||
await LibraryScanner.scan(req.library)
|
||||
const forceRescan = req.query.force === '1'
|
||||
await LibraryScanner.scan(req.library, forceRescan)
|
||||
|
||||
await Database.resetLibraryIssuesFilterData(req.library.id)
|
||||
Logger.info('[LibraryController] Scan complete')
|
||||
|
@ -11,6 +11,7 @@ const oldLibrary = require('../objects/Library')
|
||||
* @property {string} autoScanCronExpression
|
||||
* @property {boolean} audiobooksOnly
|
||||
* @property {boolean} hideSingleBookSeries Do not show series that only have 1 book
|
||||
* @property {string[]} metadataPrecedence
|
||||
*/
|
||||
|
||||
class Library extends Model {
|
||||
|
@ -1,9 +1,7 @@
|
||||
const Path = require('path')
|
||||
const Logger = require('../../Logger')
|
||||
const BookMetadata = require('../metadata/BookMetadata')
|
||||
const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
|
||||
const { parseOpfMetadataXML } = require('../../utils/parsers/parseOpfMetadata')
|
||||
const { parseOverdriveMediaMarkersAsChapters } = require('../../utils/parsers/parseOverdriveMediaMarkers')
|
||||
const abmetadataGenerator = require('../../utils/generators/abmetadataGenerator')
|
||||
const { readTextFile, filePathToPOSIX } = require('../../utils/fileUtils')
|
||||
const AudioFile = require('../files/AudioFile')
|
||||
@ -248,108 +246,6 @@ class Book {
|
||||
}
|
||||
}
|
||||
|
||||
// Look for desc.txt, reader.txt, metadata.abs and opf file then update details if found
|
||||
async syncMetadataFiles(textMetadataFiles, opfMetadataOverrideDetails) {
|
||||
let metadataUpdatePayload = {}
|
||||
let hasUpdated = false
|
||||
|
||||
const descTxt = textMetadataFiles.find(lf => lf.metadata.filename === 'desc.txt')
|
||||
if (descTxt) {
|
||||
const descriptionText = await readTextFile(descTxt.metadata.path)
|
||||
if (descriptionText) {
|
||||
Logger.debug(`[Book] "${this.metadata.title}" found desc.txt updating description with "${descriptionText.slice(0, 20)}..."`)
|
||||
metadataUpdatePayload.description = descriptionText
|
||||
}
|
||||
}
|
||||
const readerTxt = textMetadataFiles.find(lf => lf.metadata.filename === 'reader.txt')
|
||||
if (readerTxt) {
|
||||
const narratorText = await readTextFile(readerTxt.metadata.path)
|
||||
if (narratorText) {
|
||||
Logger.debug(`[Book] "${this.metadata.title}" found reader.txt updating narrator with "${narratorText}"`)
|
||||
metadataUpdatePayload.narrators = this.metadata.parseNarratorsTag(narratorText)
|
||||
}
|
||||
}
|
||||
|
||||
const metadataIsJSON = global.ServerSettings.metadataFileFormat === 'json'
|
||||
const metadataAbs = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.abs')
|
||||
const metadataJson = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.json')
|
||||
|
||||
const metadataFile = metadataIsJSON ? metadataJson : metadataAbs
|
||||
if (metadataFile) {
|
||||
Logger.debug(`[Book] Found ${metadataFile.metadata.filename} file for "${this.metadata.title}"`)
|
||||
const metadataText = await readTextFile(metadataFile.metadata.path)
|
||||
const abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this, 'book', metadataIsJSON)
|
||||
if (abmetadataUpdates && Object.keys(abmetadataUpdates).length) {
|
||||
Logger.debug(`[Book] "${this.metadata.title}" changes found in metadata.abs file`, abmetadataUpdates)
|
||||
|
||||
if (abmetadataUpdates.tags) { // Set media tags if updated
|
||||
this.tags = abmetadataUpdates.tags
|
||||
hasUpdated = true
|
||||
}
|
||||
if (abmetadataUpdates.chapters) { // Set chapters if updated
|
||||
this.chapters = abmetadataUpdates.chapters
|
||||
hasUpdated = true
|
||||
}
|
||||
if (abmetadataUpdates.metadata) {
|
||||
metadataUpdatePayload = {
|
||||
...metadataUpdatePayload,
|
||||
...abmetadataUpdates.metadata
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (metadataAbs || metadataJson) { // Has different metadata file format so mark as updated
|
||||
Logger.debug(`[Book] Found different format metadata file ${(metadataAbs || metadataJson).metadata.filename}, expecting .${global.ServerSettings.metadataFileFormat} for "${this.metadata.title}"`)
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
const metadataOpf = textMetadataFiles.find(lf => lf.isOPFFile || lf.metadata.filename === 'metadata.xml')
|
||||
if (metadataOpf) {
|
||||
const xmlText = await readTextFile(metadataOpf.metadata.path)
|
||||
if (xmlText) {
|
||||
const opfMetadata = await parseOpfMetadataXML(xmlText)
|
||||
if (opfMetadata) {
|
||||
for (const key in opfMetadata) {
|
||||
|
||||
if (key === 'tags') { // Add tags only if tags are empty
|
||||
if (opfMetadata.tags.length && (!this.tags.length || opfMetadataOverrideDetails)) {
|
||||
this.tags = opfMetadata.tags
|
||||
hasUpdated = true
|
||||
}
|
||||
} else if (key === 'genres') { // Add genres only if genres are empty
|
||||
if (opfMetadata.genres.length && (!this.metadata.genres.length || opfMetadataOverrideDetails)) {
|
||||
metadataUpdatePayload[key] = opfMetadata.genres
|
||||
}
|
||||
} else if (key === 'authors') {
|
||||
if (opfMetadata.authors && opfMetadata.authors.length && (!this.metadata.authors.length || opfMetadataOverrideDetails)) {
|
||||
metadataUpdatePayload.authors = opfMetadata.authors.map(authorName => {
|
||||
return {
|
||||
id: `new-${Math.floor(Math.random() * 1000000)}`,
|
||||
name: authorName
|
||||
}
|
||||
})
|
||||
}
|
||||
} else if (key === 'narrators') {
|
||||
if (opfMetadata.narrators?.length && (!this.metadata.narrators.length || opfMetadataOverrideDetails)) {
|
||||
metadataUpdatePayload.narrators = opfMetadata.narrators
|
||||
}
|
||||
} else if (key === 'series') {
|
||||
if (opfMetadata.series && (!this.metadata.series.length || opfMetadataOverrideDetails)) {
|
||||
metadataUpdatePayload.series = this.metadata.parseSeriesTag(opfMetadata.series, opfMetadata.sequence)
|
||||
}
|
||||
} else if (opfMetadata[key] && ((!this.metadata[key] && !metadataUpdatePayload[key]) || opfMetadataOverrideDetails)) {
|
||||
metadataUpdatePayload[key] = opfMetadata[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(metadataUpdatePayload).length) {
|
||||
return this.metadata.update(metadataUpdatePayload) || hasUpdated
|
||||
}
|
||||
return hasUpdated
|
||||
}
|
||||
|
||||
searchQuery(query) {
|
||||
const payload = {
|
||||
tags: this.tags.filter(t => cleanStringForSearch(t).includes(query)),
|
||||
@ -426,113 +322,6 @@ class Book {
|
||||
Logger.debug(`[Book] Tracks being rebuilt...!`)
|
||||
this.audioFiles.sort((a, b) => a.index - b.index)
|
||||
this.missingParts = []
|
||||
this.setChapters()
|
||||
this.checkUpdateMissingTracks()
|
||||
}
|
||||
|
||||
checkUpdateMissingTracks() {
|
||||
var currMissingParts = (this.missingParts || []).join(',') || ''
|
||||
|
||||
var current_index = 1
|
||||
var missingParts = []
|
||||
|
||||
for (let i = 0; i < this.tracks.length; i++) {
|
||||
var _track = this.tracks[i]
|
||||
if (_track.index > current_index) {
|
||||
var num_parts_missing = _track.index - current_index
|
||||
for (let x = 0; x < num_parts_missing && x < 9999; x++) {
|
||||
missingParts.push(current_index + x)
|
||||
}
|
||||
}
|
||||
current_index = _track.index + 1
|
||||
}
|
||||
|
||||
this.missingParts = missingParts
|
||||
|
||||
var newMissingParts = (this.missingParts || []).join(',') || ''
|
||||
var wasUpdated = newMissingParts !== currMissingParts
|
||||
if (wasUpdated && this.missingParts.length) {
|
||||
Logger.info(`[Audiobook] "${this.metadata.title}" has ${missingParts.length} missing parts`)
|
||||
}
|
||||
|
||||
return wasUpdated
|
||||
}
|
||||
|
||||
setChapters() {
|
||||
const preferOverdriveMediaMarker = !!global.ServerSettings.scannerPreferOverdriveMediaMarker
|
||||
|
||||
// If 1 audio file without chapters, then no chapters will be set
|
||||
const includedAudioFiles = this.audioFiles.filter(af => !af.exclude)
|
||||
if (!includedAudioFiles.length) return
|
||||
|
||||
// If overdrive media markers are present and preferred, use those instead
|
||||
if (preferOverdriveMediaMarker) {
|
||||
const overdriveChapters = parseOverdriveMediaMarkersAsChapters(includedAudioFiles)
|
||||
if (overdriveChapters) {
|
||||
Logger.info('[Book] Overdrive Media Markers and preference found! Using these for chapter definitions')
|
||||
this.chapters = overdriveChapters
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If first audio file has embedded chapters then use embedded chapters
|
||||
if (includedAudioFiles[0].chapters?.length) {
|
||||
// If all files chapters are the same, then only make chapters for the first file
|
||||
if (
|
||||
includedAudioFiles.length === 1 ||
|
||||
includedAudioFiles.length > 1 &&
|
||||
includedAudioFiles[0].chapters.length === includedAudioFiles[1].chapters?.length &&
|
||||
includedAudioFiles[0].chapters.every((c, i) => c.title === includedAudioFiles[1].chapters[i].title)
|
||||
) {
|
||||
Logger.debug(`[Book] setChapters: Using embedded chapters in first audio file ${includedAudioFiles[0].metadata?.path}`)
|
||||
this.chapters = includedAudioFiles[0].chapters.map((c) => ({ ...c }))
|
||||
} else {
|
||||
Logger.debug(`[Book] setChapters: Using embedded chapters from all audio files ${includedAudioFiles[0].metadata?.path}`)
|
||||
this.chapters = []
|
||||
let currChapterId = 0
|
||||
let currStartTime = 0
|
||||
|
||||
includedAudioFiles.forEach((file) => {
|
||||
if (file.duration) {
|
||||
const chapters = file.chapters?.map((c) => ({
|
||||
...c,
|
||||
id: c.id + currChapterId,
|
||||
start: c.start + currStartTime,
|
||||
end: c.end + currStartTime,
|
||||
})) ?? []
|
||||
this.chapters = this.chapters.concat(chapters)
|
||||
|
||||
currChapterId += file.chapters?.length ?? 0
|
||||
currStartTime += file.duration
|
||||
}
|
||||
})
|
||||
}
|
||||
} else if (includedAudioFiles.length > 1) {
|
||||
const preferAudioMetadata = !!global.ServerSettings.scannerPreferAudioMetadata
|
||||
|
||||
// Build chapters from audio files
|
||||
this.chapters = []
|
||||
let currChapterId = 0
|
||||
let currStartTime = 0
|
||||
includedAudioFiles.forEach((file) => {
|
||||
if (file.duration) {
|
||||
let title = file.metadata.filename ? Path.basename(file.metadata.filename, Path.extname(file.metadata.filename)) : `Chapter ${currChapterId}`
|
||||
|
||||
// When prefer audio metadata server setting is set then use ID3 title tag as long as it is not the same as the book title
|
||||
if (preferAudioMetadata && file.metaTags?.tagTitle && file.metaTags?.tagTitle !== this.metadata.title) {
|
||||
title = file.metaTags.tagTitle
|
||||
}
|
||||
|
||||
this.chapters.push({
|
||||
id: currChapterId++,
|
||||
start: currStartTime,
|
||||
end: currStartTime + file.duration,
|
||||
title
|
||||
})
|
||||
currStartTime += file.duration
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Only checks container format
|
||||
|
@ -140,10 +140,6 @@ class Music {
|
||||
return this.metadata.setDataFromAudioMetaTags(this.audioFile.metaTags, overrideExistingDetails)
|
||||
}
|
||||
|
||||
syncMetadataFiles(textMetadataFiles, opfMetadataOverrideDetails) {
|
||||
return false
|
||||
}
|
||||
|
||||
searchQuery(query) {
|
||||
return {}
|
||||
}
|
||||
|
@ -203,37 +203,6 @@ class Podcast {
|
||||
this.lastEpisodeCheck = Date.now() // Makes sure new episodes are after this
|
||||
}
|
||||
|
||||
async syncMetadataFiles(textMetadataFiles, opfMetadataOverrideDetails) {
|
||||
let metadataUpdatePayload = {}
|
||||
let tagsUpdated = false
|
||||
|
||||
const metadataAbs = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.abs' || lf.metadata.filename === 'metadata.json')
|
||||
if (metadataAbs) {
|
||||
const isJSON = metadataAbs.metadata.filename === 'metadata.json'
|
||||
const metadataText = await readTextFile(metadataAbs.metadata.path)
|
||||
const abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this, 'podcast', isJSON)
|
||||
if (abmetadataUpdates && Object.keys(abmetadataUpdates).length) {
|
||||
Logger.debug(`[Podcast] "${this.metadata.title}" changes found in metadata.abs file`, abmetadataUpdates)
|
||||
|
||||
if (abmetadataUpdates.tags) { // Set media tags if updated
|
||||
this.tags = abmetadataUpdates.tags
|
||||
tagsUpdated = true
|
||||
}
|
||||
if (abmetadataUpdates.metadata) {
|
||||
metadataUpdatePayload = {
|
||||
...metadataUpdatePayload,
|
||||
...abmetadataUpdates.metadata
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(metadataUpdatePayload).length) {
|
||||
return this.metadata.update(metadataUpdatePayload) || tagsUpdated
|
||||
}
|
||||
return tagsUpdated
|
||||
}
|
||||
|
||||
searchEpisodes(query) {
|
||||
return this.episodes.filter(ep => ep.searchQuery(query))
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ class LibrarySettings {
|
||||
this.autoScanCronExpression = null
|
||||
this.audiobooksOnly = false
|
||||
this.hideSingleBookSeries = false // Do not show series that only have 1 book
|
||||
this.metadataPrecedence = ['folderStructure', 'audioMetatags', 'txtFiles', 'opfFile', 'absMetadata']
|
||||
|
||||
if (settings) {
|
||||
this.construct(settings)
|
||||
@ -23,6 +24,12 @@ class LibrarySettings {
|
||||
this.autoScanCronExpression = settings.autoScanCronExpression || null
|
||||
this.audiobooksOnly = !!settings.audiobooksOnly
|
||||
this.hideSingleBookSeries = !!settings.hideSingleBookSeries
|
||||
if (settings.metadataPrecedence) {
|
||||
this.metadataPrecedence = [...settings.metadataPrecedence]
|
||||
} else {
|
||||
// Added in v2.4.5
|
||||
this.metadataPrecedence = ['folderStructure', 'audioMetatags', 'txtFiles', 'opfFile', 'absMetadata']
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
@ -33,14 +40,20 @@ class LibrarySettings {
|
||||
skipMatchingMediaWithIsbn: this.skipMatchingMediaWithIsbn,
|
||||
autoScanCronExpression: this.autoScanCronExpression,
|
||||
audiobooksOnly: this.audiobooksOnly,
|
||||
hideSingleBookSeries: this.hideSingleBookSeries
|
||||
hideSingleBookSeries: this.hideSingleBookSeries,
|
||||
metadataPrecedence: [...this.metadataPrecedence]
|
||||
}
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
let hasUpdates = false
|
||||
for (const key in payload) {
|
||||
if (this[key] !== payload[key]) {
|
||||
if (key === 'metadataPrecedence') {
|
||||
if (payload[key] && Array.isArray(payload[key]) && payload[key].join() !== this[key].join()) {
|
||||
this[key] = payload[key]
|
||||
hasUpdates = true
|
||||
}
|
||||
} else if (this[key] !== payload[key]) {
|
||||
this[key] = payload[key]
|
||||
hasUpdates = true
|
||||
}
|
||||
|
@ -10,11 +10,8 @@ class ServerSettings {
|
||||
this.scannerParseSubtitle = false
|
||||
this.scannerFindCovers = false
|
||||
this.scannerCoverProvider = 'google'
|
||||
this.scannerPreferAudioMetadata = false
|
||||
this.scannerPreferOpfMetadata = false
|
||||
this.scannerPreferMatchedMetadata = false
|
||||
this.scannerDisableWatcher = false
|
||||
this.scannerPreferOverdriveMediaMarker = false
|
||||
|
||||
// Metadata - choose to store inside users library item folder
|
||||
this.storeCoverWithItem = false
|
||||
@ -65,11 +62,8 @@ class ServerSettings {
|
||||
this.scannerFindCovers = !!settings.scannerFindCovers
|
||||
this.scannerCoverProvider = settings.scannerCoverProvider || 'google'
|
||||
this.scannerParseSubtitle = settings.scannerParseSubtitle
|
||||
this.scannerPreferAudioMetadata = !!settings.scannerPreferAudioMetadata
|
||||
this.scannerPreferOpfMetadata = !!settings.scannerPreferOpfMetadata
|
||||
this.scannerPreferMatchedMetadata = !!settings.scannerPreferMatchedMetadata
|
||||
this.scannerDisableWatcher = !!settings.scannerDisableWatcher
|
||||
this.scannerPreferOverdriveMediaMarker = !!settings.scannerPreferOverdriveMediaMarker
|
||||
|
||||
this.storeCoverWithItem = !!settings.storeCoverWithItem
|
||||
this.storeMetadataWithItem = !!settings.storeMetadataWithItem
|
||||
@ -130,11 +124,8 @@ class ServerSettings {
|
||||
scannerFindCovers: this.scannerFindCovers,
|
||||
scannerCoverProvider: this.scannerCoverProvider,
|
||||
scannerParseSubtitle: this.scannerParseSubtitle,
|
||||
scannerPreferAudioMetadata: this.scannerPreferAudioMetadata,
|
||||
scannerPreferOpfMetadata: this.scannerPreferOpfMetadata,
|
||||
scannerPreferMatchedMetadata: this.scannerPreferMatchedMetadata,
|
||||
scannerDisableWatcher: this.scannerDisableWatcher,
|
||||
scannerPreferOverdriveMediaMarker: this.scannerPreferOverdriveMediaMarker,
|
||||
storeCoverWithItem: this.storeCoverWithItem,
|
||||
storeMetadataWithItem: this.storeMetadataWithItem,
|
||||
metadataFileFormat: this.metadataFileFormat,
|
||||
|
65
server/scanner/AbsMetadataFileScanner.js
Normal file
65
server/scanner/AbsMetadataFileScanner.js
Normal file
@ -0,0 +1,65 @@
|
||||
const Path = require('path')
|
||||
const fsExtra = require('../libs/fsExtra')
|
||||
const { readTextFile } = require('../utils/fileUtils')
|
||||
const { LogLevel } = require('../utils/constants')
|
||||
const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
|
||||
|
||||
class AbsMetadataFileScanner {
|
||||
constructor() { }
|
||||
|
||||
/**
|
||||
* Check for metadata.json or metadata.abs file and set book metadata
|
||||
*
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||
* @param {Object} bookMetadata
|
||||
* @param {string} [existingLibraryItemId]
|
||||
*/
|
||||
async scanBookMetadataFile(libraryScan, libraryItemData, bookMetadata, existingLibraryItemId = null) {
|
||||
const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile || libraryItemData.metadataAbsLibraryFile
|
||||
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
|
||||
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')
|
||||
}
|
||||
|
||||
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) continue
|
||||
bookMetadata[key] = abMetadata.metadata[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = new AbsMetadataFileScanner()
|
@ -1,6 +1,9 @@
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const prober = require('../utils/prober')
|
||||
const { LogLevel } = require('../utils/constants')
|
||||
const { parseOverdriveMediaMarkersAsChapters } = require('../utils/parsers/parseOverdriveMediaMarkers')
|
||||
const parseNameString = require('../utils/parsers/parseNameString')
|
||||
const LibraryItem = require('../models/LibraryItem')
|
||||
const AudioFile = require('../objects/files/AudioFile')
|
||||
|
||||
@ -205,5 +208,204 @@ class AudioFileScanner {
|
||||
Logger.debug(`[AudioFileScanner] Running ffprobe for audio file at "${audioFile.metadata.path}"`)
|
||||
return prober.rawProbe(audioFile.metadata.path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set book metadata & chapters from audio file meta tags
|
||||
*
|
||||
* @param {string} bookTitle
|
||||
* @param {import('../models/Book').AudioFileObject} audioFile
|
||||
* @param {Object} bookMetadata
|
||||
* @param {LibraryScan} libraryScan
|
||||
*/
|
||||
setBookMetadataFromAudioMetaTags(bookTitle, audioFiles, bookMetadata, libraryScan) {
|
||||
const MetadataMapArray = [
|
||||
{
|
||||
tag: 'tagComposer',
|
||||
key: 'narrators'
|
||||
},
|
||||
{
|
||||
tag: 'tagDescription',
|
||||
altTag: 'tagComment',
|
||||
key: 'description'
|
||||
},
|
||||
{
|
||||
tag: 'tagPublisher',
|
||||
key: 'publisher'
|
||||
},
|
||||
{
|
||||
tag: 'tagDate',
|
||||
key: 'publishedYear'
|
||||
},
|
||||
{
|
||||
tag: 'tagSubtitle',
|
||||
key: 'subtitle'
|
||||
},
|
||||
{
|
||||
tag: 'tagAlbum',
|
||||
altTag: 'tagTitle',
|
||||
key: 'title',
|
||||
},
|
||||
{
|
||||
tag: 'tagArtist',
|
||||
altTag: 'tagAlbumArtist',
|
||||
key: 'authors'
|
||||
},
|
||||
{
|
||||
tag: 'tagGenre',
|
||||
key: 'genres'
|
||||
},
|
||||
{
|
||||
tag: 'tagSeries',
|
||||
key: 'series'
|
||||
},
|
||||
{
|
||||
tag: 'tagIsbn',
|
||||
key: 'isbn'
|
||||
},
|
||||
{
|
||||
tag: 'tagLanguage',
|
||||
key: 'language'
|
||||
},
|
||||
{
|
||||
tag: 'tagASIN',
|
||||
key: 'asin'
|
||||
}
|
||||
]
|
||||
|
||||
const firstScannedFile = audioFiles[0]
|
||||
const audioFileMetaTags = firstScannedFile.metaTags
|
||||
MetadataMapArray.forEach((mapping) => {
|
||||
let value = audioFileMetaTags[mapping.tag]
|
||||
if (!value && mapping.altTag) {
|
||||
value = audioFileMetaTags[mapping.altTag]
|
||||
}
|
||||
|
||||
if (value && typeof value === 'string') {
|
||||
value = value.trim() // Trim whitespace
|
||||
|
||||
if (mapping.key === 'narrators') {
|
||||
bookMetadata.narrators = parseNameString.parse(value)?.names || []
|
||||
} else if (mapping.key === 'authors') {
|
||||
bookMetadata.authors = parseNameString.parse(value)?.names || []
|
||||
} else if (mapping.key === 'genres') {
|
||||
bookMetadata.genres = this.parseGenresString(value)
|
||||
} else if (mapping.key === 'series') {
|
||||
bookMetadata.series = [
|
||||
{
|
||||
name: value,
|
||||
sequence: audioFileMetaTags.tagSeriesPart || null
|
||||
}
|
||||
]
|
||||
} else {
|
||||
bookMetadata[mapping.key] = value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Set chapters
|
||||
const chapters = this.getBookChaptersFromAudioFiles(bookTitle, audioFiles, libraryScan)
|
||||
if (chapters.length) {
|
||||
bookMetadata.chapters = chapters
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bookTitle
|
||||
* @param {AudioFile[]} audioFiles
|
||||
* @param {LibraryScan} libraryScan
|
||||
* @returns {import('../models/Book').ChapterObject[]}
|
||||
*/
|
||||
getBookChaptersFromAudioFiles(bookTitle, audioFiles, libraryScan) {
|
||||
// If overdrive media markers are present then use those instead
|
||||
const overdriveChapters = parseOverdriveMediaMarkersAsChapters(audioFiles)
|
||||
if (overdriveChapters?.length) {
|
||||
libraryScan.addLog(LogLevel.DEBUG, 'Overdrive Media Markers and preference found! Using these for chapter definitions')
|
||||
|
||||
return overdriveChapters
|
||||
}
|
||||
|
||||
let chapters = []
|
||||
|
||||
// If first audio file has embedded chapters then use embedded chapters
|
||||
if (audioFiles[0].chapters?.length) {
|
||||
// If all files chapters are the same, then only make chapters for the first file
|
||||
if (
|
||||
audioFiles.length === 1 ||
|
||||
audioFiles.length > 1 &&
|
||||
audioFiles[0].chapters.length === audioFiles[1].chapters?.length &&
|
||||
audioFiles[0].chapters.every((c, i) => c.title === audioFiles[1].chapters[i].title)
|
||||
) {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `setChapters: Using embedded chapters in first audio file ${audioFiles[0].metadata?.path}`)
|
||||
chapters = audioFiles[0].chapters.map((c) => ({ ...c }))
|
||||
} else {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `setChapters: Using embedded chapters from all audio files ${audioFiles[0].metadata?.path}`)
|
||||
let currChapterId = 0
|
||||
let currStartTime = 0
|
||||
|
||||
audioFiles.forEach((file) => {
|
||||
if (file.duration) {
|
||||
const afChapters = file.chapters?.map((c) => ({
|
||||
...c,
|
||||
id: c.id + currChapterId,
|
||||
start: c.start + currStartTime,
|
||||
end: c.end + currStartTime,
|
||||
})) ?? []
|
||||
chapters = chapters.concat(afChapters)
|
||||
|
||||
currChapterId += file.chapters?.length ?? 0
|
||||
currStartTime += file.duration
|
||||
}
|
||||
})
|
||||
return chapters
|
||||
}
|
||||
} else if (audioFiles.length > 1) {
|
||||
|
||||
// In some cases the ID3 title tag for each file is the chapter title, the criteria to determine if this will be used
|
||||
// 1. Every audio file has an ID3 title tag set
|
||||
// 2. None of the title tags are the same as the book title
|
||||
// 3. Every ID3 title tag is unique
|
||||
const metaTagTitlesFound = [...new Set(audioFiles.map(af => af.metaTags?.tagTitle).filter(tagTitle => !!tagTitle && tagTitle !== bookTitle))]
|
||||
const useMetaTagAsTitle = metaTagTitlesFound.length === audioFiles.length
|
||||
|
||||
// Build chapters from audio files
|
||||
let currChapterId = 0
|
||||
let currStartTime = 0
|
||||
audioFiles.forEach((file) => {
|
||||
if (file.duration) {
|
||||
let title = file.metadata.filename ? Path.basename(file.metadata.filename, Path.extname(file.metadata.filename)) : `Chapter ${currChapterId}`
|
||||
if (useMetaTagAsTitle) {
|
||||
title = file.metaTags.tagTitle
|
||||
}
|
||||
|
||||
chapters.push({
|
||||
id: currChapterId++,
|
||||
start: currStartTime,
|
||||
end: currStartTime + file.duration,
|
||||
title
|
||||
})
|
||||
currStartTime += file.duration
|
||||
}
|
||||
})
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a genre string into multiple genres
|
||||
* @example "Fantasy;Sci-Fi;History" => ["Fantasy", "Sci-Fi", "History"]
|
||||
*
|
||||
* @param {string} genreTag
|
||||
* @returns {string[]}
|
||||
*/
|
||||
parseGenresString(genreTag) {
|
||||
if (!genreTag?.length) return []
|
||||
const separators = ['/', '//', ';']
|
||||
for (let i = 0; i < separators.length; i++) {
|
||||
if (genreTag.includes(separators[i])) {
|
||||
return genreTag.split(separators[i]).map(genre => genre.trim()).filter(g => !!g)
|
||||
}
|
||||
}
|
||||
return [genreTag]
|
||||
}
|
||||
}
|
||||
module.exports = new AudioFileScanner()
|
@ -3,8 +3,6 @@ const Path = require('path')
|
||||
const sequelize = require('sequelize')
|
||||
const { LogLevel } = require('../utils/constants')
|
||||
const { getTitleIgnorePrefix, areEquivalent } = require('../utils/index')
|
||||
const { parseOpfMetadataXML } = require('../utils/parsers/parseOpfMetadata')
|
||||
const { parseOverdriveMediaMarkersAsChapters } = require('../utils/parsers/parseOverdriveMediaMarkers')
|
||||
const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
|
||||
const parseNameString = require('../utils/parsers/parseNameString')
|
||||
const globals = require('../utils/globals')
|
||||
@ -16,9 +14,12 @@ const CoverManager = require('../managers/CoverManager')
|
||||
const LibraryFile = require('../objects/files/LibraryFile')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const fsExtra = require("../libs/fsExtra")
|
||||
const LibraryScan = require("./LibraryScan")
|
||||
const BookFinder = require('../finders/BookFinder')
|
||||
|
||||
const LibraryScan = require("./LibraryScan")
|
||||
const OpfFileScanner = require('./OpfFileScanner')
|
||||
const AbsMetadataFileScanner = require('./AbsMetadataFileScanner')
|
||||
|
||||
/**
|
||||
* Metadata for books pulled from files
|
||||
* @typedef BookMetadataObject
|
||||
@ -50,7 +51,7 @@ class BookScanner {
|
||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||
* @param {import('../models/Library').LibrarySettingsObject} librarySettings
|
||||
* @param {LibraryScan} libraryScan
|
||||
* @returns {Promise<import('../models/LibraryItem')>}
|
||||
* @returns {Promise<{libraryItem:import('../models/LibraryItem'), wasUpdated:boolean}>}
|
||||
*/
|
||||
async rescanExistingBookLibraryItem(existingLibraryItem, libraryItemData, librarySettings, libraryScan) {
|
||||
/** @type {import('../models/Book')} */
|
||||
@ -168,7 +169,7 @@ class BookScanner {
|
||||
hasMediaChanges = true
|
||||
}
|
||||
|
||||
const bookMetadata = await this.getBookMetadataFromScanData(media.audioFiles, libraryItemData, libraryScan, existingLibraryItem.id)
|
||||
const bookMetadata = await this.getBookMetadataFromScanData(media.audioFiles, libraryItemData, libraryScan, librarySettings, existingLibraryItem.id)
|
||||
let authorsUpdated = false
|
||||
const bookAuthorsRemoved = []
|
||||
let seriesUpdated = false
|
||||
@ -360,7 +361,10 @@ class BookScanner {
|
||||
libraryScan.seriesRemovedFromBooks.push(...bookSeriesRemoved)
|
||||
libraryScan.authorsRemovedFromBooks.push(...bookAuthorsRemoved)
|
||||
|
||||
return existingLibraryItem
|
||||
return {
|
||||
libraryItem: existingLibraryItem,
|
||||
wasUpdated: hasMediaChanges || libraryItemUpdated || seriesUpdated || authorsUpdated
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -389,7 +393,7 @@ class BookScanner {
|
||||
ebookLibraryFile.ebookFormat = ebookLibraryFile.metadata.ext.slice(1).toLowerCase()
|
||||
}
|
||||
|
||||
const bookMetadata = await this.getBookMetadataFromScanData(scannedAudioFiles, libraryItemData, libraryScan)
|
||||
const bookMetadata = await this.getBookMetadataFromScanData(scannedAudioFiles, libraryItemData, libraryScan, librarySettings)
|
||||
bookMetadata.explicit = !!bookMetadata.explicit // Ensure boolean
|
||||
bookMetadata.abridged = !!bookMetadata.abridged // Ensure boolean
|
||||
|
||||
@ -548,226 +552,41 @@ class BookScanner {
|
||||
* @param {import('../models/Book').AudioFileObject[]} audioFiles
|
||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||
* @param {LibraryScan} libraryScan
|
||||
* @param {import('../models/Library').LibrarySettingsObject} librarySettings
|
||||
* @param {string} [existingLibraryItemId]
|
||||
* @returns {Promise<BookMetadataObject>}
|
||||
*/
|
||||
async getBookMetadataFromScanData(audioFiles, libraryItemData, libraryScan, existingLibraryItemId = null) {
|
||||
async getBookMetadataFromScanData(audioFiles, libraryItemData, libraryScan, librarySettings, existingLibraryItemId = null) {
|
||||
// First set book metadata from folder/file names
|
||||
const bookMetadata = {
|
||||
title: libraryItemData.mediaMetadata.title,
|
||||
titleIgnorePrefix: getTitleIgnorePrefix(libraryItemData.mediaMetadata.title),
|
||||
subtitle: libraryItemData.mediaMetadata.subtitle || undefined,
|
||||
publishedYear: libraryItemData.mediaMetadata.publishedYear || undefined,
|
||||
title: libraryItemData.mediaMetadata.title, // required
|
||||
titleIgnorePrefix: undefined,
|
||||
subtitle: undefined,
|
||||
publishedYear: undefined,
|
||||
publisher: undefined,
|
||||
description: undefined,
|
||||
isbn: undefined,
|
||||
asin: undefined,
|
||||
language: undefined,
|
||||
narrators: parseNameString.parse(libraryItemData.mediaMetadata.narrators)?.names || [],
|
||||
narrators: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
authors: parseNameString.parse(libraryItemData.mediaMetadata.author)?.names || [],
|
||||
authors: [],
|
||||
series: [],
|
||||
chapters: [],
|
||||
explicit: undefined,
|
||||
abridged: undefined,
|
||||
coverPath: undefined
|
||||
}
|
||||
if (libraryItemData.mediaMetadata.series) {
|
||||
bookMetadata.series.push({
|
||||
name: libraryItemData.mediaMetadata.series,
|
||||
sequence: libraryItemData.mediaMetadata.sequence || null
|
||||
})
|
||||
}
|
||||
|
||||
// Fill in or override book metadata from audio file meta tags
|
||||
if (audioFiles.length) {
|
||||
const MetadataMapArray = [
|
||||
{
|
||||
tag: 'tagComposer',
|
||||
key: 'narrators'
|
||||
},
|
||||
{
|
||||
tag: 'tagDescription',
|
||||
altTag: 'tagComment',
|
||||
key: 'description'
|
||||
},
|
||||
{
|
||||
tag: 'tagPublisher',
|
||||
key: 'publisher'
|
||||
},
|
||||
{
|
||||
tag: 'tagDate',
|
||||
key: 'publishedYear'
|
||||
},
|
||||
{
|
||||
tag: 'tagSubtitle',
|
||||
key: 'subtitle'
|
||||
},
|
||||
{
|
||||
tag: 'tagAlbum',
|
||||
altTag: 'tagTitle',
|
||||
key: 'title',
|
||||
},
|
||||
{
|
||||
tag: 'tagArtist',
|
||||
altTag: 'tagAlbumArtist',
|
||||
key: 'authors'
|
||||
},
|
||||
{
|
||||
tag: 'tagGenre',
|
||||
key: 'genres'
|
||||
},
|
||||
{
|
||||
tag: 'tagSeries',
|
||||
key: 'series'
|
||||
},
|
||||
{
|
||||
tag: 'tagIsbn',
|
||||
key: 'isbn'
|
||||
},
|
||||
{
|
||||
tag: 'tagLanguage',
|
||||
key: 'language'
|
||||
},
|
||||
{
|
||||
tag: 'tagASIN',
|
||||
key: 'asin'
|
||||
}
|
||||
]
|
||||
const overrideExistingDetails = Database.serverSettings.scannerPreferAudioMetadata
|
||||
const firstScannedFile = audioFiles[0]
|
||||
const audioFileMetaTags = firstScannedFile.metaTags
|
||||
MetadataMapArray.forEach((mapping) => {
|
||||
let value = audioFileMetaTags[mapping.tag]
|
||||
if (!value && mapping.altTag) {
|
||||
value = audioFileMetaTags[mapping.altTag]
|
||||
}
|
||||
|
||||
if (value && typeof value === 'string') {
|
||||
value = value.trim() // Trim whitespace
|
||||
|
||||
if (mapping.key === 'narrators' && (!bookMetadata.narrators.length || overrideExistingDetails)) {
|
||||
bookMetadata.narrators = parseNameString.parse(value)?.names || []
|
||||
} else if (mapping.key === 'authors' && (!bookMetadata.authors.length || overrideExistingDetails)) {
|
||||
bookMetadata.authors = parseNameString.parse(value)?.names || []
|
||||
} else if (mapping.key === 'genres' && (!bookMetadata.genres.length || overrideExistingDetails)) {
|
||||
bookMetadata.genres = this.parseGenresString(value)
|
||||
} else if (mapping.key === 'series' && (!bookMetadata.series.length || overrideExistingDetails)) {
|
||||
bookMetadata.series = [
|
||||
{
|
||||
name: value,
|
||||
sequence: audioFileMetaTags.tagSeriesPart || null
|
||||
}
|
||||
]
|
||||
} else if (!bookMetadata[mapping.key] || overrideExistingDetails) {
|
||||
bookMetadata[mapping.key] = value
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// If desc.txt in library item folder then use this for description
|
||||
if (libraryItemData.descTxtLibraryFile) {
|
||||
const description = await readTextFile(libraryItemData.descTxtLibraryFile.metadata.path)
|
||||
if (description.trim()) bookMetadata.description = description.trim()
|
||||
}
|
||||
|
||||
// If reader.txt in library item folder then use this for narrator
|
||||
if (libraryItemData.readerTxtLibraryFile) {
|
||||
let narrator = await readTextFile(libraryItemData.readerTxtLibraryFile.metadata.path)
|
||||
narrator = narrator.split(/\r?\n/)[0]?.trim() || '' // Only use first line
|
||||
if (narrator) {
|
||||
bookMetadata.narrators = parseNameString.parse(narrator)?.names || []
|
||||
}
|
||||
}
|
||||
|
||||
// If opf file is found look for metadata
|
||||
if (libraryItemData.metadataOpfLibraryFile) {
|
||||
const xmlText = await readTextFile(libraryItemData.metadataOpfLibraryFile.metadata.path)
|
||||
const opfMetadata = xmlText ? await parseOpfMetadataXML(xmlText) : null
|
||||
if (opfMetadata) {
|
||||
const opfMetadataOverrideDetails = Database.serverSettings.scannerPreferOpfMetadata
|
||||
for (const key in opfMetadata) {
|
||||
if (key === 'tags') { // Add tags only if tags are empty
|
||||
if (opfMetadata.tags.length && (!bookMetadata.tags.length || opfMetadataOverrideDetails)) {
|
||||
bookMetadata.tags = opfMetadata.tags
|
||||
}
|
||||
} else if (key === 'genres') { // Add genres only if genres are empty
|
||||
if (opfMetadata.genres.length && (!bookMetadata.genres.length || opfMetadataOverrideDetails)) {
|
||||
bookMetadata.genres = opfMetadata.genres
|
||||
}
|
||||
} else if (key === 'authors') {
|
||||
if (opfMetadata.authors?.length && (!bookMetadata.authors.length || opfMetadataOverrideDetails)) {
|
||||
bookMetadata.authors = opfMetadata.authors
|
||||
}
|
||||
} else if (key === 'narrators') {
|
||||
if (opfMetadata.narrators?.length && (!bookMetadata.narrators.length || opfMetadataOverrideDetails)) {
|
||||
bookMetadata.narrators = opfMetadata.narrators
|
||||
}
|
||||
} else if (key === 'series') {
|
||||
if (opfMetadata.series && (!bookMetadata.series.length || opfMetadataOverrideDetails)) {
|
||||
bookMetadata.series = [{
|
||||
name: opfMetadata.series,
|
||||
sequence: opfMetadata.sequence || null
|
||||
}]
|
||||
}
|
||||
} else if (opfMetadata[key] && (!bookMetadata[key] || opfMetadataOverrideDetails)) {
|
||||
bookMetadata[key] = opfMetadata[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If metadata.json or metadata.abs use this for metadata
|
||||
const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile || libraryItemData.metadataAbsLibraryFile
|
||||
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
|
||||
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)
|
||||
const bookMetadataSourceHandler = new BookScanner.BookMetadataSourceHandler(bookMetadata, audioFiles, libraryItemData, libraryScan, existingLibraryItemId)
|
||||
for (const metadataSource of librarySettings.metadataPrecedence) {
|
||||
if (bookMetadataSourceHandler[metadataSource]) {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Getting metadata from source "${metadataSource}"`)
|
||||
await bookMetadataSourceHandler[metadataSource]()
|
||||
} else {
|
||||
abMetadata = abmetadataGenerator.parse(metadataText, 'book')
|
||||
libraryScan.addLog(LogLevel.ERROR, `Invalid metadata source "${metadataSource}"`)
|
||||
}
|
||||
|
||||
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) continue
|
||||
bookMetadata[key] = abMetadata.metadata[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set chapters from audio files if not already set
|
||||
if (!bookMetadata.chapters.length) {
|
||||
bookMetadata.chapters = this.getChaptersFromAudioFiles(bookMetadata.title, audioFiles, libraryScan)
|
||||
}
|
||||
|
||||
// Set cover from library file if one is found otherwise check audiofile
|
||||
@ -781,102 +600,76 @@ class BookScanner {
|
||||
return bookMetadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a genre string into multiple genres
|
||||
* @example "Fantasy;Sci-Fi;History" => ["Fantasy", "Sci-Fi", "History"]
|
||||
* @param {string} genreTag
|
||||
* @returns {string[]}
|
||||
*/
|
||||
parseGenresString(genreTag) {
|
||||
if (!genreTag?.length) return []
|
||||
const separators = ['/', '//', ';']
|
||||
for (let i = 0; i < separators.length; i++) {
|
||||
if (genreTag.includes(separators[i])) {
|
||||
return genreTag.split(separators[i]).map(genre => genre.trim()).filter(g => !!g)
|
||||
}
|
||||
}
|
||||
return [genreTag]
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bookTitle
|
||||
* @param {AudioFile[]} audioFiles
|
||||
* @param {LibraryScan} libraryScan
|
||||
* @returns {import('../models/Book').ChapterObject[]}
|
||||
*/
|
||||
getChaptersFromAudioFiles(bookTitle, audioFiles, libraryScan) {
|
||||
if (!audioFiles.length) return []
|
||||
|
||||
// If overdrive media markers are present and preferred, use those instead
|
||||
if (Database.serverSettings.scannerPreferOverdriveMediaMarker) {
|
||||
const overdriveChapters = parseOverdriveMediaMarkersAsChapters(audioFiles)
|
||||
if (overdriveChapters) {
|
||||
libraryScan.addLog(LogLevel.DEBUG, 'Overdrive Media Markers and preference found! Using these for chapter definitions')
|
||||
|
||||
return overdriveChapters
|
||||
}
|
||||
static BookMetadataSourceHandler = class {
|
||||
/**
|
||||
*
|
||||
* @param {Object} bookMetadata
|
||||
* @param {import('../models/Book').AudioFileObject[]} audioFiles
|
||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||
* @param {LibraryScan} libraryScan
|
||||
* @param {string} existingLibraryItemId
|
||||
*/
|
||||
constructor(bookMetadata, audioFiles, libraryItemData, libraryScan, existingLibraryItemId) {
|
||||
this.bookMetadata = bookMetadata
|
||||
this.audioFiles = audioFiles
|
||||
this.libraryItemData = libraryItemData
|
||||
this.libraryScan = libraryScan
|
||||
this.existingLibraryItemId = existingLibraryItemId
|
||||
}
|
||||
|
||||
let chapters = []
|
||||
/**
|
||||
* Metadata parsed from folder names/structure
|
||||
*/
|
||||
folderStructure() {
|
||||
this.libraryItemData.setBookMetadataFromFilenames(this.bookMetadata)
|
||||
}
|
||||
|
||||
// If first audio file has embedded chapters then use embedded chapters
|
||||
if (audioFiles[0].chapters?.length) {
|
||||
// If all files chapters are the same, then only make chapters for the first file
|
||||
if (
|
||||
audioFiles.length === 1 ||
|
||||
audioFiles.length > 1 &&
|
||||
audioFiles[0].chapters.length === audioFiles[1].chapters?.length &&
|
||||
audioFiles[0].chapters.every((c, i) => c.title === audioFiles[1].chapters[i].title)
|
||||
) {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `setChapters: Using embedded chapters in first audio file ${audioFiles[0].metadata?.path}`)
|
||||
chapters = audioFiles[0].chapters.map((c) => ({ ...c }))
|
||||
} else {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `setChapters: Using embedded chapters from all audio files ${audioFiles[0].metadata?.path}`)
|
||||
let currChapterId = 0
|
||||
let currStartTime = 0
|
||||
/**
|
||||
* Metadata from audio file meta tags
|
||||
*/
|
||||
audioMetatags() {
|
||||
if (!this.audioFiles.length) return
|
||||
// Modifies bookMetadata with metadata mapped from audio file meta tags
|
||||
const bookTitle = this.bookMetadata.title || this.libraryItemData.mediaMetadata.title
|
||||
AudioFileScanner.setBookMetadataFromAudioMetaTags(bookTitle, this.audioFiles, this.bookMetadata, this.libraryScan)
|
||||
}
|
||||
|
||||
audioFiles.forEach((file) => {
|
||||
if (file.duration) {
|
||||
const afChapters = file.chapters?.map((c) => ({
|
||||
...c,
|
||||
id: c.id + currChapterId,
|
||||
start: c.start + currStartTime,
|
||||
end: c.end + currStartTime,
|
||||
})) ?? []
|
||||
chapters = chapters.concat(afChapters)
|
||||
|
||||
currChapterId += file.chapters?.length ?? 0
|
||||
currStartTime += file.duration
|
||||
}
|
||||
})
|
||||
return chapters
|
||||
/**
|
||||
* Description from desc.txt and narrator from reader.txt
|
||||
*/
|
||||
async txtFiles() {
|
||||
// If desc.txt in library item folder then use this for description
|
||||
if (this.libraryItemData.descTxtLibraryFile) {
|
||||
const description = await readTextFile(this.libraryItemData.descTxtLibraryFile.metadata.path)
|
||||
if (description.trim()) this.bookMetadata.description = description.trim()
|
||||
}
|
||||
} else if (audioFiles.length > 1) {
|
||||
const preferAudioMetadata = !!Database.serverSettings.scannerPreferAudioMetadata
|
||||
|
||||
// Build chapters from audio files
|
||||
let currChapterId = 0
|
||||
let currStartTime = 0
|
||||
audioFiles.forEach((file) => {
|
||||
if (file.duration) {
|
||||
let title = file.metadata.filename ? Path.basename(file.metadata.filename, Path.extname(file.metadata.filename)) : `Chapter ${currChapterId}`
|
||||
|
||||
// When prefer audio metadata server setting is set then use ID3 title tag as long as it is not the same as the book title
|
||||
if (preferAudioMetadata && file.metaTags?.tagTitle && file.metaTags?.tagTitle !== bookTitle) {
|
||||
title = file.metaTags.tagTitle
|
||||
}
|
||||
|
||||
chapters.push({
|
||||
id: currChapterId++,
|
||||
start: currStartTime,
|
||||
end: currStartTime + file.duration,
|
||||
title
|
||||
})
|
||||
currStartTime += file.duration
|
||||
// If reader.txt in library item folder then use this for narrator
|
||||
if (this.libraryItemData.readerTxtLibraryFile) {
|
||||
let narrator = await readTextFile(this.libraryItemData.readerTxtLibraryFile.metadata.path)
|
||||
narrator = narrator.split(/\r?\n/)[0]?.trim() || '' // Only use first line
|
||||
if (narrator) {
|
||||
this.bookMetadata.narrators = parseNameString.parse(narrator)?.names || []
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata from opf file
|
||||
*/
|
||||
async opfFile() {
|
||||
if (!this.libraryItemData.metadataOpfLibraryFile) return
|
||||
await OpfFileScanner.scanBookOpfFile(this.libraryItemData.metadataOpfLibraryFile, this.bookMetadata)
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata from metadata.json or metadata.abs
|
||||
*/
|
||||
async absMetadata() {
|
||||
// If metadata.json or metadata.abs use this for metadata
|
||||
await AbsMetadataFileScanner.scanBookMetadataFile(this.libraryScan, this.libraryItemData, this.bookMetadata, this.existingLibraryItemId)
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -25,7 +25,7 @@ class LibraryItemScanData {
|
||||
this.relPath = data.relPath
|
||||
/** @type {boolean} */
|
||||
this.isFile = data.isFile
|
||||
/** @type {{author:string, title:string, subtitle:string, series:string, sequence:string, publishedYear:string, narrators:string}} */
|
||||
/** @type {import('../utils/scandir').LibraryItemFilenameMetadata} */
|
||||
this.mediaMetadata = data.mediaMetadata
|
||||
/** @type {import('../objects/files/LibraryFile')[]} */
|
||||
this.libraryFiles = data.libraryFiles
|
||||
@ -233,10 +233,9 @@ class LibraryItemScanData {
|
||||
}
|
||||
await existingLibraryItem.save()
|
||||
return true
|
||||
} else {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" is up-to-date`)
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
@ -303,5 +302,34 @@ class LibraryItemScanData {
|
||||
|
||||
return !this.ebookLibraryFiles.some(lf => lf.ino === ebookFile.ino)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set data parsed from filenames
|
||||
*
|
||||
* @param {Object} bookMetadata
|
||||
*/
|
||||
setBookMetadataFromFilenames(bookMetadata) {
|
||||
const keysToMap = ['title', 'subtitle', 'publishedYear']
|
||||
for (const key in this.mediaMetadata) {
|
||||
if (keysToMap.includes(key) && this.mediaMetadata[key]) {
|
||||
bookMetadata[key] = this.mediaMetadata[key]
|
||||
}
|
||||
}
|
||||
|
||||
if (this.mediaMetadata.authors?.length) {
|
||||
bookMetadata.authors = this.mediaMetadata.authors
|
||||
}
|
||||
if (this.mediaMetadata.narrators?.length) {
|
||||
bookMetadata.narrators = this.mediaMetadata.narrators
|
||||
}
|
||||
if (this.mediaMetadata.seriesName) {
|
||||
bookMetadata.series = [
|
||||
{
|
||||
name: this.mediaMetadata.seriesName,
|
||||
sequence: this.mediaMetadata.seriesSequence || null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = LibraryItemScanData
|
@ -58,7 +58,7 @@ class LibraryItemScanner {
|
||||
|
||||
if (await libraryItemScanData.checkLibraryItemData(libraryItem, scanLogger)) {
|
||||
if (libraryItemScanData.hasLibraryFileChanges || libraryItemScanData.hasPathChange) {
|
||||
const expandedLibraryItem = await this.rescanLibraryItem(libraryItem, libraryItemScanData, library.settings, scanLogger)
|
||||
const { libraryItem: expandedLibraryItem } = await this.rescanLibraryItemMedia(libraryItem, libraryItemScanData, library.settings, scanLogger)
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(expandedLibraryItem)
|
||||
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
||||
|
||||
@ -71,6 +71,7 @@ class LibraryItemScanner {
|
||||
|
||||
return ScanResult.UPDATED
|
||||
}
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Library item "${libraryItem.relPath}" is up-to-date`)
|
||||
return ScanResult.UPTODATE
|
||||
}
|
||||
|
||||
@ -156,16 +157,14 @@ class LibraryItemScanner {
|
||||
* @param {LibraryItemScanData} libraryItemData
|
||||
* @param {import('../models/Library').LibrarySettingsObject} librarySettings
|
||||
* @param {LibraryScan} libraryScan
|
||||
* @returns {Promise<LibraryItem>}
|
||||
* @returns {Promise<{libraryItem:LibraryItem, wasUpdated:boolean}>}
|
||||
*/
|
||||
async rescanLibraryItem(existingLibraryItem, libraryItemData, librarySettings, libraryScan) {
|
||||
let newLibraryItem = null
|
||||
rescanLibraryItemMedia(existingLibraryItem, libraryItemData, librarySettings, libraryScan) {
|
||||
if (existingLibraryItem.mediaType === 'book') {
|
||||
newLibraryItem = await BookScanner.rescanExistingBookLibraryItem(existingLibraryItem, libraryItemData, librarySettings, libraryScan)
|
||||
return BookScanner.rescanExistingBookLibraryItem(existingLibraryItem, libraryItemData, librarySettings, libraryScan)
|
||||
} else {
|
||||
newLibraryItem = await PodcastScanner.rescanExistingPodcastLibraryItem(existingLibraryItem, libraryItemData, librarySettings, libraryScan)
|
||||
return PodcastScanner.rescanExistingPodcastLibraryItem(existingLibraryItem, libraryItemData, librarySettings, libraryScan)
|
||||
}
|
||||
return newLibraryItem
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -44,9 +44,9 @@ class LibraryScanner {
|
||||
/**
|
||||
*
|
||||
* @param {import('../objects/Library')} library
|
||||
* @param {*} options
|
||||
* @param {boolean} [forceRescan]
|
||||
*/
|
||||
async scan(library, options = {}) {
|
||||
async scan(library, forceRescan = false) {
|
||||
if (this.isLibraryScanning(library.id)) {
|
||||
Logger.error(`[Scanner] Already scanning ${library.id}`)
|
||||
return
|
||||
@ -64,9 +64,9 @@ class LibraryScanner {
|
||||
|
||||
SocketAuthority.emitter('scan_start', libraryScan.getScanEmitData)
|
||||
|
||||
Logger.info(`[Scanner] Starting library scan ${libraryScan.id} for ${libraryScan.libraryName}`)
|
||||
Logger.info(`[Scanner] Starting${forceRescan ? ' (forced)' : ''} library scan ${libraryScan.id} for ${libraryScan.libraryName}`)
|
||||
|
||||
const canceled = await this.scanLibrary(libraryScan)
|
||||
const canceled = await this.scanLibrary(libraryScan, forceRescan)
|
||||
|
||||
if (canceled) {
|
||||
Logger.info(`[Scanner] Library scan canceled for "${libraryScan.libraryName}"`)
|
||||
@ -95,9 +95,10 @@ class LibraryScanner {
|
||||
/**
|
||||
*
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @returns {boolean} true if scan canceled
|
||||
* @param {boolean} forceRescan
|
||||
* @returns {Promise<boolean>} true if scan canceled
|
||||
*/
|
||||
async scanLibrary(libraryScan) {
|
||||
async scanLibrary(libraryScan, forceRescan) {
|
||||
// Make sure library filter data is set
|
||||
// this is used to check for existing authors & series
|
||||
await libraryFilters.getFilterData(libraryScan.library.mediaType, libraryScan.libraryId)
|
||||
@ -155,17 +156,25 @@ class LibraryScanner {
|
||||
}
|
||||
} else {
|
||||
libraryItemDataFound = libraryItemDataFound.filter(lidf => lidf !== libraryItemData)
|
||||
if (await libraryItemData.checkLibraryItemData(existingLibraryItem, libraryScan)) {
|
||||
libraryScan.resultsUpdated++
|
||||
if (libraryItemData.hasLibraryFileChanges || libraryItemData.hasPathChange) {
|
||||
const libraryItem = await LibraryItemScanner.rescanLibraryItem(existingLibraryItem, libraryItemData, libraryScan.library.settings, libraryScan)
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
oldLibraryItemsUpdated.push(oldLibraryItem)
|
||||
let libraryItemDataUpdated = await libraryItemData.checkLibraryItemData(existingLibraryItem, libraryScan)
|
||||
if (libraryItemDataUpdated || forceRescan) {
|
||||
if (forceRescan || libraryItemData.hasLibraryFileChanges || libraryItemData.hasPathChange) {
|
||||
const { libraryItem, wasUpdated } = await LibraryItemScanner.rescanLibraryItemMedia(existingLibraryItem, libraryItemData, libraryScan.library.settings, libraryScan)
|
||||
if (!forceRescan || wasUpdated) {
|
||||
libraryScan.resultsUpdated++
|
||||
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||
oldLibraryItemsUpdated.push(oldLibraryItem)
|
||||
} else {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" is up-to-date`)
|
||||
}
|
||||
} else {
|
||||
libraryScan.resultsUpdated++
|
||||
// TODO: Temporary while using old model to socket emit
|
||||
const oldLibraryItem = await Database.libraryItemModel.getOldById(existingLibraryItem.id)
|
||||
oldLibraryItemsUpdated.push(oldLibraryItem)
|
||||
}
|
||||
} else {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" is up-to-date`)
|
||||
}
|
||||
}
|
||||
|
||||
|
48
server/scanner/OpfFileScanner.js
Normal file
48
server/scanner/OpfFileScanner.js
Normal file
@ -0,0 +1,48 @@
|
||||
const { parseOpfMetadataXML } = require('../utils/parsers/parseOpfMetadata')
|
||||
const { readTextFile } = require('../utils/fileUtils')
|
||||
|
||||
class OpfFileScanner {
|
||||
constructor() { }
|
||||
|
||||
/**
|
||||
* Parse metadata from .opf file found in library scan and update bookMetadata
|
||||
*
|
||||
* @param {import('../models/LibraryItem').LibraryFileObject} opfLibraryFileObj
|
||||
* @param {Object} bookMetadata
|
||||
*/
|
||||
async scanBookOpfFile(opfLibraryFileObj, bookMetadata) {
|
||||
const xmlText = await readTextFile(opfLibraryFileObj.metadata.path)
|
||||
const opfMetadata = xmlText ? await parseOpfMetadataXML(xmlText) : null
|
||||
if (opfMetadata) {
|
||||
for (const key in opfMetadata) {
|
||||
if (key === 'tags') { // Add tags only if tags are empty
|
||||
if (opfMetadata.tags.length) {
|
||||
bookMetadata.tags = opfMetadata.tags
|
||||
}
|
||||
} else if (key === 'genres') { // Add genres only if genres are empty
|
||||
if (opfMetadata.genres.length) {
|
||||
bookMetadata.genres = opfMetadata.genres
|
||||
}
|
||||
} else if (key === 'authors') {
|
||||
if (opfMetadata.authors?.length) {
|
||||
bookMetadata.authors = opfMetadata.authors
|
||||
}
|
||||
} else if (key === 'narrators') {
|
||||
if (opfMetadata.narrators?.length) {
|
||||
bookMetadata.narrators = opfMetadata.narrators
|
||||
}
|
||||
} else if (key === 'series') {
|
||||
if (opfMetadata.series) {
|
||||
bookMetadata.series = [{
|
||||
name: opfMetadata.series,
|
||||
sequence: opfMetadata.sequence || null
|
||||
}]
|
||||
}
|
||||
} else if (opfMetadata[key]) {
|
||||
bookMetadata[key] = opfMetadata[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = new OpfFileScanner()
|
@ -39,7 +39,7 @@ class PodcastScanner {
|
||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||
* @param {import('../models/Library').LibrarySettingsObject} librarySettings
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @returns {Promise<import('../models/LibraryItem')>}
|
||||
* @returns {Promise<{libraryItem:import('../models/LibraryItem'), wasUpdated:boolean}>}
|
||||
*/
|
||||
async rescanExistingPodcastLibraryItem(existingLibraryItem, libraryItemData, librarySettings, libraryScan) {
|
||||
/** @type {import('../models/Podcast')} */
|
||||
@ -201,7 +201,10 @@ class PodcastScanner {
|
||||
await existingLibraryItem.save()
|
||||
}
|
||||
|
||||
return existingLibraryItem
|
||||
return {
|
||||
libraryItem: existingLibraryItem,
|
||||
wasUpdated: hasMediaChanges || libraryItemUpdated
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -335,7 +338,6 @@ class PodcastScanner {
|
||||
|
||||
if (podcastEpisodes.length) {
|
||||
const audioFileMetaTags = podcastEpisodes[0].audioFile.metaTags
|
||||
const overrideExistingDetails = Database.serverSettings.scannerPreferAudioMetadata
|
||||
|
||||
const MetadataMapArray = [
|
||||
{
|
||||
@ -376,10 +378,10 @@ class PodcastScanner {
|
||||
if (value && typeof value === 'string') {
|
||||
value = value.trim() // Trim whitespace
|
||||
|
||||
if (mapping.key === 'genres' && (!podcastMetadata.genres.length || overrideExistingDetails)) {
|
||||
if (mapping.key === 'genres') {
|
||||
podcastMetadata.genres = this.parseGenresString(value)
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Mapping metadata to key ${tagToUse} => ${mapping.key}: ${podcastMetadata.genres.join(', ')}`)
|
||||
} else if (!podcastMetadata[mapping.key] || overrideExistingDetails) {
|
||||
} else {
|
||||
podcastMetadata[mapping.key] = value
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Mapping metadata to key ${tagToUse} => ${mapping.key}: ${podcastMetadata[mapping.key]}`)
|
||||
}
|
||||
@ -628,7 +630,6 @@ class PodcastScanner {
|
||||
]
|
||||
|
||||
const audioFileMetaTags = podcastEpisode.audioFile.metaTags
|
||||
const overrideExistingDetails = Database.serverSettings.scannerPreferAudioMetadata
|
||||
MetadataMapArray.forEach((mapping) => {
|
||||
let value = audioFileMetaTags[mapping.tag]
|
||||
let tagToUse = mapping.tag
|
||||
@ -640,7 +641,7 @@ class PodcastScanner {
|
||||
if (value && typeof value === 'string') {
|
||||
value = value.trim() // Trim whitespace
|
||||
|
||||
if (mapping.key === 'pubDate' && (!podcastEpisode.pubDate || overrideExistingDetails)) {
|
||||
if (mapping.key === 'pubDate') {
|
||||
const pubJsDate = new Date(value)
|
||||
if (pubJsDate && !isNaN(pubJsDate)) {
|
||||
podcastEpisode.publishedAt = pubJsDate.valueOf()
|
||||
@ -649,14 +650,14 @@ class PodcastScanner {
|
||||
} else {
|
||||
scanLogger.addLog(LogLevel.WARN, `Mapping pubDate with tag ${tagToUse} has invalid date "${value}"`)
|
||||
}
|
||||
} else if (mapping.key === 'episodeType' && (!podcastEpisode.episodeType || overrideExistingDetails)) {
|
||||
} else if (mapping.key === 'episodeType') {
|
||||
if (['full', 'trailer', 'bonus'].includes(value)) {
|
||||
podcastEpisode.episodeType = value
|
||||
scanLogger.addLog(LogLevel.DEBUG, `Mapping metadata to key ${tagToUse} => ${mapping.key}: ${podcastEpisode[mapping.key]}`)
|
||||
} else {
|
||||
scanLogger.addLog(LogLevel.WARN, `Mapping episodeType with invalid value "${value}". Must be one of [full, trailer, bonus].`)
|
||||
}
|
||||
} else if (!podcastEpisode[mapping.key] || overrideExistingDetails) {
|
||||
} else {
|
||||
podcastEpisode[mapping.key] = value
|
||||
scanLogger.addLog(LogLevel.DEBUG, `Mapping metadata to key ${tagToUse} => ${mapping.key}: ${podcastEpisode[mapping.key]}`)
|
||||
}
|
||||
|
@ -1,13 +1,6 @@
|
||||
const xml2js = require('xml2js')
|
||||
const Logger = require('../../Logger')
|
||||
|
||||
// given a list of audio files, extract all of the Overdrive Media Markers metaTags, and return an array of them as XML
|
||||
function extractOverdriveMediaMarkers(includedAudioFiles) {
|
||||
Logger.debug('[parseOverdriveMediaMarkers] Extracting overdrive media markers')
|
||||
var markers = includedAudioFiles.map((af) => af.metaTags.tagOverdriveMediaMarker).filter(af => af) || []
|
||||
|
||||
return markers
|
||||
}
|
||||
|
||||
// given the array of Overdrive Media Markers from generateOverdriveMediaMarkers()
|
||||
// parse and clean them in to something a bit more usable
|
||||
function cleanOverdriveMediaMarkers(overdriveMediaMarkers) {
|
||||
@ -29,12 +22,11 @@ function cleanOverdriveMediaMarkers(overdriveMediaMarkers) {
|
||||
]
|
||||
*/
|
||||
|
||||
var parseString = require('xml2js').parseString // function to convert xml to JSON
|
||||
var parsedOverdriveMediaMarkers = []
|
||||
|
||||
const parsedOverdriveMediaMarkers = []
|
||||
overdriveMediaMarkers.forEach((item, index) => {
|
||||
var parsed_result = null
|
||||
parseString(item, function (err, result) {
|
||||
let parsed_result = null
|
||||
// convert xml to JSON
|
||||
xml2js.parseString(item, function (err, result) {
|
||||
/*
|
||||
result.Markers.Marker is the result of parsing the XML for the MediaMarker tags for the MP3 file (Part##.mp3)
|
||||
it is shaped like this, and needs further cleaning below:
|
||||
@ -54,7 +46,7 @@ function cleanOverdriveMediaMarkers(overdriveMediaMarkers) {
|
||||
*/
|
||||
|
||||
// The values for Name and Time in results.Markers.Marker are returned as Arrays from parseString and should be strings
|
||||
if (result && result.Markers && result.Markers.Marker) {
|
||||
if (result?.Markers?.Marker) {
|
||||
parsed_result = objectValuesArrayToString(result.Markers.Marker)
|
||||
}
|
||||
})
|
||||
@ -138,22 +130,13 @@ function generateParsedChapters(includedAudioFiles, cleanedOverdriveMediaMarkers
|
||||
return parsedChapters
|
||||
}
|
||||
|
||||
module.exports.overdriveMediaMarkersExist = (includedAudioFiles) => {
|
||||
return extractOverdriveMediaMarkers(includedAudioFiles).length > 1
|
||||
}
|
||||
|
||||
module.exports.parseOverdriveMediaMarkersAsChapters = (includedAudioFiles) => {
|
||||
Logger.info('[parseOverdriveMediaMarkers] Parsing of Overdrive Media Markers started')
|
||||
|
||||
var overdriveMediaMarkers = extractOverdriveMediaMarkers(includedAudioFiles)
|
||||
const overdriveMediaMarkers = includedAudioFiles.map((af) => af.metaTags.tagOverdriveMediaMarker).filter(af => af) || []
|
||||
if (!overdriveMediaMarkers.length) return null
|
||||
|
||||
var cleanedOverdriveMediaMarkers = cleanOverdriveMediaMarkers(overdriveMediaMarkers)
|
||||
// TODO: generateParsedChapters requires overdrive media markers and included audio files length to be the same
|
||||
// so if not equal then we must exit
|
||||
if (cleanedOverdriveMediaMarkers.length !== includedAudioFiles.length) return null
|
||||
|
||||
var parsedChapters = generateParsedChapters(includedAudioFiles, cleanedOverdriveMediaMarkers)
|
||||
|
||||
return parsedChapters
|
||||
return generateParsedChapters(includedAudioFiles, cleanedOverdriveMediaMarkers)
|
||||
}
|
@ -2,6 +2,18 @@ const Path = require('path')
|
||||
const { filePathToPOSIX } = require('./fileUtils')
|
||||
const globals = require('./globals')
|
||||
const LibraryFile = require('../objects/files/LibraryFile')
|
||||
const parseNameString = require('./parsers/parseNameString')
|
||||
|
||||
/**
|
||||
* @typedef LibraryItemFilenameMetadata
|
||||
* @property {string} title
|
||||
* @property {string} subtitle Book mediaType only
|
||||
* @property {string[]} authors Book mediaType only
|
||||
* @property {string[]} narrators Book mediaType only
|
||||
* @property {string} seriesName Book mediaType only
|
||||
* @property {string} seriesSequence Book mediaType only
|
||||
* @property {string} publishedYear Book mediaType only
|
||||
*/
|
||||
|
||||
function isMediaFile(mediaType, ext, audiobooksOnly = false) {
|
||||
if (!ext) return false
|
||||
@ -210,10 +222,15 @@ function buildLibraryFile(libraryItemPath, files) {
|
||||
}
|
||||
module.exports.buildLibraryFile = buildLibraryFile
|
||||
|
||||
// Input relative filepath, output all details that can be parsed
|
||||
function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) {
|
||||
relPath = filePathToPOSIX(relPath)
|
||||
var splitDir = relPath.split('/')
|
||||
/**
|
||||
* Get details parsed from filenames
|
||||
*
|
||||
* @param {string} relPath
|
||||
* @param {boolean} parseSubtitle
|
||||
* @returns {LibraryItemFilenameMetadata}
|
||||
*/
|
||||
function getBookDataFromDir(relPath, parseSubtitle = false) {
|
||||
const splitDir = relPath.split('/')
|
||||
|
||||
var folder = splitDir.pop() // Audio files will always be in the directory named for the title
|
||||
series = (splitDir.length > 1) ? splitDir.pop() : null // If there are at least 2 more directories, next furthest will be the series
|
||||
@ -226,17 +243,13 @@ function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) {
|
||||
var [title, subtitle] = parseSubtitle ? getSubtitle(folder) : [folder, null]
|
||||
|
||||
return {
|
||||
mediaMetadata: {
|
||||
author,
|
||||
title,
|
||||
subtitle,
|
||||
series,
|
||||
sequence,
|
||||
publishedYear,
|
||||
narrators,
|
||||
},
|
||||
relPath: relPath, // relative audiobook path i.e. /Author Name/Book Name/..
|
||||
path: Path.posix.join(folderPath, relPath) // i.e. /audiobook/Author Name/Book Name/..
|
||||
title,
|
||||
subtitle,
|
||||
authors: parseNameString.parse(author)?.names || [],
|
||||
narrators: parseNameString.parse(narrators)?.names || [],
|
||||
seriesName: series,
|
||||
seriesSequence: sequence,
|
||||
publishedYear
|
||||
}
|
||||
}
|
||||
module.exports.getBookDataFromDir = getBookDataFromDir
|
||||
@ -301,28 +314,43 @@ function getSubtitle(folder) {
|
||||
return [splitTitle.shift(), splitTitle.join(' - ')]
|
||||
}
|
||||
|
||||
function getPodcastDataFromDir(folderPath, relPath) {
|
||||
relPath = filePathToPOSIX(relPath)
|
||||
/**
|
||||
*
|
||||
* @param {string} relPath
|
||||
* @returns {LibraryItemFilenameMetadata}
|
||||
*/
|
||||
function getPodcastDataFromDir(relPath) {
|
||||
const splitDir = relPath.split('/')
|
||||
|
||||
// Audio files will always be in the directory named for the title
|
||||
const title = splitDir.pop()
|
||||
return {
|
||||
mediaMetadata: {
|
||||
title
|
||||
},
|
||||
relPath: relPath, // relative podcast path i.e. /Podcast Name/..
|
||||
path: Path.posix.join(folderPath, relPath) // i.e. /podcasts/Podcast Name/..
|
||||
title
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} libraryMediaType
|
||||
* @param {string} folderPath
|
||||
* @param {string} relPath
|
||||
* @returns {{ mediaMetadata: LibraryItemFilenameMetadata, relPath: string, path: string}}
|
||||
*/
|
||||
function getDataFromMediaDir(libraryMediaType, folderPath, relPath) {
|
||||
relPath = filePathToPOSIX(relPath)
|
||||
let fullPath = Path.posix.join(folderPath, relPath)
|
||||
let mediaMetadata = null
|
||||
|
||||
if (libraryMediaType === 'podcast') {
|
||||
return getPodcastDataFromDir(folderPath, relPath)
|
||||
} else if (libraryMediaType === 'book') {
|
||||
return getBookDataFromDir(folderPath, relPath, !!global.ServerSettings.scannerParseSubtitle)
|
||||
} else {
|
||||
return getPodcastDataFromDir(folderPath, relPath)
|
||||
mediaMetadata = getPodcastDataFromDir(relPath)
|
||||
} else { // book
|
||||
mediaMetadata = getBookDataFromDir(relPath, !!global.ServerSettings.scannerParseSubtitle)
|
||||
}
|
||||
|
||||
return {
|
||||
mediaMetadata,
|
||||
relPath,
|
||||
path: fullPath
|
||||
}
|
||||
}
|
||||
module.exports.getDataFromMediaDir = getDataFromMediaDir
|
||||
|
Loading…
Reference in New Issue
Block a user