Compare commits

...

19 Commits

Author SHA1 Message Date
advplyr
104cadb0b3 Version bump 2.3.2 2023-07-17 17:49:12 -05:00
advplyr
6814adffcc Update:Only load feeds when needed 2023-07-17 16:48:46 -05:00
advplyr
20c11e381e Update docker file heap size 2023-07-17 14:41:21 -05:00
advplyr
b5952f16eb Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-07-17 13:58:25 -05:00
advplyr
5b6878e5de Fix:Crash on local playback sessions #1912 2023-07-17 13:58:19 -05:00
advplyr
89a25bcf39 Merge pull request #1917 from burghy86/patch-10
Update it.json
2023-07-17 08:09:28 -05:00
advplyr
d0cd512be8 Fix:Crash when updating sequence on series #1919 2023-07-17 08:09:08 -05:00
advplyr
3543dea0fb Merge pull request #1923 from JBlond/master
Update German language file
2023-07-17 08:09:07 -05:00
advplyr
1949e25ccb Merge pull request #1925 from Machou/patch-1
Update fr.json
2023-07-17 08:08:43 -05:00
advplyr
b715ef3bfc Increase heap size to 4gb in Dockerfile 2023-07-17 07:48:23 -05:00
Machou
954050df81 Update fr.json 2023-07-17 14:28:56 +02:00
JBlond
e4aa7f10fa Update German language file 2023-07-17 10:02:54 +02:00
advplyr
2afd0e2acd Update dbMigration for old main library ids 2023-07-16 16:39:59 -05:00
advplyr
0829237166 Fix:Libraries out of order #1911 2023-07-16 15:43:46 -05:00
advplyr
541975f038 Version bump 2.3.1 2023-07-16 15:34:35 -05:00
advplyr
01bf58ab97 Fix createAuthor 2023-07-16 15:29:43 -05:00
advplyr
d99b2c25e8 Fixes for db migration & local playback sessions 2023-07-16 15:05:51 -05:00
burghy86
a31df5ff81 Update it.json
traslate new string and fix error
2023-07-16 21:50:15 +02:00
advplyr
63e5cf2e60 Fix:Accessing series page for some users #787 2023-07-16 08:39:08 -05:00
29 changed files with 1028 additions and 320 deletions

View File

@@ -10,6 +10,7 @@ FROM sandreas/tone:v0.1.5 AS tone
FROM node:16-alpine
ENV NODE_ENV=production
RUN apk update && \
apk add --no-cache --update \
curl \
@@ -28,6 +29,8 @@ RUN npm ci --only=production
RUN apk del make python3 g++
ENV NODE_OPTIONS=--max-old-space-size=8192
EXPOSE 80
HEALTHCHECK \
--interval=30s \

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.3.0",
"version": "2.3.2",
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",
"scripts": {

View File

@@ -3,7 +3,7 @@
"ButtonAddChapters": "Kapitel hinzufügen",
"ButtonAddPodcasts": "Podcasts hinzufügen",
"ButtonAddYourFirstLibrary": "Erstelle deine erste Bibliothek",
"ButtonApply": "Anwenden",
"ButtonApply": "Übernehmen",
"ButtonApplyChapters": "Kapitel anwenden",
"ButtonAuthors": "Autoren",
"ButtonBrowseForFolder": "Ordnersuche",
@@ -37,7 +37,7 @@
"ButtonMapChapterTitles": "Kapitelüberschriften zuordnen",
"ButtonMatchAllAuthors": "Online Metadaten-Abgleich (alle Autoren)",
"ButtonMatchBooks": "Online Metadaten-Abgleich (alle Medien)",
"ButtonNevermind": "Vergiss es",
"ButtonNevermind": "Abbrechen",
"ButtonOk": "Ok",
"ButtonOpenFeed": "Feed öffnen",
"ButtonOpenManager": "Manager öffnen",
@@ -98,12 +98,12 @@
"HeaderCurrentDownloads": "Aktuelle Downloads",
"HeaderDetails": "Details",
"HeaderDownloadQueue": "Download Warteschlange",
"HeaderEbookFiles": "Ebook Files",
"HeaderEbookFiles": "E-Book Dateien",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEmailSettings": "Email Einstellungen",
"HeaderEpisodes": "Episoden",
"HeaderEreaderDevices": "Ereader Devices",
"HeaderEreaderSettings": "Ereader Settings",
"HeaderEreaderDevices": "Ereader Geräte",
"HeaderEreaderSettings": "Ereader Einstellungen",
"HeaderFiles": "Dateien",
"HeaderFindChapters": "Kapitel suchen",
"HeaderIgnoredFiles": "Ignorierte Dateien",
@@ -155,7 +155,7 @@
"HeaderStatsRecentSessions": "Neueste Ereignisse",
"HeaderStatsTop10Authors": "Top 10 Autoren",
"HeaderStatsTop5Genres": "Top 5 Kategorien",
"HeaderTableOfContents": "Table of Contents",
"HeaderTableOfContents": "Inhaltsverzeichnis",
"HeaderTools": "Werkzeuge",
"HeaderUpdateAccount": "Konto aktualisieren",
"HeaderUpdateAuthor": "Autor aktualisieren",
@@ -195,7 +195,7 @@
"LabelBooks": "Bücher",
"LabelChangePassword": "Passwort ändern",
"LabelChannels": "Kanäle",
"LabelChapters": "Chapters",
"LabelChapters": "Kapitel",
"LabelChaptersFound": "gefundene Kapitel",
"LabelChapterTitle": "Kapitelüberschrift",
"LabelClosePlayer": "Player schließen",
@@ -205,7 +205,7 @@
"LabelComplete": "Vollständig",
"LabelConfirmPassword": "Passwort bestätigen",
"LabelContinueListening": "Weiterhören",
"LabelContinueReading": "Continue Reading",
"LabelContinueReading": "Lesen fortsetzen",
"LabelContinueSeries": "Serien fortsetzen",
"LabelCover": "Titelbild",
"LabelCoverImageURL": "URL des Titelbildes",
@@ -226,14 +226,14 @@
"LabelDownloadNEpisodes": "Download {0} episodes",
"LabelDuration": "Laufzeit",
"LabelDurationFound": "Gefundene Laufzeit:",
"LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEbook": "E-Book",
"LabelEbooks": "E-Books",
"LabelEdit": "Bearbeiten",
"LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "From Address",
"LabelEmailSettingsSecure": "Secure",
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
"LabelEmailSettingsTestAddress": "Test Address",
"LabelEmailSettingsFromAddress": "Von Address",
"LabelEmailSettingsSecure": "Sicherheit",
"LabelEmailSettingsSecureHelp": "Wenn \"true\", verwendet die Verbindung TLS, wenn sie eine Verbindung zum Server herstellt. Bei \"false\" wird TLS verwendet, wenn der Server die STARTTLS-Erweiterung unterstützt. In den meisten Fällen setzen Sie diesen Wert auf \"true\", wenn Sie eine Verbindung zu Port 465 herstellen. Für Port 587 oder 25 behalten Sie den Wert \"false\" bei. (von nodemailer.com/smtp/#authentication)",
"LabelEmailSettingsTestAddress": "Test Addresse",
"LabelEmbeddedCover": "Eingebettetes Cover",
"LabelEnable": "Aktivieren",
"LabelEnd": "Ende",
@@ -244,7 +244,7 @@
"LabelExplicit": "Explizit (Altersbeschränkung)",
"LabelFeedURL": "Feed URL",
"LabelFile": "Datei",
"LabelFileBirthtime": "Datei Geburtsdatum",
"LabelFileBirthtime": "Datei erstellt",
"LabelFileModified": "Datei geändert",
"LabelFilename": "Dateiname",
"LabelFilterByUser": "Nach Benutzern filtern",
@@ -252,13 +252,13 @@
"LabelFinished": "beendet",
"LabelFolder": "Ordner",
"LabelFolders": "Verzeichnisse",
"LabelFontScale": "Font scale",
"LabelFontScale": "Schriftgröße",
"LabelFormat": "Format",
"LabelGenre": "Kategorie",
"LabelGenres": "Kategorien",
"LabelHardDeleteFile": "Datei dauerhaft löschen",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHasEbook": "mit E-Book",
"LabelHasSupplementaryEbook": "mit zusätlichem E-Book",
"LabelHost": "Host",
"LabelHour": "Stunde",
"LabelIcon": "Symbol",
@@ -275,7 +275,7 @@
"LabelIntervalEveryDay": "Jeden Tag",
"LabelIntervalEveryHour": "Jede Stunde",
"LabelInvalidParts": "Ungültige Teile",
"LabelInvert": "Invert",
"LabelInvert": "Umkehren",
"LabelItem": "Medium",
"LabelLanguage": "Sprache",
"LabelLanguageDefaultServer": "Standard-Server-Sprache",
@@ -285,15 +285,15 @@
"LabelLastTime": "Letztes Mal",
"LabelLastUpdate": "Letzte Aktualisierung",
"LabelLayout": "Layout",
"LabelLayoutSinglePage": "Single page",
"LabelLayoutSplitPage": "Split page",
"LabelLayoutSinglePage": "Eine Seite",
"LabelLayoutSplitPage": "Geteilte Seite",
"LabelLess": "Weniger",
"LabelLibrariesAccessibleToUser": "Für Benutzer zugängliche Bibliotheken",
"LabelLibrary": "Bibliothek",
"LabelLibraryItem": "Bibliothekseintrag",
"LabelLibraryName": "Bibliotheksname",
"LabelLimit": "Begrenzung",
"LabelLineSpacing": "Line spacing",
"LabelLineSpacing": "Zeilenabstand",
"LabelListenAgain": "Erneut anhören",
"LabelLogLevelDebug": "Fehlersuche",
"LabelLogLevelInfo": "Informationen",
@@ -308,7 +308,7 @@
"LabelMissing": "Fehlend",
"LabelMissingParts": "Fehlende Teile",
"LabelMore": "Mehr",
"LabelMoreInfo": "More Info",
"LabelMoreInfo": "Mehr Info",
"LabelName": "Name",
"LabelNarrator": "Erzähler",
"LabelNarrators": "Erzähler",
@@ -318,7 +318,7 @@
"LabelNewPassword": "Neues Passwort",
"LabelNextBackupDate": "Nächstes Sicherungsdatum",
"LabelNextScheduledRun": "Nächster planmäßiger Durchlauf",
"LabelNoEpisodesSelected": "No episodes selected",
"LabelNoEpisodesSelected": "Keine Episoden ausgewählt",
"LabelNotes": "Hinweise",
"LabelNotFinished": "nicht beendet",
"LabelNotificationAppriseURL": "Apprise URL(s)",
@@ -353,15 +353,15 @@
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Zu ignorierende(s) Vorwort(e) (Groß- und Kleinschreibung wird nicht berücksichtigt)",
"LabelPreventIndexing": "Verhindere, dass dein Feed von iTunes- und Google-Podcast-Verzeichnissen indiziert wird",
"LabelPrimaryEbook": "Primary ebook",
"LabelPrimaryEbook": "Haupt-E-Book",
"LabelProgress": "Fortschritt",
"LabelProvider": "Anbieter",
"LabelPubDate": "Veröffentlichungsdatum",
"LabelPublisher": "Herausgeber",
"LabelPublishYear": "Jahr",
"LabelRead": "Read",
"LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRead": "Lesen",
"LabelReadAgain": "Nocheinmal Lesen",
"LabelReadEbookWithoutProgress": "E-Book lesen und Fortschritt verwerfen",
"LabelRecentlyAdded": "Kürzlich hinzugefügt",
"LabelRecentSeries": "Aktuelle Serien",
"LabelRecommended": "Empfohlen",
@@ -378,17 +378,17 @@
"LabelSearchTitle": "Titel",
"LabelSearchTitleOrASIN": "Titel oder ASIN",
"LabelSeason": "Staffel",
"LabelSelectAllEpisodes": "Select all episodes",
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSelectAllEpisodes": "Alle Episoden auswählen",
"LabelSelectEpisodesShowing": "{0} ausgewählte Episoden werden angezeigt",
"LabelSendEbookToDevice": "E-Book senden an...",
"LabelSequence": "Reihenfolge",
"LabelSeries": "Serien",
"LabelSeriesName": "Serienname",
"LabelSeriesProgress": "Serienfortschritt",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
"LabelSetEbookAsPrimary": "Setzen als Hauptbuch",
"LabelSetEbookAsSupplementary": "Setzen als Ergänzung",
"LabelSettingsAudiobooksOnly": "nur Hörbücher",
"LabelSettingsAudiobooksOnlyHelp": "Wenn Sie diese Einstellung aktivieren, werden E-Book-Dateien ignoriert, es sei denn, sie befinden sich in einem Hörbuchordner. In diesem Fall werden sie als zusätzliche E-Books festgelegt",
"LabelSettingsBookshelfViewHelp": "Skeumorphes Design mit Holzeinlegeböden",
"LabelSettingsChromecastSupport": "Chromecastunterstützung",
"LabelSettingsDateFormat": "Datumsformat",
@@ -399,8 +399,8 @@
"LabelSettingsExperimentalFeaturesHelp": "Funktionen welche sich in der Entwicklung befinden, benötigen Ihr Feedback und Ihre Hilfe beim Testen. Klicken Sie hier, um die Github-Diskussion zu öffnen.",
"LabelSettingsFindCovers": "Suche Titelbilder",
"LabelSettingsFindCoversHelp": "Wenn Ihr Medium kein eingebettetes Titelbild oder kein Titelbild im Ordner hat, versucht der Scanner, ein Titelbild online zu finden.<br>Hinweis: Dies verlängert die Scandauer",
"LabelSettingsHideSingleBookSeries": "Hide single book series",
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
"LabelSettingsHideSingleBookSeries": "Ausblenden einzelzne Bücher",
"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",
@@ -519,8 +519,8 @@
"MessageConfirmDeleteLibrary": "Sind Sie sicher, dass Sie die Bibliothek \"{0}\" dauerhaft löschen wollen?",
"MessageConfirmDeleteSession": "Sind Sie sicher, dass Sie diese Sitzung löschen möchten?",
"MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?",
"MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?",
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
"MessageConfirmMarkAllEpisodesFinished": "Sind Sie sicher, dass Sie alle Episoden als abgeschlossen markieren möchten?",
"MessageConfirmMarkAllEpisodesNotFinished": "Sind Sie sicher, dass Sie alle Episoden als nicht abgeschlossen markieren möchten?",
"MessageConfirmMarkSeriesFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als abgeschlossen markieren wollen?",
"MessageConfirmMarkSeriesNotFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als nicht abgeschlossen markieren wollen?",
"MessageConfirmRemoveAllChapters": "Sind Sie sicher, dass Sie alle Kapitel entfernen möchten?",
@@ -535,7 +535,7 @@
"MessageConfirmRenameTag": "Sind Sie sicher, dass Sie den Tag \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts umbenennen wollen?",
"MessageConfirmRenameTagMergeNote": "Hinweis: Tag existiert bereits -> Tags werden zusammengelegt.",
"MessageConfirmRenameTagWarning": "Warnung! Ein ähnlicher Tag mit einem anderen Wortlaut existiert bereits: \"{0}\".",
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
"MessageConfirmSendEbookToDevice": "Sind Sie sicher, dass sie {0} ebook \"{1}\" auf das Gerät \"{2}\" senden wollen?",
"MessageDownloadingEpisode": "Episode herunterladen",
"MessageDragFilesIntoTrackOrder": "Verschieben Sie die Dateien in die richtige Reihenfolge",
"MessageEmbedFinished": "Einbettung abgeschlossen!",
@@ -554,8 +554,8 @@
"MessageM4BFailed": "M4B fehlgeschlagen!",
"MessageM4BFinished": "M4B beendet!",
"MessageMapChapterTitles": "Zuordnen von Kapiteltiteln zu Ihren vorhandenen Medienkapiteln ohne Anpassung der Zeitangaben",
"MessageMarkAllEpisodesFinished": "Mark all episodes finished",
"MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished",
"MessageMarkAllEpisodesFinished": "Alle Episoden als beendet markieren",
"MessageMarkAllEpisodesNotFinished": "Alle Episoden als ungehört markieren",
"MessageMarkAsFinished": "Als beendet markieren",
"MessageMarkAsNotFinished": "Als nicht abgeschlossen markieren",
"MessageMatchBooksDescription": "versucht, Bücher in der Bibliothek mit einem Buch des ausgewählten Suchanbieters abzugleichen und leere Details und das Titelbild auszufüllen. Details werden nicht überschrieben.",
@@ -691,8 +691,8 @@
"ToastRemoveItemFromCollectionSuccess": "Medium aus der Sammlung gelöscht",
"ToastRSSFeedCloseFailed": "RSS-Feed konnte nicht geschlossen werden",
"ToastRSSFeedCloseSuccess": "RSS-Feed geschlossen",
"ToastSendEbookToDeviceFailed": "Failed to send ebook to device",
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
"ToastSendEbookToDeviceFailed": "E-Book konnte nicht auf Gerät übertragen werden",
"ToastSendEbookToDeviceSuccess": "E-Book an Gerät senden \"{0}\"",
"ToastSeriesUpdateFailed": "Aktualisierung der Serien fehlgeschlagen",
"ToastSeriesUpdateSuccess": "Serien aktualisiert",
"ToastSessionDeleteFailed": "Sitzung konnte nicht gelöscht werden",
@@ -702,4 +702,4 @@
"ToastSocketFailedToConnect": "Verbindung zum WebSocket fehlgeschlagen",
"ToastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden",
"ToastUserDeleteSuccess": "Benutzer gelöscht"
}
}

