chore: merge master

This commit is contained in:
jfrazx 2024-01-16 18:31:29 -08:00
commit 4c9b2ad08b
No known key found for this signature in database
GPG Key ID: 7E72C3BCC0F85A7B
39 changed files with 1277 additions and 572 deletions

View File

@ -349,7 +349,7 @@ export default {
}
if ('mediaSession' in navigator) {
var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png')
var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png', true)
const artwork = [
{
src: coverImageSrc

View File

@ -2,8 +2,11 @@
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
<p class="text-xl font-semibold mb-2">{{ $strings.HeaderAudiobookTools }}</p>
<!-- alert for windows install -->
<widgets-alert v-if="isWindowsInstall" type="warning" class="my-8 text-base">Not supported for the Windows install yet</widgets-alert>
<!-- Merge to m4b -->
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
<div v-if="showM4bDownload && !isWindowsInstall" class="w-full border border-black-200 p-4 my-8">
<div class="flex flex-wrap items-center">
<div>
<p class="text-lg">{{ $strings.LabelToolsMakeM4b }}</p>
@ -19,22 +22,8 @@
</div>
</div>
<!-- Split to mp3 -->
<!-- <div v-if="showMp3Split" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">{{ $strings.LabelToolsSplitM4b }}</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">{{ $strings.LabelToolsSplitM4bDescription }}</p>
</div>
<div class="flex-grow" />
<div>
<ui-btn :disabled="true">{{ $strings.MessageNotYetImplemented }}</ui-btn>
</div>
</div>
</div> -->
<!-- Embed Metadata -->
<div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8">
<div v-if="mediaTracks.length && !isWindowsInstall" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">{{ $strings.LabelToolsEmbedMetadata }}</p>
@ -122,6 +111,12 @@ export default {
},
isEncodeTaskRunning() {
return this.encodeTask && !this.encodeTask?.isFinished
},
isWindowsInstall() {
return this.Source == 'windows'
},
Source() {
return this.$store.state.Source
}
},
methods: {

View File

@ -35,7 +35,6 @@
<div v-else class="py-12 text-center max-w-sm mx-auto">
<p class="text-lg mb-2">{{ $strings.MessageNoFoldersAvailable }}</p>
<p class="text-gray-300 mb-2">{{ $strings.NoteFolderPicker }}</p>
<p v-if="isDebian" class="text-red-400">{{ $strings.NoteFolderPickerDebian }}</p>
</div>
<div class="w-full py-2">
@ -93,12 +92,6 @@ export default {
...d
}
})
},
isDebian() {
return this.Source == 'debian'
},
Source() {
return this.$store.state.Source
}
},
methods: {

View File

@ -1,7 +1,7 @@
<template>
<div class="w-full h-full">
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 left-8 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400" :style="{ width: pageMenuWidth + 'px' }">
<div v-for="(file, index) in cleanedPageNames" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index + 1)">
<div v-for="(file, index) in cleanedPageNames" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index + 1 ? 'bg-black-200' : ''" @click="setPage(index + 1)">
<p class="text-sm truncate">{{ file }}</p>
</div>
</div>

View File

@ -1,12 +1,12 @@
{
"name": "audiobookshelf-client",
"version": "2.7.1",
"version": "2.7.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
"version": "2.7.1",
"version": "2.7.2",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",

View File

@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.7.1",
"version": "2.7.2",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",

View File

@ -668,7 +668,6 @@
"NoteChangeRootPassword": "Uživatel root je jediný uživatel, který může mít prázdné heslo",
"NoteChapterEditorTimes": "Poznámka: Čas začátku první kapitoly musí zůstat v 0:00 a čas začátku poslední kapitoly nesmí překročit tuto dobu trvání audioknihy.",
"NoteFolderPicker": "Poznámka: složky, které jsou již namapovány, nebudou zobrazeny",
"NoteFolderPickerDebian": "Poznámka: Výběr složek pro instalaci debianu není plně implementován. Cestu ke své knihovně byste měli zadat přímo.",
"NoteRSSFeedPodcastAppsHttps": "Upozornění: Většina aplikací pro podcasty bude vyžadovat, aby adresa URL kanálu RSS používala protokol HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Upozornění: 1 nebo více epizod nemá datum vydání. Některé podcastové aplikace to vyžadují.",
"NoteUploaderFoldersWithMediaFiles": "Se složkami s multimediálními soubory bude zacházeno jako se samostatnými položkami knihovny.",

View File

@ -668,7 +668,6 @@
"NoteChangeRootPassword": "Root-brugeren er den eneste bruger, der kan have en tom adgangskode",
"NoteChapterEditorTimes": "Bemærk: Første kapitel starttidspunkt skal forblive kl. 0:00, og det sidste kapitel starttidspunkt må ikke overstige denne lydbogs varighed.",
"NoteFolderPicker": "Bemærk: Mapper, der allerede er mappet, vises ikke",
"NoteFolderPickerDebian": "Bemærk: Mappicker for Debian-installationen er ikke fuldt implementeret. Du bør indtaste stien til dit bibliotek direkte.",
"NoteRSSFeedPodcastAppsHttps": "Advarsel: De fleste podcast-apps kræver, at RSS-feedets URL bruger HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Advarsel: En eller flere af dine episoder har ikke en Pub Date. Nogle podcast-apps kræver dette.",
"NoteUploaderFoldersWithMediaFiles": "Mapper med mediefiler håndteres som separate bibliotekselementer.",

View File

@ -668,7 +668,6 @@
"NoteChangeRootPassword": "Der Root-Benutzer (Hauptbenutzer) ist der einzige Benutzer, der ein leeres Passwort haben kann",
"NoteChapterEditorTimes": "Hinweis: Die Anfangszeit des ersten Kapitels muss bei 0:00 beginnen und die Anfangszeit des letzten Kapitels darf die Dauer des Mediums nicht überschreiten.",
"NoteFolderPicker": "Hinweis: Bereits zugeordnete Ordner werden nicht angezeigt.",
"NoteFolderPickerDebian": "Hinweis: Der Ordnerauswahldialog für die Debian-Installation ist nicht vollständig implementiert. Sie sollten den Pfad zu Ihrer Bibliothek direkt eingeben.",
"NoteRSSFeedPodcastAppsHttps": "Warnung: Die meisten Podcast-Apps verlangen, dass die URL des RSS-Feeds HTTPS verwendet.",
"NoteRSSFeedPodcastAppsPubDate": "Warnung: 1 oder mehrere Ihrer Episoden haben kein Veröffentlichungsdatum. Einige Podcast-Apps verlangen dies.",
"NoteUploaderFoldersWithMediaFiles": "Ordner mit Mediendateien werden als separate Bibliothekselemente behandelt.",
@ -750,4 +749,4 @@
"ToastSocketFailedToConnect": "Verbindung zum WebSocket fehlgeschlagen",
"ToastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden",
"ToastUserDeleteSuccess": "Benutzer gelöscht"
}
}

View File

@ -668,7 +668,6 @@
"NoteChangeRootPassword": "Root user is the only user that can have an empty password",
"NoteChapterEditorTimes": "Note: First chapter start time must remain at 0:00 and the last chapter start time cannot exceed this audiobooks duration.",
"NoteFolderPicker": "Note: folders already mapped will not be shown",
"NoteFolderPickerDebian": "Note: Folder picker for the debian install is not fully implemented. You should enter the path to your library directly.",
"NoteRSSFeedPodcastAppsHttps": "Warning: Most podcast apps will require the RSS feed URL is using HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.",
"NoteUploaderFoldersWithMediaFiles": "Folders with media files will be handled as separate library items.",

View File

@ -668,7 +668,6 @@
"NoteChangeRootPassword": "El usuario Root es el único usuario que puede no tener una contraseña",
"NoteChapterEditorTimes": "Nota: El tiempo de inicio del primer capítulo debe permanecer en 0:00, y el tiempo de inicio del último capítulo no puede exceder la duración del audiolibro.",
"NoteFolderPicker": "Nota: Las carpetas ya asignadas no se mostrarán",
"NoteFolderPickerDebian": "Nota: El selector de archivos no está completamente implementado para instalaciones en Debian. Deberá ingresar la ruta de la carpeta de su biblioteca directamente.",
"NoteRSSFeedPodcastAppsHttps": "Advertencia: La mayoría de las aplicaciones de podcast requieren que la URL de la fuente RSS use HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Advertencia: 1 o más de sus episodios no tienen fecha de publicación. Algunas aplicaciones de podcast lo requieren.",
"NoteUploaderFoldersWithMediaFiles": "Las carpetas con archivos multimedia se manejarán como elementos separados en la biblioteca.",

View File

@ -668,7 +668,6 @@
"NoteChangeRootPassword": "seul lutilisateur « root » peut utiliser un mot de passe vide",
"NoteChapterEditorTimes": "Information : lhorodatage du premier chapitre doit être à 0:00 et celui du dernier chapitre ne peut se situer au-delà de la durée du livre audio.",
"NoteFolderPicker": "Information : Les dossiers déjà surveillés ne sont pas affichés",
"NoteFolderPickerDebian": "Information : La sélection de dossier sur une installation debian nest pas finalisée. Merci de renseigner le chemin complet vers votre bibliothèque manuellement.",
"NoteRSSFeedPodcastAppsHttps": "Attention : la majorité des application de podcast nécessite une adresse de flux en HTTPS.",
"NoteRSSFeedPodcastAppsPubDate": "Attention : un ou plusieurs de vos épisodes ne possèdent pas de date de publication. Certaines applications de podcast le requièrent.",
"NoteUploaderFoldersWithMediaFiles": "Les dossiers contenant des fichiers multimédias seront traités comme des éléments distincts de la bibliothèque.",
@ -750,4 +749,4 @@
"ToastSocketFailedToConnect": "Échec de la connexion WebSocket",
"ToastUserDeleteFailed": "Échec de la suppression de lutilisateur",
"ToastUserDeleteSuccess": "Utilisateur supprimé"
}
}

View File

@ -668,7 +668,6 @@
"NoteChangeRootPassword": "Root user is the only user that can have an empty password",
"NoteChapterEditorTimes": "Note: First chapter start time must remain at 0:00 and the last chapter start time cannot exceed this audiobooks duration.",
"NoteFolderPicker": "Note: folders already mapped will not be shown",
"NoteFolderPickerDebian": "Note: Folder picker for the debian install is not fully implemented. You should enter the path to your library directly.",
"NoteRSSFeedPodcastAppsHttps": "Warning: Most podcast apps will require the RSS feed URL is using HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.",
"NoteUploaderFoldersWithMediaFiles": "Folders with media files will be handled as separate library items.",

View File

@ -668,7 +668,6 @@
"NoteChangeRootPassword": "रूट user is the only user that can have an empty password",
"NoteChapterEditorTimes": "Note: First chapter start time must remain at 0:00 and the last chapter start time cannot exceed this audiobooks duration.",
"NoteFolderPicker": "Note: folders already mapped will not be shown",
"NoteFolderPickerDebian": "Note: Folder picker for the debian install is not fully implemented. You should enter the path to your library directly.",
"NoteRSSFeedPodcastAppsHttps": "Warning: Most podcast apps will require the RSS feed URL is using HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.",
"NoteUploaderFoldersWithMediaFiles": "Folders with media files will be handled as separate library items.",

View File