View File

@@ -97,13 +97,13 @@
"HeaderCover": "Couverture",
"HeaderCurrentDownloads": "Téléchargements en cours",
"HeaderDetails": "Détails",
"HeaderDownloadQueue": "File d'attente de téléchargements",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "E-mails",
"HeaderEmailSettings": "Configuration des e-mails",
"HeaderDownloadQueue": "File dattente de téléchargements",
"HeaderEbookFiles": "Fichier des livres numériques",
"HeaderEmail": "Courriels",
"HeaderEmailSettings": "Configuration des courriels",
"HeaderEpisodes": "Épisodes",
"HeaderEreaderDevices": "Lecteurs d'e-books",
"HeaderEreaderSettings": "Ereader Settings",
"HeaderEreaderDevices": "Lecteur de livres numériques",
"HeaderEreaderSettings": "Options Ereader",
"HeaderFiles": "Fichiers",
"HeaderFindChapters": "Trouver les chapitres",
"HeaderIgnoredFiles": "Fichiers Ignorés",
@@ -223,16 +223,16 @@
"LabelDiscFromFilename": "Disque depuis le fichier",
"LabelDiscFromMetadata": "Disque depuis les métadonnées",
"LabelDownload": "Téléchargement",
"LabelDownloadNEpisodes": "Download {0} episodes",
"LabelDownloadNEpisodes": "Télécharger {0} épisode(s)",
"LabelDuration": "Durée",
"LabelDurationFound": "Durée trouvée :",
"LabelEbook": "E-book",
"LabelEbooks": "Ebooks",
"LabelEbook": "Livre numérique",
"LabelEbooks": "Livres numériques",
"LabelEdit": "Modifier",
"LabelEmail": "E-mail",
"LabelEmail": "Courriel",
"LabelEmailSettingsFromAddress": "Expéditeur",
"LabelEmailSettingsSecure": "Sécurisé",
"LabelEmailSettingsSecureHelp": "Si coché, la connexion utilisera TLS lors de la connexion au serveur. Sinon TLS est utilisé si le serveur prend en charge l'extension STARTTLS. Dans la plupart des cas, cochez si vous vous connectez au port 465. Décochez pour le port 587 ou 25. (source: nodemailer.com/smtp/#authentication)",
"LabelEmailSettingsSecureHelp": "Si coché, la connexion utilisera TLS lors de la connexion au serveur. Sinon TLS est utilisé si le serveur prend en charge lextension STARTTLS. Dans la plupart des cas, cochez si vous vous connectez au port 465. Décochez pour le port 587 ou 25. (source: nodemailer.com/smtp/#authentication)",
"LabelEmailSettingsTestAddress": "Test Address",
"LabelEmbeddedCover": "Couverture du livre intégrée",
"LabelEnable": "Activer",
@@ -257,8 +257,8 @@
"LabelGenre": "Genre",
"LabelGenres": "Genres",
"LabelHardDeleteFile": "Suppression du fichier",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHasEbook": "Dispose dun livre numérique",
"LabelHasSupplementaryEbook": "Dispose dun livre numérique supplémentaire",
"LabelHost": "Hôte",
"LabelHour": "Heure",
"LabelIcon": "Icone",
@@ -353,22 +353,22 @@
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Préfixes à Ignorer (Insensible à la Casse)",
"LabelPreventIndexing": "Empêcher lindexation de votre flux par les bases de données iTunes et Google podcast",
"LabelPrimaryEbook": "Primary ebook",
"LabelPrimaryEbook": "Premier livre numérique",
"LabelProgress": "Progression",
"LabelProvider": "Fournisseur",
"LabelPubDate": "Date de publication",
"LabelPublisher": "Éditeur",
"LabelPublishYear": "Année dédition",
"LabelRead": "Read",
"LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRead": "Lire",
"LabelReadAgain": "Lire à nouveau",
"LabelReadEbookWithoutProgress": "Lire le livre numérique sans sauvegarder la progression",
"LabelRecentlyAdded": "Derniers ajouts",
"LabelRecentSeries": "Séries récentes",
"LabelRecommended": "Recommandé",
"LabelRegion": "Région",
"LabelReleaseDate": "Date de parution",
"LabelRemoveCover": "Supprimer la couverture",
"LabelRSSFeedCustomOwnerEmail": "E-mail propriétaire personnalisé",
"LabelRSSFeedCustomOwnerEmail": "Courriel du propriétaire personnalisé",
"LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé",
"LabelRSSFeedOpen": "Flux RSS ouvert",
"LabelRSSFeedPreventIndexing": "Empêcher lindexation",
@@ -379,16 +379,16 @@
"LabelSearchTitleOrASIN": "Recherche du titre ou ASIN",
"LabelSeason": "Saison",
"LabelSelectAllEpisodes": "Select all episodes",
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
"LabelSendEbookToDevice": "Envoyer l'e-book à...",
"LabelSelectEpisodesShowing": "Sélectionner {0} episode(s) en cours",
"LabelSendEbookToDevice": "Envoyer le livre numérique à...",
"LabelSequence": "Séquence",
"LabelSeries": "Séries",
"LabelSeriesName": "Nom de la série",
"LabelSeriesProgress": "Progression de séries",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
"LabelSetEbookAsPrimary": "Définir comme principale",
"LabelSetEbookAsSupplementary": "Définir comme supplémentaire",
"LabelSettingsAudiobooksOnly": "Livres audios seulement",
"LabelSettingsAudiobooksOnlyHelp": "Lactivation de ce paramètre ignorera les fichiers “ ebook ”, à moins quils ne se trouvent dans un dossier de livres audio, auquel cas ils seront définis comme des livres numériques supplémentaires.",
"LabelSettingsBookshelfViewHelp": "Interface skeuomorphique avec une étagère en bois",
"LabelSettingsChromecastSupport": "Support du Chromecast",
"LabelSettingsDateFormat": "Format de date",
@@ -399,8 +399,8 @@
"LabelSettingsExperimentalFeaturesHelp": "Fonctionnalités en cours de développement sur lesquelles nous attendons votre retour et expérience. Cliquez pour ouvrir la discussion Github.",
"LabelSettingsFindCovers": "Chercher des couvertures de livre",
"LabelSettingsFindCoversHelp": "Si votre livre audio ne possède pas de couverture intégrée ou une image de couverture dans le dossier, lanalyseur tentera de récupérer une couverture.<br>Attention, cela peut augmenter le temps danalyse.",
"LabelSettingsHideSingleBookSeries": "Hide single book series",
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
"LabelSettingsHideSingleBookSeries": "Masquer les séries de livres uniques",
"LabelSettingsHideSingleBookSeriesHelp": "Les séries qui ne comportent quun seul livre seront masquées sur la page de la série et sur les étagères de la page daccueil.",
"LabelSettingsHomePageBookshelfView": "La page daccueil utilise la vue étagère",
"LabelSettingsLibraryBookshelfView": "La bibliothèque utilise la vue étagère",
"LabelSettingsOverdriveMediaMarkers": "Utiliser Overdrive Media Marker pour les chapitres",
@@ -512,14 +512,14 @@
"MessageChapterErrorFirstNotZero": "Le premier capitre doit débuter à 0",
"MessageChapterErrorStartGteDuration": "Horodatage invalide car il doit débuter avant la fin du livre",
"MessageChapterErrorStartLtPrev": "Horodatage invalide car il doit débuter au moins après le précédent chapitre",
"MessageChapterStartIsAfter": "Le Chapitre Début est situé au début de votre Livre Audio",
"MessageChapterStartIsAfter": "Le premier chapitre est situé au début de votre livre audio",
"MessageCheckingCron": "Vérification du cron…",
"MessageConfirmDeleteBackup": "Êtes-vous sûr de vouloir supprimer la Sauvegarde de {0} ?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteFile": "Cela Le fichier sera supprimer de votre système. Êtes-vous sûr ?",
"MessageConfirmDeleteLibrary": "Êtes-vous sûr de vouloir supprimer définitivement la bibliothèque « {0} » ?",
"MessageConfirmDeleteSession": "Êtes-vous sûr de vouloir supprimer cette session ?",
"MessageConfirmForceReScan": "Êtes-vous sûr de vouloir lancer une Analyse Forcée ?",
"MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?",
"MessageConfirmMarkAllEpisodesFinished": "Êtes-vous sûr de marquer tous les épisodes comme terminés ?",
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
"MessageConfirmMarkSeriesFinished": "Êtes-vous sûr de vouloir marquer comme terminé tous les livres de cette série ?",
"MessageConfirmMarkSeriesNotFinished": "Êtes-vous sûr de vouloir marquer comme non terminé tous les livres de cette série ?",
@@ -535,7 +535,7 @@
"MessageConfirmRenameTag": "Êtes-vous sûr de vouloir renommer létiquette « {0} » en « {1} » pour tous les articles ?",
"MessageConfirmRenameTagMergeNote": "Information: Cette étiquette existe déjà et sera fusionnée.",
"MessageConfirmRenameTagWarning": "Attention ! Une étiquette similaire avec une casse différente existe déjà « {0} ».",
"MessageConfirmSendEbookToDevice": "Êtes-vous sûr de vouloir envoyer l'ebook {0} \"{1}\" à l'appareil \"{2}\"?",
"MessageConfirmSendEbookToDevice": "Êtes-vous sûr de vouloir envoyer le livre numérique {0} \"{1}\" à lappareil \"{2}\"?",
"MessageDownloadingEpisode": "Téléchargement de lépisode",
"MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans lordre correct",
"MessageEmbedFinished": "Intégration Terminée !",
@@ -554,10 +554,10 @@
"MessageM4BFailed": "M4B en échec !",
"MessageM4BFinished": "M4B terminé !",
"MessageMapChapterTitles": "Faire correspondre les titres des chapitres aux chapitres existants de votre livre audio sans ajuster lhorodatage.",
"MessageMarkAllEpisodesFinished": "Mark all episodes finished",
"MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished",
"MessageMarkAllEpisodesFinished": "Marquer tous les épisodes terminés",
"MessageMarkAllEpisodesNotFinished": "Marquer tous les épisodes non terminés",
"MessageMarkAsFinished": "Marquer comme terminé",
"MessageMarkAsNotFinished": "Marquer comme non Terminé",
"MessageMarkAsNotFinished": "Marquer comme non terminé",
"MessageMatchBooksDescription": "tentera de faire correspondre les livres de la bibliothèque avec les livres du fournisseur sélectionné pour combler les détails et couverture manquants. Nécrase pas les données existantes.",
"MessageNoAudioTracks": "Aucune piste audio",
"MessageNoAuthors": "Aucun auteur",
@@ -691,8 +691,8 @@
"ToastRemoveItemFromCollectionSuccess": "Article supprimé de la collection",
"ToastRSSFeedCloseFailed": "Échec de la fermeture du flux RSS",
"ToastRSSFeedCloseSuccess": "Flux RSS fermé",
"ToastSendEbookToDeviceFailed": "Échec de l'envoi de l'e-book à l'appareil",
"ToastSendEbookToDeviceSuccess": "E-book envoyé à l'appareil \"{0}\"",
"ToastSendEbookToDeviceFailed": "Échec de lenvoi du livre numérique à lappareil",
"ToastSendEbookToDeviceSuccess": "Livre numérique envoyé à lappareil : {0}",
"ToastSeriesUpdateFailed": "Échec de la mise à jour de la série",
"ToastSeriesUpdateSuccess": "Mise à jour de la série réussie",
"ToastSessionDeleteFailed": "Échec de la suppression de session",
@@ -702,4 +702,4 @@
"ToastSocketFailedToConnect": "Échec de la connexion WebSocket",
"ToastUserDeleteFailed": "Échec de la suppression de lutilisateur",
"ToastUserDeleteSuccess": "Utilisateur supprimé"
}
}