@ -668,7 +668,6 @@
"NoteChangeRootPassword": "Root korisnik je jedini korisnik koji može imati praznu lozinku",
"NoteChapterEditorTimes": "Bilješka: Prvo početno vrijeme poglavlja mora ostati na 0:00 i posljednje vrijeme poglavlja ne smije preći vrijeme trajanja ove audio knjige.",
"NoteFolderPicker": "Bilješka: več mapirani folderi neće biti prikazani",
"NoteFolderPickerDebian": "Bilješka: Folder picker za debian instalaciju nije potpuno implementiran. Trebate unjeti direktnu putanju do biblioteke.",
"NoteRSSFeedPodcastAppsHttps": "Upozorenje: Večina podcasta će trebati RSS feed URL koji koristi HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Upozorenje: 1 ili više vaših epizoda nemaju datum objavljivanja. Neke podcast aplikacije zahtjevaju to.",
"NoteUploaderFoldersWithMediaFiles": "Folderi sa media datotekama će biti tretirane kao odvojene stavke u biblioteki.",

View File

@ -668,7 +668,6 @@
"NoteChangeRootPassword": "L'utente root è l'unico utente che può avere una password vuota",
"NoteChapterEditorTimes": "Nota: l'ora di inizio del primo capitolo deve rimanere alle 0:00 e l'ora di inizio dell'ultimo capitolo non può superare la durata di questo audiolibro.",
"NoteFolderPicker": "Nota: le cartelle già mappate non verranno visualizzate",
"NoteFolderPickerDebian": "Nota: il selettore di cartelle per l'installazione di Debian non è completamente implementato. Dovresti inserire direttamente il percorso della tua libreria.",
"NoteRSSFeedPodcastAppsHttps": "Avviso: la maggior parte delle app di podcast richiede che l'URL del feed RSS utilizzi HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Avviso: 1 o più delle tue puntate non hanno una data di pubblicazione. Alcune app di podcast lo richiedono.",
"NoteUploaderFoldersWithMediaFiles": "Le cartelle con file multimediali verranno gestite come elementi della libreria separati.",

View File

@ -668,7 +668,6 @@
"NoteChangeRootPassword": "Tik root vartotojas gali turėti tuščią slaptažodį",
"NoteChapterEditorTimes": "Pastaba: Pirmasis skyriaus pradžios laikas turi likti 0:00, o paskutinio skyriaus pradžios laikas negali viršyti šios garso knygos trukmės.",
"NoteFolderPicker": "Pastaba: jau susieti aplankai nebus rodomi",
"NoteFolderPickerDebian": "Pastaba: Aplanko pasirinkimo įrankis „Debian“ sistemoje nėra visiškai įgyvendintas. Turėtumėte tiesiogiai įvesti kelią į savo biblioteką.",
"NoteRSSFeedPodcastAppsHttps": "Įspėjimas: Dauguma tinklalaidžių programų reikalauja, kad RSS kanalo URL būtų naudojamas su HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Įspėjimas: Vienas ar daugiau jūsų epizodų neturi publikavimo datos. Kai kurios tinklalaidžių programos to reikalauja.",
"NoteUploaderFoldersWithMediaFiles": "Aplankai su medijos failais bus tvarkomi kaip atskiri bibliotekos elementai.",

View File

@ -668,7 +668,6 @@
"NoteChangeRootPassword": "Root-gebruiker is de enige gebruiker die een leeg wachtwoord kan hebben",
"NoteChapterEditorTimes": "Opmerking: Starttijd van het eerste hoofdstuk moet op 0:00 blijven en de starttijd van het laatste hoofdstuk mag niet de duur van het audioboek overschrijden.",
"NoteFolderPicker": "Opmerking: Reeds gemapte mappen worden niet getoond",
"NoteFolderPickerDebian": "Opmerking: Mappenkiezer voor de debian installatie is niet volledig geimplementeerd. Je moet het pad naar je map zelf invoeren.",
"NoteRSSFeedPodcastAppsHttps": "Waarschuwing: De meeste podcast-apps zullen eisen dat de RSS-feed URL HTTPS gebruikt",
"NoteRSSFeedPodcastAppsPubDate": "Waarschuwing: 1 of meer van je afleveringen hebben geen Pub Datum. Sommige podcast-apps vereisen dit.",
"NoteUploaderFoldersWithMediaFiles": "Mappen met mediabestanden zullen worden behandeld als aparte bibliotheekonderdelen.",

View File

@ -668,7 +668,6 @@
"NoteChangeRootPassword": "Root-bruker er eneste bruker som kan ha tumt passord",
"NoteChapterEditorTimes": "Notis: Første kapittel start tid må være 0:00 og siste kapittel start tid kan ikke overskride denne lydbokens lengde.",
"NoteFolderPicker": "Notis: allerede funnet mapper vil ikke bli vist",
"NoteFolderPickerDebian": "Notis: Mappevelger for debian er ikke fullstendig implementert. Du burde skrive inn stien til biblioteket direkte.",
"NoteRSSFeedPodcastAppsHttps": "Advarsel! De fleste podcast applikasjoner trenger RSS feed URL som bruker HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Advarsel! 1 eller flere av episodene har ikke publikasjonsdato. Noen podcast applikasjoner trenger dette.",
"NoteUploaderFoldersWithMediaFiles": "Mapper med mediefiler vil bli behandlet som separate bibliotekgjenstander.",

View File

@ -668,7 +668,6 @@
"NoteChangeRootPassword": "Tylko użytkownik root, może posiadać puste hasło",
"NoteChapterEditorTimes": "Uwaga: Czas rozpoczęcia pierwszego rozdziału musi pozostać na poziomie 0:00, a czas rozpoczęcia ostatniego rozdziału nie może przekroczyć czasu trwania audiobooka.",
"NoteFolderPicker": "Uwaga: dotychczas zmapowane foldery nie zostaną wyświetlone",
"NoteFolderPickerDebian": "Uwaga: Wybór folderu w instalcji opartej o system debian nie jest w pełni zaimplementowany. Powinieneś wprowadzić ścieżkę do swojej biblioteki bezpośrednio.",
"NoteRSSFeedPodcastAppsHttps": "Ostrzeżenie: Większość aplikacji do obsługi podcastów wymaga, aby adres URL kanału RSS korzystał z protokołu HTTPS.",
"NoteRSSFeedPodcastAppsPubDate": "Ostrzeżenie: 1 lub więcej odcinków nie ma daty publikacji. Niektóre aplikacje do słuchania podcastów tego wymagają.",
"NoteUploaderFoldersWithMediaFiles": "Foldery z plikami multimedialnymi będą traktowane jako osobne elementy w bibliotece.",

View File

@ -668,7 +668,6 @@
"NoteChangeRootPassword": "Пользователь root — единственный пользователь, который может иметь пустой пароль",
"NoteChapterEditorTimes": "Примечание: Время начала первой главы должно оставаться в 0:00, а время начала последней главы не может превышать продолжительность этой аудиокниги.",
"NoteFolderPicker": "Примечание: папки, уже сопоставленные, не будут отображаться",
"NoteFolderPickerDebian": "Примечание: Выбор папок debian не реализован полностью. Необходимо ввести путь к библиотеке напрямую.",
"NoteRSSFeedPodcastAppsHttps": "Предупреждение: Большинству приложений подкастов потребуется, чтобы URL-адрес RSS-канала использовал HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Предупреждение: 1 или более эпизодов не имеют даты публикации. Некоторые приложения для подкастов требуют этого.",
"NoteUploaderFoldersWithMediaFiles": "Папки с медиафайлами будут обрабатываться как отдельные элементы библиотеки.",

View File

@ -668,7 +668,6 @@
"NoteChangeRootPassword": "Rotanvändaren är den enda användaren som kan ha ett tomt lösenord",
"NoteChapterEditorTimes": "Obs: Starttiden för första kapitlet måste förbli 0:00 och starttiden för det sista kapitlet får inte överstiga ljudbokens varaktighet.",
"NoteFolderPicker": "Obs: Mappar som redan är kartlagda kommer inte att visas",
"NoteFolderPickerDebian": "Obs: Mappväljaren för Debian-installationen är inte fullständigt implementerad. Du bör ange sökvägen till ditt bibliotek direkt.",
"NoteRSSFeedPodcastAppsHttps": "Varning: De flesta podcastappar kräver att RSS-flödets URL används med HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Varning: 1 eller flera av dina avsnitt har inte ett publiceringsdatum. Vissa podcastappar kräver detta.",
"NoteUploaderFoldersWithMediaFiles": "Mappar med mediefiler hanteras som separata biblioteksobjekt.",

View File

@ -668,7 +668,6 @@
"NoteChangeRootPassword": "Root 是唯一可以拥有空密码的用户",
"NoteChapterEditorTimes": "注意: 第一章开始时间必须保持在 0:00, 最后一章开始时间不能超过有声读物持续时间.",
"NoteFolderPicker": "注意: 将不显示已映射的文件夹",
"NoteFolderPickerDebian": "注意: debian 安装的文件夹选择器尚未完全实现. 您应该直接输入媒体库的路径.",
"NoteRSSFeedPodcastAppsHttps": "警告: 大多数播客应用程序都需要 RSS 源 URL 使用 HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "警告: 您的一集或多集没有发布日期. 一些播客应用程序要求这样做.",
"NoteUploaderFoldersWithMediaFiles": "包含媒体文件的文件夹将作为单独的媒体库项目处理.",