View File

@@ -55,7 +55,7 @@
"ButtonRemoveAll": "Rimuovi Tutto",
"ButtonRemoveAllLibraryItems": "Rimuovi tutto il contenuto della libreria",
"ButtonRemoveFromContinueListening": "Rimuovi per proseguire l'ascolto",
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
"ButtonRemoveFromContinueReading": "Rimuovi per proseguire la lettura",
"ButtonRemoveSeriesFromContinueSeries": "Rimuovi la Serie per Continuarla",
"ButtonReScan": "Ri-scansiona",
"ButtonReset": "Reset",
@@ -95,15 +95,15 @@
"HeaderCollection": "Raccolta",
"HeaderCollectionItems": "Elementi della Raccolta",
"HeaderCover": "Cover",
"HeaderCurrentDownloads": "Current Downloads",
"HeaderCurrentDownloads": "Download Correnti",
"HeaderDetails": "Dettagli",
"HeaderDownloadQueue": "Download Queue",
"HeaderEbookFiles": "Ebook Files",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Episodi",
"HeaderEreaderDevices": "Ereader Devices",
"HeaderEreaderSettings": "Ereader Settings",
"HeaderEreaderDevices": "Dispositivo Ereader",
"HeaderEreaderSettings": "Impostazioni Ereader",
"HeaderFiles": "File",
"HeaderFindChapters": "Trova Capitoli",
"HeaderIgnoredFiles": "File Ignorati",
@@ -149,13 +149,13 @@
"HeaderSettingsGeneral": "Generale",
"HeaderSettingsScanner": "Scanner",
"HeaderSleepTimer": "Sveglia",
"HeaderStatsLargestItems": "Largest Items",
"HeaderStatsLargestItems": "Oggetti Grandi",
"HeaderStatsLongestItems": "libri più lunghi (ore)",
"HeaderStatsMinutesListeningChart": "Minuti ascoltati (Ultimi 7 Giorni)",
"HeaderStatsRecentSessions": "Sessioni Recenti",
"HeaderStatsTop10Authors": "Top 10 Autori",
"HeaderStatsTop5Genres": "Top 5 Generi",
"HeaderTableOfContents": "Table of Contents",
"HeaderTableOfContents": "Tabellla dei Contenuti",
"HeaderTools": "Strumenti",
"HeaderUpdateAccount": "Aggiorna Account",
"HeaderUpdateAuthor": "Aggiorna Autore",
@@ -163,13 +163,13 @@
"HeaderUpdateLibrary": "Aggiorna Libreria",
"HeaderUsers": "Utenti",
"HeaderYourStats": "Statistiche Personali",
"LabelAbridged": "Abridged",
"LabelAbridged": "Abbreviato",
"LabelAccountType": "Tipo di Account",
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Ospite",
"LabelAccountTypeUser": "Utente",
"LabelActivity": "Attività",
"LabelAdded": "Added",
"LabelAdded": "Aggiunto",
"LabelAddedAt": "Aggiunto il",
"LabelAddToCollection": "Aggiungi alla Raccolta",
"LabelAddToCollectionBatch": "Aggiungi {0} Libri alla Raccolta",
@@ -194,8 +194,8 @@
"LabelBitrate": "Bitrate",
"LabelBooks": "Libri",
"LabelChangePassword": "Cambia Password",
"LabelChannels": "Channels",
"LabelChapters": "Chapters",
"LabelChannels": "Canali",
"LabelChapters": "Capitoli",
"LabelChaptersFound": "Capitoli Trovati",
"LabelChapterTitle": "Titoli dei Capitoli",
"LabelClosePlayer": "Chiudi player",
@@ -205,7 +205,7 @@
"LabelComplete": "Completo",
"LabelConfirmPassword": "Conferma Password",
"LabelContinueListening": "Continua ad Ascoltare",
"LabelContinueReading": "Continue Reading",
"LabelContinueReading": "Continua la Lettura",
"LabelContinueSeries": "Continua Serie",
"LabelCover": "Cover",
"LabelCoverImageURL": "Cover Image URL",
@@ -230,17 +230,17 @@
"LabelEbooks": "Ebooks",
"LabelEdit": "Modifica",
"LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "From Address",
"LabelEmailSettingsFromAddress": "Da Indirizzo",
"LabelEmailSettingsSecure": "Secure",
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
"LabelEmailSettingsTestAddress": "Test Address",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEmailSettingsSecureHelp": "Se vero, la connessione utilizzerà TLS durante la connessione al server. Se false, viene utilizzato TLS se il server supporta l'estensione STARTTLS. Nella maggior parte dei casi impostare questo valore su true se ci si connette alla porta 465. Per la porta 587 o 25 mantenerlo false. (da nodemailer.com/smtp/#authentication)",
"LabelEmailSettingsTestAddress": "Test Indirizzo",
"LabelEmbeddedCover": "Cover Integrata",
"LabelEnable": "Abilita",
"LabelEnd": "Fine",
"LabelEpisode": "Episodio",
"LabelEpisodeTitle": "Titolo Episodio",
"LabelEpisodeType": "Tipo Episodio",
"LabelExample": "Example",
"LabelExample": "Esempio",
"LabelExplicit": "Esplicito",
"LabelFeedURL": "Feed URL",
"LabelFile": "File",
@@ -252,13 +252,13 @@
"LabelFinished": "Finita",
"LabelFolder": "Cartella",
"LabelFolders": "Cartelle",
"LabelFontScale": "Font scale",
"LabelFormat": "Format",
"LabelFontScale": "Dimensione Font",
"LabelFormat": "Formato",
"LabelGenre": "Genere",
"LabelGenres": "Generi",
"LabelHardDeleteFile": "Elimina Definitivamente",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHasEbook": "Un ebook",
"LabelHasSupplementaryEbook": "Un ebook Supplementare",
"LabelHost": "Host",
"LabelHour": "Ora",
"LabelIcon": "Icona",
@@ -275,18 +275,18 @@
"LabelIntervalEveryDay": "Ogni Giorno",
"LabelIntervalEveryHour": "Ogni ora",
"LabelInvalidParts": "Parti Invalide",
"LabelInvert": "Invert",
"LabelInvert": "Inverti",
"LabelItem": "Oggetti",
"LabelLanguage": "Lingua",
"LabelLanguageDefaultServer": "Lingua di Default",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastBookAdded": "Ultimo Libro Aggiunto",
"LabelLastBookUpdated": "Ultimo Libro Aggiornato",
"LabelLastSeen": "Ultimi Visti",
"LabelLastTime": "Ultima Volta",
"LabelLastUpdate": "Ultimo Aggiornamento",
"LabelLayout": "Layout",
"LabelLayoutSinglePage": "Single page",
"LabelLayoutSplitPage": "Split page",
"LabelLayoutSinglePage": "Pagina Singola",
"LabelLayoutSplitPage": "DIvidi Pagina",
"LabelLess": "Poco",
"LabelLibrariesAccessibleToUser": "Librerie Accessibili agli Utenti",
"LabelLibrary": "Libreria",
@@ -308,7 +308,7 @@
"LabelMissing": "Altro",
"LabelMissingParts": "Parti rimantenti",
"LabelMore": "Molto",
"LabelMoreInfo": "More Info",
"LabelMoreInfo": "Più Info",
"LabelName": "Nome",
"LabelNarrator": "Narratore",
"LabelNarrators": "Narratori",
@@ -318,7 +318,7 @@
"LabelNewPassword": "Nuova Password",
"LabelNextBackupDate": "Data Prossimo Backup",
"LabelNextScheduledRun": "Data prossima esecuzione schedulata",
"LabelNoEpisodesSelected": "No episodes selected",
"LabelNoEpisodesSelected": "Nessun Episodio Selezionato",
"LabelNotes": "Note",
"LabelNotFinished": "Da Completare",
"LabelNotificationAppriseURL": "Apprendi URL(s)",
@@ -349,19 +349,19 @@
"LabelPlayMethod": "Metodo di riproduzione",
"LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts",
"LabelPodcastType": "Timo di Podcast",
"LabelPodcastType": "Tipo di Podcast",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Suffissi da ignorare (specificando maiuscole e minuscole)",
"LabelPreventIndexing": "Impedisci che il tuo feed venga indicizzato da iTunes e dalle directory dei podcast di Google",
"LabelPrimaryEbook": "Primary ebook",
"LabelPrimaryEbook": "Libri Principlae",
"LabelProgress": "Cominciati",
"LabelProvider": "Provider",
"LabelPubDate": "Data Pubblicazione",
"LabelPublisher": "Editore",
"LabelPublishYear": "Anno Pubblicazione",
"LabelRead": "Read",
"LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRead": "Leggi",
"LabelReadAgain": "Leggi Ancora",
"LabelReadEbookWithoutProgress": "Leggi l'ebook senza mantenere i progressi",
"LabelRecentlyAdded": "Aggiunti Recentemente",
"LabelRecentSeries": "Serie Recenti",
"LabelRecommended": "Raccomandati",
@@ -378,17 +378,17 @@
"LabelSearchTitle": "Cerca Titolo",
"LabelSearchTitleOrASIN": "Cerca titolo o ASIN",
"LabelSeason": "Stagione",
"LabelSelectAllEpisodes": "Select all episodes",
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSelectAllEpisodes": "Seleziona tutti gli Episodi",
"LabelSelectEpisodesShowing": "Episodi {0} selezionati ",
"LabelSendEbookToDevice": "Invia ebook a...",
"LabelSequence": "Sequenza",
"LabelSeries": "Serie",
"LabelSeriesName": "Nome Serie",
"LabelSeriesProgress": "Cominciato",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
"LabelSetEbookAsPrimary": "Immposta come Primario",
"LabelSetEbookAsSupplementary": "Imposta come Suplementare",
"LabelSettingsAudiobooksOnly": "Solo Audiolibri",
"LabelSettingsAudiobooksOnlyHelp": "L'abilitazione di questa impostazione ignorerà i file di ebook a meno che non si trovino all'interno di una cartella di audiolibri, nel qual caso verranno impostati come ebook supplementari",
"LabelSettingsBookshelfViewHelp": "Design con scaffali in legno",
"LabelSettingsChromecastSupport": "Supporto a Chromecast",
"LabelSettingsDateFormat": "Formato Data",
@@ -399,8 +399,8 @@
"LabelSettingsExperimentalFeaturesHelp": "Funzionalità in fase di sviluppo che potrebbero utilizzare i tuoi feedback e aiutare i test. Fare clic per aprire la discussione github.",
"LabelSettingsFindCovers": "Trova covers",
"LabelSettingsFindCoversHelp": "Se il tuo audiolibro non ha una copertina incorporata o un'immagine di copertina all'interno della cartella, questa funzione tenterà di trovare una copertina.<br>Nota: aumenta il tempo di scansione",
"LabelSettingsHideSingleBookSeries": "Hide single book series",
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
"LabelSettingsHideSingleBookSeries": "Nascondi una singola serie di libri",
"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",
@@ -451,9 +451,9 @@
"LabelTag": "Tag",
"LabelTags": "Tags",
"LabelTagsAccessibleToUser": "Tags permessi agli Utenti",
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
"LabelTagsNotAccessibleToUser": "Tags non accessibile agli Utenti",
"LabelTasks": "Processi in esecuzione",
"LabelTheme": "Theme",
"LabelTheme": "Tema",
"LabelThemeDark": "Dark",
"LabelThemeLight": "Light",
"LabelTimeBase": "Time Base",
@@ -476,7 +476,7 @@
"LabelTracksMultiTrack": "Multi-traccia",
"LabelTracksSingleTrack": "Traccia-singola",
"LabelType": "Tipo",
"LabelUnabridged": "Unabridged",
"LabelUnabridged": "Integrale",
"LabelUnknown": "Sconosciuto",
"LabelUpdateCover": "Aggiornamento Cover",
"LabelUpdateCoverHelp": "Consenti la sovrascrittura delle copertine esistenti per i libri selezionati quando viene trovata una corrispondenza",
@@ -515,19 +515,19 @@
"MessageChapterStartIsAfter": "L'inizio del capitolo è dopo la fine del tuo audiolibro",
"MessageCheckingCron": "Controllo cron...",
"MessageConfirmDeleteBackup": "Sei sicuro di voler eliminare il backup {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteFile": "Questo eliminerà il file dal tuo file system. Sei sicuro?",
"MessageConfirmDeleteLibrary": "Sei sicuro di voler eliminare definitivamente la libreria \"{0}\"?",
"MessageConfirmDeleteSession": "Sei sicuro di voler eliminare questa sessione?",
"MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?",
"MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?",
"MessageConfirmMarkAllEpisodesFinished": "Sei sicuro di voler contrassegnare tutti gli episodi come finiti?",
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
"MessageConfirmMarkSeriesFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come completati?",
"MessageConfirmMarkSeriesNotFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come non completati?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveAllChapters": "Sei sicuro di voler rimuovere tutti i capitoli?",
"MessageConfirmRemoveCollection": "Sei sicuro di voler rimuovere la Raccolta \"{0}\"?",
"MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?",
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
"MessageConfirmRemoveNarrator": "Sei sicuro di voler rimuovere il narratore \"{0}\"?",
"MessageConfirmRemovePlaylist": "Sei sicuro di voler rimuovere la tua playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Sei sicuro di voler rinominare il genere \"{0}\" in \"{1}\" per tutti gli oggetti?",
"MessageConfirmRenameGenreMergeNote": "Note: Questo genere esiste già quindi verra unito.",
@@ -535,7 +535,7 @@
"MessageConfirmRenameTag": "Sei sicuro di voler rinominare il tag \"{0}\" in \"{1}\" per tutti gli oggetti?",
"MessageConfirmRenameTagMergeNote": "Nota: Questo tag esiste già e verrà unito nel vecchio.",
"MessageConfirmRenameTagWarning": "Avvertimento! Esiste già un tag simile con un nome simile \"{0}\".",
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
"MessageConfirmSendEbookToDevice": "Sei sicuro di voler inviare {0} ebook \"{1}\" al Device \"{2}\"?",
"MessageDownloadingEpisode": "Download episodio in corso",
"MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto",
"MessageEmbedFinished": "Incorporamento finito!",
@@ -554,8 +554,8 @@
"MessageM4BFailed": "M4B Fallito!",
"MessageM4BFinished": "M4B Finito!",
"MessageMapChapterTitles": "Associa i titoli dei capitoli ai capitoli dell'audiolibro esistente senza modificare i timestamp",
"MessageMarkAllEpisodesFinished": "Mark all episodes finished",
"MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished",
"MessageMarkAllEpisodesFinished": "Segna tutti gli episodi come finiti",
"MessageMarkAllEpisodesNotFinished": "Segna tutti gli episodi come non finiti",
"MessageMarkAsFinished": "Segna come finito",
"MessageMarkAsNotFinished": "Segna come da completare",
"MessageMatchBooksDescription": "tenterà di abbinare i libri nella biblioteca con un libro del provider di ricerca selezionato e inserirà i dettagli vuoti e la copertina. Non sovrascrive i dettagli.",
@@ -691,8 +691,8 @@
"ToastRemoveItemFromCollectionSuccess": "Oggetto rimosso dalla Raccolta",
"ToastRSSFeedCloseFailed": "Errore chiusura RSS feed",
"ToastRSSFeedCloseSuccess": "RSS feed chiuso",
"ToastSendEbookToDeviceFailed": "Failed to Send Ebook to device",
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
"ToastSendEbookToDeviceFailed": "Impossibile inviare l'ebook al dispositivo",
"ToastSendEbookToDeviceSuccess": "Ebook inviato al dispositivo \"{0}\"",
"ToastSeriesUpdateFailed": "Aggiornaento Serie Fallito",
"ToastSeriesUpdateSuccess": "Serie Aggornate",
"ToastSessionDeleteFailed": "Errore eliminazione sessione",
@@ -702,4 +702,4 @@
"ToastSocketFailedToConnect": "Socket non riesce a connettersi",
"ToastUserDeleteFailed": "Errore eliminazione utente",
"ToastUserDeleteSuccess": "Utente eliminato"
}
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
"version": "2.3.0",
"version": "2.3.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
"version": "2.3.0",
"version": "2.3.2",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.3.0",
"version": "2.3.2",
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
"scripts": {

View File

@@ -23,7 +23,6 @@ class Database {
this.playlists = []
this.authors = []
this.series = []
this.feeds = []
this.serverSettings = null
this.notificationSettings = null
@@ -128,6 +127,18 @@ class Database {
const startTime = Date.now()
const settingsData = await this.models.setting.getOldSettings()
this.settings = settingsData.settings
this.emailSettings = settingsData.emailSettings
this.serverSettings = settingsData.serverSettings
this.notificationSettings = settingsData.notificationSettings
global.ServerSettings = this.serverSettings.toJSON()
// Version specific migrations
if (this.serverSettings.version === '2.3.0' && packageJson.version !== '2.3.0') {
await dbMigration.migrationPatch(this)
}
this.libraryItems = await this.models.libraryItem.getAllOldLibraryItems()
this.users = await this.models.user.getOldUsers()
this.libraries = await this.models.library.getAllOldLibraries()
@@ -135,14 +146,6 @@ class Database {
this.playlists = await this.models.playlist.getOldPlaylists()
this.authors = await this.models.author.getOldAuthors()
this.series = await this.models.series.getAllOldSeries()
this.feeds = await this.models.feed.getOldFeeds()
const settingsData = await this.models.setting.getOldSettings()
this.settings = settingsData.settings
this.emailSettings = settingsData.emailSettings
this.serverSettings = settingsData.serverSettings
this.notificationSettings = settingsData.notificationSettings
global.ServerSettings = this.serverSettings.toJSON()
Logger.info(`[Database] Db data loaded in ${Date.now() - startTime}ms`)
@@ -357,7 +360,11 @@ class Database {
}
getLibraryItem(libraryItemId) {
if (!this.sequelize) return false
if (!this.sequelize || !libraryItemId) return false
// Temp support for old library item ids from mobile
if (libraryItemId.startsWith('li_')) return this.libraryItems.find(li => li.oldLibraryItemId === libraryItemId)
return this.libraryItems.find(li => li.id === libraryItemId)
}
@@ -399,7 +406,6 @@ class Database {
async createFeed(oldFeed) {
if (!this.sequelize) return false
await this.models.feed.fullCreateFromOld(oldFeed)
this.feeds.push(oldFeed)
}
updateFeed(oldFeed) {
@@ -410,7 +416,6 @@ class Database {
async removeFeed(feedId) {
if (!this.sequelize) return false
await this.models.feed.removeById(feedId)
this.feeds = this.feeds.filter(f => f.id !== feedId)
}
updateSeries(oldSeries) {
@@ -438,7 +443,7 @@ class Database {
async createAuthor(oldAuthor) {
if (!this.sequelize) return false
await this.models.createFromOld(oldAuthor)
await this.models.author.createFromOld(oldAuthor)
this.authors.push(oldAuthor)
}

View File

@@ -26,14 +26,14 @@ class CollectionController {
})
}
findOne(req, res) {
async findOne(req, res) {
const includeEntities = (req.query.include || '').split(',')
const collectionExpanded = req.collection.toJSONExpanded(Database.libraryItems)
if (includeEntities.includes('rssfeed')) {
const feedData = this.rssFeedManager.findFeedForEntityId(collectionExpanded.id)
collectionExpanded.rssFeed = feedData ? feedData.toJSONMinified() : null
const feedData = await this.rssFeedManager.findFeedForEntityId(collectionExpanded.id)
collectionExpanded.rssFeed = feedData?.toJSONMinified() || null
}
res.json(collectionExpanded)

View File

@@ -179,7 +179,7 @@ class LibraryController {
// api/libraries/:id/items
// TODO: Optimize this method, items are iterated through several times but can be combined
getLibraryItems(req, res) {
async getLibraryItems(req, res) {
let libraryItems = req.libraryItems
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
@@ -203,7 +203,7 @@ class LibraryController {
// Step 1 - Filter the retrieved library items
let filterSeries = null
if (payload.filterBy) {
libraryItems = libraryHelpers.getFilteredLibraryItems(libraryItems, payload.filterBy, req.user, Database.feeds)
libraryItems = await libraryHelpers.getFilteredLibraryItems(libraryItems, payload.filterBy, req.user)
payload.total = libraryItems.length
// Determining if we are filtering titles by a series, and if so, which series
@@ -319,7 +319,7 @@ class LibraryController {
}
// Step 4 - Transform the items to pass to the client side
payload.results = libraryItems.map(li => {
payload.results = await Promise.all(libraryItems.map(async li => {
const json = payload.minified ? li.toJSONMinified() : li.toJSON()
if (li.collapsedSeries) {
@@ -356,7 +356,7 @@ class LibraryController {
} else {
// add rssFeed object if "include=rssfeed" was put in query string (only for non-collapsed series)
if (include.includes('rssfeed')) {
const feedData = this.rssFeedManager.findFeedForEntityId(json.id)
const feedData = await this.rssFeedManager.findFeedForEntityId(json.id)
json.rssFeed = feedData ? feedData.toJSONMinified() : null
}
@@ -372,7 +372,7 @@ class LibraryController {
}
return json
})
}))
res.json(payload)
}
@@ -449,11 +449,11 @@ class LibraryController {
// add rssFeed when "include=rssfeed" is in query string
if (include.includes('rssfeed')) {
series = series.map((se) => {
const feedData = this.rssFeedManager.findFeedForEntityId(se.id)
series = await Promise.all(series.map(async (se) => {
const feedData = await this.rssFeedManager.findFeedForEntityId(se.id)
se.rssFeed = feedData?.toJSONMinified() || null
return se
})
}))
}
payload.results = series
@@ -489,7 +489,7 @@ class LibraryController {
}
if (include.includes('rssfeed')) {
const feedObj = this.rssFeedManager.findFeedForEntityId(seriesJson.id)
const feedObj = await this.rssFeedManager.findFeedForEntityId(seriesJson.id)
seriesJson.rssFeed = feedObj?.toJSONMinified() || null
}
@@ -514,19 +514,21 @@ class LibraryController {
include: include.join(',')
}
let collections = Database.collections.filter(c => c.libraryId === req.library.id).map(c => {
let collections = await Promise.all(Database.collections.filter(c => c.libraryId === req.library.id).map(async c => {
const expanded = c.toJSONExpanded(libraryItems, payload.minified)
// If all books restricted to user in this collection then hide this collection
if (!expanded.books.length && c.books.length) return null
if (include.includes('rssfeed')) {
const feedData = this.rssFeedManager.findFeedForEntityId(c.id)
const feedData = await this.rssFeedManager.findFeedForEntityId(c.id)
expanded.rssFeed = feedData?.toJSONMinified() || null
}
return expanded
}).filter(c => !!c)
}))
collections = collections.filter(c => !!c)
payload.total = collections.length
@@ -595,7 +597,7 @@ class LibraryController {
const limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) || 10 : 10
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
const categories = libraryHelpers.buildPersonalizedShelves(this, req.user, req.libraryItems, req.library, limitPerShelf, include)
const categories = await libraryHelpers.buildPersonalizedShelves(this, req.user, req.libraryItems, req.library, limitPerShelf, include)
res.json(categories)
}

View File

@@ -13,7 +13,7 @@ class LibraryItemController {
constructor() { }
// Example expand with authors: api/items/:id?expanded=1&include=authors
findOne(req, res) {
async findOne(req, res) {
const includeEntities = (req.query.include || '').split(',')
if (req.query.expanded == 1) {
var item = req.libraryItem.toJSONExpanded()
@@ -25,8 +25,8 @@ class LibraryItemController {
}
if (includeEntities.includes('rssfeed')) {
const feedData = this.rssFeedManager.findFeedForEntityId(item.id)
item.rssFeed = feedData ? feedData.toJSONMinified() : null
const feedData = await this.rssFeedManager.findFeedForEntityId(item.id)
item.rssFeed = feedData?.toJSONMinified() || null
}
if (item.mediaType == 'book') {
@@ -100,6 +100,7 @@ class LibraryItemController {
async updateMedia(req, res) {
const libraryItem = req.libraryItem
const mediaPayload = req.body
// Item has cover and update is removing cover so purge it from cache
if (libraryItem.media.coverPath && (mediaPayload.coverPath === '' || mediaPayload.coverPath === null)) {
await this.cacheManager.purgeCoverCache(libraryItem.id)

View File

@@ -30,7 +30,7 @@ class RSSFeedController {
}
// Check that this slug is not being used for another feed (slug will also be the Feed id)
if (this.rssFeedManager.findFeedBySlug(options.slug)) {
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
return res.status(400).send('Slug already in use')
}
@@ -55,7 +55,7 @@ class RSSFeedController {
}
// Check that this slug is not being used for another feed (slug will also be the Feed id)
if (this.rssFeedManager.findFeedBySlug(options.slug)) {
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
return res.status(400).send('Slug already in use')
}
@@ -89,7 +89,7 @@ class RSSFeedController {
}
// Check that this slug is not being used for another feed (slug will also be the Feed id)
if (this.rssFeedManager.findFeedBySlug(options.slug)) {
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
return res.status(400).send('Slug already in use')
}

View File

@@ -35,7 +35,7 @@ class SeriesController {
}
if (include.includes('rssfeed')) {
const feedObj = this.rssFeedManager.findFeedForEntityId(seriesJson.id)
const feedObj = await this.rssFeedManager.findFeedForEntityId(seriesJson.id)
seriesJson.rssFeed = feedObj?.toJSONMinified() || null
}
@@ -70,7 +70,7 @@ class SeriesController {
* Filter out any library items not accessible to user
*/
const libraryItems = Database.libraryItems.filter(li => li.media.metadata.hasSeries?.(series.id))
const libraryItemsAccessible = libraryItems.filter(req.user.checkCanAccessLibraryItem)
const libraryItemsAccessible = libraryItems.filter(li => req.user.checkCanAccessLibraryItem(li))
if (libraryItems.length && !libraryItemsAccessible.length) {
Logger.warn(`[SeriesController] User attempted to access series "${series.id}" without access to any of the books`, req.user)
return res.sendStatus(403)

View File

@@ -94,7 +94,7 @@ class SessionController {
// POST: api/session/local
syncLocal(req, res) {
this.playbackSessionManager.syncLocalSessionRequest(req.user, req.body, res)
this.playbackSessionManager.syncLocalSessionRequest(req, res)
}
// POST: api/session/local-all

View File

@@ -1,3 +1,4 @@
const uuidv4 = require("uuid").v4
const Path = require('path')
const serverVersion = require('../../package.json').version
const Logger = require('../Logger')
@@ -19,6 +20,7 @@ class PlaybackSessionManager {
constructor() {
this.StreamsPath = Path.join(global.MetadataPath, 'streams')
this.oldPlaybackSessionMap = {} // TODO: Remove after updated mobile versions
this.sessions = []
}
@@ -74,13 +76,14 @@ class PlaybackSessionManager {
}
async syncLocalSessionsRequest(req, res) {
const deviceInfo = await this.getDeviceInfo(req)
const user = req.user
const sessions = req.body.sessions || []
const syncResults = []
for (const sessionJson of sessions) {
Logger.info(`[PlaybackSessionManager] Syncing local session "${sessionJson.displayTitle}" (${sessionJson.id})`)
const result = await this.syncLocalSession(user, sessionJson)
const result = await this.syncLocalSession(user, sessionJson, deviceInfo)
syncResults.push(result)
}
@@ -89,7 +92,7 @@ class PlaybackSessionManager {
})
}
async syncLocalSession(user, sessionJson) {
async syncLocalSession(user, sessionJson, deviceInfo) {
const libraryItem = Database.getLibraryItem(sessionJson.libraryItemId)
const episode = (sessionJson.episodeId && libraryItem && libraryItem.isPodcast) ? libraryItem.media.getEpisode(sessionJson.episodeId) : null
if (!libraryItem || (libraryItem.isPodcast && !episode)) {
@@ -101,10 +104,40 @@ class PlaybackSessionManager {
}
}
sessionJson.userId = user.id
sessionJson.serverVersion = serverVersion
// TODO: Temp update local playback session id to uuidv4 & library item/book/episode ids
if (sessionJson.id?.startsWith('play_local_')) {
if (!this.oldPlaybackSessionMap[sessionJson.id]) {
const newSessionId = uuidv4()
this.oldPlaybackSessionMap[sessionJson.id] = newSessionId
sessionJson.id = newSessionId
} else {
sessionJson.id = this.oldPlaybackSessionMap[sessionJson.id]
}
}
if (sessionJson.libraryItemId !== libraryItem.id) {
Logger.info(`[PlaybackSessionManager] Mapped old libraryItemId "${sessionJson.libraryItemId}" to ${libraryItem.id}`)
sessionJson.libraryItemId = libraryItem.id
sessionJson.bookId = episode ? null : libraryItem.media.id
}
if (!sessionJson.bookId && !episode) {
sessionJson.bookId = libraryItem.media.id
}
if (episode && sessionJson.episodeId !== episode.id) {
Logger.info(`[PlaybackSessionManager] Mapped old episodeId "${sessionJson.episodeId}" to ${episode.id}`)
sessionJson.episodeId = episode.id
}
if (sessionJson.libraryId !== libraryItem.libraryId) {
sessionJson.libraryId = libraryItem.libraryId
}
let session = await Database.getPlaybackSession(sessionJson.id)
if (!session) {
// New session from local
session = new PlaybackSession(sessionJson)
session.deviceInfo = deviceInfo
Logger.debug(`[PlaybackSessionManager] Inserting new session for "${session.displayTitle}" (${session.id})`)
await Database.createPlaybackSession(session)
} else {
@@ -152,8 +185,11 @@ class PlaybackSessionManager {
return result
}
async syncLocalSessionRequest(user, sessionJson, res) {
const result = await this.syncLocalSession(user, sessionJson)
async syncLocalSessionRequest(req, res) {
const deviceInfo = await this.getDeviceInfo(req)
const user = req.user
const sessionJson = req.body
const result = await this.syncLocalSession(user, sessionJson, deviceInfo)
if (result.error) {
res.status(500).send(result.error)
} else {

View File

@@ -35,8 +35,12 @@ class RssFeedManager {
return true
}
/**
* Validate all feeds and remove invalid
*/
async init() {
for (const feed of Database.feeds) {
const feeds = await Database.models.feed.getOldFeeds()
for (const feed of feeds) {
// Remove invalid feeds
if (!this.validateFeedEntity(feed)) {
await Database.removeFeed(feed.id)
@@ -44,20 +48,35 @@ class RssFeedManager {
}
}
/**
* Find open feed for an entity (e.g. collection id, playlist id, library item id)
* @param {string} entityId
* @returns {Promise<objects.Feed>} oldFeed
*/
findFeedForEntityId(entityId) {
return Database.feeds.find(feed => feed.entityId === entityId)
return Database.models.feed.findOneOld({ entityId })
}
/**
* Find open feed for a slug
* @param {string} slug
* @returns {Promise<objects.Feed>} oldFeed
*/
findFeedBySlug(slug) {
return Database.feeds.find(feed => feed.slug === slug)
return Database.models.feed.findOneOld({ slug })
}
/**
* Find open feed for a slug
* @param {string} slug
* @returns {Promise<objects.Feed>} oldFeed
*/
findFeed(id) {
return Database.feeds.find(feed => feed.id === id)
return Database.models.feed.findByPkOld(id)
}
async getFeed(req, res) {
const feed = this.findFeedBySlug(req.params.slug)
const feed = await this.findFeedBySlug(req.params.slug)
if (!feed) {
Logger.warn(`[RssFeedManager] Feed not found ${req.params.slug}`)
res.sendStatus(404)
@@ -134,8 +153,8 @@ class RssFeedManager {
res.send(xml)
}
getFeedItem(req, res) {
const feed = this.findFeedBySlug(req.params.slug)
async getFeedItem(req, res) {
const feed = await this.findFeedBySlug(req.params.slug)
if (!feed) {
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
res.sendStatus(404)
@@ -150,8 +169,8 @@ class RssFeedManager {
res.sendFile(episodePath)
}
getFeedCover(req, res) {
const feed = this.findFeedBySlug(req.params.slug)
async getFeedCover(req, res) {
const feed = await this.findFeedBySlug(req.params.slug)
if (!feed) {
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
res.sendStatus(404)
@@ -225,7 +244,7 @@ class RssFeedManager {
}
async closeRssFeed(req, res) {
const feed = this.findFeed(req.params.id)
const feed = await this.findFeed(req.params.id)
if (!feed) {
Logger.error(`[RssFeedManager] RSS feed not found with id "${req.params.id}"`)
return res.sendStatus(404)
@@ -234,8 +253,8 @@ class RssFeedManager {
res.sendStatus(200)
}
closeFeedForEntityId(entityId) {
const feed = this.findFeedForEntityId(entityId)
async closeFeedForEntityId(entityId) {
const feed = await this.findFeedForEntityId(entityId)
if (!feed) return
return this.handleCloseFeed(feed)
}

View File

@@ -56,6 +56,53 @@ module.exports = (sequelize) => {
})
}
/**
* Find all library item ids that have an open feed (used in library filter)
* @returns {Promise<Array<String>>} array of library item ids
*/
static async findAllLibraryItemIds() {
const feeds = await this.findAll({
attributes: ['entityId'],
where: {
entityType: 'libraryItem'
}
})
return feeds.map(f => f.entityId).filter(f => f) || []
}
/**
* Find feed where and return oldFeed
* @param {object} where sequelize where object
* @returns {Promise<objects.Feed>} oldFeed
*/
static async findOneOld(where) {
if (!where) return null
const feedExpanded = await this.findOne({
where,
include: {
model: sequelize.models.feedEpisode
}
})
if (!feedExpanded) return null
return this.getOldFeed(feedExpanded)
}
/**
* Find feed and return oldFeed
* @param {string} id
* @returns {Promise<objects.Feed>} oldFeed
*/
static async findByPkOld(id) {
if (!id) return null
const feedExpanded = await this.findByPk(id, {
include: {
model: sequelize.models.feedEpisode
}
})
if (!feedExpanded) return null
return this.getOldFeed(feedExpanded)
}
static async fullCreateFromOld(oldFeed) {
const feedObj = this.getFromOld(oldFeed)
const newFeed = await this.create(feedObj)

View File

@@ -6,7 +6,8 @@ module.exports = (sequelize) => {
class Library extends Model {
static async getAllOldLibraries() {
const libraries = await this.findAll({
include: sequelize.models.libraryFolder
include: sequelize.models.libraryFolder,
order: [['displayOrder', 'ASC']]
})
return libraries.map(lib => this.getOldLibrary(lib))
}
@@ -22,6 +23,7 @@ module.exports = (sequelize) => {
})
return new oldLibrary({
id: libraryExpanded.id,
oldLibraryId: libraryExpanded.extraData?.oldLibraryId || null,
name: libraryExpanded.name,
folders,
displayOrder: libraryExpanded.displayOrder,
@@ -92,6 +94,10 @@ module.exports = (sequelize) => {
}
static getFromOld(oldLibrary) {
const extraData = {}
if (oldLibrary.oldLibraryId) {
extraData.oldLibraryId = oldLibrary.oldLibraryId
}
return {
id: oldLibrary.id,
name: oldLibrary.name,
@@ -101,7 +107,8 @@ module.exports = (sequelize) => {
provider: oldLibrary.provider,
settings: oldLibrary.settings?.toJSON() || {},
createdAt: oldLibrary.createdAt,
updatedAt: oldLibrary.lastUpdate
updatedAt: oldLibrary.lastUpdate,
extraData
}
}
@@ -127,7 +134,8 @@ module.exports = (sequelize) => {
provider: DataTypes.STRING,
lastScan: DataTypes.DATE,
lastScanVersion: DataTypes.STRING,
settings: DataTypes.JSON
settings: DataTypes.JSON,
extraData: DataTypes.JSON
}, {
sequelize,
modelName: 'library'

View File

@@ -49,6 +49,7 @@ module.exports = (sequelize) => {
return new oldLibraryItem({
id: libraryItemExpanded.id,
ino: libraryItemExpanded.ino,
oldLibraryItemId: libraryItemExpanded.extraData?.oldLibraryItemId || null,
libraryId: libraryItemExpanded.libraryId,
folderId: libraryItemExpanded.libraryFolderId,
path: libraryItemExpanded.path,
@@ -118,7 +119,7 @@ module.exports = (sequelize) => {
{
model: sequelize.models.series,
through: {
attributes: ['sequence']
attributes: ['id', 'sequence']
}
}
]
@@ -219,7 +220,7 @@ module.exports = (sequelize) => {
hasUpdates = true
} else if (existingSeriesMatch.bookSeries.sequence !== updatedSeries.sequence) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" sequence was updated from "${existingSeriesMatch.bookSeries.sequence}" to "${updatedSeries.sequence}"`)
await existingSeriesMatch.bookSeries.update({ sequence: updatedSeries.sequence })
await existingSeriesMatch.bookSeries.update({ id: updatedSeries.id, sequence: updatedSeries.sequence })
hasUpdates = true
}
}
@@ -261,6 +262,10 @@ module.exports = (sequelize) => {
}
static getFromOld(oldLibraryItem) {
const extraData = {}
if (oldLibraryItem.oldLibraryItemId) {
extraData.oldLibraryItemId = oldLibraryItem.oldLibraryItemId
}
return {
id: oldLibraryItem.id,
ino: oldLibraryItem.ino,
@@ -278,7 +283,8 @@ module.exports = (sequelize) => {
lastScanVersion: oldLibraryItem.scanVersion,
libraryId: oldLibraryItem.libraryId,
libraryFolderId: oldLibraryItem.folderId,
libraryFiles: oldLibraryItem.libraryFiles?.map(lf => lf.toJSON()) || []
libraryFiles: oldLibraryItem.libraryFiles?.map(lf => lf.toJSON()) || [],
extraData
}
}
@@ -317,7 +323,8 @@ module.exports = (sequelize) => {
birthtime: DataTypes.DATE(6),
lastScan: DataTypes.DATE,
lastScanVersion: DataTypes.STRING,
libraryFiles: DataTypes.JSON
libraryFiles: DataTypes.JSON,
extraData: DataTypes.JSON
}, {
sequelize,
modelName: 'libraryItem'

View File

@@ -15,6 +15,7 @@ module.exports = (sequelize) => {
libraryItemId: libraryItemId || null,
podcastId: this.podcastId,
id: this.id,
oldEpisodeId: this.extraData?.oldEpisodeId || null,
index: this.index,
season: this.season,
episode: this.episode,
@@ -38,6 +39,10 @@ module.exports = (sequelize) => {
}
static getFromOld(oldEpisode) {
const extraData = {}
if (oldEpisode.oldEpisodeId) {
extraData.oldEpisodeId = oldEpisode.oldEpisodeId
}
return {
id: oldEpisode.id,
index: oldEpisode.index,
@@ -54,7 +59,8 @@ module.exports = (sequelize) => {
publishedAt: oldEpisode.publishedAt,
podcastId: oldEpisode.podcastId,
audioFile: oldEpisode.audioFile?.toJSON() || null,
chapters: oldEpisode.chapters
chapters: oldEpisode.chapters,
extraData
}
}
}
@@ -79,7 +85,8 @@ module.exports = (sequelize) => {
publishedAt: DataTypes.DATE,
audioFile: DataTypes.JSON,
chapters: DataTypes.JSON
chapters: DataTypes.JSON,
extraData: DataTypes.JSON
}, {
sequelize,
modelName: 'podcastEpisode'

View File

@@ -6,6 +6,7 @@ const { filePathToPOSIX } = require('../utils/fileUtils')
class Library {
constructor(library = null) {
this.id = null
this.oldLibraryId = null // TODO: Temp
this.name = null
this.folders = []
this.displayOrder = 1
@@ -39,6 +40,7 @@ class Library {
construct(library) {
this.id = library.id
this.oldLibraryId = library.oldLibraryId
this.name = library.name
this.folders = (library.folders || []).map(f => new Folder(f))
this.displayOrder = library.displayOrder || 1
@@ -74,6 +76,7 @@ class Library {
toJSON() {
return {
id: this.id,
oldLibraryId: this.oldLibraryId,
name: this.name,
folders: (this.folders || []).map(f => f.toJSON()),
displayOrder: this.displayOrder,

View File

@@ -16,6 +16,7 @@ class LibraryItem {
constructor(libraryItem = null) {
this.id = null
this.ino = null // Inode
this.oldLibraryItemId = null
this.libraryId = null
this.folderId = null
@@ -52,6 +53,7 @@ class LibraryItem {
construct(libraryItem) {
this.id = libraryItem.id
this.ino = libraryItem.ino || null
this.oldLibraryItemId = libraryItem.oldLibraryItemId
this.libraryId = libraryItem.libraryId
this.folderId = libraryItem.folderId
this.path = libraryItem.path
@@ -97,6 +99,7 @@ class LibraryItem {
return {
id: this.id,
ino: this.ino,
oldLibraryItemId: this.oldLibraryItemId,
libraryId: this.libraryId,
folderId: this.folderId,
path: this.path,
@@ -121,6 +124,7 @@ class LibraryItem {
return {
id: this.id,
ino: this.ino,
oldLibraryItemId: this.oldLibraryItemId,
libraryId: this.libraryId,
folderId: this.folderId,
path: this.path,
@@ -145,6 +149,7 @@ class LibraryItem {
return {
id: this.id,
ino: this.ino,
oldLibraryItemId: this.oldLibraryItemId,
libraryId: this.libraryId,
folderId: this.folderId,
path: this.path,

View File

@@ -115,13 +115,24 @@ class PlaybackSession {
this.userId = session.userId
this.libraryId = session.libraryId || null
this.libraryItemId = session.libraryItemId
this.bookId = session.bookId
this.bookId = session.bookId || null
this.episodeId = session.episodeId
this.mediaType = session.mediaType
this.duration = session.duration
this.playMethod = session.playMethod
this.mediaPlayer = session.mediaPlayer || null
// Temp do not store old IDs
if (this.libraryId?.startsWith('lib_')) {
this.libraryId = null
}
if (this.libraryItemId?.startsWith('li_') || this.libraryItemId?.startsWith('local_')) {
this.libraryItemId = null
}
if (this.episodeId?.startsWith('ep_') || this.episodeId?.startsWith('local_')) {
this.episodeId = null
}
if (session.deviceInfo instanceof DeviceInfo) {
this.deviceInfo = new DeviceInfo(session.deviceInfo.toJSON())
} else {

View File

@@ -10,6 +10,7 @@ class PodcastEpisode {
this.libraryItemId = null
this.podcastId = null
this.id = null
this.oldEpisodeId = null
this.index = null
this.season = null
@@ -36,6 +37,7 @@ class PodcastEpisode {
this.libraryItemId = episode.libraryItemId
this.podcastId = episode.podcastId
this.id = episode.id
this.oldEpisodeId = episode.oldEpisodeId
this.index = episode.index
this.season = episode.season
this.episode = episode.episode
@@ -59,6 +61,7 @@ class PodcastEpisode {
libraryItemId: this.libraryItemId,
podcastId: this.podcastId,
id: this.id,
oldEpisodeId: this.oldEpisodeId,
index: this.index,
season: this.season,
episode: this.episode,
@@ -81,6 +84,7 @@ class PodcastEpisode {
libraryItemId: this.libraryItemId,
podcastId: this.podcastId,
id: this.id,
oldEpisodeId: this.oldEpisodeId,
index: this.index,
season: this.season,
episode: this.episode,

View File

@@ -335,6 +335,11 @@ class Podcast {
}
getEpisode(episodeId) {
if (!episodeId) return null
// Support old episode ids for mobile downloads
if (episodeId.startsWith('ep_')) return this.episodes.find(ep => ep.oldEpisodeId == episodeId)
return this.episodes.find(ep => ep.id == episodeId)
}

View File

@@ -11,7 +11,7 @@ module.exports = {
return Buffer.from(decodeURIComponent(text), 'base64').toString()
},
getFilteredLibraryItems(libraryItems, filterBy, user, feedsArray) {
async getFilteredLibraryItems(libraryItems, filterBy, user) {
let filtered = libraryItems
const searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators', 'publishers', 'missing', 'languages', 'tracks', 'ebooks']
@@ -71,7 +71,9 @@ module.exports = {
} else if (filterBy === 'issues') {
filtered = filtered.filter(li => li.hasIssues)
} else if (filterBy === 'feed-open') {
filtered = filtered.filter(li => feedsArray.some(feed => feed.entityId === li.id))
const libraryItemIdsWithFeed = await Database.models.feed.findAllLibraryItemIds()
filtered = filtered.filter(li => libraryItemIdsWithFeed.includes(li.id))
// filtered = filtered.filter(li => feedsArray.some(feed => feed.entityId === li.id))
} else if (filterBy === 'abridged') {
filtered = filtered.filter(li => !!li.media.metadata?.abridged)
} else if (filterBy === 'ebook') {
@@ -356,7 +358,7 @@ module.exports = {
return filteredLibraryItems
},
buildPersonalizedShelves(ctx, user, libraryItems, library, maxEntitiesPerShelf, include) {
async buildPersonalizedShelves(ctx, user, libraryItems, library, maxEntitiesPerShelf, include) {
const mediaType = library.mediaType
const isPodcastLibrary = mediaType === 'podcast'
const includeRssFeed = include.includes('rssfeed')
@@ -846,27 +848,30 @@ module.exports = {
const categoriesWithItems = Object.values(categoryMap).filter(cat => cat.items.length)
return categoriesWithItems.map(cat => {
const shelf = shelves.find(s => s.id === cat.id)
shelf.entities = cat.items
const finalShelves = []
for (const categoryWithItems of categoriesWithItems) {
const shelf = shelves.find(s => s.id === categoryWithItems.id)
shelf.entities = categoryWithItems.items
// Add rssFeed to entities if query string "include=rssfeed" was on request
if (includeRssFeed) {
if (shelf.type === 'book' || shelf.type === 'podcast') {
shelf.entities = shelf.entities.map((item) => {
item.rssFeed = ctx.rssFeedManager.findFeedForEntityId(item.id)?.toJSONMinified() || null
shelf.entities = await Promise.all(shelf.entities.map(async (item) => {
const feed = await ctx.rssFeedManager.findFeedForEntityId(item.id)
item.rssFeed = feed?.toJSONMinified() || null
return item
})
}))
} else if (shelf.type === 'series') {
shelf.entities = shelf.entities.map((series) => {
series.rssFeed = ctx.rssFeedManager.findFeedForEntityId(series.id)?.toJSONMinified() || null
shelf.entities = await Promise.all(shelf.entities.map(async (series) => {
const feed = await ctx.rssFeedManager.findFeedForEntityId(series.id)
series.rssFeed = feed?.toJSONMinified() || null
return series
})
}))
}
}
return shelf
})
finalShelves.push(shelf)
}
return finalShelves
},
groupMusicLibraryItemsIntoAlbums(libraryItems) {

View File

@@ -1,3 +1,4 @@
const { DataTypes, QueryInterface } = require('sequelize')
const Path = require('path')
const uuidv4 = require("uuid").v4
const Logger = require('../../Logger')
@@ -17,29 +18,6 @@ const oldDbIdMap = {
podcasts: {}, // key is library item id
devices: {} // key is a json stringify of the old DeviceInfo data OR deviceId if it exists
}
const newRecords = {
user: [],
library: [],
libraryFolder: [],
author: [],
book: [],
podcast: [],
libraryItem: [],
bookAuthor: [],
series: [],
bookSeries: [],
podcastEpisode: [],
mediaProgress: [],
device: [],
playbackSession: [],
collection: [],
collectionBook: [],
playlist: [],
playlistMediaItem: [],
feed: [],
feedEpisode: [],
setting: []
}
function getDeviceInfoString(deviceInfo, UserId) {
if (!deviceInfo) return null
@@ -60,9 +38,22 @@ function getDeviceInfoString(deviceInfo, UserId) {
return 'temp-' + Buffer.from(keys.join('-'), 'utf-8').toString('base64')
}
/**
* Migrate oldLibraryItem.media to Book model
* Migrate BookSeries and BookAuthor
* @param {objects.LibraryItem} oldLibraryItem
* @param {object} LibraryItem models.LibraryItem object
* @returns {object} { book: object, bookSeries: [], bookAuthor: [] }
*/
function migrateBook(oldLibraryItem, LibraryItem) {
const oldBook = oldLibraryItem.media
const _newRecords = {
book: null,
bookSeries: [],
bookAuthor: []
}
//
// Migrate Book
//
@@ -91,17 +82,23 @@ function migrateBook(oldLibraryItem, LibraryItem) {
tags: oldBook.tags,
genres: oldBook.metadata.genres
}
newRecords.book.push(Book)
_newRecords.book = Book
oldDbIdMap.books[oldLibraryItem.id] = Book.id
//
// Migrate BookAuthors
//
const bookAuthorsInserted = []
for (const oldBookAuthor of oldBook.metadata.authors) {
if (oldDbIdMap.authors[LibraryItem.libraryId][oldBookAuthor.id]) {
newRecords.bookAuthor.push({
const authorId = oldDbIdMap.authors[LibraryItem.libraryId][oldBookAuthor.id]
if (bookAuthorsInserted.includes(authorId)) continue // Duplicate prevention
bookAuthorsInserted.push(authorId)
_newRecords.bookAuthor.push({
id: uuidv4(),
authorId: oldDbIdMap.authors[LibraryItem.libraryId][oldBookAuthor.id],
authorId,
bookId: Book.id
})
} else {
@@ -112,22 +109,40 @@ function migrateBook(oldLibraryItem, LibraryItem) {
//
// Migrate BookSeries
//
const bookSeriesInserted = []
for (const oldBookSeries of oldBook.metadata.series) {
if (oldDbIdMap.series[LibraryItem.libraryId][oldBookSeries.id]) {
const BookSeries = {
const seriesId = oldDbIdMap.series[LibraryItem.libraryId][oldBookSeries.id]
if (bookSeriesInserted.includes(seriesId)) continue // Duplicate prevention
bookSeriesInserted.push(seriesId)
_newRecords.bookSeries.push({
id: uuidv4(),
sequence: oldBookSeries.sequence,
seriesId: oldDbIdMap.series[LibraryItem.libraryId][oldBookSeries.id],
bookId: Book.id
}
newRecords.bookSeries.push(BookSeries)
})
} else {
Logger.warn(`[dbMigration] migrateBook: Series not found "${oldBookSeries.name}"`)
}
}
return _newRecords
}
/**
* Migrate oldLibraryItem.media to Podcast model
* Migrate PodcastEpisode
* @param {objects.LibraryItem} oldLibraryItem
* @param {object} LibraryItem models.LibraryItem object
* @returns {object} { podcast: object, podcastEpisode: [] }
*/
function migratePodcast(oldLibraryItem, LibraryItem) {
const _newRecords = {
podcast: null,
podcastEpisode: []
}
const oldPodcast = oldLibraryItem.media
const oldPodcastMetadata = oldPodcast.metadata
@@ -161,7 +176,7 @@ function migratePodcast(oldLibraryItem, LibraryItem) {
tags: oldPodcast.tags,
genres: oldPodcastMetadata.genres
}
newRecords.podcast.push(Podcast)
_newRecords.podcast = Podcast
oldDbIdMap.podcasts[oldLibraryItem.id] = Podcast.id
//
@@ -173,6 +188,7 @@ function migratePodcast(oldLibraryItem, LibraryItem) {
const PodcastEpisode = {
id: uuidv4(),
oldEpisodeId: oldEpisode.id,
index: oldEpisode.index,
season: oldEpisode.season || null,
episode: oldEpisode.episode || null,
@@ -191,12 +207,26 @@ function migratePodcast(oldLibraryItem, LibraryItem) {
audioFile: oldEpisode.audioFile,
chapters: oldEpisode.chapters || []
}
newRecords.podcastEpisode.push(PodcastEpisode)
_newRecords.podcastEpisode.push(PodcastEpisode)
oldDbIdMap.podcastEpisodes[oldEpisode.id] = PodcastEpisode.id
}
return _newRecords
}
/**
* Migrate libraryItems to LibraryItem, Book, Podcast models
* @param {Array<objects.LibraryItem>} oldLibraryItems
* @returns {object} { libraryItem: [], book: [], podcast: [], podcastEpisode: [], bookSeries: [], bookAuthor: [] }
*/
function migrateLibraryItems(oldLibraryItems) {
const _newRecords = {
book: [],
podcast: [],
podcastEpisode: [],
bookSeries: [],
bookAuthor: [],
libraryItem: []
}
for (const oldLibraryItem of oldLibraryItems) {
const libraryFolderId = oldDbIdMap.libraryFolders[oldLibraryItem.folderId]
if (!libraryFolderId) {
@@ -218,6 +248,7 @@ function migrateLibraryItems(oldLibraryItems) {
//
const LibraryItem = {
id: uuidv4(),
oldLibraryItemId: oldLibraryItem.id,
ino: oldLibraryItem.ino,
path: oldLibraryItem.path,
relPath: oldLibraryItem.relPath,
@@ -241,22 +272,39 @@ function migrateLibraryItems(oldLibraryItems) {
})
}
oldDbIdMap.libraryItems[oldLibraryItem.id] = LibraryItem.id
newRecords.libraryItem.push(LibraryItem)
_newRecords.libraryItem.push(LibraryItem)
//
// Migrate Book/Podcast
//
if (oldLibraryItem.mediaType === 'book') {
migrateBook(oldLibraryItem, LibraryItem)
const bookRecords = migrateBook(oldLibraryItem, LibraryItem)
_newRecords.book.push(bookRecords.book)
_newRecords.bookAuthor.push(...bookRecords.bookAuthor)
_newRecords.bookSeries.push(...bookRecords.bookSeries)
LibraryItem.mediaId = oldDbIdMap.books[oldLibraryItem.id]
} else if (oldLibraryItem.mediaType === 'podcast') {
migratePodcast(oldLibraryItem, LibraryItem)
const podcastRecords = migratePodcast(oldLibraryItem, LibraryItem)
_newRecords.podcast.push(podcastRecords.podcast)
_newRecords.podcastEpisode.push(...podcastRecords.podcastEpisode)
LibraryItem.mediaId = oldDbIdMap.podcasts[oldLibraryItem.id]
}
}
return _newRecords
}
/**
* Migrate Library and LibraryFolder
* @param {Array<objects.Library>} oldLibraries
* @returns {object} { library: [], libraryFolder: [] }
*/
function migrateLibraries(oldLibraries) {
const _newRecords = {
library: [],
libraryFolder: []
}
for (const oldLibrary of oldLibraries) {
if (!['book', 'podcast'].includes(oldLibrary.mediaType)) {
Logger.error(`[dbMigration] migrateLibraries: Not migrating library with mediaType=${oldLibrary.mediaType}`)
@@ -268,6 +316,7 @@ function migrateLibraries(oldLibraries) {
//
const Library = {
id: uuidv4(),
oldLibraryId: oldLibrary.id,
name: oldLibrary.name,
displayOrder: oldLibrary.displayOrder,
icon: oldLibrary.icon || null,
@@ -278,7 +327,7 @@ function migrateLibraries(oldLibraries) {
updatedAt: oldLibrary.lastUpdate
}
oldDbIdMap.libraries[oldLibrary.id] = Library.id
newRecords.library.push(Library)
_newRecords.library.push(Library)
//
// Migrate LibraryFolders
@@ -292,12 +341,21 @@ function migrateLibraries(oldLibraries) {
libraryId: Library.id
}
oldDbIdMap.libraryFolders[oldFolder.id] = LibraryFolder.id
newRecords.libraryFolder.push(LibraryFolder)
_newRecords.libraryFolder.push(LibraryFolder)
}
}
return _newRecords
}
/**
* Migrate Author
* Previously Authors were shared between libraries, this will ensure every author has one library
* @param {Array<objects.entities.Author>} oldAuthors
* @param {Array<objects.LibraryItem>} oldLibraryItems
* @returns {Array<object>} Array of Author model objs
*/
function migrateAuthors(oldAuthors, oldLibraryItems) {
const _newRecords = []
for (const oldAuthor of oldAuthors) {
// Get an array of NEW library ids that have this author
const librariesWithThisAuthor = [...new Set(oldLibraryItems.map(li => {
@@ -325,12 +383,21 @@ function migrateAuthors(oldAuthors, oldLibraryItems) {
}
if (!oldDbIdMap.authors[libraryId]) oldDbIdMap.authors[libraryId] = {}
oldDbIdMap.authors[libraryId][oldAuthor.id] = Author.id
newRecords.author.push(Author)
_newRecords.push(Author)
}
}
return _newRecords
}
/**
* Migrate Series
* Previously Series were shared between libraries, this will ensure every series has one library
* @param {Array<objects.entities.Series>} oldSerieses
* @param {Array<objects.LibraryItem>} oldLibraryItems
* @returns {Array<object>} Array of Series model objs
*/
function migrateSeries(oldSerieses, oldLibraryItems) {
const _newRecords = []
// Originaly series were shared between libraries if they had the same name
// Series will be separate between libraries
for (const oldSeries of oldSerieses) {
@@ -355,16 +422,47 @@ function migrateSeries(oldSerieses, oldLibraryItems) {
}
if (!oldDbIdMap.series[libraryId]) oldDbIdMap.series[libraryId] = {}
oldDbIdMap.series[libraryId][oldSeries.id] = Series.id
newRecords.series.push(Series)
_newRecords.push(Series)
}
}
return _newRecords
}
/**
* Migrate users to User and MediaProgress models
* @param {Array<objects.User>} oldUsers
* @returns {object} { user: [], mediaProgress: [] }
*/
function migrateUsers(oldUsers) {
const _newRecords = {
user: [],
mediaProgress: []
}
for (const oldUser of oldUsers) {
//
// Migrate User
//
// Convert old library ids to new ids
const librariesAccessible = (oldUser.librariesAccessible || []).map((lid) => oldDbIdMap.libraries[lid]).filter(li => li)
// Convert old library item ids to new ids
const bookmarks = (oldUser.bookmarks || []).map(bm => {
bm.libraryItemId = oldDbIdMap.libraryItems[bm.libraryItemId]
return bm
}).filter(bm => bm.libraryItemId)
// Convert old series ids to new
const seriesHideFromContinueListening = (oldUser.seriesHideFromContinueListening || []).map(oldSeriesId => {
// Series were split to be per library
// This will use the first series it finds
for (const libraryId in oldDbIdMap.series) {
if (oldDbIdMap.series[libraryId][oldSeriesId]) {
return oldDbIdMap.series[libraryId][oldSeriesId]
}
}
return null
}).filter(se => se)
const User = {
id: uuidv4(),
username: oldUser.username,
@@ -374,19 +472,19 @@ function migrateUsers(oldUsers) {
isActive: !!oldUser.isActive,
lastSeen: oldUser.lastSeen || null,
extraData: {
seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [],
seriesHideFromContinueListening,
oldUserId: oldUser.id // Used to keep old tokens
},
createdAt: oldUser.createdAt || Date.now(),
permissions: {
...oldUser.permissions,
librariesAccessible: oldUser.librariesAccessible || [],
librariesAccessible,
itemTagsSelected: oldUser.itemTagsSelected || []
},
bookmarks: oldUser.bookmarks
bookmarks
}
oldDbIdMap.users[oldUser.id] = User.id
newRecords.user.push(User)
_newRecords.user.push(User)
//
// Migrate MediaProgress
@@ -425,12 +523,23 @@ function migrateUsers(oldUsers) {
progress: oldMediaProgress.progress
}
}
newRecords.mediaProgress.push(MediaProgress)
_newRecords.mediaProgress.push(MediaProgress)
}
}
return _newRecords
}
/**
* Migrate playbackSessions to PlaybackSession and Device models
* @param {Array<objects.PlaybackSession>} oldSessions
* @returns {object} { playbackSession: [], device: [] }
*/
function migrateSessions(oldSessions) {
const _newRecords = {
device: [],
playbackSession: []
}
for (const oldSession of oldSessions) {
const userId = oldDbIdMap.users[oldSession.userId]
if (!userId) {
@@ -495,12 +604,12 @@ function migrateSessions(oldSessions) {
userId,
extraData
}
newRecords.device.push(Device)
deviceId = Device.id
_newRecords.device.push(Device)
oldDbIdMap.devices[deviceDeviceId] = Device.id
}
}
//
// Migrate PlaybackSession
//
@@ -528,7 +637,7 @@ function migrateSessions(oldSessions) {
serverVersion: oldSession.deviceInfo?.serverVersion || null,
createdAt: oldSession.startedAt,
updatedAt: oldSession.updatedAt,
userId, // Can be null
userId,
deviceId,
timeListening: oldSession.timeListening,
coverPath: oldSession.coverPath,
@@ -539,11 +648,21 @@ function migrateSessions(oldSessions) {
libraryItemId: oldDbIdMap.libraryItems[oldSession.libraryItemId]
}
}
newRecords.playbackSession.push(PlaybackSession)
_newRecords.playbackSession.push(PlaybackSession)
}
return _newRecords
}
/**
* Migrate collections to Collection & CollectionBook
* @param {Array<objects.Collection>} oldCollections
* @returns {object} { collection: [], collectionBook: [] }
*/
function migrateCollections(oldCollections) {
const _newRecords = {
collection: [],
collectionBook: []
}
for (const oldCollection of oldCollections) {
const libraryId = oldDbIdMap.libraries[oldCollection.libraryId]
if (!libraryId) {
@@ -566,7 +685,7 @@ function migrateCollections(oldCollections) {
libraryId
}
oldDbIdMap.collections[oldCollection.id] = Collection.id
newRecords.collection.push(Collection)
_newRecords.collection.push(Collection)
let order = 1
BookIds.forEach((bookId) => {
@@ -577,12 +696,22 @@ function migrateCollections(oldCollections) {
collectionId: Collection.id,
order: order++
}
newRecords.collectionBook.push(CollectionBook)
_newRecords.collectionBook.push(CollectionBook)
})
}
return _newRecords
}
/**
* Migrate playlists to Playlist and PlaylistMediaItem
* @param {Array<objects.Playlist>} oldPlaylists
* @returns {object} { playlist: [], playlistMediaItem: [] }
*/
function migratePlaylists(oldPlaylists) {
const _newRecords = {
playlist: [],
playlistMediaItem: []
}
for (const oldPlaylist of oldPlaylists) {
const libraryId = oldDbIdMap.libraries[oldPlaylist.libraryId]
if (!libraryId) {
@@ -622,7 +751,7 @@ function migratePlaylists(oldPlaylists) {
userId,
libraryId
}
newRecords.playlist.push(Playlist)
_newRecords.playlist.push(Playlist)
let order = 1
MediaItemIds.forEach((mediaItemId) => {
@@ -634,12 +763,22 @@ function migratePlaylists(oldPlaylists) {
playlistId: Playlist.id,
order: order++
}
newRecords.playlistMediaItem.push(PlaylistMediaItem)
_newRecords.playlistMediaItem.push(PlaylistMediaItem)
})
}
return _newRecords
}
/**
* Migrate feeds to Feed and FeedEpisode models
* @param {Array<objects.Feed>} oldFeeds
* @returns {object} { feed: [], feedEpisode: [] }
*/
function migrateFeeds(oldFeeds) {
const _newRecords = {
feed: [],
feedEpisode: []
}
for (const oldFeed of oldFeeds) {
if (!oldFeed.episodes?.length) {
continue
@@ -698,7 +837,7 @@ function migrateFeeds(oldFeeds) {
updatedAt: oldFeed.updatedAt,
userId
}
newRecords.feed.push(Feed)
_newRecords.feed.push(Feed)
//
// Migrate FeedEpisodes
@@ -724,65 +863,227 @@ function migrateFeeds(oldFeeds) {
updatedAt: oldFeed.updatedAt,
feedId: Feed.id
}
newRecords.feedEpisode.push(FeedEpisode)
_newRecords.feedEpisode.push(FeedEpisode)
}
}
return _newRecords
}
/**
* Migrate ServerSettings, NotificationSettings and EmailSettings to Setting model
* @param {Array<objects.settings.*>} oldSettings
* @returns {Array<object>} Array of Setting model objs
*/
function migrateSettings(oldSettings) {
const _newRecords = []
const serverSettings = oldSettings.find(s => s.id === 'server-settings')
const notificationSettings = oldSettings.find(s => s.id === 'notification-settings')
const emailSettings = oldSettings.find(s => s.id === 'email-settings')
if (serverSettings) {
newRecords.setting.push({
_newRecords.push({
key: 'server-settings',
value: serverSettings
})
}
if (notificationSettings) {
newRecords.setting.push({
_newRecords.push({
key: 'notification-settings',
value: notificationSettings
})
}
if (emailSettings) {
newRecords.setting.push({
_newRecords.push({
key: 'email-settings',
value: emailSettings
})
}
return _newRecords
}
/**
* Load old libraries and bulkCreate new Library and LibraryFolder rows
* @param {Map<string,Model>} DatabaseModels
*/
async function handleMigrateLibraries(DatabaseModels) {
const oldLibraries = await oldDbFiles.loadOldData('libraries')
const newLibraryRecords = migrateLibraries(oldLibraries)
for (const model in newLibraryRecords) {
Logger.info(`[dbMigration] Inserting ${newLibraryRecords[model].length} ${model} rows`)
await DatabaseModels[model].bulkCreate(newLibraryRecords[model])
}
}
/**
* Load old EmailSettings, NotificationSettings and ServerSettings and bulkCreate new Setting rows
* @param {Map<string,Model>} DatabaseModels
*/
async function handleMigrateSettings(DatabaseModels) {
const oldSettings = await oldDbFiles.loadOldData('settings')
const newSettings = migrateSettings(oldSettings)
Logger.info(`[dbMigration] Inserting ${newSettings.length} setting rows`)
await DatabaseModels.setting.bulkCreate(newSettings)
}
/**
* Load old authors and bulkCreate new Author rows
* @param {Map<string,Model>} DatabaseModels
* @param {Array<objects.LibraryItem>} oldLibraryItems
*/
async function handleMigrateAuthors(DatabaseModels, oldLibraryItems) {
const oldAuthors = await oldDbFiles.loadOldData('authors')
const newAuthors = migrateAuthors(oldAuthors, oldLibraryItems)
Logger.info(`[dbMigration] Inserting ${newAuthors.length} author rows`)
await DatabaseModels.author.bulkCreate(newAuthors)
}
/**
* Load old series and bulkCreate new Series rows
* @param {Map<string,Model>} DatabaseModels
* @param {Array<objects.LibraryItem>} oldLibraryItems
*/
async function handleMigrateSeries(DatabaseModels, oldLibraryItems) {
const oldSeries = await oldDbFiles.loadOldData('series')
const newSeries = migrateSeries(oldSeries, oldLibraryItems)
Logger.info(`[dbMigration] Inserting ${newSeries.length} series rows`)
await DatabaseModels.series.bulkCreate(newSeries)
}
/**
* bulkCreate new LibraryItem, Book and Podcast rows
* @param {Map<string,Model>} DatabaseModels
* @param {Array<objects.LibraryItem>} oldLibraryItems
*/
async function handleMigrateLibraryItems(DatabaseModels, oldLibraryItems) {
const newItemsBooksPodcasts = migrateLibraryItems(oldLibraryItems)
for (const model in newItemsBooksPodcasts) {
Logger.info(`[dbMigration] Inserting ${newItemsBooksPodcasts[model].length} ${model} rows`)
await DatabaseModels[model].bulkCreate(newItemsBooksPodcasts[model])
}
}
/**
* Migrate authors, series then library items in chunks
* Authors and series require old library items loaded first
* @param {Map<string,Model>} DatabaseModels
*/
async function handleMigrateAuthorsSeriesAndLibraryItems(DatabaseModels) {
const oldLibraryItems = await oldDbFiles.loadOldData('libraryItems')
await handleMigrateAuthors(DatabaseModels, oldLibraryItems)
await handleMigrateSeries(DatabaseModels, oldLibraryItems)
// Migrate library items in chunks of 1000
const numChunks = Math.ceil(oldLibraryItems.length / 1000)
for (let i = 0; i < numChunks; i++) {
let start = i * 1000
await handleMigrateLibraryItems(DatabaseModels, oldLibraryItems.slice(start, start + 1000))
}
}
/**
* Load old users and bulkCreate new User rows
* @param {Map<string,Model>} DatabaseModels
*/
async function handleMigrateUsers(DatabaseModels) {
const oldUsers = await oldDbFiles.loadOldData('users')
const newUserRecords = migrateUsers(oldUsers)
for (const model in newUserRecords) {
Logger.info(`[dbMigration] Inserting ${newUserRecords[model].length} ${model} rows`)
await DatabaseModels[model].bulkCreate(newUserRecords[model])
}
}
/**
* Load old sessions and bulkCreate new PlaybackSession & Device rows
* @param {Map<string,Model>} DatabaseModels
*/
async function handleMigrateSessions(DatabaseModels) {
const oldSessions = await oldDbFiles.loadOldData('sessions')
let chunkSize = 1000
let numChunks = Math.ceil(oldSessions.length / chunkSize)
for (let i = 0; i < numChunks; i++) {
let start = i * chunkSize
const newSessionRecords = migrateSessions(oldSessions.slice(start, start + chunkSize))
for (const model in newSessionRecords) {
Logger.info(`[dbMigration] Inserting ${newSessionRecords[model].length} ${model} rows`)
await DatabaseModels[model].bulkCreate(newSessionRecords[model])
}
}
}
/**
* Load old collections and bulkCreate new Collection, CollectionBook models
* @param {Map<string,Model>} DatabaseModels
*/
async function handleMigrateCollections(DatabaseModels) {
const oldCollections = await oldDbFiles.loadOldData('collections')
const newCollectionRecords = migrateCollections(oldCollections)
for (const model in newCollectionRecords) {
Logger.info(`[dbMigration] Inserting ${newCollectionRecords[model].length} ${model} rows`)
await DatabaseModels[model].bulkCreate(newCollectionRecords[model])
}
}
/**
* Load old playlists and bulkCreate new Playlist, PlaylistMediaItem models
* @param {Map<string,Model>} DatabaseModels
*/
async function handleMigratePlaylists(DatabaseModels) {
const oldPlaylists = await oldDbFiles.loadOldData('playlists')
const newPlaylistRecords = migratePlaylists(oldPlaylists)
for (const model in newPlaylistRecords) {
Logger.info(`[dbMigration] Inserting ${newPlaylistRecords[model].length} ${model} rows`)
await DatabaseModels[model].bulkCreate(newPlaylistRecords[model])
}
}
/**
* Load old feeds and bulkCreate new Feed, FeedEpisode models
* @param {Map<string,Model>} DatabaseModels
*/
async function handleMigrateFeeds(DatabaseModels) {
const oldFeeds = await oldDbFiles.loadOldData('feeds')
const newFeedRecords = migrateFeeds(oldFeeds)
for (const model in newFeedRecords) {
Logger.info(`[dbMigration] Inserting ${newFeedRecords[model].length} ${model} rows`)
await DatabaseModels[model].bulkCreate(newFeedRecords[model])
}
}
module.exports.migrate = async (DatabaseModels) => {
Logger.info(`[dbMigration] Starting migration`)
const data = await oldDbFiles.init()
const start = Date.now()
migrateSettings(data.settings)
migrateLibraries(data.libraries)
migrateAuthors(data.authors, data.libraryItems)
migrateSeries(data.series, data.libraryItems)
migrateLibraryItems(data.libraryItems)
migrateUsers(data.users)
migrateSessions(data.sessions)
migrateCollections(data.collections)
migratePlaylists(data.playlists)
migrateFeeds(data.feeds)
let totalRecords = 0
for (const model in newRecords) {
Logger.info(`[dbMigration] Inserting ${newRecords[model].length} ${model} rows`)
if (newRecords[model].length) {
await DatabaseModels[model].bulkCreate(newRecords[model])
totalRecords += newRecords[model].length
}
}
// Migrate to Library and LibraryFolder models
await handleMigrateLibraries(DatabaseModels)
const elapsed = Date.now() - start
// Migrate EmailSettings, NotificationSettings and ServerSettings to Setting model
await handleMigrateSettings(DatabaseModels)
// Migrate Series, Author, LibraryItem, Book, Podcast
await handleMigrateAuthorsSeriesAndLibraryItems(DatabaseModels)
// Migrate User, MediaProgress
await handleMigrateUsers(DatabaseModels)
// Migrate PlaybackSession, Device
await handleMigrateSessions(DatabaseModels)
// Migrate Collection, CollectionBook
await handleMigrateCollections(DatabaseModels)
// Migrate Playlist, PlaylistMediaItem
await handleMigratePlaylists(DatabaseModels)
// Migrate Feed, FeedEpisode
await handleMigrateFeeds(DatabaseModels)
// Purge author images and cover images from cache
try {
@@ -796,7 +1097,8 @@ module.exports.migrate = async (DatabaseModels) => {
// Put all old db folders into a zipfile oldDb.zip
await oldDbFiles.zipWrapOldDb()
Logger.info(`[dbMigration] Migration complete. ${totalRecords} rows. Elapsed ${(elapsed / 1000).toFixed(2)}s`)
const elapsed = Date.now() - start
Logger.info(`[dbMigration] Migration complete. Elapsed ${(elapsed / 1000).toFixed(2)}s`)
}
/**
@@ -805,4 +1107,205 @@ module.exports.migrate = async (DatabaseModels) => {
module.exports.checkShouldMigrate = async () => {
if (await oldDbFiles.checkHasOldDb()) return true
return oldDbFiles.checkHasOldDbZip()
}
/**
* Migration from 2.3.0 to 2.3.1 - create extraData columns in LibraryItem and PodcastEpisode
* @param {QueryInterface} queryInterface
*/
async function migrationPatchNewColumns(queryInterface) {
try {
return queryInterface.sequelize.transaction(t => {
return Promise.all([
queryInterface.addColumn('libraryItems', 'extraData', {
type: DataTypes.JSON
}, { transaction: t }),
queryInterface.addColumn('podcastEpisodes', 'extraData', {
type: DataTypes.JSON
}, { transaction: t }),
queryInterface.addColumn('libraries', 'extraData', {
type: DataTypes.JSON
}, { transaction: t })
])
})
} catch (error) {
Logger.error(`[dbMigration] Migration from 2.3.0+ column creation failed`, error)
return false
}
}
/**
* Migration from 2.3.0 to 2.3.1 - old library item ids
* @param {/src/Database} ctx
*/
async function handleOldLibraryItems(ctx) {
const oldLibraryItems = await oldDbFiles.loadOldData('libraryItems')
const libraryItems = await ctx.models.libraryItem.getAllOldLibraryItems()
const bulkUpdateItems = []
const bulkUpdateEpisodes = []
for (const libraryItem of libraryItems) {
// Find matching old library item by ino
const matchingOldLibraryItem = oldLibraryItems.find(oli => oli.ino === libraryItem.ino)
if (matchingOldLibraryItem) {
oldDbIdMap.libraryItems[matchingOldLibraryItem.id] = libraryItem.id
bulkUpdateItems.push({
id: libraryItem.id,
extraData: {
oldLibraryItemId: matchingOldLibraryItem.id
}
})
if (libraryItem.media.episodes?.length && matchingOldLibraryItem.media.episodes?.length) {
for (const podcastEpisode of libraryItem.media.episodes) {
// Find matching old episode by audio file ino
const matchingOldPodcastEpisode = matchingOldLibraryItem.media.episodes.find(oep => oep.audioFile?.ino && oep.audioFile.ino === podcastEpisode.audioFile?.ino)
if (matchingOldPodcastEpisode) {
oldDbIdMap.podcastEpisodes[matchingOldPodcastEpisode.id] = podcastEpisode.id
bulkUpdateEpisodes.push({
id: podcastEpisode.id,
extraData: {
oldEpisodeId: matchingOldPodcastEpisode.id
}
})
}
}
}
}
}
if (bulkUpdateEpisodes.length) {
await ctx.models.podcastEpisode.bulkCreate(bulkUpdateEpisodes, {
updateOnDuplicate: ['extraData']
})
}
if (bulkUpdateItems.length) {
await ctx.models.libraryItem.bulkCreate(bulkUpdateItems, {
updateOnDuplicate: ['extraData']
})
}
Logger.info(`[dbMigration] Migration 2.3.0+: Updated ${bulkUpdateItems.length} library items & ${bulkUpdateEpisodes.length} episodes`)
}
/**
* Migration from 2.3.0 to 2.3.1 - updating oldLibraryId
* @param {/src/Database} ctx
*/
async function handleOldLibraries(ctx) {
const oldLibraries = await oldDbFiles.loadOldData('libraries')
const libraries = await ctx.models.library.getAllOldLibraries()
let librariesUpdated = 0
for (const library of libraries) {
// Find matching old library using exact match on folder paths, exact match on library name
const matchingOldLibrary = oldLibraries.find(ol => {
if (ol.name !== library.name) {
return false
}
const folderPaths = ol.folders?.map(f => f.fullPath) || []
return folderPaths.join(',') === library.folderPaths.join(',')
})
if (matchingOldLibrary) {
library.oldLibraryId = matchingOldLibrary.id
oldDbIdMap.libraries[library.oldLibraryId] = library.id
await ctx.models.library.updateFromOld(library)
librariesUpdated++
}
}
Logger.info(`[dbMigration] Migration 2.3.0+: Updated ${librariesUpdated} libraries`)
}
/**
* Migration from 2.3.0 to 2.3.1 - fixing librariesAccessible and bookmarks
* @param {/src/Database} ctx
*/
async function handleOldUsers(ctx) {
const users = await ctx.models.user.getOldUsers()
let usersUpdated = 0
for (const user of users) {
let hasUpdates = false
if (user.bookmarks?.length) {
user.bookmarks = user.bookmarks.map(bm => {
// Only update if this is not the old id format
if (!bm.libraryItemId.startsWith('li_')) return bm
bm.libraryItemId = oldDbIdMap.libraryItems[bm.libraryItemId]
hasUpdates = true
return bm
}).filter(bm => bm.libraryItemId)
}
// Convert old library ids to new library ids
if (user.librariesAccessible?.length) {
user.librariesAccessible = user.librariesAccessible.map(lid => {
if (!lid.startsWith('lib_') && lid !== 'main') return lid // Already not an old library id so dont change
hasUpdates = true
return oldDbIdMap.libraries[lid]
}).filter(lid => lid)
}
if (user.seriesHideFromContinueListening?.length) {
user.seriesHideFromContinueListening = user.seriesHideFromContinueListening.map((seriesId) => {
if (seriesId.startsWith('se_')) {
hasUpdates = true
return null // Filter out old series ids
}
return seriesId
}).filter(se => se)
}
if (hasUpdates) {
await ctx.models.user.updateFromOld(user)
usersUpdated++
}
}
Logger.info(`[dbMigration] Migration 2.3.0+: Updated ${usersUpdated} users`)
}
/**
* Migration from 2.3.0 to 2.3.1
* @param {/src/Database} ctx
*/
module.exports.migrationPatch = async (ctx) => {
const queryInterface = ctx.sequelize.getQueryInterface()
const librariesTableDescription = await queryInterface.describeTable('libraries')
if (librariesTableDescription?.extraData) {
Logger.info(`[dbMigration] Migration patch 2.3.0+ - extraData columns already on model`)
} else {
const migrationResult = await migrationPatchNewColumns(queryInterface)
if (migrationResult === false) {
return
}
}
const oldDbPath = Path.join(global.ConfigPath, 'oldDb.zip')
if (!await fs.pathExists(oldDbPath)) {
Logger.info(`[dbMigration] Migration patch 2.3.0+ unnecessary - no oldDb.zip found`)
return
}
const migrationStart = Date.now()
Logger.info(`[dbMigration] Applying migration patch from 2.3.0+`)
// Extract from oldDb.zip
if (!await oldDbFiles.checkExtractItemsUsersAndLibraries()) {
return
}
await handleOldLibraryItems(ctx)
await handleOldLibraries(ctx)
await handleOldUsers(ctx)
await oldDbFiles.removeOldItemsUsersAndLibrariesFolders()
const elapsed = Date.now() - migrationStart
Logger.info(`[dbMigration] Migration patch 2.3.0+ finished. Elapsed ${(elapsed / 1000).toFixed(2)}s`)
}

View File

@@ -71,27 +71,11 @@ async function loadDbData(dbpath) {
}
}
module.exports.init = async () => {
const dbs = {
libraryItems: Path.join(global.ConfigPath, 'libraryItems', 'data'),
users: Path.join(global.ConfigPath, 'users', 'data'),
sessions: Path.join(global.ConfigPath, 'sessions', 'data'),
libraries: Path.join(global.ConfigPath, 'libraries', 'data'),
settings: Path.join(global.ConfigPath, 'settings', 'data'),
collections: Path.join(global.ConfigPath, 'collections', 'data'),
playlists: Path.join(global.ConfigPath, 'playlists', 'data'),
authors: Path.join(global.ConfigPath, 'authors', 'data'),
series: Path.join(global.ConfigPath, 'series', 'data'),
feeds: Path.join(global.ConfigPath, 'feeds', 'data')
}
const data = {}
for (const key in dbs) {
data[key] = await loadDbData(dbs[key])
Logger.info(`[oldDbFiles] ${data[key].length} ${key} loaded`)
}
return data
module.exports.loadOldData = async (dbName) => {
const dbPath = Path.join(global.ConfigPath, dbName, 'data')
const dbData = await loadDbData(dbPath) || []
Logger.info(`[oldDbFiles] ${dbData.length} ${dbName} loaded`)
return dbData
}
module.exports.zipWrapOldDb = async () => {
@@ -184,6 +168,59 @@ module.exports.checkHasOldDbZip = async () => {
// Extract oldDb.zip
const zip = new StreamZip.async({ file: oldDbPath })
await zip.extract(null, global.ConfigPath)
await zip.close()
return this.checkHasOldDb()
}
/**
* Used for migration from 2.3.0 -> 2.3.1
* @returns {boolean} true if extracted
*/
module.exports.checkExtractItemsUsersAndLibraries = async () => {
const oldDbPath = Path.join(global.ConfigPath, 'oldDb.zip')
const zip = new StreamZip.async({ file: oldDbPath })
const libraryItemsPath = Path.join(global.ConfigPath, 'libraryItems')
await zip.extract('libraryItems/', libraryItemsPath)
if (!await fs.pathExists(libraryItemsPath)) {
Logger.error(`[oldDbFiles] Failed to extract old libraryItems from oldDb.zip`)
return false
}
const usersPath = Path.join(global.ConfigPath, 'users')
await zip.extract('users/', usersPath)
if (!await fs.pathExists(usersPath)) {
Logger.error(`[oldDbFiles] Failed to extract old users from oldDb.zip`)
await fs.remove(libraryItemsPath) // Remove old library items folder
return false
}
const librariesPath = Path.join(global.ConfigPath, 'libraries')
await zip.extract('libraries/', librariesPath)
if (!await fs.pathExists(librariesPath)) {
Logger.error(`[oldDbFiles] Failed to extract old libraries from oldDb.zip`)
await fs.remove(usersPath) // Remove old users folder
await fs.remove(libraryItemsPath) // Remove old library items folder
return false
}
await zip.close()
return true
}
/**
* Used for migration from 2.3.0 -> 2.3.1
*/
module.exports.removeOldItemsUsersAndLibrariesFolders = async () => {
const libraryItemsPath = Path.join(global.ConfigPath, 'libraryItems')
const usersPath = Path.join(global.ConfigPath, 'users')
const librariesPath = Path.join(global.ConfigPath, 'libraries')
await fs.remove(libraryItemsPath)
await fs.remove(usersPath)
await fs.remove(librariesPath)
}