934
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.7.1",
"version": "2.7.2",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 ნიკა
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,262 @@
/**
* Modified from https://github.com/nika-begiashvili/libarchivejs
*/
const Path = require('path')
const { Worker } = require('worker_threads')
/**
* Represents compressed file before extraction
*/
class CompressedFile {
constructor(name, size, path, archiveRef) {
this._name = name
this._size = size
this._path = path
this._archiveRef = archiveRef
}
/**
* file name
*/
get name() {
return this._name
}
/**
* file size
*/
get size() {
return this._size
}
/**
* Extract file from archive
* @returns {Promise<File>} extracted file
*/
extract() {
return this._archiveRef.extractSingleFile(this._path)
}
}
class Archive {
/**
* Creates new archive instance from browser native File object
* @param {Buffer} fileBuffer
* @param {object} options
* @returns {Archive}
*/
static open(fileBuffer) {
const arch = new Archive(fileBuffer, { workerUrl: Path.join(__dirname, 'libarchiveWorker.js') })
return arch.open()
}
/**
* Create new archive
* @param {File} file
* @param {Object} options
*/
constructor(file, options) {
this._worker = new Worker(options.workerUrl)
this._worker.on('message', this._workerMsg.bind(this))
this._callbacks = []
this._content = {}
this._processed = 0
this._file = file
}
/**
* Prepares file for reading
* @returns {Promise<Archive>} archive instance
*/
async open() {
await this._postMessage({ type: 'HELLO' }, (resolve, reject, msg) => {
if (msg.type === 'READY') {
resolve()
}
})
return await this._postMessage({ type: 'OPEN', file: this._file }, (resolve, reject, msg) => {
if (msg.type === 'OPENED') {
resolve(this)
}
})
}
/**
* Terminate worker to free up memory
*/
close() {
this._worker.terminate()
this._worker = null
}
/**
* detect if archive has encrypted data
* @returns {boolean|null} null if could not be determined
*/
hasEncryptedData() {
return this._postMessage({ type: 'CHECK_ENCRYPTION' },
(resolve, reject, msg) => {
if (msg.type === 'ENCRYPTION_STATUS') {
resolve(msg.status)
}
}
)
}
/**
* set password to be used when reading archive
*/
usePassword(archivePassword) {
return this._postMessage({ type: 'SET_PASSPHRASE', passphrase: archivePassword },
(resolve, reject, msg) => {
if (msg.type === 'PASSPHRASE_STATUS') {
resolve(msg.status)
}
}
)
}
/**
* Returns object containing directory structure and file information
* @returns {Promise<object>}
*/
getFilesObject() {
if (this._processed > 0) {
return Promise.resolve().then(() => this._content)
}
return this._postMessage({ type: 'LIST_FILES' }, (resolve, reject, msg) => {
if (msg.type === 'ENTRY') {
const entry = msg.entry
const [target, prop] = this._getProp(this._content, entry.path)
if (entry.type === 'FILE') {
target[prop] = new CompressedFile(entry.fileName, entry.size, entry.path, this)
}
return true
} else if (msg.type === 'END') {
this._processed = 1
resolve(this._cloneContent(this._content))
}
})
}
getFilesArray() {
return this.getFilesObject().then((obj) => {
return this._objectToArray(obj)
})
}
extractSingleFile(target) {
// Prevent extraction if worker already terminated
if (this._worker === null) {
throw new Error("Archive already closed")
}
return this._postMessage({ type: 'EXTRACT_SINGLE_FILE', target: target },
(resolve, reject, msg) => {
if (msg.type === 'FILE') {
resolve(msg.entry)
}
}
)
}
/**
* Returns object containing directory structure and extracted File objects
* @param {Function} extractCallback
*
*/
extractFiles(extractCallback) {
if (this._processed > 1) {
return Promise.resolve().then(() => this._content)
}
return this._postMessage({ type: 'EXTRACT_FILES' }, (resolve, reject, msg) => {
if (msg.type === 'ENTRY') {
const [target, prop] = this._getProp(this._content, msg.entry.path)
if (msg.entry.type === 'FILE') {
target[prop] = msg.entry
if (extractCallback !== undefined) {
setTimeout(extractCallback.bind(null, {
file: target[prop],
path: msg.entry.path,
}))
}
}
return true
} else if (msg.type === 'END') {
this._processed = 2
this._worker.terminate()
resolve(this._cloneContent(this._content))
}
})
}
_cloneContent(obj) {
if (obj instanceof CompressedFile || obj === null) return obj
const o = {}
for (const prop of Object.keys(obj)) {
o[prop] = this._cloneContent(obj[prop])
}
return o
}
_objectToArray(obj, path = '') {
const files = []
for (const key of Object.keys(obj)) {
if (obj[key] instanceof CompressedFile || obj[key] === null) {
files.push({
file: obj[key] || key,
path: path
})
} else {
files.push(...this._objectToArray(obj[key], `${path}${key}/`))
}
}
return files
}
_getProp(obj, path) {
const parts = path.split('/')
if (parts[parts.length - 1] === '') parts.pop()
let cur = obj, prev = null
for (const part of parts) {
cur[part] = cur[part] || {}
prev = cur
cur = cur[part]
}
return [prev, parts[parts.length - 1]]
}
_postMessage(msg, callback) {
this._worker.postMessage(msg)
return new Promise((resolve, reject) => {
this._callbacks.push(this._msgHandler.bind(this, callback, resolve, reject))
})
}
_msgHandler(callback, resolve, reject, msg) {
if (!msg) {
reject('invalid msg')
return
}
if (msg.type === 'BUSY') {
reject('worker is busy')
} else if (msg.type === 'ERROR') {
reject(msg.error)
} else {
return callback(resolve, reject, msg)
}
}
_workerMsg(msg) {
const callback = this._callbacks[this._callbacks.length - 1]
const next = callback(msg)
if (!next) {
this._callbacks.pop()
}
}
}
module.exports = Archive

View File

@ -0,0 +1,72 @@
/**
* Modified from https://github.com/nika-begiashvili/libarchivejs
*/
const { parentPort } = require('worker_threads')
const { getArchiveReader } = require('./wasm-module')
let reader = null
let busy = false
getArchiveReader((_reader) => {
reader = _reader
busy = false
parentPort.postMessage({ type: 'READY' })
})
parentPort.on('message', async msg => {
if (busy) {
parentPort.postMessage({ type: 'BUSY' })
return
}
let skipExtraction = false
busy = true
try {
switch (msg.type) {
case 'HELLO': // module will respond READY when it's ready
break
case 'OPEN':
await reader.open(msg.file)
parentPort.postMessage({ type: 'OPENED' })
break
case 'LIST_FILES':
skipExtraction = true
// eslint-disable-next-line no-fallthrough
case 'EXTRACT_FILES':
for (const entry of reader.entries(skipExtraction)) {
parentPort.postMessage({ type: 'ENTRY', entry })
}
parentPort.postMessage({ type: 'END' })
break
case 'EXTRACT_SINGLE_FILE':
for (const entry of reader.entries(true, msg.target)) {
if (entry.fileData) {
parentPort.postMessage({ type: 'FILE', entry })
}
}
break
case 'CHECK_ENCRYPTION':
parentPort.postMessage({ type: 'ENCRYPTION_STATUS', status: reader.hasEncryptedData() })
break
case 'SET_PASSPHRASE':
reader.setPassphrase(msg.passphrase)
parentPort.postMessage({ type: 'PASSPHRASE_STATUS', status: true })
break
default:
throw new Error('Invalid Command')
}
} catch (err) {
parentPort.postMessage({
type: 'ERROR',
error: {
message: err.message,
name: err.name,
stack: err.stack
}
})
} finally {
// eslint-disable-next-line require-atomic-updates
busy = false
}
})

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,235 @@
/**
* Modified from https://github.com/nika-begiashvili/libarchivejs
*/
const Path = require('path')
const libarchive = require('./wasm-libarchive')
const TYPE_MAP = {
32768: 'FILE',
16384: 'DIR',
40960: 'SYMBOLIC_LINK',
49152: 'SOCKET',
8192: 'CHARACTER_DEVICE',
24576: 'BLOCK_DEVICE',
4096: 'NAMED_PIPE',
}
class ArchiveReader {
/**
* archive reader
* @param {WasmModule} wasmModule emscripten module
*/
constructor(wasmModule) {
this._wasmModule = wasmModule
this._runCode = wasmModule.runCode
this._file = null
this._passphrase = null
}
/**
* open archive, needs to closed manually
* @param {File} file
*/
open(file) {
if (this._file !== null) {
console.warn('Closing previous file')
this.close()
}
const { promise, resolve, reject } = this._promiseHandles()
this._file = file
this._loadFile(file, resolve, reject)
return promise
}
/**
* close archive
*/
close() {
this._runCode.closeArchive(this._archive)
this._wasmModule._free(this._filePtr)
this._file = null
this._filePtr = null
this._archive = null
}
/**
* detect if archive has encrypted data
* @returns {boolean|null} null if could not be determined
*/
hasEncryptedData() {
this._archive = this._runCode.openArchive(this._filePtr, this._fileLength, this._passphrase)
this._runCode.getNextEntry(this._archive)
const status = this._runCode.hasEncryptedEntries(this._archive)
if (status === 0) {
return false
} else if (status > 0) {
return true
} else {
return null
}
}
/**
* set passphrase to be used with archive
* @param {*} passphrase
*/
setPassphrase(passphrase) {
this._passphrase = passphrase
}
/**
* get archive entries
* @param {boolean} skipExtraction
* @param {string} except don't skip this entry
*/
*entries(skipExtraction = false, except = null) {
this._archive = this._runCode.openArchive(this._filePtr, this._fileLength, this._passphrase)
let entry
while (true) {
entry = this._runCode.getNextEntry(this._archive)
if (entry === 0) break
const entryData = {
size: this._runCode.getEntrySize(entry),
path: this._runCode.getEntryName(entry),
type: TYPE_MAP[this._runCode.getEntryType(entry)],
ref: entry,
}
if (entryData.type === 'FILE') {
let fileName = entryData.path.split('/')
entryData.fileName = fileName[fileName.length - 1]
}
if (skipExtraction && except !== entryData.path) {
this._runCode.skipEntry(this._archive)
} else {
const ptr = this._runCode.getFileData(this._archive, entryData.size)
if (ptr < 0) {
throw new Error(this._runCode.getError(this._archive))
}
entryData.fileData = this._wasmModule.HEAP8.slice(ptr, ptr + entryData.size)
this._wasmModule._free(ptr)
}
yield entryData
}
}
_loadFile(fileBuffer, resolve, reject) {
try {
const array = new Uint8Array(fileBuffer)
this._fileLength = array.length
this._filePtr = this._runCode.malloc(this._fileLength)
this._wasmModule.HEAP8.set(array, this._filePtr)
resolve()
} catch (error) {
reject(error)
}
}
_promiseHandles() {
let resolve = null, reject = null
const promise = new Promise((_resolve, _reject) => {
resolve = _resolve
reject = _reject
})
return { promise, resolve, reject }
}
}
class WasmModule {
constructor() {
this.preRun = []
this.postRun = []
this.totalDependencies = 0
}
print(...text) {
console.log(text)
}
printErr(...text) {
console.error(text)
}
initFunctions() {
this.runCode = {
// const char * get_version()
getVersion: this.cwrap('get_version', 'string', []),
// void * archive_open( const void * buffer, size_t buffer_size)
// retuns archive pointer
openArchive: this.cwrap('archive_open', 'number', ['number', 'number', 'string']),
// void * get_entry(void * archive)
// return archive entry pointer
getNextEntry: this.cwrap('get_next_entry', 'number', ['number']),
// void * get_filedata( void * archive, size_t bufferSize )
getFileData: this.cwrap('get_filedata', 'number', ['number', 'number']),
// int archive_read_data_skip(struct archive *_a)
skipEntry: this.cwrap('archive_read_data_skip', 'number', ['number']),
// void archive_close( void * archive )
closeArchive: this.cwrap('archive_close', null, ['number']),
// la_int64_t archive_entry_size( struct archive_entry * )
getEntrySize: this.cwrap('archive_entry_size', 'number', ['number']),
// const char * archive_entry_pathname( struct archive_entry * )
getEntryName: this.cwrap('archive_entry_pathname', 'string', ['number']),
// __LA_MODE_T archive_entry_filetype( struct archive_entry * )
/*
#define AE_IFMT ((__LA_MODE_T)0170000)
#define AE_IFREG ((__LA_MODE_T)0100000) // Regular file
#define AE_IFLNK ((__LA_MODE_T)0120000) // Sybolic link
#define AE_IFSOCK ((__LA_MODE_T)0140000) // Socket
#define AE_IFCHR ((__LA_MODE_T)0020000) // Character device
#define AE_IFBLK ((__LA_MODE_T)0060000) // Block device
#define AE_IFDIR ((__LA_MODE_T)0040000) // Directory
#define AE_IFIFO ((__LA_MODE_T)0010000) // Named pipe
*/
getEntryType: this.cwrap('archive_entry_filetype', 'number', ['number']),
// const char * archive_error_string(struct archive *);
getError: this.cwrap('archive_error_string', 'string', ['number']),
/*
* Returns 1 if the archive contains at least one encrypted entry.
* If the archive format not support encryption at all
* ARCHIVE_READ_FORMAT_ENCRYPTION_UNSUPPORTED is returned.
* If for any other reason (e.g. not enough data read so far)
* we cannot say whether there are encrypted entries, then
* ARCHIVE_READ_FORMAT_ENCRYPTION_DONT_KNOW is returned.
* In general, this function will return values below zero when the
* reader is uncertain or totally incapable of encryption support.
* When this function returns 0 you can be sure that the reader
* supports encryption detection but no encrypted entries have
* been found yet.
*
* NOTE: If the metadata/header of an archive is also encrypted, you
* cannot rely on the number of encrypted entries. That is why this
* function does not return the number of encrypted entries but#
* just shows that there are some.
*/
// __LA_DECL int archive_read_has_encrypted_entries(struct archive *);
entryIsEncrypted: this.cwrap('archive_entry_is_encrypted', 'number', ['number']),
hasEncryptedEntries: this.cwrap('archive_read_has_encrypted_entries', 'number', ['number']),
// __LA_DECL int archive_read_add_passphrase(struct archive *, const char *);
addPassphrase: this.cwrap('archive_read_add_passphrase', 'number', ['number', 'string']),
//this.stringToUTF(str), //
string: (str) => this.allocate(this.intArrayFromString(str), 'i8', 0),
malloc: this.cwrap('malloc', 'number', ['number']),
free: this.cwrap('free', null, ['number']),
}
}
monitorRunDependencies() { }
locateFile(path /* ,prefix */) {
const wasmFilepath = Path.join(__dirname, `../../../client/dist/libarchive/wasm-gen/${path}`)
return wasmFilepath
}
}
module.exports.getArchiveReader = (cb) => {
libarchive(new WasmModule()).then((module) => {
module.initFunctions()
cb(new ArchiveReader(module))
})
}

View File

@ -18,6 +18,19 @@ const Logger = require('../Logger')
* @property {string} title
*/
/**
* @typedef SeriesExpandedProperties
* @property {{sequence:string}} bookSeries
*
* @typedef {import('./Series') & SeriesExpandedProperties} SeriesExpanded
*
* @typedef BookExpandedProperties
* @property {import('./Author')[]} authors
* @property {SeriesExpanded[]} series
*
* @typedef {Book & BookExpandedProperties} BookExpanded
*/
/**
* @typedef AudioFileObject
* @property {number} index
@ -54,6 +67,8 @@ class Book extends Model {
/** @type {string} */
this.titleIgnorePrefix
/** @type {string} */
this.subtitle
/** @type {string} */
this.publishedYear
/** @type {string} */
this.publishedDate

View File

@ -15,6 +15,13 @@ const Podcast = require('./Podcast')
* @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata
*/
/**
* @typedef LibraryItemExpandedProperties
* @property {Book.BookExpanded|Podcast.PodcastExpanded} media
*
* @typedef {LibraryItem & LibraryItemExpandedProperties} LibraryItemExpanded
*/
class LibraryItem extends Model {
constructor(values, options) {
super(values, options)
@ -412,6 +419,55 @@ class LibraryItem extends Model {
})
}
/**
*
* @param {string} libraryItemId
* @returns {Promise<LibraryItemExpanded>}
*/
static async getExpandedById(libraryItemId) {
if (!libraryItemId) return null
const libraryItem = await this.findByPk(libraryItemId)
if (!libraryItem) {
Logger.error(`[LibraryItem] Library item not found with id "${libraryItemId}"`)
return null
}
if (libraryItem.mediaType === 'podcast') {
libraryItem.media = await libraryItem.getMedia({
include: [
{
model: this.sequelize.models.podcastEpisode
}
]
})
} else {
libraryItem.media = await libraryItem.getMedia({
include: [
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
],
order: [
[this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],
[this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']
]
})
}
if (!libraryItem.media) return null
return libraryItem
}
/**
* Get old library item by id
* @param {string} libraryItemId

View File

@ -1,5 +1,12 @@
const { DataTypes, Model } = require('sequelize')
/**
* @typedef PodcastExpandedProperties
* @property {import('./PodcastEpisode')[]} podcastEpisodes
*
* @typedef {Podcast & PodcastExpandedProperties} PodcastExpanded
*/
class Podcast extends Model {
constructor(values, options) {
super(values, options)

View File

@ -1,10 +1,10 @@
const axios = require('axios')
const { levenshteinDistance } = require('../utils/index')
const Logger = require('../Logger')
const { RateLimiter } = require('limiter');
const { RateLimiter } = require('limiter')
class Audnexus {
static _instance = null;
static _instance = null
constructor() {
// ensures Audnexus class is singleton

View File

@ -681,7 +681,7 @@ class BookScanner {
const bookTitle = this.bookMetadata.title || this.libraryItemData.mediaMetadata.title
AudioFileScanner.setBookMetadataFromAudioMetaTags(bookTitle, this.audioFiles, this.bookMetadata, this.libraryScan)
} else if (this.ebookFileScanData) {
const ebookMetdataObject = this.ebookFileScanData.metadata
const ebookMetdataObject = this.ebookFileScanData.metadata || {}
for (const key in ebookMetdataObject) {
if (key === 'tags') {
if (ebookMetdataObject.tags.length) {

View File

@ -0,0 +1,35 @@
/**
* TODO: Add more fields
* @see https://anansi-project.github.io/docs/comicinfo/intro
*
* @param {Object} comicInfoJson
* @returns {import('../../scanner/BookScanner').BookMetadataObject}
*/
module.exports.parse = (comicInfoJson) => {
if (!comicInfoJson?.ComicInfo) return null
const ComicSeries = comicInfoJson.ComicInfo.Series?.[0]?.trim() || null
const ComicNumber = comicInfoJson.ComicInfo.Number?.[0]?.trim() || null
const ComicSummary = comicInfoJson.ComicInfo.Summary?.[0]?.trim() || null
let title = null
const series = []
if (ComicSeries) {
series.push({
name: ComicSeries,
sequence: ComicNumber
})
title = ComicSeries
if (ComicNumber) {
title += ` ${ComicNumber}`
}
}
return {
title,
series,
description: ComicSummary
}
}

View File

@ -0,0 +1,109 @@
const Path = require('path')
const globals = require('../globals')
const fs = require('../../libs/fsExtra')
const Logger = require('../../Logger')
const Archive = require('../../libs/libarchive/archive')
const { xmlToJSON } = require('../index')
const parseComicInfoMetadata = require('./parseComicInfoMetadata')
/**
*
* @param {string} filepath
* @returns {Promise<Buffer>}
*/
async function getComicFileBuffer(filepath) {
if (!await fs.pathExists(filepath)) {
Logger.error(`Comic path does not exist "${filepath}"`)
return null
}
try {
return fs.readFile(filepath)
} catch (error) {
Logger.error(`Failed to read comic at "${filepath}"`, error)
return null
}
}
/**
* Extract cover image from comic return true if success
*
* @param {string} comicPath
* @param {string} comicImageFilepath
* @param {string} outputCoverPath
* @returns {Promise<boolean>}
*/
async function extractCoverImage(comicPath, comicImageFilepath, outputCoverPath) {
const comicFileBuffer = await getComicFileBuffer(comicPath)
if (!comicFileBuffer) return null
const archive = await Archive.open(comicFileBuffer)
const fileEntry = await archive.extractSingleFile(comicImageFilepath)
if (!fileEntry?.fileData) {
Logger.error(`[parseComicMetadata] Invalid file entry data for comicPath "${comicPath}"/${comicImageFilepath}`)
return false
}
try {
await fs.writeFile(outputCoverPath, fileEntry.fileData)
return true
} catch (error) {
Logger.error(`[parseComicMetadata] Failed to extract image from comicPath "${comicPath}"`, error)
return false
}
}
module.exports.extractCoverImage = extractCoverImage
/**
* Parse metadata from comic
*
* @param {import('../../models/Book').EBookFileObject} ebookFile
* @returns {Promise<import('./parseEbookMetadata').EBookFileScanData>}
*/
async function parse(ebookFile) {
const comicPath = ebookFile.metadata.path
Logger.debug(`Parsing metadata from comic at "${comicPath}"`)
const comicFileBuffer = await getComicFileBuffer(comicPath)
if (!comicFileBuffer) return null
const archive = await Archive.open(comicFileBuffer)
const fileObjects = await archive.getFilesArray()
fileObjects.sort((a, b) => {
return a.file.name.localeCompare(b.file.name, undefined, {
numeric: true,
sensitivity: 'base'
})
})
let metadata = null
const comicInfo = fileObjects.find(fo => fo.file.name === 'ComicInfo.xml')
if (comicInfo) {
const comicInfoEntry = await comicInfo.file.extract()
if (comicInfoEntry?.fileData) {
const comicInfoStr = new TextDecoder().decode(comicInfoEntry.fileData)
const comicInfoJson = await xmlToJSON(comicInfoStr)
if (comicInfoJson) {
metadata = parseComicInfoMetadata.parse(comicInfoJson)
}
}
}
const payload = {
path: comicPath,
ebookFormat: ebookFile.ebookFormat,
metadata
}
const firstImage = fileObjects.find(fo => globals.SupportedImageTypes.includes(Path.extname(fo.file.name).toLowerCase().slice(1)))
if (firstImage?.file?._path) {
payload.ebookCoverPath = firstImage.file._path
} else {
Logger.warn(`Cover image not found in comic at "${comicPath}"`)
}
return payload
}
module.exports.parse = parse

View File

@ -1,4 +1,5 @@
const parseEpubMetadata = require('./parseEpubMetadata')
const parseComicMetadata = require('./parseComicMetadata')
/**
* @typedef EBookFileScanData
@ -18,7 +19,9 @@ async function parse(ebookFile) {
if (!ebookFile) return null
if (ebookFile.ebookFormat === 'epub') {
return parseEpubMetadata.parse(ebookFile.metadata.path)
return parseEpubMetadata.parse(ebookFile)
} else if (['cbz', 'cbr'].includes(ebookFile.ebookFormat)) {
return parseComicMetadata.parse(ebookFile)
}
return null
}
@ -36,6 +39,8 @@ async function extractCoverImage(ebookFileScanData, outputCoverPath) {
if (ebookFileScanData.ebookFormat === 'epub') {
return parseEpubMetadata.extractCoverImage(ebookFileScanData.path, ebookFileScanData.ebookCoverPath, outputCoverPath)
} else if (['cbz', 'cbr'].includes(ebookFileScanData.ebookFormat)) {
return parseComicMetadata.extractCoverImage(ebookFileScanData.path, ebookFileScanData.ebookCoverPath, outputCoverPath)
}
return false
}

View File

@ -60,10 +60,11 @@ module.exports.extractCoverImage = extractCoverImage
/**
* Parse metadata from epub
*
* @param {string} epubPath
* @param {import('../../models/Book').EBookFileObject} ebookFile
* @returns {Promise<import('./parseEbookMetadata').EBookFileScanData>}
*/
async function parse(epubPath) {
async function parse(ebookFile) {
const epubPath = ebookFile.metadata.path
Logger.debug(`Parsing metadata from epub at "${epubPath}"`)
// Entrypoint of the epub that contains the filepath to the package document (opf file)
const containerJson = await extractXmlToJson(epubPath, 'META-INF/container.xml')