-
+
+ No Cover
|
-
- {{ item.episode.title || 'Unknown' }}
- {{ item.media.metadata.title }}
-
-
- {{ item.media.metadata.title || 'Unknown' }}
- by {{ item.media.metadata.authorName }}
-
+ {{ item.displayTitle || 'Unknown' }}
+ {{ item.displaySubtitle }}
|
{{ Math.floor(item.progress * 100) }}%
@@ -124,9 +119,6 @@ export default {
mediaProgress() {
return this.user.mediaProgress.sort((a, b) => b.lastUpdate - a.lastUpdate)
},
- mediaProgressWithMedia() {
- return this.mediaProgress.filter((mp) => mp.media)
- },
totalListeningTime() {
return this.listeningStats.totalTime || 0
},
diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue
index ac4e3d8a..176725b9 100644
--- a/client/pages/item/_id/index.vue
+++ b/client/pages/item/_id/index.vue
@@ -160,7 +160,7 @@ export default {
}
// Include episode downloads for podcasts
- var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=authors,downloads,rssfeed`).catch((error) => {
+ var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=downloads,rssfeed`).catch((error) => {
console.error('Failed', error)
return false
})
@@ -761,6 +761,7 @@ export default {
if (this.libraryId) {
this.$store.commit('libraries/setCurrentLibrary', this.libraryId)
}
+ this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
this.$root.socket.on('item_updated', this.libraryItemUpdated)
this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
@@ -769,6 +770,7 @@ export default {
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
},
beforeDestroy() {
+ this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
this.$root.socket.off('item_updated', this.libraryItemUpdated)
this.$root.socket.off('rss_feed_open', this.rssFeedOpen)
this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)
diff --git a/client/store/libraries.js b/client/store/libraries.js
index e0151626..fd8af4ae 100644
--- a/client/store/libraries.js
+++ b/client/store/libraries.js
@@ -234,6 +234,10 @@ export const mutations = {
setNumUserPlaylists(state, numUserPlaylists) {
state.numUserPlaylists = numUserPlaylists
},
+ removeSeriesFromFilterData(state, seriesId) {
+ if (!seriesId || !state.filterData) return
+ state.filterData.series = state.filterData.series.filter(se => se.id !== seriesId)
+ },
updateFilterDataWithItem(state, libraryItem) {
if (!libraryItem || !state.filterData) return
if (state.currentLibraryId !== libraryItem.libraryId) return
diff --git a/client/strings/de.json b/client/strings/de.json
index f6edab6d..8ed43eae 100644
--- a/client/strings/de.json
+++ b/client/strings/de.json
@@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Lösche {0} Episoden",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS-Feed ist geöffnet",
+ "HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Gespeicherte Hörfortschritte",
"HeaderSchedule": "Zeitplan",
"HeaderScheduleLibraryScans": "Automatische Bibliotheksscans",
@@ -201,6 +202,7 @@
"LabelClosePlayer": "Player schließen",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Serien zusammenfassen",
+ "LabelCollection": "Sammlung",
"LabelCollections": "Sammlungen",
"LabelComplete": "Vollständig",
"LabelConfirmPassword": "Passwort bestätigen",
@@ -222,7 +224,7 @@
"LabelDirectory": "Verzeichnis",
"LabelDiscFromFilename": "CD aus dem Dateinamen",
"LabelDiscFromMetadata": "CD aus den Metadaten",
- "LabelDiscover": "Discover",
+ "LabelDiscover": "Finden",
"LabelDownload": "Herunterladen",
"LabelDownloadNEpisodes": "Download {0} episodes",
"LabelDuration": "Laufzeit",
@@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Überwachung deaktivieren",
"LabelSettingsDisableWatcherForLibrary": "Ordnerüberwachung für die Bibliothek deaktivieren",
"LabelSettingsDisableWatcherHelp": "Deaktiviert das automatische Hinzufügen/Aktualisieren von Elementen, wenn Dateiänderungen erkannt werden. *Erfordert einen Server-Neustart",
+ "LabelSettingsEnableWatcher": "Enable Watcher",
+ "LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
+ "LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "Experimentelle Funktionen",
"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",
@@ -428,6 +433,7 @@
"LabelShowAll": "Alles anzeigen",
"LabelSize": "Größe",
"LabelSleepTimer": "Einschlaf-Timer",
+ "LabelSlug": "URL Teil",
"LabelStart": "Start",
"LabelStarted": "Gestartet",
"LabelStartedAt": "Gestartet am",
@@ -475,7 +481,7 @@
"LabelTrackFromMetadata": "Titel aus Metadaten",
"LabelTracks": "Dateien",
"LabelTracksMultiTrack": "Mehrfachdatei",
- "LabelTracksNone": "No tracks",
+ "LabelTracksNone": "Keine Dateien",
"LabelTracksSingleTrack": "Einzeldatei",
"LabelType": "Typ",
"LabelUnabridged": "Ungekürzt",
@@ -496,7 +502,7 @@
"LabelViewBookmarks": "Lesezeichen anzeigen",
"LabelViewChapters": "Kapitel anzeigen",
"LabelViewQueue": "Spieler-Warteschlange anzeigen",
- "LabelVolume": "Volume",
+ "LabelVolume": "Volumen",
"LabelWeekdaysToRun": "Wochentage für die Ausführung",
"LabelYourAudiobookDuration": "Laufzeit Ihres Mediums",
"LabelYourBookmarks": "Lesezeichen",
@@ -516,8 +522,9 @@
"MessageChapterErrorStartLtPrev": "Ungültige Kapitelstartzeit: Kapitelanfang < Kapitelanfang vorheriges Kapitel (Kapitelanfang liegt zeitlich vor dem Beginn des vorherigen Kapitels -> Lösung: Kapitelanfang >= Startzeit des vorherigen Kapitels)",
"MessageChapterStartIsAfter": "Ungültige Kapitelstartzeit: Kapitelanfang > Mediumende (Kapitelanfang liegt nach dem Ende des Mediums)",
"MessageCheckingCron": "Überprüfe Cron...",
+ "MessageConfirmCloseFeed": "Sind Sie sicher, dass Sie diesen Feed schließen wollen?",
"MessageConfirmDeleteBackup": "Sind Sie sicher, dass Sie die Sicherung für {0} löschen wollen?",
- "MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
+ "MessageConfirmDeleteFile": "Es wird die Datei vom System löschen. Sind Sie sicher?",
"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?",
@@ -560,7 +567,7 @@
"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.",
+ "MessageMatchBooksDescription": "Es wird versucht die Bücher in der Bibliothek mit einem Buch des ausgewählten Suchanbieters abzugleichen um leere Details und das Titelbild auszufüllen. Vorhandene Details werden nicht überschrieben.",
"MessageNoAudioTracks": "Keine Audiodateien",
"MessageNoAuthors": "Keine Autoren",
"MessageNoBackups": "Keine Sicherungen",
@@ -596,7 +603,7 @@
"MessagePauseChapter": "Kapitelwiedergabe pausieren",
"MessagePlayChapter": "Kapitelanfang anhören",
"MessagePlaylistCreateFromCollection": "Erstelle eine Wiedergabeliste aus der Sammlung",
- "MessagePodcastHasNoRSSFeedForMatching": "Podcast hat keine RSS-Feed-Url welche für den Online-Abgleich verwendet werden kann",
+ "MessagePodcastHasNoRSSFeedForMatching": "Der Podcast hat keine RSS-Feed-Url welche für den Online-Abgleich verwendet werden kann",
"MessageQuickMatchDescription": "Füllt leere Details und Titelbilder mit dem ersten Treffer aus '{0}'. Überschreibt keine Details, es sei denn, die Server-Einstellung \"Passende Metadaten bevorzugen\" ist aktiviert.",
"MessageRemoveChapter": "Kapitel löschen",
"MessageRemoveEpisodes": "Entferne {0} Episode(n)",
diff --git a/client/strings/en-us.json b/client/strings/en-us.json
index 3937e8c5..75606da2 100644
--- a/client/strings/en-us.json
+++ b/client/strings/en-us.json
@@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Remove {0} Episodes",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
+ "HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Saved Media Progress",
"HeaderSchedule": "Schedule",
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
@@ -201,6 +202,7 @@
"LabelClosePlayer": "Close player",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Collapse Series",
+ "LabelCollection": "Collection",
"LabelCollections": "Collections",
"LabelComplete": "Complete",
"LabelConfirmPassword": "Confirm Password",
@@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Disable Watcher",
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
"LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
+ "LabelSettingsEnableWatcher": "Enable Watcher",
+ "LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
+ "LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "Experimental features",
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
"LabelSettingsFindCovers": "Find covers",
@@ -428,6 +433,7 @@
"LabelShowAll": "Show All",
"LabelSize": "Size",
"LabelSleepTimer": "Sleep timer",
+ "LabelSlug": "Slug",
"LabelStart": "Start",
"LabelStarted": "Started",
"LabelStartedAt": "Started At",
@@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
"MessageCheckingCron": "Checking cron...",
+ "MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
diff --git a/client/strings/es.json b/client/strings/es.json
index 5be1e3b5..08aafe61 100644
--- a/client/strings/es.json
+++ b/client/strings/es.json
@@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Remover {0} Episodios",
"HeaderRSSFeedGeneral": "Detalles RSS",
"HeaderRSSFeedIsOpen": "Fuente RSS esta abierta",
+ "HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Guardar Progreso de multimedia",
"HeaderSchedule": "Horario",
"HeaderScheduleLibraryScans": "Programar Escaneo Automático de Biblioteca",
@@ -201,6 +202,7 @@
"LabelClosePlayer": "Close player",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Colapsar Series",
+ "LabelCollection": "Collection",
"LabelCollections": "Colecciones",
"LabelComplete": "Completo",
"LabelConfirmPassword": "Confirmar Contraseña",
@@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Deshabilitar Watcher",
"LabelSettingsDisableWatcherForLibrary": "Deshabilitar Watcher de Carpetas para esta biblioteca",
"LabelSettingsDisableWatcherHelp": "Deshabilitar la función automática de agregar/actualizar los elementos, cuando se detecta cambio en los archivos. *Require Reiniciar el Servidor",
+ "LabelSettingsEnableWatcher": "Enable Watcher",
+ "LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
+ "LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "Funciones Experimentales",
"LabelSettingsExperimentalFeaturesHelp": "Funciones en desarrollo sobre las que esperamos sus comentarios y experiencia. Haga click aquí para abrir una conversación en Github.",
"LabelSettingsFindCovers": "Buscar Portadas",
@@ -428,6 +433,7 @@
"LabelShowAll": "Mostrar Todos",
"LabelSize": "Tamaño",
"LabelSleepTimer": "Temporizador para Dormir",
+ "LabelSlug": "Slug",
"LabelStart": "Iniciar",
"LabelStarted": "Indiciado",
"LabelStartedAt": "Iniciado En",
@@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "El tiempo de inicio no es válida debe ser mayor o igual que la hora de inicio del capítulo anterior",
"MessageChapterStartIsAfter": "El comienzo del capítulo es después del final de su audiolibro",
"MessageCheckingCron": "Checking cron...",
+ "MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Esta seguro que desea eliminar el respaldo {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Esta seguro que desea eliminar permanentemente la biblioteca \"{0}\"?",
diff --git a/client/strings/fr.json b/client/strings/fr.json
index ea98d08e..230e7348 100644
--- a/client/strings/fr.json
+++ b/client/strings/fr.json
@@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Suppression de {0} épisodes",
"HeaderRSSFeedGeneral": "Détails de flux RSS",
"HeaderRSSFeedIsOpen": "Le Flux RSS est actif",
+ "HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Progression de la sauvegarde des médias",
"HeaderSchedule": "Programmation",
"HeaderScheduleLibraryScans": "Analyse automatique de la bibliothèque",
@@ -201,6 +202,7 @@
"LabelClosePlayer": "Fermer le lecteur",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Réduire les séries",
+ "LabelCollection": "Collection",
"LabelCollections": "Collections",
"LabelComplete": "Complet",
"LabelConfirmPassword": "Confirmer le mot de passe",
@@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Désactiver la surveillance",
"LabelSettingsDisableWatcherForLibrary": "Désactiver la surveillance des dossiers pour la bibliothèque",
"LabelSettingsDisableWatcherHelp": "Désactive la mise à jour automatique lorsque les fichiers changent. *Nécessite un redémarrage*",
+ "LabelSettingsEnableWatcher": "Enable Watcher",
+ "LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
+ "LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "Fonctionnalités expérimentales",
"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",
@@ -428,6 +433,7 @@
"LabelShowAll": "Afficher Tout",
"LabelSize": "Taille",
"LabelSleepTimer": "Minuterie",
+ "LabelSlug": "Slug",
"LabelStart": "Démarrer",
"LabelStarted": "Démarré",
"LabelStartedAt": "Démarré à",
@@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "Horodatage invalide car il doit débuter au moins après le précédent chapitre",
"MessageChapterStartIsAfter": "Le premier chapitre est situé au début de votre livre audio",
"MessageCheckingCron": "Vérification du cron…",
+ "MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Êtes-vous sûr de vouloir supprimer la Sauvegarde de {0} ?",
"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} » ?",
diff --git a/client/strings/gu.json b/client/strings/gu.json
index 9c6fd369..65884cff 100644
--- a/client/strings/gu.json
+++ b/client/strings/gu.json
@@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Remove {0} Episodes",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
+ "HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Saved Media Progress",
"HeaderSchedule": "Schedule",
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
@@ -201,6 +202,7 @@
"LabelClosePlayer": "Close player",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Collapse Series",
+ "LabelCollection": "Collection",
"LabelCollections": "Collections",
"LabelComplete": "Complete",
"LabelConfirmPassword": "Confirm Password",
@@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Disable Watcher",
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
"LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
+ "LabelSettingsEnableWatcher": "Enable Watcher",
+ "LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
+ "LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "Experimental features",
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
"LabelSettingsFindCovers": "Find covers",
@@ -428,6 +433,7 @@
"LabelShowAll": "Show All",
"LabelSize": "Size",
"LabelSleepTimer": "Sleep timer",
+ "LabelSlug": "Slug",
"LabelStart": "Start",
"LabelStarted": "Started",
"LabelStartedAt": "Started At",
@@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
"MessageCheckingCron": "Checking cron...",
+ "MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
diff --git a/client/strings/hi.json b/client/strings/hi.json
index 96d551f4..2f5a5234 100644
--- a/client/strings/hi.json
+++ b/client/strings/hi.json
@@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Remove {0} Episodes",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
+ "HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Saved Media Progress",
"HeaderSchedule": "Schedule",
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
@@ -201,6 +202,7 @@
"LabelClosePlayer": "Close player",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Collapse Series",
+ "LabelCollection": "Collection",
"LabelCollections": "Collections",
"LabelComplete": "Complete",
"LabelConfirmPassword": "Confirm Password",
@@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Disable Watcher",
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
"LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
+ "LabelSettingsEnableWatcher": "Enable Watcher",
+ "LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
+ "LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "Experimental features",
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
"LabelSettingsFindCovers": "Find covers",
@@ -428,6 +433,7 @@
"LabelShowAll": "Show All",
"LabelSize": "Size",
"LabelSleepTimer": "Sleep timer",
+ "LabelSlug": "Slug",
"LabelStart": "Start",
"LabelStarted": "Started",
"LabelStartedAt": "Started At",
@@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
"MessageCheckingCron": "Checking cron...",
+ "MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
diff --git a/client/strings/hr.json b/client/strings/hr.json
index f36d62c4..aa8fc2cc 100644
--- a/client/strings/hr.json
+++ b/client/strings/hr.json
@@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Ukloni {0} epizoda/-e",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed je otvoren",
+ "HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Spremljen Media Progress",
"HeaderSchedule": "Schedule",
"HeaderScheduleLibraryScans": "Zakaži automatsko skeniranje biblioteke",
@@ -201,6 +202,7 @@
"LabelClosePlayer": "Close player",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Collapse Series",
+ "LabelCollection": "Collection",
"LabelCollections": "Kolekcije",
"LabelComplete": "Complete",
"LabelConfirmPassword": "Potvrdi lozinku",
@@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Isključi Watchera",
"LabelSettingsDisableWatcherForLibrary": "Isključi folder watchera za biblioteku",
"LabelSettingsDisableWatcherHelp": "Isključi automatsko dodavanje/aktualiziranje stavci ako su promjene prepoznate. *Potreban restart servera",
+ "LabelSettingsEnableWatcher": "Enable Watcher",
+ "LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
+ "LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "Eksperimentalni features",
"LabelSettingsExperimentalFeaturesHelp": "Features u razvoju trebaju vaš feedback i pomoć pri testiranju. Klikni da odeš to Github discussionsa.",
"LabelSettingsFindCovers": "Pronađi covers",
@@ -428,6 +433,7 @@
"LabelShowAll": "Prikaži sve",
"LabelSize": "Veličina",
"LabelSleepTimer": "Sleep timer",
+ "LabelSlug": "Slug",
"LabelStart": "Pokreni",
"LabelStarted": "Pokrenuto",
"LabelStartedAt": "Pokrenuto",
@@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
"MessageChapterStartIsAfter": "Početak poglavlja je nakon kraja audioknjige.",
"MessageCheckingCron": "Provjeravam cron...",
+ "MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Jeste li sigurni da želite obrisati backup za {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Jeste li sigurni da želite trajno obrisati biblioteku \"{0}\"?",
diff --git a/client/strings/it.json b/client/strings/it.json
index 68912781..45777f9d 100644
--- a/client/strings/it.json
+++ b/client/strings/it.json
@@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Rimuovi {0} Episodi",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed è aperto",
+ "HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Progressi salvati",
"HeaderSchedule": "Schedula",
"HeaderScheduleLibraryScans": "Schedula la scansione della libreria",
@@ -201,6 +202,7 @@
"LabelClosePlayer": "Chiudi player",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Comprimi Serie",
+ "LabelCollection": "Collection",
"LabelCollections": "Raccolte",
"LabelComplete": "Completo",
"LabelConfirmPassword": "Conferma Password",
@@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Disattiva Watcher",
"LabelSettingsDisableWatcherForLibrary": "Disattiva Watcher per le librerie",
"LabelSettingsDisableWatcherHelp": "Disattiva il controllo automatico libri nelle cartelle delle librerie. *Richiede il Riavvio del Server",
+ "LabelSettingsEnableWatcher": "Enable Watcher",
+ "LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
+ "LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "Opzioni Sperimentali",
"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",
@@ -428,6 +433,7 @@
"LabelShowAll": "Mostra Tutto",
"LabelSize": "Dimensione",
"LabelSleepTimer": "Sleep timer",
+ "LabelSlug": "Slug",
"LabelStart": "Inizo",
"LabelStarted": "Iniziato",
"LabelStartedAt": "Iniziato al",
@@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "L'ora di inizio non valida deve essere maggiore o uguale all'ora di inizio del capitolo precedente",
"MessageChapterStartIsAfter": "L'inizio del capitolo è dopo la fine del tuo audiolibro",
"MessageCheckingCron": "Controllo cron...",
+ "MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Sei sicuro di voler eliminare il backup {0}?",
"MessageConfirmDeleteFile": "Questo eliminerà il file dal tuo file system. Sei sicuro?",
"MessageConfirmDeleteLibrary": "Sei sicuro di voler eliminare definitivamente la libreria \"{0}\"?",
diff --git a/client/strings/lt.json b/client/strings/lt.json
index 0c4cda1f..14f5b480 100644
--- a/client/strings/lt.json
+++ b/client/strings/lt.json
@@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Pašalinti {0} epizodus",
"HeaderRSSFeedGeneral": "RSS informacija",
"HeaderRSSFeedIsOpen": "RSS srautas yra atidarytas",
+ "HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Išsaugota medijos pažanga",
"HeaderSchedule": "Tvarkaraštis",
"HeaderScheduleLibraryScans": "Nustatyti bibliotekų nuskaitymo tvarkaraštį",
@@ -201,6 +202,7 @@
"LabelClosePlayer": "Uždaryti grotuvą",
"LabelCodec": "Kodekas",
"LabelCollapseSeries": "Suskleisti seriją",
+ "LabelCollection": "Collection",
"LabelCollections": "Kolekcijos",
"LabelComplete": "Baigta",
"LabelConfirmPassword": "Patvirtinkite slaptažodį",
@@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Išjungti stebėtoją",
"LabelSettingsDisableWatcherForLibrary": "Išjungti aplankų stebėtoją bibliotekai",
"LabelSettingsDisableWatcherHelp": "Išjungia automatinį elementų pridėjimą/atnaujinimą, jei pastebėti failų pokyčiai. *Reikalingas serverio paleidimas iš naujo",
+ "LabelSettingsEnableWatcher": "Enable Watcher",
+ "LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
+ "LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "Eksperimentiniai funkcionalumai",
"LabelSettingsExperimentalFeaturesHelp": "Funkcijos, kurios yra kuriamos ir laukiami jūsų komentarai. Spustelėkite, kad atidarytumėte „GitHub“ diskusiją.",
"LabelSettingsFindCovers": "Rasti viršelius",
@@ -428,6 +433,7 @@
"LabelShowAll": "Rodyti viską",
"LabelSize": "Dydis",
"LabelSleepTimer": "Miego laikmatis",
+ "LabelSlug": "Slug",
"LabelStart": "Pradėti",
"LabelStarted": "Pradėta",
"LabelStartedAt": "Pradėta",
@@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "Netinkamas pradžios laikas. Turi būti didesnis arba lygus ankstesnio skyriaus pradžios laikui",
"MessageChapterStartIsAfter": "Skyriaus pradžia yra po jūsų garso knygos pabaigos",
"MessageCheckingCron": "Tikrinamas cron...",
+ "MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Ar tikrai norite ištrinti atsarginę kopiją, skirtą {0}?",
"MessageConfirmDeleteFile": "Tai ištrins failą iš jūsų failų sistemos. Ar tikrai?",
"MessageConfirmDeleteLibrary": "Ar tikrai norite visam laikui ištrinti biblioteką \"{0}\"?",
diff --git a/client/strings/nl.json b/client/strings/nl.json
index 62b3be68..a1b3f3e3 100644
--- a/client/strings/nl.json
+++ b/client/strings/nl.json
@@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Verwijder {0} afleveringen",
"HeaderRSSFeedGeneral": "RSS-details",
"HeaderRSSFeedIsOpen": "RSS-feed is open",
+ "HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Opgeslagen mediavoortgang",
"HeaderSchedule": "Schema",
"HeaderScheduleLibraryScans": "Schema automatische bibliotheekscans",
@@ -201,6 +202,7 @@
"LabelClosePlayer": "Sluit speler",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Series inklappen",
+ "LabelCollection": "Collection",
"LabelCollections": "Collecties",
"LabelComplete": "Compleet",
"LabelConfirmPassword": "Bevestig wachtwoord",
@@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Watcher uitschakelen",
"LabelSettingsDisableWatcherForLibrary": "Map-watcher voor bibliotheek uitschakelen",
"LabelSettingsDisableWatcherHelp": "Schakelt het automatisch toevoegen/bijwerken van onderdelen wanneer bestandswijzigingen gedetecteerd zijn uit. *Vereist herstart server",
+ "LabelSettingsEnableWatcher": "Enable Watcher",
+ "LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
+ "LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "Experimentele functies",
"LabelSettingsExperimentalFeaturesHelp": "Functies in ontwikkeling die je feedback en testing kunnen gebruiken. Klik om de Github-discussie te openen.",
"LabelSettingsFindCovers": "Zoek covers",
@@ -428,6 +433,7 @@
"LabelShowAll": "Toon alle",
"LabelSize": "Grootte",
"LabelSleepTimer": "Slaaptimer",
+ "LabelSlug": "Slug",
"LabelStart": "Start",
"LabelStarted": "Gestart",
"LabelStartedAt": "Gestart op",
@@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "Ongeldig: starttijd moet be groter zijn dan of equal aan starttijd van vorig hoofdstuk",
"MessageChapterStartIsAfter": "Start van hoofdstuk is na het einde van je audioboek",
"MessageCheckingCron": "Cron aan het checken...",
+ "MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Weet je zeker dat je de backup voor {0} wil verwijderen?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Weet je zeker dat je de bibliotheek \"{0}\" permanent wil verwijderen?",
diff --git a/client/strings/pl.json b/client/strings/pl.json
index c55e7360..f3b15e64 100644
--- a/client/strings/pl.json
+++ b/client/strings/pl.json
@@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Usuń {0} odcinków",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "Kanał RSS jest otwarty",
+ "HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Zapisany postęp",
"HeaderSchedule": "Harmonogram",
"HeaderScheduleLibraryScans": "Zaplanuj automatyczne skanowanie biblioteki",
@@ -201,6 +202,7 @@
"LabelClosePlayer": "Zamknij odtwarzacz",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Podsumuj serię",
+ "LabelCollection": "Collection",
"LabelCollections": "Kolekcje",
"LabelComplete": "Ukończone",
"LabelConfirmPassword": "Potwierdź hasło",
@@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Wyłącz monitorowanie",
"LabelSettingsDisableWatcherForLibrary": "Wyłącz monitorowanie folderów dla biblioteki",
"LabelSettingsDisableWatcherHelp": "Wyłącz automatyczne dodawanie/aktualizowanie elementów po wykryciu zmian w plikach. *Wymaga restartu serwera",
+ "LabelSettingsEnableWatcher": "Enable Watcher",
+ "LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
+ "LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "Funkcje eksperymentalne",
"LabelSettingsExperimentalFeaturesHelp": "Funkcje w trakcie rozwoju, które mogą zyskanć na Twojej opinii i pomocy w testowaniu. Kliknij, aby otworzyć dyskusję na githubie.",
"LabelSettingsFindCovers": "Szukanie okładek",
@@ -428,6 +433,7 @@
"LabelShowAll": "Pokaż wszystko",
"LabelSize": "Rozmiar",
"LabelSleepTimer": "Wyłącznik czasowy",
+ "LabelSlug": "Slug",
"LabelStart": "Rozpocznij",
"LabelStarted": "Rozpoczęty",
"LabelStartedAt": "Rozpoczęto",
@@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
"MessageChapterStartIsAfter": "Początek rozdziału następuje po zakończeniu audiobooka",
"MessageCheckingCron": "Sprawdzanie cron...",
+ "MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Czy na pewno chcesz usunąć kopię zapasową dla {0}?",
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
"MessageConfirmDeleteLibrary": "Czy na pewno chcesz trwale usunąć bibliotekę \"{0}\"?",
diff --git a/client/strings/ru.json b/client/strings/ru.json
index ab986e85..68319a32 100644
--- a/client/strings/ru.json
+++ b/client/strings/ru.json
@@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "Удалить {0} эпизодов",
"HeaderRSSFeedGeneral": "Сведения о RSS",
"HeaderRSSFeedIsOpen": "RSS-канал открыт",
+ "HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "Прогресс медиа сохранен",
"HeaderSchedule": "Планировщик",
"HeaderScheduleLibraryScans": "Планировщик автоматического сканирования библиотеки",
@@ -201,6 +202,7 @@
"LabelClosePlayer": "Закрыть проигрыватель",
"LabelCodec": "Кодек",
"LabelCollapseSeries": "Свернуть серии",
+ "LabelCollection": "Collection",
"LabelCollections": "Коллекции",
"LabelComplete": "Завершить",
"LabelConfirmPassword": "Подтвердить пароль",
@@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "Отключить отслеживание",
"LabelSettingsDisableWatcherForLibrary": "Отключить отслеживание для библиотеки",
"LabelSettingsDisableWatcherHelp": "Отключает автоматическое добавление/обновление элементов, когда обнаружено изменение файлов. *Требуется перезапуск сервера",
+ "LabelSettingsEnableWatcher": "Enable Watcher",
+ "LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
+ "LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "Экспериментальные функции",
"LabelSettingsExperimentalFeaturesHelp": "Функционал в разработке на который Вы могли бы дать отзыв или помочь в тестировании. Нажмите для открытия обсуждения на github.",
"LabelSettingsFindCovers": "Найти обложки",
@@ -428,6 +433,7 @@
"LabelShowAll": "Показать все",
"LabelSize": "Размер",
"LabelSleepTimer": "Таймер сна",
+ "LabelSlug": "Slug",
"LabelStart": "Начало",
"LabelStarted": "Начат",
"LabelStartedAt": "Начато В",
@@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "Неверное время начала, должно быть больше или равно времени начала предыдущей главы",
"MessageChapterStartIsAfter": "Глава начинается после окончания аудиокниги",
"MessageCheckingCron": "Проверка cron...",
+ "MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "Вы уверены, что хотите удалить бэкап для {0}?",
"MessageConfirmDeleteFile": "Это удалит файл из Вашей файловой системы. Вы уверены?",
"MessageConfirmDeleteLibrary": "Вы уверены, что хотите навсегда удалить библиотеку \"{0}\"?",
diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json
index de11b591..17f1be7e 100644
--- a/client/strings/zh-cn.json
+++ b/client/strings/zh-cn.json
@@ -138,6 +138,7 @@
"HeaderRemoveEpisodes": "移除 {0} 剧集",
"HeaderRSSFeedGeneral": "RSS 详细信息",
"HeaderRSSFeedIsOpen": "RSS 源已打开",
+ "HeaderRSSFeeds": "RSS Feeds",
"HeaderSavedMediaProgress": "保存媒体进度",
"HeaderSchedule": "计划任务",
"HeaderScheduleLibraryScans": "自动扫描媒体库",
@@ -201,6 +202,7 @@
"LabelClosePlayer": "关闭播放器",
"LabelCodec": "编解码",
"LabelCollapseSeries": "折叠系列",
+ "LabelCollection": "Collection",
"LabelCollections": "收藏",
"LabelComplete": "已完成",
"LabelConfirmPassword": "确认密码",
@@ -396,6 +398,9 @@
"LabelSettingsDisableWatcher": "禁用监视程序",
"LabelSettingsDisableWatcherForLibrary": "禁用媒体库的文件夹监视程序",
"LabelSettingsDisableWatcherHelp": "检测到文件更改时禁用自动添加和更新项目. *需要重启服务器",
+ "LabelSettingsEnableWatcher": "Enable Watcher",
+ "LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
+ "LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsExperimentalFeatures": "实验功能",
"LabelSettingsExperimentalFeaturesHelp": "开发中的功能需要你的反馈并帮助测试. 点击打开 github 讨论.",
"LabelSettingsFindCovers": "查找封面",
@@ -428,6 +433,7 @@
"LabelShowAll": "全部显示",
"LabelSize": "文件大小",
"LabelSleepTimer": "睡眠定时",
+ "LabelSlug": "Slug",
"LabelStart": "开始",
"LabelStarted": "开始于",
"LabelStartedAt": "从这开始",
@@ -516,6 +522,7 @@
"MessageChapterErrorStartLtPrev": "无效的开始时间, 必须大于或等于上一章节的开始时间",
"MessageChapterStartIsAfter": "章节开始是在有声读物结束之后",
"MessageCheckingCron": "检查计划任务...",
+ "MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"MessageConfirmDeleteBackup": "你确定要删除备份 {0}?",
"MessageConfirmDeleteFile": "这将从文件系统中删除该文件. 你确定吗?",
"MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?",
diff --git a/docker-compose.yml b/docker-compose.yml
index 43acbfac..da3fa1f2 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -8,6 +8,7 @@ services:
- 13378:80
volumes:
- ./audiobooks:/audiobooks
+ - ./podcasts:/podcasts
- ./metadata:/metadata
- ./config:/config
restart: unless-stopped
diff --git a/package-lock.json b/package-lock.json
index ae903c37..397babb5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
- "version": "2.3.3",
- "lockfileVersion": 3,
+ "version": "2.4.1",
+ "lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
- "version": "2.3.3",
+ "version": "2.4.1",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",
@@ -2882,4 +2882,4 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
}
}
-}
+}
\ No newline at end of file
diff --git a/package.json b/package.json
index 51a15633..182c3033 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
- "version": "2.3.3",
+ "version": "2.4.1",
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
"scripts": {
diff --git a/server/Auth.js b/server/Auth.js
index 24dbe2fb..0885c88a 100644
--- a/server/Auth.js
+++ b/server/Auth.js
@@ -188,7 +188,7 @@ class Auth {
await Database.updateServerSettings()
// New token secret creation added in v2.1.0 so generate new API tokens for each user
- const users = await Database.models.user.getOldUsers()
+ const users = await Database.userModel.getOldUsers()
if (users.length) {
for (const user of users) {
user.token = await this.generateAccessToken({ userId: user.id, username: user.username })
diff --git a/server/Database.js b/server/Database.js
index bf04529f..a959d0a9 100644
--- a/server/Database.js
+++ b/server/Database.js
@@ -15,15 +15,16 @@ class Database {
this.isNew = false // New absdatabase.sqlite created
this.hasRootUser = false // Used to show initialization page in web ui
- // Temporarily using format of old DB
- // TODO: below data should be loaded from the DB as needed
- this.libraryItems = []
this.settings = []
- this.authors = []
- this.series = []
+ // Cached library filter data
+ this.libraryFilterData = {}
+
+ /** @type {import('./objects/settings/ServerSettings')} */
this.serverSettings = null
+ /** @type {import('./objects/settings/NotificationSettings')} */
this.notificationSettings = null
+ /** @type {import('./objects/settings/EmailSettings')} */
this.emailSettings = null
}
@@ -31,6 +32,105 @@ class Database {
return this.sequelize?.models || {}
}
+ /** @type {typeof import('./models/User')} */
+ get userModel() {
+ return this.models.user
+ }
+
+ /** @type {typeof import('./models/Library')} */
+ get libraryModel() {
+ return this.models.library
+ }
+
+ /** @type {typeof import('./models/LibraryFolder')} */
+ get libraryFolderModel() {
+ return this.models.libraryFolder
+ }
+
+ /** @type {typeof import('./models/Author')} */
+ get authorModel() {
+ return this.models.author
+ }
+
+ /** @type {typeof import('./models/Series')} */
+ get seriesModel() {
+ return this.models.series
+ }
+
+ /** @type {typeof import('./models/Book')} */
+ get bookModel() {
+ return this.models.book
+ }
+
+ /** @type {typeof import('./models/BookSeries')} */
+ get bookSeriesModel() {
+ return this.models.bookSeries
+ }
+
+ /** @type {typeof import('./models/BookAuthor')} */
+ get bookAuthorModel() {
+ return this.models.bookAuthor
+ }
+
+ /** @type {typeof import('./models/Podcast')} */
+ get podcastModel() {
+ return this.models.podcast
+ }
+
+ /** @type {typeof import('./models/PodcastEpisode')} */
+ get podcastEpisodeModel() {
+ return this.models.podcastEpisode
+ }
+
+ /** @type {typeof import('./models/LibraryItem')} */
+ get libraryItemModel() {
+ return this.models.libraryItem
+ }
+
+ /** @type {typeof import('./models/PodcastEpisode')} */
+ get podcastEpisodeModel() {
+ return this.models.podcastEpisode
+ }
+
+ /** @type {typeof import('./models/MediaProgress')} */
+ get mediaProgressModel() {
+ return this.models.mediaProgress
+ }
+
+ /** @type {typeof import('./models/Collection')} */
+ get collectionModel() {
+ return this.models.collection
+ }
+
+ /** @type {typeof import('./models/CollectionBook')} */
+ get collectionBookModel() {
+ return this.models.collectionBook
+ }
+
+ /** @type {typeof import('./models/Playlist')} */
+ get playlistModel() {
+ return this.models.playlist
+ }
+
+ /** @type {typeof import('./models/PlaylistMediaItem')} */
+ get playlistMediaItemModel() {
+ return this.models.playlistMediaItem
+ }
+
+ /** @type {typeof import('./models/Feed')} */
+ get feedModel() {
+ return this.models.feed
+ }
+
+ /** @type {typeof import('./models/Feed')} */
+ get feedEpisodeModel() {
+ return this.models.feedEpisode
+ }
+
+ /**
+ * Check if db file exists
+ * @returns {boolean}
+ */
async checkHasDb() {
if (!await fs.pathExists(this.dbPath)) {
Logger.info(`[Database] absdatabase.sqlite not found at ${this.dbPath}`)
@@ -39,6 +139,10 @@ class Database {
return true
}
+ /**
+ * Connect to db, build models and run migrations
+ * @param {boolean} [force=false] Used for testing, drops & re-creates all tables
+ */
async init(force = false) {
this.dbPath = Path.join(global.ConfigPath, 'absdatabase.sqlite')
@@ -52,9 +156,14 @@ class Database {
await this.buildModels(force)
Logger.info(`[Database] Db initialized with models:`, Object.keys(this.sequelize.models).join(', '))
+
await this.loadData()
}
+ /**
+ * Connect to db
+ * @returns {boolean}
+ */
async connect() {
Logger.info(`[Database] Initializing db at "${this.dbPath}"`)
this.sequelize = new Sequelize({
@@ -77,39 +186,45 @@ class Database {
}
}
+ /**
+ * Disconnect from db
+ */
async disconnect() {
Logger.info(`[Database] Disconnecting sqlite db`)
await this.sequelize.close()
this.sequelize = null
}
+ /**
+ * Reconnect to db and init
+ */
async reconnect() {
Logger.info(`[Database] Reconnecting sqlite db`)
await this.init()
}
buildModels(force = false) {
- require('./models/User')(this.sequelize)
- require('./models/Library')(this.sequelize)
- require('./models/LibraryFolder')(this.sequelize)
- require('./models/Book')(this.sequelize)
- require('./models/Podcast')(this.sequelize)
- require('./models/PodcastEpisode')(this.sequelize)
- require('./models/LibraryItem')(this.sequelize)
- require('./models/MediaProgress')(this.sequelize)
- require('./models/Series')(this.sequelize)
- require('./models/BookSeries')(this.sequelize)
- require('./models/Author')(this.sequelize)
- require('./models/BookAuthor')(this.sequelize)
- require('./models/Collection')(this.sequelize)
- require('./models/CollectionBook')(this.sequelize)
- require('./models/Playlist')(this.sequelize)
- require('./models/PlaylistMediaItem')(this.sequelize)
- require('./models/Device')(this.sequelize)
- require('./models/PlaybackSession')(this.sequelize)
- require('./models/Feed')(this.sequelize)
- require('./models/FeedEpisode')(this.sequelize)
- require('./models/Setting')(this.sequelize)
+ require('./models/User').init(this.sequelize)
+ require('./models/Library').init(this.sequelize)
+ require('./models/LibraryFolder').init(this.sequelize)
+ require('./models/Book').init(this.sequelize)
+ require('./models/Podcast').init(this.sequelize)
+ require('./models/PodcastEpisode').init(this.sequelize)
+ require('./models/LibraryItem').init(this.sequelize)
+ require('./models/MediaProgress').init(this.sequelize)
+ require('./models/Series').init(this.sequelize)
+ require('./models/BookSeries').init(this.sequelize)
+ require('./models/Author').init(this.sequelize)
+ require('./models/BookAuthor').init(this.sequelize)
+ require('./models/Collection').init(this.sequelize)
+ require('./models/CollectionBook').init(this.sequelize)
+ require('./models/Playlist').init(this.sequelize)
+ require('./models/PlaylistMediaItem').init(this.sequelize)
+ require('./models/Device').init(this.sequelize)
+ require('./models/PlaybackSession').init(this.sequelize)
+ require('./models/Feed').init(this.sequelize)
+ require('./models/FeedEpisode').init(this.sequelize)
+ require('./models/Setting').init(this.sequelize)
return this.sequelize.sync({ force, alter: false })
}
@@ -138,8 +253,6 @@ class Database {
await dbMigration.migrate(this.models)
}
- const startTime = Date.now()
-
const settingsData = await this.models.setting.getOldSettings()
this.settings = settingsData.settings
this.emailSettings = settingsData.emailSettings
@@ -155,22 +268,11 @@ class Database {
await dbMigration.migrationPatch2(this)
}
- Logger.info(`[Database] Loading db data...`)
-
- this.libraryItems = await this.models.libraryItem.loadAllLibraryItems()
- Logger.info(`[Database] Loaded ${this.libraryItems.length} library items`)
-
- this.authors = await this.models.author.getOldAuthors()
- Logger.info(`[Database] Loaded ${this.authors.length} authors`)
-
- this.series = await this.models.series.getAllOldSeries()
- Logger.info(`[Database] Loaded ${this.series.length} series`)
+ await this.cleanDatabase()
// Set if root user has been created
this.hasRootUser = await this.models.user.getHasRootUser()
- Logger.info(`[Database] Db data loaded in ${((Date.now() - startTime) / 1000).toFixed(2)}s`)
-
if (packageJson.version !== this.serverSettings.version) {
Logger.info(`[Database] Server upgrade detected from ${this.serverSettings.version} to ${packageJson.version}`)
this.serverSettings.version = packageJson.version
@@ -219,9 +321,9 @@ class Database {
return Promise.all(oldUsers.map(u => this.updateUser(u)))
}
- async removeUser(userId) {
+ removeUser(userId) {
if (!this.sequelize) return false
- await this.models.user.removeById(userId)
+ return this.models.user.removeById(userId)
}
upsertMediaProgress(oldMediaProgress) {
@@ -239,9 +341,9 @@ class Database {
return Promise.all(oldBooks.map(oldBook => this.models.book.saveFromOld(oldBook)))
}
- async createLibrary(oldLibrary) {
+ createLibrary(oldLibrary) {
if (!this.sequelize) return false
- await this.models.library.createFromOld(oldLibrary)
+ return this.models.library.createFromOld(oldLibrary)
}
updateLibrary(oldLibrary) {
@@ -249,56 +351,9 @@ class Database {
return this.models.library.updateFromOld(oldLibrary)
}
- async removeLibrary(libraryId) {
+ removeLibrary(libraryId) {
if (!this.sequelize) return false
- await this.models.library.removeById(libraryId)
- }
-
- async createCollection(oldCollection) {
- if (!this.sequelize) return false
- const newCollection = await this.models.collection.createFromOld(oldCollection)
- // Create CollectionBooks
- if (newCollection) {
- const collectionBooks = []
- oldCollection.books.forEach((libraryItemId) => {
- const libraryItem = this.libraryItems.find(li => li.id === libraryItemId)
- if (libraryItem) {
- collectionBooks.push({
- collectionId: newCollection.id,
- bookId: libraryItem.media.id
- })
- }
- })
- if (collectionBooks.length) {
- await this.createBulkCollectionBooks(collectionBooks)
- }
- }
- }
-
- updateCollection(oldCollection) {
- if (!this.sequelize) return false
- const collectionBooks = []
- let order = 1
- oldCollection.books.forEach((libraryItemId) => {
- const libraryItem = this.getLibraryItem(libraryItemId)
- if (!libraryItem) return
- collectionBooks.push({
- collectionId: oldCollection.id,
- bookId: libraryItem.media.id,
- order: order++
- })
- })
- return this.models.collection.fullUpdateFromOld(oldCollection, collectionBooks)
- }
-
- async removeCollection(collectionId) {
- if (!this.sequelize) return false
- await this.models.collection.removeById(collectionId)
- }
-
- createCollectionBook(collectionBook) {
- if (!this.sequelize) return false
- return this.models.collectionBook.create(collectionBook)
+ return this.models.library.removeById(libraryId)
}
createBulkCollectionBooks(collectionBooks) {
@@ -306,62 +361,6 @@ class Database {
return this.models.collectionBook.bulkCreate(collectionBooks)
}
- removeCollectionBook(collectionId, bookId) {
- if (!this.sequelize) return false
- return this.models.collectionBook.removeByIds(collectionId, bookId)
- }
-
- async createPlaylist(oldPlaylist) {
- if (!this.sequelize) return false
- const newPlaylist = await this.models.playlist.createFromOld(oldPlaylist)
- if (newPlaylist) {
- const playlistMediaItems = []
- let order = 1
- for (const mediaItemObj of oldPlaylist.items) {
- const libraryItem = this.libraryItems.find(li => li.id === mediaItemObj.libraryItemId)
- if (!libraryItem) continue
-
- let mediaItemId = libraryItem.media.id // bookId
- let mediaItemType = 'book'
- if (mediaItemObj.episodeId) {
- mediaItemType = 'podcastEpisode'
- mediaItemId = mediaItemObj.episodeId
- }
- playlistMediaItems.push({
- playlistId: newPlaylist.id,
- mediaItemId,
- mediaItemType,
- order: order++
- })
- }
- if (playlistMediaItems.length) {
- await this.createBulkPlaylistMediaItems(playlistMediaItems)
- }
- }
- }
-
- updatePlaylist(oldPlaylist) {
- if (!this.sequelize) return false
- const playlistMediaItems = []
- let order = 1
- oldPlaylist.items.forEach((item) => {
- const libraryItem = this.getLibraryItem(item.libraryItemId)
- if (!libraryItem) return
- playlistMediaItems.push({
- playlistId: oldPlaylist.id,
- mediaItemId: item.episodeId || libraryItem.media.id,
- mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
- order: order++
- })
- })
- return this.models.playlist.fullUpdateFromOld(oldPlaylist, playlistMediaItems)
- }
-
- async removePlaylist(playlistId) {
- if (!this.sequelize) return false
- await this.models.playlist.removeById(playlistId)
- }
-
createPlaylistMediaItem(playlistMediaItem) {
if (!this.sequelize) return false
return this.models.playlistMediaItem.create(playlistMediaItem)
@@ -372,25 +371,10 @@ class Database {
return this.models.playlistMediaItem.bulkCreate(playlistMediaItems)
}
- removePlaylistMediaItem(playlistId, mediaItemId) {
- if (!this.sequelize) return false
- return this.models.playlistMediaItem.removeByIds(playlistId, mediaItemId)
- }
-
- getLibraryItem(libraryItemId) {
- 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)
- }
-
async createLibraryItem(oldLibraryItem) {
if (!this.sequelize) return false
await oldLibraryItem.saveMetadata()
await this.models.libraryItem.fullCreateFromOld(oldLibraryItem)
- this.libraryItems.push(oldLibraryItem)
}
async updateLibraryItem(oldLibraryItem) {
@@ -399,32 +383,9 @@ class Database {
return this.models.libraryItem.fullUpdateFromOld(oldLibraryItem)
}
- async updateBulkLibraryItems(oldLibraryItems) {
- if (!this.sequelize) return false
- let updatesMade = 0
- for (const oldLibraryItem of oldLibraryItems) {
- await oldLibraryItem.saveMetadata()
- const hasUpdates = await this.models.libraryItem.fullUpdateFromOld(oldLibraryItem)
- if (hasUpdates) {
- updatesMade++
- }
- }
- return updatesMade
- }
-
- async createBulkLibraryItems(oldLibraryItems) {
- if (!this.sequelize) return false
- for (const oldLibraryItem of oldLibraryItems) {
- await oldLibraryItem.saveMetadata()
- await this.models.libraryItem.fullCreateFromOld(oldLibraryItem)
- this.libraryItems.push(oldLibraryItem)
- }
- }
-
async removeLibraryItem(libraryItemId) {
if (!this.sequelize) return false
await this.models.libraryItem.removeById(libraryItemId)
- this.libraryItems = this.libraryItems.filter(li => li.id !== libraryItemId)
}
async createFeed(oldFeed) {
@@ -450,31 +411,26 @@ class Database {
async createSeries(oldSeries) {
if (!this.sequelize) return false
await this.models.series.createFromOld(oldSeries)
- this.series.push(oldSeries)
}
async createBulkSeries(oldSeriesObjs) {
if (!this.sequelize) return false
await this.models.series.createBulkFromOld(oldSeriesObjs)
- this.series.push(...oldSeriesObjs)
}
async removeSeries(seriesId) {
if (!this.sequelize) return false
await this.models.series.removeById(seriesId)
- this.series = this.series.filter(se => se.id !== seriesId)
}
async createAuthor(oldAuthor) {
if (!this.sequelize) return false
await this.models.author.createFromOld(oldAuthor)
- this.authors.push(oldAuthor)
}
async createBulkAuthors(oldAuthors) {
if (!this.sequelize) return false
await this.models.author.createBulkFromOld(oldAuthors)
- this.authors.push(...oldAuthors)
}
updateAuthor(oldAuthor) {
@@ -485,24 +441,17 @@ class Database {
async removeAuthor(authorId) {
if (!this.sequelize) return false
await this.models.author.removeById(authorId)
- this.authors = this.authors.filter(au => au.id !== authorId)
}
async createBulkBookAuthors(bookAuthors) {
if (!this.sequelize) return false
await this.models.bookAuthor.bulkCreate(bookAuthors)
- this.authors.push(...bookAuthors)
}
async removeBulkBookAuthors(authorId = null, bookId = null) {
if (!this.sequelize) return false
if (!authorId && !bookId) return
await this.models.bookAuthor.removeByIds(authorId, bookId)
- this.authors = this.authors.filter(au => {
- if (authorId && au.authorId !== authorId) return true
- if (bookId && au.bookId !== bookId) return true
- return false
- })
}
getPlaybackSessions(where = null) {
@@ -544,6 +493,204 @@ class Database {
if (!this.sequelize) return false
return this.models.device.createFromOld(oldDevice)
}
+
+ replaceTagInFilterData(oldTag, newTag) {
+ for (const libraryId in this.libraryFilterData) {
+ const indexOf = this.libraryFilterData[libraryId].tags.findIndex(n => n === oldTag)
+ if (indexOf >= 0) {
+ this.libraryFilterData[libraryId].tags.splice(indexOf, 1, newTag)
+ }
+ }
+ }
+
+ removeTagFromFilterData(tag) {
+ for (const libraryId in this.libraryFilterData) {
+ this.libraryFilterData[libraryId].tags = this.libraryFilterData[libraryId].tags.filter(t => t !== tag)
+ }
+ }
+
+ addTagsToFilterData(libraryId, tags) {
+ if (!this.libraryFilterData[libraryId] || !tags?.length) return
+ tags.forEach((t) => {
+ if (!this.libraryFilterData[libraryId].tags.includes(t)) {
+ this.libraryFilterData[libraryId].tags.push(t)
+ }
+ })
+ }
+
+ replaceGenreInFilterData(oldGenre, newGenre) {
+ for (const libraryId in this.libraryFilterData) {
+ const indexOf = this.libraryFilterData[libraryId].genres.findIndex(n => n === oldGenre)
+ if (indexOf >= 0) {
+ this.libraryFilterData[libraryId].genres.splice(indexOf, 1, newGenre)
+ }
+ }
+ }
+
+ removeGenreFromFilterData(genre) {
+ for (const libraryId in this.libraryFilterData) {
+ this.libraryFilterData[libraryId].genres = this.libraryFilterData[libraryId].genres.filter(g => g !== genre)
+ }
+ }
+
+ addGenresToFilterData(libraryId, genres) {
+ if (!this.libraryFilterData[libraryId] || !genres?.length) return
+ genres.forEach((g) => {
+ if (!this.libraryFilterData[libraryId].genres.includes(g)) {
+ this.libraryFilterData[libraryId].genres.push(g)
+ }
+ })
+ }
+
+ replaceNarratorInFilterData(oldNarrator, newNarrator) {
+ for (const libraryId in this.libraryFilterData) {
+ const indexOf = this.libraryFilterData[libraryId].narrators.findIndex(n => n === oldNarrator)
+ if (indexOf >= 0) {
+ this.libraryFilterData[libraryId].narrators.splice(indexOf, 1, newNarrator)
+ }
+ }
+ }
+
+ removeNarratorFromFilterData(narrator) {
+ for (const libraryId in this.libraryFilterData) {
+ this.libraryFilterData[libraryId].narrators = this.libraryFilterData[libraryId].narrators.filter(n => n !== narrator)
+ }
+ }
+
+ addNarratorsToFilterData(libraryId, narrators) {
+ if (!this.libraryFilterData[libraryId] || !narrators?.length) return
+ narrators.forEach((n) => {
+ if (!this.libraryFilterData[libraryId].narrators.includes(n)) {
+ this.libraryFilterData[libraryId].narrators.push(n)
+ }
+ })
+ }
+
+ removeSeriesFromFilterData(libraryId, seriesId) {
+ if (!this.libraryFilterData[libraryId]) return
+ this.libraryFilterData[libraryId].series = this.libraryFilterData[libraryId].series.filter(se => se.id !== seriesId)
+ }
+
+ addSeriesToFilterData(libraryId, seriesName, seriesId) {
+ if (!this.libraryFilterData[libraryId]) return
+ // Check if series is already added
+ if (this.libraryFilterData[libraryId].series.some(se => se.id === seriesId)) return
+ this.libraryFilterData[libraryId].series.push({
+ id: seriesId,
+ name: seriesName
+ })
+ }
+
+ removeAuthorFromFilterData(libraryId, authorId) {
+ if (!this.libraryFilterData[libraryId]) return
+ this.libraryFilterData[libraryId].authors = this.libraryFilterData[libraryId].authors.filter(au => au.id !== authorId)
+ }
+
+ addAuthorToFilterData(libraryId, authorName, authorId) {
+ if (!this.libraryFilterData[libraryId]) return
+ // Check if author is already added
+ if (this.libraryFilterData[libraryId].authors.some(au => au.id === authorId)) return
+ this.libraryFilterData[libraryId].authors.push({
+ id: authorId,
+ name: authorName
+ })
+ }
+
+ addPublisherToFilterData(libraryId, publisher) {
+ if (!this.libraryFilterData[libraryId] || !publisher || this.libraryFilterData[libraryId].publishers.includes(publisher)) return
+ this.libraryFilterData[libraryId].publishers.push(publisher)
+ }
+
+ addLanguageToFilterData(libraryId, language) {
+ if (!this.libraryFilterData[libraryId] || !language || this.libraryFilterData[libraryId].languages.includes(language)) return
+ this.libraryFilterData[libraryId].languages.push(language)
+ }
+
+ /**
+ * Used when updating items to make sure author id exists
+ * If library filter data is set then use that for check
+ * otherwise lookup in db
+ * @param {string} libraryId
+ * @param {string} authorId
+ * @returns {Promise}
+ */
+ async checkAuthorExists(libraryId, authorId) {
+ if (!this.libraryFilterData[libraryId]) {
+ return this.authorModel.checkExistsById(authorId)
+ }
+ return this.libraryFilterData[libraryId].authors.some(au => au.id === authorId)
+ }
+
+ /**
+ * Used when updating items to make sure series id exists
+ * If library filter data is set then use that for check
+ * otherwise lookup in db
+ * @param {string} libraryId
+ * @param {string} seriesId
+ * @returns {Promise}
+ */
+ async checkSeriesExists(libraryId, seriesId) {
+ if (!this.libraryFilterData[libraryId]) {
+ return this.seriesModel.checkExistsById(seriesId)
+ }
+ return this.libraryFilterData[libraryId].series.some(se => se.id === seriesId)
+ }
+
+ /**
+ * Reset numIssues for library
+ * @param {string} libraryId
+ */
+ async resetLibraryIssuesFilterData(libraryId) {
+ if (!this.libraryFilterData[libraryId]) return // Do nothing if filter data is not set
+
+ this.libraryFilterData[libraryId].numIssues = await this.libraryItemModel.count({
+ where: {
+ libraryId,
+ [Sequelize.Op.or]: [
+ {
+ isMissing: true
+ },
+ {
+ isInvalid: true
+ }
+ ]
+ }
+ })
+ }
+
+ /**
+ * Clean invalid records in database
+ * Series should have atleast one Book
+ * Book and Podcast must have an associated LibraryItem
+ */
+ async cleanDatabase() {
+ // Remove invalid Podcast records
+ const podcastsWithNoLibraryItem = await this.podcastModel.findAll({
+ where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM libraryItems li WHERE li.mediaId = podcast.id)`), 0)
+ })
+ for (const podcast of podcastsWithNoLibraryItem) {
+ Logger.warn(`Found podcast "${podcast.title}" with no libraryItem - removing it`)
+ await podcast.destroy()
+ }
+
+ // Remove invalid Book records
+ const booksWithNoLibraryItem = await this.bookModel.findAll({
+ where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM libraryItems li WHERE li.mediaId = book.id)`), 0)
+ })
+ for (const book of booksWithNoLibraryItem) {
+ Logger.warn(`Found book "${book.title}" with no libraryItem - removing it`)
+ await book.destroy()
+ }
+
+ // Remove empty series
+ const emptySeries = await this.seriesModel.findAll({
+ where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM bookSeries bs WHERE bs.seriesId = series.id)`), 0)
+ })
+ for (const series of emptySeries) {
+ Logger.warn(`Found series "${series.name}" with no books - removing it`)
+ await series.destroy()
+ }
+ }
}
module.exports = new Database()
\ No newline at end of file
diff --git a/server/Server.js b/server/Server.js
index 448561a0..9156c021 100644
--- a/server/Server.js
+++ b/server/Server.js
@@ -9,24 +9,19 @@ const rateLimit = require('./libs/expressRateLimit')
const { version } = require('../package.json')
// Utils
-const filePerms = require('./utils/filePerms')
const fileUtils = require('./utils/fileUtils')
const Logger = require('./Logger')
const Auth = require('./Auth')
const Watcher = require('./Watcher')
-const Scanner = require('./scanner/Scanner')
const Database = require('./Database')
const SocketAuthority = require('./SocketAuthority')
-const routes = require('./routes/index')
-
const ApiRouter = require('./routers/ApiRouter')
const HlsRouter = require('./routers/HlsRouter')
const NotificationManager = require('./managers/NotificationManager')
const EmailManager = require('./managers/EmailManager')
-const CoverManager = require('./managers/CoverManager')
const AbMergeManager = require('./managers/AbMergeManager')
const CacheManager = require('./managers/CacheManager')
const LogManager = require('./managers/LogManager')
@@ -37,6 +32,7 @@ const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
const RssFeedManager = require('./managers/RssFeedManager')
const CronManager = require('./managers/CronManager')
const TaskManager = require('./managers/TaskManager')
+const LibraryScanner = require('./scanner/LibraryScanner')
//Import the main Passport and Express-Session library
const passport = require('passport')
@@ -58,11 +54,9 @@ class Server {
if (!fs.pathExistsSync(global.ConfigPath)) {
fs.mkdirSync(global.ConfigPath)
- filePerms.setDefaultDirSync(global.ConfigPath, false)
}
if (!fs.pathExistsSync(global.MetadataPath)) {
fs.mkdirSync(global.MetadataPath)
- filePerms.setDefaultDirSync(global.MetadataPath, false)
}
this.watcher = new Watcher()
@@ -74,16 +68,12 @@ class Server {
this.emailManager = new EmailManager()
this.backupManager = new BackupManager()
this.logManager = new LogManager()
- this.cacheManager = new CacheManager()
this.abMergeManager = new AbMergeManager(this.taskManager)
this.playbackSessionManager = new PlaybackSessionManager()
- this.coverManager = new CoverManager(this.cacheManager)
this.podcastManager = new PodcastManager(this.watcher, this.notificationManager, this.taskManager)
this.audioMetadataManager = new AudioMetadataMangaer(this.taskManager)
this.rssFeedManager = new RssFeedManager()
-
- this.scanner = new Scanner(this.coverManager, this.taskManager)
- this.cronManager = new CronManager(this.scanner, this.podcastManager)
+ this.cronManager = new CronManager(this.podcastManager)
// Routers
this.apiRouter = new ApiRouter(this)
@@ -99,6 +89,14 @@ class Server {
this.auth.isAuthenticated(req, res, next)
}
+ cancelLibraryScan(libraryId) {
+ LibraryScanner.setCancelLibraryScan(libraryId)
+ }
+
+ getLibrariesScanning() {
+ return LibraryScanner.librariesScanning
+ }
+
/**
* Initialize database, backups, logs, rss feeds, cron jobs & watcher
* Cleanup stale/invalid data
@@ -115,22 +113,20 @@ class Server {
}
await this.cleanUserData() // Remove invalid user item progress
- await this.cacheManager.ensureCachePaths()
+ await CacheManager.ensureCachePaths()
await this.backupManager.init()
await this.logManager.init()
- await this.apiRouter.checkRemoveEmptySeries(Database.series) // Remove empty series
await this.rssFeedManager.init()
- const libraries = await Database.models.library.getAllOldLibraries()
- this.cronManager.init(libraries)
+ const libraries = await Database.libraryModel.getAllOldLibraries()
+ await this.cronManager.init(libraries)
if (Database.serverSettings.scannerDisableWatcher) {
Logger.info(`[Server] Watcher is disabled`)
this.watcher.disabled = true
} else {
this.watcher.initWatcher(libraries)
- this.watcher.on('files', this.filesChanged.bind(this))
}
}
@@ -269,17 +265,12 @@ class Server {
res.sendStatus(200)
}
- async filesChanged(fileUpdates) {
- Logger.info('[Server]', fileUpdates.length, 'Files Changed')
- await this.scanner.scanFilesChanged(fileUpdates)
- }
-
/**
* Remove user media progress for items that no longer exist & remove seriesHideFrom that no longer exist
*/
async cleanUserData() {
// Get all media progress without an associated media item
- const mediaProgressToRemove = await Database.models.mediaProgress.findAll({
+ const mediaProgressToRemove = await Database.mediaProgressModel.findAll({
where: {
'$podcastEpisode.id$': null,
'$book.id$': null
@@ -287,18 +278,18 @@ class Server {
attributes: ['id'],
include: [
{
- model: Database.models.book,
+ model: Database.bookModel,
attributes: ['id']
},
{
- model: Database.models.podcastEpisode,
+ model: Database.podcastEpisodeModel,
attributes: ['id']
}
]
})
if (mediaProgressToRemove.length) {
// Remove media progress
- const mediaProgressRemoved = await Database.models.mediaProgress.destroy({
+ const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
where: {
id: {
[Sequelize.Op.in]: mediaProgressToRemove.map(mp => mp.id)
@@ -311,12 +302,19 @@ class Server {
}
// Remove series from hide from continue listening that no longer exist
- const users = await Database.models.user.getOldUsers()
+ const users = await Database.userModel.getOldUsers()
for (const _user of users) {
let hasUpdated = false
if (_user.seriesHideFromContinueListening.length) {
+ const seriesHiding = (await Database.seriesModel.findAll({
+ where: {
+ id: _user.seriesHideFromContinueListening
+ },
+ attributes: ['id'],
+ raw: true
+ })).map(se => se.id)
_user.seriesHideFromContinueListening = _user.seriesHideFromContinueListening.filter(seriesId => {
- if (!Database.series.some(se => se.id === seriesId)) { // Series removed
+ if (!seriesHiding.includes(seriesId)) { // Series removed
hasUpdated = true
return false
}
diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js
index 63f34cc2..5c3c8715 100644
--- a/server/SocketAuthority.js
+++ b/server/SocketAuthority.js
@@ -10,8 +10,11 @@ class SocketAuthority {
this.clients = {}
}
- // returns an array of User.toJSONForPublic with `connections` for the # of socket connections
- // a user can have many socket connections
+ /**
+ * returns an array of User.toJSONForPublic with `connections` for the # of socket connections
+ * a user can have many socket connections
+ * @returns {object[]}
+ */
getUsersOnline() {
const onlineUsersMap = {}
Object.values(this.clients).filter(c => c.user).forEach(client => {
@@ -19,7 +22,7 @@ class SocketAuthority {
onlineUsersMap[client.user.id].connections++
} else {
onlineUsersMap[client.user.id] = {
- ...client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, Database.libraryItems),
+ ...client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions),
connections: 1
}
}
@@ -31,9 +34,12 @@ class SocketAuthority {
return Object.values(this.clients).filter(c => c.user && c.user.id === userId)
}
- // Emits event to all authorized clients
- // optional filter function to only send event to specific users
- // TODO: validate that filter is actually a function
+ /**
+ * Emits event to all authorized clients
+ * @param {string} evt
+ * @param {any} data
+ * @param {Function} [filter] optional filter function to only send event to specific users
+ */
emitter(evt, data, filter = null) {
for (const socketId in this.clients) {
if (this.clients[socketId].user) {
@@ -89,7 +95,7 @@ class SocketAuthority {
socket.on('auth', (token) => this.authenticateSocket(socket, token))
// Scanning
- socket.on('cancel_scan', this.cancelScan.bind(this))
+ socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))
// Logs
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
@@ -108,7 +114,7 @@ class SocketAuthority {
delete this.clients[socket.id]
} else {
Logger.debug('[SocketAuthority] User Offline ' + _client.user.username)
- this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, Database.libraryItems))
+ this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
const disconnectTime = Date.now() - _client.connected_at
Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
@@ -165,7 +171,7 @@ class SocketAuthority {
Logger.debug(`[SocketAuthority] User Online ${client.user.username}`)
- this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, Database.libraryItems))
+ this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
// Update user lastSeen
user.lastSeen = Date.now()
@@ -174,7 +180,7 @@ class SocketAuthority {
const initialPayload = {
userId: client.user.id,
username: client.user.username,
- librariesScanning: this.Server.scanner.librariesScanning
+ librariesScanning: this.Server.getLibrariesScanning()
}
if (user.isAdminOrUp) {
initialPayload.usersOnline = this.getUsersOnline()
@@ -191,7 +197,7 @@ class SocketAuthority {
if (client.user) {
Logger.debug('[SocketAuthority] User Offline ' + client.user.username)
- this.adminEmitter('user_offline', client.user.toJSONForPublic(null, Database.libraryItems))
+ this.adminEmitter('user_offline', client.user.toJSONForPublic())
}
delete this.clients[socketId].user
@@ -203,7 +209,7 @@ class SocketAuthority {
cancelScan(id) {
Logger.debug('[SocketAuthority] Cancel scan', id)
- this.Server.scanner.setCancelLibraryScan(id)
+ this.Server.cancelLibraryScan(id)
}
}
module.exports = new SocketAuthority()
\ No newline at end of file
diff --git a/server/Watcher.js b/server/Watcher.js
index 7d62296b..bc6b6094 100644
--- a/server/Watcher.js
+++ b/server/Watcher.js
@@ -1,21 +1,34 @@
+const Path = require('path')
const EventEmitter = require('events')
const Watcher = require('./libs/watcher/watcher')
const Logger = require('./Logger')
+const LibraryScanner = require('./scanner/LibraryScanner')
const { filePathToPOSIX } = require('./utils/fileUtils')
+/**
+ * @typedef PendingFileUpdate
+ * @property {string} path
+ * @property {string} relPath
+ * @property {string} folderId
+ * @property {string} type
+ */
class FolderWatcher extends EventEmitter {
constructor() {
super()
- this.paths = [] // Not used
- this.pendingFiles = [] // Not used
+ /** @type {{id:string, name:string, folders:import('./objects/Folder')[], paths:string[], watcher:Watcher[]}[]} */
this.libraryWatchers = []
+ /** @type {PendingFileUpdate[]} */
this.pendingFileUpdates = []
this.pendingDelay = 4000
this.pendingTimeout = null
+ /** @type {string[]} */
this.ignoreDirs = []
+ /** @type {string[]} */
+ this.pendingDirsToRemoveFromIgnore = []
+
this.disabled = false
}
@@ -29,11 +42,12 @@ class FolderWatcher extends EventEmitter {
return
}
Logger.info(`[Watcher] Initializing watcher for "${library.name}".`)
- var folderPaths = library.folderPaths
+
+ const folderPaths = library.folderPaths
folderPaths.forEach((fp) => {
Logger.debug(`[Watcher] Init watcher for library folder path "${fp}"`)
})
- var watcher = new Watcher(folderPaths, {
+ const watcher = new Watcher(folderPaths, {
ignored: /(^|[\/\\])\../, // ignore dotfiles
renameDetection: true,
renameTimeout: 2000,
@@ -144,6 +158,12 @@ class FolderWatcher extends EventEmitter {
this.addFileUpdate(libraryId, pathTo, 'renamed')
}
+ /**
+ * File update detected from watcher
+ * @param {string} libraryId
+ * @param {string} path
+ * @param {string} type
+ */
addFileUpdate(libraryId, path, type) {
path = filePathToPOSIX(path)
if (this.pendingFilePaths.includes(path)) return
@@ -161,11 +181,18 @@ class FolderWatcher extends EventEmitter {
Logger.error(`[Watcher] New file folder not found in library "${libwatcher.name}" with path "${path}"`)
return
}
+
const folderFullPath = filePathToPOSIX(folder.fullPath)
- var relPath = path.replace(folderFullPath, '')
+ const relPath = path.replace(folderFullPath, '')
- var hasDotPath = relPath.split('/').find(p => p.startsWith('.'))
+ if (Path.extname(relPath).toLowerCase() === '.part') {
+ Logger.debug(`[Watcher] Ignoring .part file "${relPath}"`)
+ return
+ }
+
+ // Ignore files/folders starting with "."
+ const hasDotPath = relPath.split('/').find(p => p.startsWith('.'))
if (hasDotPath) {
Logger.debug(`[Watcher] Ignoring dot path "${relPath}" | Piece "${hasDotPath}"`)
return
@@ -184,7 +211,8 @@ class FolderWatcher extends EventEmitter {
// Notify server of update after "pendingDelay"
clearTimeout(this.pendingTimeout)
this.pendingTimeout = setTimeout(() => {
- this.emit('files', this.pendingFileUpdates)
+ // this.emit('files', this.pendingFileUpdates)
+ LibraryScanner.scanFilesChanged(this.pendingFileUpdates)
this.pendingFileUpdates = []
}, this.pendingDelay)
}
@@ -195,24 +223,50 @@ class FolderWatcher extends EventEmitter {
})
}
+ /**
+ * Convert to POSIX and remove trailing slash
+ * @param {string} path
+ * @returns {string}
+ */
cleanDirPath(path) {
path = filePathToPOSIX(path)
if (path.endsWith('/')) path = path.slice(0, -1)
return path
}
+ /**
+ * Ignore this directory if files are picked up by watcher
+ * @param {string} path
+ */
addIgnoreDir(path) {
path = this.cleanDirPath(path)
if (this.ignoreDirs.includes(path)) return
+ this.pendingDirsToRemoveFromIgnore = this.pendingDirsToRemoveFromIgnore.filter(p => p !== path)
Logger.debug(`[Watcher] Ignoring directory "${path}"`)
this.ignoreDirs.push(path)
}
+ /**
+ * When downloading a podcast episode we dont want the scanner triggering for that podcast
+ * when the episode finishes the watcher may have a delayed response so a timeout is added
+ * to prevent the watcher from picking up the episode
+ *
+ * @param {string} path
+ */
removeIgnoreDir(path) {
path = this.cleanDirPath(path)
- if (!this.ignoreDirs.includes(path)) return
- Logger.debug(`[Watcher] No longer ignoring directory "${path}"`)
- this.ignoreDirs = this.ignoreDirs.filter(p => p !== path)
+ if (!this.ignoreDirs.includes(path) || this.pendingDirsToRemoveFromIgnore.includes(path)) return
+
+ // Add a 5 second delay before removing the ignore from this dir
+ this.pendingDirsToRemoveFromIgnore.push(path)
+ setTimeout(() => {
+ if (this.pendingDirsToRemoveFromIgnore.includes(path)) {
+ this.pendingDirsToRemoveFromIgnore = this.pendingDirsToRemoveFromIgnore.filter(p => p !== path)
+ Logger.debug(`[Watcher] No longer ignoring directory "${path}"`)
+ this.ignoreDirs = this.ignoreDirs.filter(p => p !== path)
+ }
+ }, 5000)
+
}
}
module.exports = FolderWatcher
\ No newline at end of file
diff --git a/server/controllers/AuthorController.js b/server/controllers/AuthorController.js
index 5133d1cb..531d2b42 100644
--- a/server/controllers/AuthorController.js
+++ b/server/controllers/AuthorController.js
@@ -1,10 +1,13 @@
-
+const sequelize = require('sequelize')
const fs = require('../libs/fsExtra')
const { createNewSortInstance } = require('../libs/fastSort')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
+const CacheManager = require('../managers/CacheManager')
+const CoverManager = require('../managers/CoverManager')
+const AuthorFinder = require('../finders/AuthorFinder')
const { reqSupportsWebp } = require('../utils/index')
@@ -21,7 +24,7 @@ class AuthorController {
// Used on author landing page to include library items and items grouped in series
if (include.includes('items')) {
- authorJson.libraryItems = await Database.models.libraryItem.getForAuthor(req.author, req.user)
+ authorJson.libraryItems = await Database.libraryItemModel.getForAuthor(req.author, req.user)
if (include.includes('series')) {
const seriesMap = {}
@@ -67,13 +70,13 @@ class AuthorController {
// Updating/removing cover image
if (payload.imagePath !== undefined && payload.imagePath !== req.author.imagePath) {
if (!payload.imagePath && req.author.imagePath) { // If removing image then remove file
- await this.cacheManager.purgeImageCache(req.author.id) // Purge cache
- await this.coverManager.removeFile(req.author.imagePath)
+ await CacheManager.purgeImageCache(req.author.id) // Purge cache
+ await CoverManager.removeFile(req.author.imagePath)
} else if (payload.imagePath.startsWith('http')) { // Check if image path is a url
- const imageData = await this.authorFinder.saveAuthorImage(req.author.id, payload.imagePath)
+ const imageData = await AuthorFinder.saveAuthorImage(req.author.id, payload.imagePath)
if (imageData) {
if (req.author.imagePath) {
- await this.cacheManager.purgeImageCache(req.author.id) // Purge cache
+ await CacheManager.purgeImageCache(req.author.id) // Purge cache
}
payload.imagePath = imageData.path
hasUpdated = true
@@ -85,7 +88,7 @@ class AuthorController {
}
if (req.author.imagePath) {
- await this.cacheManager.purgeImageCache(req.author.id) // Purge cache
+ await CacheManager.purgeImageCache(req.author.id) // Purge cache
}
}
}
@@ -93,10 +96,21 @@ class AuthorController {
const authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name
// Check if author name matches another author and merge the authors
- const existingAuthor = authorNameUpdate ? Database.authors.find(au => au.id !== req.author.id && payload.name === au.name) : false
+ let existingAuthor = null
+ if (authorNameUpdate) {
+ const author = await Database.authorModel.findOne({
+ where: {
+ id: {
+ [sequelize.Op.not]: req.author.id
+ },
+ name: payload.name
+ }
+ })
+ existingAuthor = author?.getOldAuthor()
+ }
if (existingAuthor) {
const bookAuthorsToCreate = []
- const itemsWithAuthor = await Database.models.libraryItem.getForAuthor(req.author)
+ const itemsWithAuthor = await Database.libraryItemModel.getForAuthor(req.author)
itemsWithAuthor.forEach(libraryItem => { // Replace old author with merging author for each book
libraryItem.media.metadata.replaceAuthor(req.author, existingAuthor)
bookAuthorsToCreate.push({
@@ -113,9 +127,11 @@ class AuthorController {
// Remove old author
await Database.removeAuthor(req.author.id)
SocketAuthority.emitter('author_removed', req.author.toJSON())
+ // Update filter data
+ Database.removeAuthorFromFilterData(req.author.libraryId, req.author.id)
// Send updated num books for merged author
- const numBooks = await Database.models.libraryItem.getForAuthor(existingAuthor).length
+ const numBooks = await Database.libraryItemModel.getForAuthor(existingAuthor).length
SocketAuthority.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks))
res.json({
@@ -130,7 +146,7 @@ class AuthorController {
if (hasUpdated) {
req.author.updatedAt = Date.now()
- const itemsWithAuthor = await Database.models.libraryItem.getForAuthor(req.author)
+ const itemsWithAuthor = await Database.libraryItemModel.getForAuthor(req.author)
if (authorNameUpdate) { // Update author name on all books
itemsWithAuthor.forEach(libraryItem => {
libraryItem.media.metadata.updateAuthor(req.author)
@@ -151,24 +167,13 @@ class AuthorController {
}
}
- async search(req, res) {
- var q = (req.query.q || '').toLowerCase()
- if (!q) return res.json([])
- var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25
- var authors = Database.authors.filter(au => au.name?.toLowerCase().includes(q))
- authors = authors.slice(0, limit)
- res.json({
- results: authors
- })
- }
-
async match(req, res) {
let authorData = null
const region = req.body.region || 'us'
if (req.body.asin) {
- authorData = await this.authorFinder.findAuthorByASIN(req.body.asin, region)
+ authorData = await AuthorFinder.findAuthorByASIN(req.body.asin, region)
} else {
- authorData = await this.authorFinder.findAuthorByName(req.body.q, region)
+ authorData = await AuthorFinder.findAuthorByName(req.body.q, region)
}
if (!authorData) {
return res.status(404).send('Author not found')
@@ -183,9 +188,9 @@ class AuthorController {
// Only updates image if there was no image before or the author ASIN was updated
if (authorData.image && (!req.author.imagePath || hasUpdates)) {
- this.cacheManager.purgeImageCache(req.author.id)
+ await CacheManager.purgeImageCache(req.author.id)
- const imageData = await this.authorFinder.saveAuthorImage(req.author.id, authorData.image)
+ const imageData = await AuthorFinder.saveAuthorImage(req.author.id, authorData.image)
if (imageData) {
req.author.imagePath = imageData.path
hasUpdates = true
@@ -202,7 +207,7 @@ class AuthorController {
await Database.updateAuthor(req.author)
- const numBooks = await Database.models.libraryItem.getForAuthor(req.author).length
+ const numBooks = await Database.libraryItemModel.getForAuthor(req.author).length
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
}
@@ -229,11 +234,11 @@ class AuthorController {
height: height ? parseInt(height) : null,
width: width ? parseInt(width) : null
}
- return this.cacheManager.handleAuthorCache(res, author, options)
+ return CacheManager.handleAuthorCache(res, author, options)
}
- middleware(req, res, next) {
- const author = Database.authors.find(au => au.id === req.params.id)
+ async middleware(req, res, next) {
+ const author = await Database.authorModel.getOldById(req.params.id)
if (!author) return res.sendStatus(404)
if (req.method == 'DELETE' && !req.user.canDelete) {
diff --git a/server/controllers/CacheController.js b/server/controllers/CacheController.js
index 815af44d..95c5fe0c 100644
--- a/server/controllers/CacheController.js
+++ b/server/controllers/CacheController.js
@@ -1,4 +1,4 @@
-const Logger = require('../Logger')
+const CacheManager = require('../managers/CacheManager')
class CacheController {
constructor() { }
@@ -8,7 +8,7 @@ class CacheController {
if (!req.user.isAdminOrUp) {
return res.sendStatus(403)
}
- await this.cacheManager.purgeAll()
+ await CacheManager.purgeAll()
res.sendStatus(200)
}
@@ -17,7 +17,7 @@ class CacheController {
if (!req.user.isAdminOrUp) {
return res.sendStatus(403)
}
- await this.cacheManager.purgeItems()
+ await CacheManager.purgeItems()
res.sendStatus(200)
}
}
diff --git a/server/controllers/CollectionController.js b/server/controllers/CollectionController.js
index 30bb2a61..5357a5dc 100644
--- a/server/controllers/CollectionController.js
+++ b/server/controllers/CollectionController.js
@@ -1,3 +1,4 @@
+const Sequelize = require('sequelize')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
@@ -7,22 +8,49 @@ const Collection = require('../objects/Collection')
class CollectionController {
constructor() { }
+ /**
+ * POST: /api/collections
+ * Create new collection
+ * @param {*} req
+ * @param {*} res
+ */
async create(req, res) {
const newCollection = new Collection()
req.body.userId = req.user.id
if (!newCollection.setData(req.body)) {
- return res.status(500).send('Invalid collection data')
+ return res.status(400).send('Invalid collection data')
+ }
+
+ // Create collection record
+ await Database.collectionModel.createFromOld(newCollection)
+
+ // Get library items in collection
+ const libraryItemsInCollection = await Database.libraryItemModel.getForCollection(newCollection)
+
+ // Create collectionBook records
+ let order = 1
+ const collectionBooksToAdd = []
+ for (const libraryItemId of newCollection.books) {
+ const libraryItem = libraryItemsInCollection.find(li => li.id === libraryItemId)
+ if (libraryItem) {
+ collectionBooksToAdd.push({
+ collectionId: newCollection.id,
+ bookId: libraryItem.media.id,
+ order: order++
+ })
+ }
+ }
+ if (collectionBooksToAdd.length) {
+ await Database.createBulkCollectionBooks(collectionBooksToAdd)
}
- const libraryItemsInCollection = await Database.models.libraryItem.getForCollection(newCollection)
const jsonExpanded = newCollection.toJSONExpanded(libraryItemsInCollection)
- await Database.createCollection(newCollection)
SocketAuthority.emitter('collection_added', jsonExpanded)
res.json(jsonExpanded)
}
async findAll(req, res) {
- const collectionsExpanded = await Database.models.collection.getOldCollectionsJsonExpanded(req.user)
+ const collectionsExpanded = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user)
res.json({
collections: collectionsExpanded
})
@@ -31,140 +59,275 @@ class CollectionController {
async findOne(req, res) {
const includeEntities = (req.query.include || '').split(',')
- const collectionExpanded = req.collection.toJSONExpanded(Database.libraryItems)
-
- if (includeEntities.includes('rssfeed')) {
- const feedData = await this.rssFeedManager.findFeedForEntityId(collectionExpanded.id)
- collectionExpanded.rssFeed = feedData?.toJSONMinified() || null
+ const collectionExpanded = await req.collection.getOldJsonExpanded(req.user, includeEntities)
+ if (!collectionExpanded) {
+ // This may happen if the user is restricted from all books
+ return res.sendStatus(404)
}
res.json(collectionExpanded)
}
+ /**
+ * PATCH: /api/collections/:id
+ * Update collection
+ * @param {*} req
+ * @param {*} res
+ */
async update(req, res) {
- const collection = req.collection
- const wasUpdated = collection.update(req.body)
- const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
+ let wasUpdated = false
+
+ // Update description and name if defined
+ const collectionUpdatePayload = {}
+ if (req.body.description !== undefined && req.body.description !== req.collection.description) {
+ collectionUpdatePayload.description = req.body.description
+ wasUpdated = true
+ }
+ if (req.body.name !== undefined && req.body.name !== req.collection.name) {
+ collectionUpdatePayload.name = req.body.name
+ wasUpdated = true
+ }
+
+ if (wasUpdated) {
+ await req.collection.update(collectionUpdatePayload)
+ }
+
+ // If books array is passed in then update order in collection
+ if (req.body.books?.length) {
+ const collectionBooks = await req.collection.getCollectionBooks({
+ include: {
+ model: Database.bookModel,
+ include: Database.libraryItemModel
+ },
+ order: [['order', 'ASC']]
+ })
+ collectionBooks.sort((a, b) => {
+ const aIndex = req.body.books.findIndex(lid => lid === a.book.libraryItem.id)
+ const bIndex = req.body.books.findIndex(lid => lid === b.book.libraryItem.id)
+ return aIndex - bIndex
+ })
+ for (let i = 0; i < collectionBooks.length; i++) {
+ if (collectionBooks[i].order !== i + 1) {
+ await collectionBooks[i].update({
+ order: i + 1
+ })
+ wasUpdated = true
+ }
+ }
+ }
+
+ const jsonExpanded = await req.collection.getOldJsonExpanded()
if (wasUpdated) {
- await Database.updateCollection(collection)
SocketAuthority.emitter('collection_updated', jsonExpanded)
}
res.json(jsonExpanded)
}
async delete(req, res) {
- const collection = req.collection
- const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
+ const jsonExpanded = await req.collection.getOldJsonExpanded()
// Close rss feed - remove from db and emit socket event
- await this.rssFeedManager.closeFeedForEntityId(collection.id)
+ await this.rssFeedManager.closeFeedForEntityId(req.collection.id)
+
+ await req.collection.destroy()
- await Database.removeCollection(collection.id)
SocketAuthority.emitter('collection_removed', jsonExpanded)
res.sendStatus(200)
}
+ /**
+ * POST: /api/collections/:id/book
+ * Add a single book to a collection
+ * Req.body { id: }
+ * @param {*} req
+ * @param {*} res
+ */
async addBook(req, res) {
- const collection = req.collection
- const libraryItem = Database.libraryItems.find(li => li.id === req.body.id)
+ const libraryItem = await Database.libraryItemModel.getOldById(req.body.id)
if (!libraryItem) {
- return res.status(500).send('Book not found')
+ return res.status(404).send('Book not found')
}
- if (libraryItem.libraryId !== collection.libraryId) {
- return res.status(500).send('Book in different library')
+ if (libraryItem.libraryId !== req.collection.libraryId) {
+ return res.status(400).send('Book in different library')
}
- if (collection.books.includes(req.body.id)) {
- return res.status(500).send('Book already in collection')
- }
- collection.addBook(req.body.id)
- const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
- const collectionBook = {
- collectionId: collection.id,
- bookId: libraryItem.media.id,
- order: collection.books.length
+ // Check if book is already in collection
+ const collectionBooks = await req.collection.getCollectionBooks()
+ if (collectionBooks.some(cb => cb.bookId === libraryItem.media.id)) {
+ return res.status(400).send('Book already in collection')
}
- await Database.createCollectionBook(collectionBook)
+
+ // Create collectionBook record
+ await Database.collectionBookModel.create({
+ collectionId: req.collection.id,
+ bookId: libraryItem.media.id,
+ order: collectionBooks.length + 1
+ })
+ const jsonExpanded = await req.collection.getOldJsonExpanded()
SocketAuthority.emitter('collection_updated', jsonExpanded)
res.json(jsonExpanded)
}
- // DELETE: api/collections/:id/book/:bookId
+ /**
+ * DELETE: /api/collections/:id/book/:bookId
+ * Remove a single book from a collection. Re-order books
+ * TODO: bookId is actually libraryItemId. Clients need updating to use bookId
+ * @param {*} req
+ * @param {*} res
+ */
async removeBook(req, res) {
- const collection = req.collection
- const libraryItem = Database.libraryItems.find(li => li.id === req.params.bookId)
+ const libraryItem = await Database.libraryItemModel.getOldById(req.params.bookId)
if (!libraryItem) {
return res.sendStatus(404)
}
- if (collection.books.includes(req.params.bookId)) {
- collection.removeBook(req.params.bookId)
- const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
+ // Get books in collection ordered
+ const collectionBooks = await req.collection.getCollectionBooks({
+ order: [['order', 'ASC']]
+ })
+
+ let jsonExpanded = null
+ const collectionBookToRemove = collectionBooks.find(cb => cb.bookId === libraryItem.media.id)
+ if (collectionBookToRemove) {
+ // Remove collection book record
+ await collectionBookToRemove.destroy()
+
+ // Update order on collection books
+ let order = 1
+ for (const collectionBook of collectionBooks) {
+ if (collectionBook.bookId === libraryItem.media.id) continue
+ if (collectionBook.order !== order) {
+ await collectionBook.update({
+ order
+ })
+ }
+ order++
+ }
+
+ jsonExpanded = await req.collection.getOldJsonExpanded()
SocketAuthority.emitter('collection_updated', jsonExpanded)
- await Database.updateCollection(collection)
+ } else {
+ jsonExpanded = await req.collection.getOldJsonExpanded()
}
- res.json(collection.toJSONExpanded(Database.libraryItems))
+ res.json(jsonExpanded)
}
- // POST: api/collections/:id/batch/add
+ /**
+ * POST: /api/collections/:id/batch/add
+ * Add multiple books to collection
+ * Req.body { books: }
+ * @param {*} req
+ * @param {*} res
+ */
async addBatch(req, res) {
- const collection = req.collection
- if (!req.body.books || !req.body.books.length) {
+ // filter out invalid libraryItemIds
+ const bookIdsToAdd = (req.body.books || []).filter(b => !!b && typeof b == 'string')
+ if (!bookIdsToAdd.length) {
return res.status(500).send('Invalid request body')
}
- const bookIdsToAdd = req.body.books
+
+ // Get library items associated with ids
+ const libraryItems = await Database.libraryItemModel.findAll({
+ where: {
+ id: {
+ [Sequelize.Op.in]: bookIdsToAdd
+ }
+ },
+ include: {
+ model: Database.bookModel
+ }
+ })
+
+ // Get collection books already in collection
+ const collectionBooks = await req.collection.getCollectionBooks()
+
+ let order = collectionBooks.length + 1
const collectionBooksToAdd = []
let hasUpdated = false
- let order = collection.books.length
- for (const libraryItemId of bookIdsToAdd) {
- const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId)
- if (!libraryItem) continue
- if (!collection.books.includes(libraryItemId)) {
- collection.addBook(libraryItemId)
+ // Check and set new collection books to add
+ for (const libraryItem of libraryItems) {
+ if (!collectionBooks.some(cb => cb.bookId === libraryItem.media.id)) {
collectionBooksToAdd.push({
- collectionId: collection.id,
+ collectionId: req.collection.id,
bookId: libraryItem.media.id,
order: order++
})
hasUpdated = true
+ } else {
+ Logger.warn(`[CollectionController] addBatch: Library item ${libraryItem.id} already in collection`)
}
}
+ let jsonExpanded = null
if (hasUpdated) {
await Database.createBulkCollectionBooks(collectionBooksToAdd)
- SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(Database.libraryItems))
+ jsonExpanded = await req.collection.getOldJsonExpanded()
+ SocketAuthority.emitter('collection_updated', jsonExpanded)
+ } else {
+ jsonExpanded = await req.collection.getOldJsonExpanded()
}
- res.json(collection.toJSONExpanded(Database.libraryItems))
+ res.json(jsonExpanded)
}
- // POST: api/collections/:id/batch/remove
+ /**
+ * POST: /api/collections/:id/batch/remove
+ * Remove multiple books from collection
+ * Req.body { books: }
+ * @param {*} req
+ * @param {*} res
+ */
async removeBatch(req, res) {
- const collection = req.collection
- if (!req.body.books || !req.body.books.length) {
+ // filter out invalid libraryItemIds
+ const bookIdsToRemove = (req.body.books || []).filter(b => !!b && typeof b == 'string')
+ if (!bookIdsToRemove.length) {
return res.status(500).send('Invalid request body')
}
- var bookIdsToRemove = req.body.books
- let hasUpdated = false
- for (const libraryItemId of bookIdsToRemove) {
- const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId)
- if (!libraryItem) continue
- if (collection.books.includes(libraryItemId)) {
- collection.removeBook(libraryItemId)
+ // Get library items associated with ids
+ const libraryItems = await Database.libraryItemModel.findAll({
+ where: {
+ id: {
+ [Sequelize.Op.in]: bookIdsToRemove
+ }
+ },
+ include: {
+ model: Database.bookModel
+ }
+ })
+
+ // Get collection books already in collection
+ const collectionBooks = await req.collection.getCollectionBooks({
+ order: [['order', 'ASC']]
+ })
+
+ // Remove collection books and update order
+ let order = 1
+ let hasUpdated = false
+ for (const collectionBook of collectionBooks) {
+ if (libraryItems.some(li => li.media.id === collectionBook.bookId)) {
+ await collectionBook.destroy()
+ hasUpdated = true
+ continue
+ } else if (collectionBook.order !== order) {
+ await collectionBook.update({
+ order
+ })
hasUpdated = true
}
+ order++
}
+
+ let jsonExpanded = await req.collection.getOldJsonExpanded()
if (hasUpdated) {
- await Database.updateCollection(collection)
- SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(Database.libraryItems))
+ SocketAuthority.emitter('collection_updated', jsonExpanded)
}
- res.json(collection.toJSONExpanded(Database.libraryItems))
+ res.json(jsonExpanded)
}
async middleware(req, res, next) {
if (req.params.id) {
- const collection = await Database.models.collection.getById(req.params.id)
+ const collection = await Database.collectionModel.findByPk(req.params.id)
if (!collection) {
return res.status(404).send('Collection not found')
}
diff --git a/server/controllers/EmailController.js b/server/controllers/EmailController.js
index ada0f5df..fefc23b6 100644
--- a/server/controllers/EmailController.js
+++ b/server/controllers/EmailController.js
@@ -54,7 +54,7 @@ class EmailController {
async sendEBookToDevice(req, res) {
Logger.debug(`[EmailController] Send ebook to device request for libraryItemId=${req.body.libraryItemId}, deviceName=${req.body.deviceName}`)
- const libraryItem = Database.getLibraryItem(req.body.libraryItemId)
+ const libraryItem = await Database.libraryItemModel.getOldById(req.body.libraryItemId)
if (!libraryItem) {
return res.status(404).send('Library item not found')
}
diff --git a/server/controllers/FileSystemController.js b/server/controllers/FileSystemController.js
index 8d538489..cee52cb2 100644
--- a/server/controllers/FileSystemController.js
+++ b/server/controllers/FileSystemController.js
@@ -17,7 +17,7 @@ class FileSystemController {
})
// Do not include existing mapped library paths in response
- const libraryFoldersPaths = await Database.models.libraryFolder.getAllLibraryFolderPaths()
+ const libraryFoldersPaths = await Database.libraryFolderModel.getAllLibraryFolderPaths()
libraryFoldersPaths.forEach((path) => {
let dir = path || ''
if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '')
diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js
index d63e834b..19e4dbdc 100644
--- a/server/controllers/LibraryController.js
+++ b/server/controllers/LibraryController.js
@@ -1,16 +1,25 @@
+const Sequelize = require('sequelize')
const Path = require('path')
const fs = require('../libs/fsExtra')
-const filePerms = require('../utils/filePerms')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Library = require('../objects/Library')
const libraryHelpers = require('../utils/libraryHelpers')
+const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
+const libraryItemFilters = require('../utils/queries/libraryItemFilters')
+const seriesFilters = require('../utils/queries/seriesFilters')
+const fileUtils = require('../utils/fileUtils')
const { sort, createNewSortInstance } = require('../libs/fastSort')
const naturalSort = createNewSortInstance({
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
})
+const LibraryScanner = require('../scanner/LibraryScanner')
+const Scanner = require('../scanner/Scanner')
const Database = require('../Database')
+const libraryFilters = require('../utils/queries/libraryFilters')
+const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
+const authorFilters = require('../utils/queries/authorFilters')
class LibraryController {
constructor() { }
@@ -26,7 +35,7 @@ class LibraryController {
// Validate folder paths exist or can be created & resolve rel paths
// returns 400 if a folder fails to access
newLibraryPayload.folders = newLibraryPayload.folders.map(f => {
- f.fullPath = Path.resolve(f.fullPath)
+ f.fullPath = fileUtils.filePathToPOSIX(Path.resolve(f.fullPath))
return f
})
for (const folder of newLibraryPayload.folders) {
@@ -34,7 +43,6 @@ class LibraryController {
const direxists = await fs.pathExists(folder.fullPath)
if (!direxists) { // If folder does not exist try to make it and set file permissions/owner
await fs.mkdir(folder.fullPath)
- await filePerms.setDefault(folder.fullPath)
}
} catch (error) {
Logger.error(`[LibraryController] Failed to ensure folder dir "${folder.fullPath}"`, error)
@@ -44,7 +52,7 @@ class LibraryController {
const library = new Library()
- let currentLargestDisplayOrder = await Database.models.library.getMaxDisplayOrder()
+ let currentLargestDisplayOrder = await Database.libraryModel.getMaxDisplayOrder()
if (isNaN(currentLargestDisplayOrder)) currentLargestDisplayOrder = 0
newLibraryPayload.displayOrder = currentLargestDisplayOrder + 1
library.setData(newLibraryPayload)
@@ -63,7 +71,7 @@ class LibraryController {
}
async findAll(req, res) {
- const libraries = await Database.models.library.getAllOldLibraries()
+ const libraries = await Database.libraryModel.getAllOldLibraries()
const librariesAccessible = req.user.librariesAccessible || []
if (librariesAccessible.length) {
@@ -80,19 +88,27 @@ class LibraryController {
async findOne(req, res) {
const includeArray = (req.query.include || '').split(',')
if (includeArray.includes('filterdata')) {
+ const filterdata = await libraryFilters.getFilterData(req.library.mediaType, req.library.id)
+
return res.json({
- filterdata: libraryHelpers.getDistinctFilterDataNew(req.libraryItems),
- issues: req.libraryItems.filter(li => li.hasIssues).length,
- numUserPlaylists: await Database.models.playlist.getNumPlaylistsForUserAndLibrary(req.user.id, req.library.id),
+ filterdata,
+ issues: filterdata.numIssues,
+ numUserPlaylists: await Database.playlistModel.getNumPlaylistsForUserAndLibrary(req.user.id, req.library.id),
library: req.library
})
}
return res.json(req.library)
}
+ /**
+ * GET: /api/libraries/:id/episode-downloads
+ * Get podcast episodes in download queue
+ * @param {*} req
+ * @param {*} res
+ */
async getEpisodeDownloadQueue(req, res) {
const libraryDownloadQueueDetails = this.podcastManager.getDownloadQueueDetails(req.library.id)
- return res.json(libraryDownloadQueueDetails)
+ res.json(libraryDownloadQueueDetails)
}
async update(req, res) {
@@ -104,7 +120,7 @@ class LibraryController {
const newFolderPaths = []
req.body.folders = req.body.folders.map(f => {
if (!f.id) {
- f.fullPath = Path.resolve(f.fullPath)
+ f.fullPath = fileUtils.filePathToPOSIX(Path.resolve(f.fullPath))
newFolderPaths.push(f.fullPath)
}
return f
@@ -120,8 +136,40 @@ class LibraryController {
if (!success) {
return res.status(400).send(`Invalid folder directory "${path}"`)
}
- // Set permissions on newly created path
- await filePerms.setDefault(path)
+ }
+ }
+
+ // Handle removing folders
+ for (const folder of library.folders) {
+ if (!req.body.folders.some(f => f.id === folder.id)) {
+ // Remove library items in folder
+ const libraryItemsInFolder = await Database.libraryItemModel.findAll({
+ where: {
+ libraryFolderId: folder.id
+ },
+ attributes: ['id', 'mediaId', 'mediaType'],
+ include: [
+ {
+ model: Database.podcastModel,
+ attributes: ['id'],
+ include: {
+ model: Database.podcastEpisodeModel,
+ attributes: ['id']
+ }
+ }
+ ]
+ })
+ Logger.info(`[LibraryController] Removed folder "${folder.fullPath}" from library "${library.name}" with ${libraryItemsInFolder.length} library items`)
+ for (const libraryItem of libraryItemsInFolder) {
+ let mediaItemIds = []
+ if (library.isPodcast) {
+ mediaItemIds = libraryItem.media.podcastEpisodes.map(pe => pe.id)
+ } else {
+ mediaItemIds.push(libraryItem.mediaId)
+ }
+ Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.fullPath}"`)
+ await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
+ }
}
}
}
@@ -135,14 +183,6 @@ class LibraryController {
// Update auto scan cron
this.cronManager.updateLibraryScanCron(library)
- // Remove libraryItems no longer in library
- const itemsToRemove = Database.libraryItems.filter(li => li.libraryId === library.id && !library.checkFullPathInLibrary(li.path))
- if (itemsToRemove.length) {
- Logger.info(`[Scanner] Updating library, removing ${itemsToRemove.length} items`)
- for (let i = 0; i < itemsToRemove.length; i++) {
- await this.handleDeleteLibraryItem(itemsToRemove[i])
- }
- }
await Database.updateLibrary(library)
// Only emit to users with access to library
@@ -150,6 +190,8 @@ class LibraryController {
return user.checkCanAccessLibrary && user.checkCanAccessLibrary(library.id)
}
SocketAuthority.emitter('library_updated', library.toJSON(), userFilter)
+
+ await Database.resetLibraryIssuesFilterData(library.id)
}
return res.json(library.toJSON())
}
@@ -167,29 +209,63 @@ class LibraryController {
this.watcher.removeLibrary(library)
// Remove collections for library
- const numCollectionsRemoved = await Database.models.collection.removeAllForLibrary(library.id)
+ const numCollectionsRemoved = await Database.collectionModel.removeAllForLibrary(library.id)
if (numCollectionsRemoved) {
Logger.info(`[Server] Removed ${numCollectionsRemoved} collections for library "${library.name}"`)
}
// Remove items in this library
- const libraryItems = Database.libraryItems.filter(li => li.libraryId === library.id)
- Logger.info(`[Server] deleting library "${library.name}" with ${libraryItems.length} items"`)
- for (let i = 0; i < libraryItems.length; i++) {
- await this.handleDeleteLibraryItem(libraryItems[i])
+ const libraryItemsInLibrary = await Database.libraryItemModel.findAll({
+ where: {
+ libraryId: library.id
+ },
+ attributes: ['id', 'mediaId', 'mediaType'],
+ include: [
+ {
+ model: Database.podcastModel,
+ attributes: ['id'],
+ include: {
+ model: Database.podcastEpisodeModel,
+ attributes: ['id']
+ }
+ }
+ ]
+ })
+ Logger.info(`[LibraryController] Removing ${libraryItemsInLibrary.length} library items in library "${library.name}"`)
+ for (const libraryItem of libraryItemsInLibrary) {
+ let mediaItemIds = []
+ if (library.isPodcast) {
+ mediaItemIds = libraryItem.media.podcastEpisodes.map(pe => pe.id)
+ } else {
+ mediaItemIds.push(libraryItem.mediaId)
+ }
+ Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${library.name}"`)
+ await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
}
const libraryJson = library.toJSON()
await Database.removeLibrary(library.id)
// Re-order libraries
- await Database.models.library.resetDisplayOrder()
+ await Database.libraryModel.resetDisplayOrder()
SocketAuthority.emitter('library_removed', libraryJson)
+
+ // Remove library filter data
+ if (Database.libraryFilterData[library.id]) {
+ delete Database.libraryFilterData[library.id]
+ }
+
return res.json(libraryJson)
}
- async getLibraryItemsNew(req, res) {
+ /**
+ * GET /api/libraries/:id/items
+ *
+ * @param {import('express').Request} req
+ * @param {import('express').Response} res
+ */
+ async getLibraryItems(req, res) {
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
const payload = {
@@ -207,215 +283,51 @@ class LibraryController {
}
payload.offset = payload.page * payload.limit
- const { libraryItems, count } = await Database.models.libraryItem.getByFilterAndSort(req.library, req.user, payload)
- payload.results = libraryItems
- payload.total = count
-
- res.json(payload)
- }
-
- // api/libraries/:id/items
- // TODO: Optimize this method, items are iterated through several times but can be combined
- async getLibraryItems(req, res) {
- let libraryItems = req.libraryItems
-
- const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
-
- const payload = {
- results: [],
- total: libraryItems.length,
- limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0,
- page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0,
- sortBy: req.query.sort,
- sortDesc: req.query.desc === '1',
- filterBy: req.query.filter,
- mediaType: req.library.mediaType,
- minified: req.query.minified === '1',
- collapseseries: req.query.collapseseries === '1',
- include: include.join(',')
- }
- const mediaIsBook = payload.mediaType === 'book'
- const mediaIsPodcast = payload.mediaType === 'podcast'
-
- // Step 1 - Filter the retrieved library items
- let filterSeries = null
- if (payload.filterBy) {
- 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
- filterSeries = (mediaIsBook && payload.filterBy.startsWith('series.')) ? libraryHelpers.decode(payload.filterBy.replace('series.', '')) : null
- if (filterSeries === 'no-series') filterSeries = null
- }
-
- // Step 2 - If selected, collapse library items by the series they belong to.
- // If also filtering by series, will not collapse the filtered series as this would lead
- // to series having a collapsed series that is just that series.
- if (payload.collapseseries) {
- let collapsedItems = libraryHelpers.collapseBookSeries(libraryItems, Database.series, filterSeries, req.library.settings.hideSingleBookSeries)
-
- if (!(collapsedItems.length == 1 && collapsedItems[0].collapsedSeries)) {
- libraryItems = collapsedItems
-
- // Get accurate total entities
- // let uniqueEntities = new Set()
- // libraryItems.forEach((item) => {
- // if (item.collapsedSeries) {
- // item.collapsedSeries.books.forEach(book => uniqueEntities.add(book.id))
- // } else {
- // uniqueEntities.add(item.id)
- // }
- // })
- payload.total = libraryItems.length
- }
- }
-
- // Step 3 - Sort the retrieved library items.
- const sortArray = []
-
- // When on the series page, sort by sequence only
- if (filterSeries && !payload.sortBy) {
- sortArray.push({ asc: (li) => li.media.metadata.getSeries(filterSeries).sequence })
- // If no series sequence then fallback to sorting by title (or collapsed series name for sub-series)
- sortArray.push({
- asc: (li) => {
- if (Database.serverSettings.sortingIgnorePrefix) {
- return li.collapsedSeries?.nameIgnorePrefix || li.media.metadata.titleIgnorePrefix
- } else {
- return li.collapsedSeries?.name || li.media.metadata.title
- }
- }
- })
- }
-
- if (payload.sortBy) {
- let sortKey = payload.sortBy
-
- // Handle server setting sortingIgnorePrefix
- const sortByTitle = sortKey === 'media.metadata.title'
- if (sortByTitle && Database.serverSettings.sortingIgnorePrefix) {
- // BookMetadata.js has titleIgnorePrefix getter
- sortKey += 'IgnorePrefix'
- }
-
- // If series are collapsed and not sorting by title or sequence,
- // sort all collapsed series to the end in alphabetical order
- const sortBySequence = filterSeries && (sortKey === 'sequence')
- if (payload.collapseseries && !(sortByTitle || sortBySequence)) {
- sortArray.push({
- asc: (li) => {
- if (li.collapsedSeries) {
- return Database.serverSettings.sortingIgnorePrefix ?
- li.collapsedSeries.nameIgnorePrefix :
- li.collapsedSeries.name
- } else {
- return ''
- }
- }
- })
- }
-
- // Sort series based on the sortBy attribute
- const direction = payload.sortDesc ? 'desc' : 'asc'
- sortArray.push({
- [direction]: (li) => {
- if (mediaIsBook && sortBySequence) {
- return li.media.metadata.getSeries(filterSeries).sequence
- } else if (mediaIsBook && sortByTitle && li.collapsedSeries) {
- return Database.serverSettings.sortingIgnorePrefix ?
- li.collapsedSeries.nameIgnorePrefix :
- li.collapsedSeries.name
- } else {
- // Supports dot notation strings i.e. "media.metadata.title"
- return sortKey.split('.').reduce((a, b) => a[b], li)
- }
- }
- })
-
- // Secondary sort when sorting by book author use series sort title
- if (mediaIsBook && payload.sortBy.includes('author')) {
- sortArray.push({
- asc: (li) => {
- if (li.media.metadata.series && li.media.metadata.series.length) {
- return li.media.metadata.getSeriesSortTitle(li.media.metadata.series[0])
- }
- return null
- }
- })
- }
- }
-
- if (sortArray.length) {
- libraryItems = naturalSort(libraryItems).by(sortArray)
- }
-
- // Step 3.5: Limit items
- if (payload.limit) {
- const startIndex = payload.page * payload.limit
- libraryItems = libraryItems.slice(startIndex, startIndex + payload.limit)
- }
-
- // Step 4 - Transform the items to pass to the client side
- payload.results = await Promise.all(libraryItems.map(async li => {
- const json = payload.minified ? li.toJSONMinified() : li.toJSON()
-
- if (li.collapsedSeries) {
- json.collapsedSeries = {
- id: li.collapsedSeries.id,
- name: li.collapsedSeries.name,
- nameIgnorePrefix: li.collapsedSeries.nameIgnorePrefix,
- libraryItemIds: li.collapsedSeries.books.map(b => b.id),
- numBooks: li.collapsedSeries.books.length
- }
-
- // If collapsing by series and filtering by a series, generate the list of sequences the collapsed
- // series represents in the filtered series
- if (filterSeries) {
- json.collapsedSeries.seriesSequenceList =
- naturalSort(li.collapsedSeries.books.filter(b => b.filterSeriesSequence).map(b => b.filterSeriesSequence)).asc()
- .reduce((ranges, currentSequence) => {
- let lastRange = ranges.at(-1)
- let isNumber = /^(\d+|\d+\.\d*|\d*\.\d+)$/.test(currentSequence)
- if (isNumber) currentSequence = parseFloat(currentSequence)
-
- if (lastRange && isNumber && lastRange.isNumber && ((lastRange.end + 1) == currentSequence)) {
- lastRange.end = currentSequence
- }
- else {
- ranges.push({ start: currentSequence, end: currentSequence, isNumber: isNumber })
- }
-
- return ranges
- }, [])
- .map(r => r.start == r.end ? r.start : `${r.start}-${r.end}`)
- .join(', ')
- }
- } else {
- // add rssFeed object if "include=rssfeed" was put in query string (only for non-collapsed series)
- if (include.includes('rssfeed')) {
- const feedData = await this.rssFeedManager.findFeedForEntityId(json.id)
- json.rssFeed = feedData ? feedData.toJSONMinified() : null
- }
-
- // add numEpisodesIncomplete if "include=numEpisodesIncomplete" was put in query string (only for podcasts)
- if (mediaIsPodcast && include.includes('numepisodesincomplete')) {
- json.numEpisodesIncomplete = req.user.getNumEpisodesIncompleteForPodcast(li)
- }
-
- if (filterSeries) {
- // If filtering by series, make sure to include the series metadata
- json.media.metadata.series = li.media.metadata.getSeries(filterSeries)
- }
- }
-
- return json
- }))
+ // TODO: Temporary way of handling collapse sub-series. Either remove feature or handle through sql queries
+ if (payload.filterBy?.split('.')[0] === 'series' && payload.collapseseries) {
+ const seriesId = libraryFilters.decode(payload.filterBy.split('.')[1])
+ payload.results = await libraryHelpers.handleCollapseSubseries(payload, seriesId, req.user, req.library)
+ } else {
+ const { libraryItems, count } = await Database.libraryItemModel.getByFilterAndSort(req.library, req.user, payload)
+ payload.results = libraryItems
+ payload.total = count
+ }
res.json(payload)
}
+ /**
+ * DELETE: /libraries/:id/issues
+ * Remove all library items missing or invalid
+ * @param {import('express').Request} req
+ * @param {import('express').Response} res
+ */
async removeLibraryItemsWithIssues(req, res) {
- const libraryItemsWithIssues = req.libraryItems.filter(li => li.hasIssues)
+ const libraryItemsWithIssues = await Database.libraryItemModel.findAll({
+ where: {
+ libraryId: req.library.id,
+ [Sequelize.Op.or]: [
+ {
+ isMissing: true
+ },
+ {
+ isInvalid: true
+ }
+ ]
+ },
+ attributes: ['id', 'mediaId', 'mediaType'],
+ include: [
+ {
+ model: Database.podcastModel,
+ attributes: ['id'],
+ include: {
+ model: Database.podcastEpisodeModel,
+ attributes: ['id']
+ }
+ }
+ ]
+ })
+
if (!libraryItemsWithIssues.length) {
Logger.warn(`[LibraryController] No library items have issues`)
return res.sendStatus(200)
@@ -423,23 +335,32 @@ class LibraryController {
Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`)
for (const libraryItem of libraryItemsWithIssues) {
- Logger.info(`[LibraryController] Removing library item "${libraryItem.media.metadata.title}"`)
- await this.handleDeleteLibraryItem(libraryItem)
+ let mediaItemIds = []
+ if (req.library.isPodcast) {
+ mediaItemIds = libraryItem.media.podcastEpisodes.map(pe => pe.id)
+ } else {
+ mediaItemIds.push(libraryItem.mediaId)
+ }
+ Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`)
+ await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
+ }
+
+ // Set numIssues to 0 for library filter data
+ if (Database.libraryFilterData[req.library.id]) {
+ Database.libraryFilterData[req.library.id].numIssues = 0
}
res.sendStatus(200)
}
/**
- * api/libraries/:id/series
- * Optional query string: `?include=rssfeed` that adds `rssFeed` to series if a feed is open
- *
- * @param {*} req
- * @param {*} res
- */
+ * GET: /api/libraries/:id/series
+ * Optional query string: `?include=rssfeed` that adds `rssFeed` to series if a feed is open
+ *
+ * @param {import('express').Request} req
+ * @param {import('express').Response} res
+ */
async getAllSeriesForLibrary(req, res) {
- const libraryItems = req.libraryItems
-
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
const payload = {
@@ -454,68 +375,34 @@ class LibraryController {
include: include.join(',')
}
- let series = libraryHelpers.getSeriesFromBooks(libraryItems, Database.series, null, payload.filterBy, req.user, payload.minified, req.library.settings.hideSingleBookSeries)
-
- const direction = payload.sortDesc ? 'desc' : 'asc'
- series = naturalSort(series).by([
- {
- [direction]: (se) => {
- if (payload.sortBy === 'numBooks') {
- return se.books.length
- } else if (payload.sortBy === 'totalDuration') {
- return se.totalDuration
- } else if (payload.sortBy === 'addedAt') {
- return se.addedAt
- } else if (payload.sortBy === 'lastBookUpdated') {
- return Math.max(...(se.books).map(x => x.updatedAt), 0)
- } else if (payload.sortBy === 'lastBookAdded') {
- return Math.max(...(se.books).map(x => x.addedAt), 0)
- } else { // sort by name
- return Database.serverSettings.sortingIgnorePrefix ? se.nameIgnorePrefixSort : se.name
- }
- }
- }
- ])
-
- payload.total = series.length
-
- if (payload.limit) {
- const startIndex = payload.page * payload.limit
- series = series.slice(startIndex, startIndex + payload.limit)
- }
-
- // add rssFeed when "include=rssfeed" is in query string
- if (include.includes('rssfeed')) {
- series = await Promise.all(series.map(async (se) => {
- const feedData = await this.rssFeedManager.findFeedForEntityId(se.id)
- se.rssFeed = feedData?.toJSONMinified() || null
- return se
- }))
- }
+ const offset = payload.page * payload.limit
+ const { series, count } = await seriesFilters.getFilteredSeries(req.library, req.user, payload.filterBy, payload.sortBy, payload.sortDesc, include, payload.limit, offset)
+ payload.total = count
payload.results = series
res.json(payload)
}
/**
- * api/libraries/:id/series/:seriesId
+ * GET: /api/libraries/:id/series/:seriesId
*
* Optional includes (e.g. `?include=rssfeed,progress`)
* rssfeed: adds `rssFeed` to series object if a feed is open
* progress: adds `progress` to series object with { libraryItemIds:Array, libraryItemIdsFinished:Array, isFinished:boolean }
*
- * @param {*} req
- * @param {*} res - Series
+ * @param {import('express').Request} req
+ * @param {import('express').Response} res - Series
*/
async getSeriesForLibrary(req, res) {
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
- const series = Database.series.find(se => se.id === req.params.seriesId)
+ const series = await Database.seriesModel.findByPk(req.params.seriesId)
if (!series) return res.sendStatus(404)
+ const oldSeries = series.getOldSeries()
- const libraryItemsInSeries = req.libraryItems.filter(li => li.media.metadata.hasSeries?.(series.id))
+ const libraryItemsInSeries = await libraryItemsBookFilters.getLibraryItemsForSeries(oldSeries, req.user)
- const seriesJson = series.toJSON()
+ const seriesJson = oldSeries.toJSON()
if (include.includes('progress')) {
const libraryItemsFinished = libraryItemsInSeries.filter(li => !!req.user.getMediaProgress(li.id)?.isFinished)
seriesJson.progress = {
@@ -533,7 +420,12 @@ class LibraryController {
res.json(seriesJson)
}
- // api/libraries/:id/collections
+ /**
+ * GET: /api/libraries/:id/collections
+ * Get all collections for library
+ * @param {*} req
+ * @param {*} res
+ */
async getCollectionsForLibrary(req, res) {
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
@@ -550,7 +442,7 @@ class LibraryController {
}
// TODO: Create paginated queries
- let collections = await Database.models.collection.getOldCollectionsJsonExpanded(req.user, req.library.id, include)
+ let collections = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user, req.library.id, include)
payload.total = collections.length
@@ -563,10 +455,15 @@ class LibraryController {
res.json(payload)
}
- // api/libraries/:id/playlists
+ /**
+ * GET: /api/libraries/:id/playlists
+ * Get playlists for user in library
+ * @param {*} req
+ * @param {*} res
+ */
async getUserPlaylistsForLibrary(req, res) {
- let playlistsForUser = await Database.models.playlist.getPlaylistsForUserAndLibrary(req.user.id, req.library.id)
- playlistsForUser = playlistsForUser.map(p => p.toJSONExpanded(Database.libraryItems))
+ let playlistsForUser = await Database.playlistModel.getPlaylistsForUserAndLibrary(req.user.id, req.library.id)
+ playlistsForUser = await Promise.all(playlistsForUser.map(async p => p.getOldJsonExpanded()))
const payload = {
results: [],
@@ -584,74 +481,41 @@ class LibraryController {
res.json(payload)
}
- // api/libraries/:id/albums
- async getAlbumsForLibrary(req, res) {
- if (!req.library.isMusic) {
- return res.status(400).send('Invalid library media type')
- }
-
- let libraryItems = Database.libraryItems.filter(li => li.libraryId === req.library.id)
- let albums = libraryHelpers.groupMusicLibraryItemsIntoAlbums(libraryItems)
- albums = naturalSort(albums).asc(a => a.title) // Alphabetical by album title
-
- const payload = {
- results: [],
- total: albums.length,
- limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0,
- page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0
- }
-
- if (payload.limit) {
- const startIndex = payload.page * payload.limit
- albums = albums.slice(startIndex, startIndex + payload.limit)
- }
-
- payload.results = albums
- res.json(payload)
- }
-
- async getLibraryFilterData(req, res) {
- res.json(libraryHelpers.getDistinctFilterDataNew(req.libraryItems))
- }
-
/**
- * GET: /api/libraries/:id/personalized2
- * TODO: new endpoint
- * @param {*} req
- * @param {*} res
+ * GET: /api/libraries/:id/filterdata
+ * @param {import('express').Request} req
+ * @param {import('express').Response} res
*/
- async getUserPersonalizedShelves(req, res) {
- 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 shelves = await Database.models.libraryItem.getPersonalizedShelves(req.library, req.user, include, limitPerShelf)
- res.json(shelves)
+ async getLibraryFilterData(req, res) {
+ const filterData = await libraryFilters.getFilterData(req.library.mediaType, req.library.id)
+ res.json(filterData)
}
/**
* GET: /api/libraries/:id/personalized
- * @param {*} req
- * @param {*} res
+ * Home page shelves
+ * @param {import('express').Request} req
+ * @param {import('express').Response} res
*/
- async getLibraryUserPersonalizedOptimal(req, res) {
+ async getUserPersonalizedShelves(req, res) {
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 = await libraryHelpers.buildPersonalizedShelves(this, req.user, req.libraryItems, req.library, limitPerShelf, include)
- res.json(categories)
+ const shelves = await Database.libraryItemModel.getPersonalizedShelves(req.library, req.user, include, limitPerShelf)
+ res.json(shelves)
}
/**
* POST: /api/libraries/order
* Change the display order of libraries
- * @param {*} req
- * @param {*} res
+ * @param {import('express').Request} req
+ * @param {import('express').Response} res
*/
async reorder(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error('[LibraryController] ReorderLibraries invalid user', req.user)
return res.sendStatus(403)
}
- const libraries = await Database.models.library.getAllOldLibraries()
+ const libraries = await Database.libraryModel.getAllOldLibraries()
const orderdata = req.body
let hasUpdates = false
@@ -679,172 +543,189 @@ class LibraryController {
})
}
- // GET: Global library search
- search(req, res) {
+ /**
+ * GET: /api/libraries/:id/search
+ * Search library items with query
+ * ?q=search
+ * @param {import('express').Request} req
+ * @param {import('express').Response} res
+ */
+ async search(req, res) {
if (!req.query.q) {
return res.status(400).send('No query string')
}
- const libraryItems = req.libraryItems
- const maxResults = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12
+ const limit = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12
+ const query = req.query.q.trim().toLowerCase()
- const itemMatches = []
- const authorMatches = {}
- const narratorMatches = {}
- const seriesMatches = {}
- const tagMatches = {}
-
- libraryItems.forEach((li) => {
- const queryResult = li.searchQuery(req.query.q)
- if (queryResult.matchKey) {
- itemMatches.push({
- libraryItem: li.toJSONExpanded(),
- matchKey: queryResult.matchKey,
- matchText: queryResult.matchText
- })
- }
- if (queryResult.series?.length) {
- queryResult.series.forEach((se) => {
- if (!seriesMatches[se.id]) {
- const _series = Database.series.find(_se => _se.id === se.id)
- if (_series) seriesMatches[se.id] = { series: _series.toJSON(), books: [li.toJSON()] }
- } else {
- seriesMatches[se.id].books.push(li.toJSON())
- }
- })
- }
- if (queryResult.authors?.length) {
- queryResult.authors.forEach((au) => {
- if (!authorMatches[au.id]) {
- const _author = Database.authors.find(_au => _au.id === au.id)
- if (_author) {
- authorMatches[au.id] = _author.toJSON()
- authorMatches[au.id].numBooks = 1
- }
- } else {
- authorMatches[au.id].numBooks++
- }
- })
- }
- if (queryResult.tags?.length) {
- queryResult.tags.forEach((tag) => {
- if (!tagMatches[tag]) {
- tagMatches[tag] = { name: tag, books: [li.toJSON()] }
- } else {
- tagMatches[tag].books.push(li.toJSON())
- }
- })
- }
- if (queryResult.narrators?.length) {
- queryResult.narrators.forEach((narrator) => {
- if (!narratorMatches[narrator]) {
- narratorMatches[narrator] = { name: narrator, books: [li.toJSON()] }
- } else {
- narratorMatches[narrator].books.push(li.toJSON())
- }
- })
- }
- })
- const itemKey = req.library.mediaType
- const results = {
- [itemKey]: itemMatches.slice(0, maxResults),
- tags: Object.values(tagMatches).slice(0, maxResults),
- authors: Object.values(authorMatches).slice(0, maxResults),
- series: Object.values(seriesMatches).slice(0, maxResults),
- narrators: Object.values(narratorMatches).slice(0, maxResults)
- }
- res.json(results)
+ const matches = await libraryItemFilters.search(req.user, req.library, query, limit)
+ res.json(matches)
}
+ /**
+ * GET: /api/libraries/:id/stats
+ * Get stats for library
+ * @param {import('express').Request} req
+ * @param {import('express').Response} res
+ */
async stats(req, res) {
- var libraryItems = req.libraryItems
- var authorsWithCount = libraryHelpers.getAuthorsWithCount(libraryItems)
- var genresWithCount = libraryHelpers.getGenresWithCount(libraryItems)
- var durationStats = libraryHelpers.getItemDurationStats(libraryItems)
- var sizeStats = libraryHelpers.getItemSizeStats(libraryItems)
- var stats = {
- totalItems: libraryItems.length,
- totalAuthors: Object.keys(authorsWithCount).length,
- totalGenres: Object.keys(genresWithCount).length,
- totalDuration: durationStats.totalDuration,
- longestItems: durationStats.longestItems,
- numAudioTracks: durationStats.numAudioTracks,
- totalSize: libraryHelpers.getLibraryItemsTotalSize(libraryItems),
- largestItems: sizeStats.largestItems,
- authorsWithCount,
- genresWithCount
+ const stats = {
+ largestItems: await libraryItemFilters.getLargestItems(req.library.id, 10)
+ }
+
+ if (req.library.isBook) {
+ const authors = await authorFilters.getAuthorsWithCount(req.library.id)
+ const genres = await libraryItemsBookFilters.getGenresWithCount(req.library.id)
+ const bookStats = await libraryItemsBookFilters.getBookLibraryStats(req.library.id)
+ const longestBooks = await libraryItemsBookFilters.getLongestBooks(req.library.id, 10)
+
+ stats.totalAuthors = authors.length
+ stats.authorsWithCount = authors
+ stats.totalGenres = genres.length
+ stats.genresWithCount = genres
+ stats.totalItems = bookStats.totalItems
+ stats.longestItems = longestBooks
+ stats.totalSize = bookStats.totalSize
+ stats.totalDuration = bookStats.totalDuration
+ stats.numAudioTracks = bookStats.numAudioFiles
+ } else {
+ const genres = await libraryItemsPodcastFilters.getGenresWithCount(req.library.id)
+ const podcastStats = await libraryItemsPodcastFilters.getPodcastLibraryStats(req.library.id)
+ const longestPodcasts = await libraryItemsPodcastFilters.getLongestPodcasts(req.library.id, 10)
+
+ stats.totalGenres = genres.length
+ stats.genresWithCount = genres
+ stats.totalItems = podcastStats.totalItems
+ stats.longestItems = longestPodcasts
+ stats.totalSize = podcastStats.totalSize
+ stats.totalDuration = podcastStats.totalDuration
+ stats.numAudioTracks = podcastStats.numAudioFiles
}
res.json(stats)
}
+ /**
+ * GET: /api/libraries/:id/authors
+ * Get authors for library
+ * @param {import('express').Request} req
+ * @param {import('express').Response} res
+ */
async getAuthors(req, res) {
- const authors = {}
- req.libraryItems.forEach((li) => {
- if (li.media.metadata.authors && li.media.metadata.authors.length) {
- li.media.metadata.authors.forEach((au) => {
- if (!authors[au.id]) {
- const _author = Database.authors.find(_au => _au.id === au.id)
- if (_author) {
- authors[au.id] = _author.toJSON()
- authors[au.id].numBooks = 1
- }
- } else {
- authors[au.id].numBooks++
- }
- })
- }
+ const { bookWhere, replacements } = libraryItemsBookFilters.getUserPermissionBookWhereQuery(req.user)
+ const authors = await Database.authorModel.findAll({
+ where: {
+ libraryId: req.library.id
+ },
+ replacements,
+ include: {
+ model: Database.bookModel,
+ attributes: ['id', 'tags', 'explicit'],
+ where: bookWhere,
+ required: true,
+ through: {
+ attributes: []
+ }
+ },
+ order: [
+ [Sequelize.literal('name COLLATE NOCASE'), 'ASC']
+ ]
})
+ const oldAuthors = []
+
+ for (const author of authors) {
+ const oldAuthor = author.getOldAuthor().toJSON()
+ oldAuthor.numBooks = author.books.length
+ oldAuthors.push(oldAuthor)
+ }
+
res.json({
- authors: naturalSort(Object.values(authors)).asc(au => au.name)
+ authors: oldAuthors
})
}
+ /**
+ * GET: /api/libraries/:id/narrators
+ * @param {*} req
+ * @param {*} res
+ */
async getNarrators(req, res) {
- const narrators = {}
- req.libraryItems.forEach((li) => {
- if (li.media.metadata.narrators?.length) {
- li.media.metadata.narrators.forEach((n) => {
- if (typeof n !== 'string') {
- Logger.error(`[LibraryController] getNarrators: Invalid narrator "${n}" on book "${li.media.metadata.title}"`)
- } else if (!narrators[n]) {
- narrators[n] = {
- id: encodeURIComponent(Buffer.from(n).toString('base64')),
- name: n,
- numBooks: 1
- }
- } else {
- narrators[n].numBooks++
- }
- })
- }
+ // Get all books with narrators
+ const booksWithNarrators = await Database.bookModel.findAll({
+ where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('narrators')), {
+ [Sequelize.Op.gt]: 0
+ }),
+ include: {
+ model: Database.libraryItemModel,
+ attributes: ['id', 'libraryId'],
+ where: {
+ libraryId: req.library.id
+ }
+ },
+ attributes: ['id', 'narrators']
})
+ const narrators = {}
+ for (const book of booksWithNarrators) {
+ book.narrators.forEach(n => {
+ if (typeof n !== 'string') {
+ Logger.error(`[LibraryController] getNarrators: Invalid narrator "${n}" on book "${book.title}"`)
+ } else if (!narrators[n]) {
+ narrators[n] = {
+ id: encodeURIComponent(Buffer.from(n).toString('base64')),
+ name: n,
+ numBooks: 1
+ }
+ } else {
+ narrators[n].numBooks++
+ }
+ })
+ }
+
res.json({
narrators: naturalSort(Object.values(narrators)).asc(n => n.name)
})
}
+ /**
+ * PATCH: /api/libraries/:id/narrators/:narratorId
+ * Update narrator name
+ * :narratorId is base64 encoded name
+ * req.body { name }
+ * @param {*} req
+ * @param {*} res
+ */
async updateNarrator(req, res) {
if (!req.user.canUpdate) {
Logger.error(`[LibraryController] Unauthorized user "${req.user.username}" attempted to update narrator`)
return res.sendStatus(403)
}
- const narratorName = libraryHelpers.decode(req.params.narratorId)
+ const narratorName = libraryFilters.decode(req.params.narratorId)
const updatedName = req.body.name
if (!updatedName) {
return res.status(400).send('Invalid request payload. Name not specified.')
}
+ // Update filter data
+ Database.replaceNarratorInFilterData(narratorName, updatedName)
+
const itemsUpdated = []
- for (const libraryItem of req.libraryItems) {
- if (libraryItem.media.metadata.updateNarrator(narratorName, updatedName)) {
- itemsUpdated.push(libraryItem)
+
+ const itemsWithNarrator = await libraryItemFilters.getAllLibraryItemsWithNarrators([narratorName])
+
+ for (const libraryItem of itemsWithNarrator) {
+ libraryItem.media.narrators = libraryItem.media.narrators.filter(n => n !== narratorName)
+ if (!libraryItem.media.narrators.includes(updatedName)) {
+ libraryItem.media.narrators.push(updatedName)
}
+ await libraryItem.media.update({
+ narrators: libraryItem.media.narrators
+ })
+ const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
+ itemsUpdated.push(oldLibraryItem)
}
if (itemsUpdated.length) {
- await Database.updateBulkBooks(itemsUpdated.map(i => i.media))
SocketAuthority.emitter('items_updated', itemsUpdated.map(li => li.toJSONExpanded()))
}
@@ -853,23 +734,38 @@ class LibraryController {
})
}
+ /**
+ * DELETE: /api/libraries/:id/narrators/:narratorId
+ * Remove narrator
+ * :narratorId is base64 encoded name
+ * @param {*} req
+ * @param {*} res
+ */
async removeNarrator(req, res) {
if (!req.user.canUpdate) {
Logger.error(`[LibraryController] Unauthorized user "${req.user.username}" attempted to remove narrator`)
return res.sendStatus(403)
}
- const narratorName = libraryHelpers.decode(req.params.narratorId)
+ const narratorName = libraryFilters.decode(req.params.narratorId)
+
+ // Update filter data
+ Database.removeNarratorFromFilterData(narratorName)
const itemsUpdated = []
- for (const libraryItem of req.libraryItems) {
- if (libraryItem.media.metadata.removeNarrator(narratorName)) {
- itemsUpdated.push(libraryItem)
- }
+
+ const itemsWithNarrator = await libraryItemFilters.getAllLibraryItemsWithNarrators([narratorName])
+
+ for (const libraryItem of itemsWithNarrator) {
+ libraryItem.media.narrators = libraryItem.media.narrators.filter(n => n !== narratorName)
+ await libraryItem.media.update({
+ narrators: libraryItem.media.narrators
+ })
+ const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
+ itemsUpdated.push(oldLibraryItem)
}
if (itemsUpdated.length) {
- await Database.updateBulkBooks(itemsUpdated.map(i => i.media))
SocketAuthority.emitter('items_updated', itemsUpdated.map(li => li.toJSONExpanded()))
}
@@ -883,7 +779,7 @@ class LibraryController {
Logger.error(`[LibraryController] Non-root user attempted to match library items`, req.user)
return res.sendStatus(403)
}
- this.scanner.matchLibraryItems(req.library)
+ Scanner.matchLibraryItems(req.library)
res.sendStatus(200)
}
@@ -893,15 +789,20 @@ class LibraryController {
Logger.error(`[LibraryController] Non-root user attempted to scan library`, req.user)
return res.sendStatus(403)
}
- const options = {
- forceRescan: req.query.force == 1
- }
res.sendStatus(200)
- await this.scanner.scan(req.library, options)
+
+ await LibraryScanner.scan(req.library)
+
+ await Database.resetLibraryIssuesFilterData(req.library.id)
Logger.info('[LibraryController] Scan complete')
}
- // GET: api/libraries/:id/recent-episode
+ /**
+ * GET: /api/libraries/:id/recent-episodes
+ * Used for latest page
+ * @param {import('express').Request} req
+ * @param {import('express').Response} res
+ */
async getRecentEpisodes(req, res) {
if (!req.library.isPodcast) {
return res.sendStatus(404)
@@ -909,49 +810,46 @@ class LibraryController {
const payload = {
episodes: [],
- total: 0,
limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0,
page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0,
}
- var allUnfinishedEpisodes = []
- for (const libraryItem of req.libraryItems) {
- const unfinishedEpisodes = libraryItem.media.episodes.filter(ep => {
- const userProgress = req.user.getMediaProgress(libraryItem.id, ep.id)
- return !userProgress || !userProgress.isFinished
- }).map(_ep => {
- const ep = _ep.toJSONExpanded()
- ep.podcast = libraryItem.media.toJSONMinified()
- ep.libraryItemId = libraryItem.id
- ep.libraryId = libraryItem.libraryId
- return ep
- })
- allUnfinishedEpisodes.push(...unfinishedEpisodes)
- }
-
- payload.total = allUnfinishedEpisodes.length
-
- allUnfinishedEpisodes = sort(allUnfinishedEpisodes).desc(ep => ep.publishedAt)
-
- if (payload.limit) {
- var startIndex = payload.page * payload.limit
- allUnfinishedEpisodes = allUnfinishedEpisodes.slice(startIndex, startIndex + payload.limit)
- }
- payload.episodes = allUnfinishedEpisodes
+ const offset = payload.page * payload.limit
+ payload.episodes = await libraryItemsPodcastFilters.getRecentEpisodes(req.user, req.library, payload.limit, offset)
res.json(payload)
}
- getOPMLFile(req, res) {
- const opmlText = this.podcastManager.generateOPMLFileText(req.libraryItems)
+ /**
+ * GET: /api/libraries/:id/opml
+ * Get OPML file for a podcast library
+ * @param {import('express').Request} req
+ * @param {import('express').Response} res
+ */
+ async getOPMLFile(req, res) {
+ const userPermissionPodcastWhere = libraryItemsPodcastFilters.getUserPermissionPodcastWhereQuery(req.user)
+ const podcasts = await Database.podcastModel.findAll({
+ attributes: ['id', 'feedURL', 'title', 'description', 'itunesPageURL', 'language'],
+ where: userPermissionPodcastWhere.podcastWhere,
+ replacements: userPermissionPodcastWhere.replacements,
+ include: {
+ model: Database.libraryItemModel,
+ attributes: ['id', 'libraryId'],
+ where: {
+ libraryId: req.library.id
+ }
+ }
+ })
+
+ const opmlText = this.podcastManager.generateOPMLFileText(podcasts)
res.type('application/xml')
res.send(opmlText)
}
/**
- * TODO: Replace with middlewareNew
- * @param {*} req
- * @param {*} res
- * @param {*} next
+ * Middleware that is not using libraryItems from memory
+ * @param {import('express').Request} req
+ * @param {import('express').Response} res
+ * @param {import('express').NextFunction} next
*/
async middleware(req, res, next) {
if (!req.user.checkCanAccessLibrary(req.params.id)) {
@@ -959,30 +857,7 @@ class LibraryController {
return res.sendStatus(403)
}
- const library = await Database.models.library.getOldById(req.params.id)
- if (!library) {
- return res.status(404).send('Library not found')
- }
- req.library = library
- req.libraryItems = Database.libraryItems.filter(li => {
- return li.libraryId === library.id && req.user.checkCanAccessLibraryItem(li)
- })
- next()
- }
-
- /**
- * Middleware that is not using libraryItems from memory
- * @param {*} req
- * @param {*} res
- * @param {*} next
- */
- async middlewareNew(req, res, next) {
- if (!req.user.checkCanAccessLibrary(req.params.id)) {
- Logger.warn(`[LibraryController] Library ${req.params.id} not accessible to user ${req.user.username}`)
- return res.sendStatus(403)
- }
-
- const library = await Database.models.library.getOldById(req.params.id)
+ const library = await Database.libraryModel.getOldById(req.params.id)
if (!library) {
return res.status(404).send('Library not found')
}
diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js
index 9c0be842..4def9c04 100644
--- a/server/controllers/LibraryItemController.js
+++ b/server/controllers/LibraryItemController.js
@@ -8,11 +8,24 @@ const zipHelpers = require('../utils/zipHelpers')
const { reqSupportsWebp } = require('../utils/index')
const { ScanResult } = require('../utils/constants')
const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils')
+const LibraryItemScanner = require('../scanner/LibraryItemScanner')
+const AudioFileScanner = require('../scanner/AudioFileScanner')
+const Scanner = require('../scanner/Scanner')
+const CacheManager = require('../managers/CacheManager')
+const CoverManager = require('../managers/CoverManager')
class LibraryItemController {
constructor() { }
- // Example expand with authors: api/items/:id?expanded=1&include=authors
+ /**
+ * GET: /api/items/:id
+ * Optional query params:
+ * ?include=progress,rssfeed,downloads
+ * ?expanded=1
+ *
+ * @param {import('express').Request} req
+ * @param {import('express').Response} res
+ */
async findOne(req, res) {
const includeEntities = (req.query.include || '').split(',')
if (req.query.expanded == 1) {
@@ -29,17 +42,7 @@ class LibraryItemController {
item.rssFeed = feedData?.toJSONMinified() || null
}
- if (item.mediaType == 'book') {
- if (includeEntities.includes('authors')) {
- item.media.metadata.authors = item.media.metadata.authors.map(au => {
- var author = Database.authors.find(_au => _au.id === au.id)
- if (!author) return null
- return {
- ...author
- }
- }).filter(au => au)
- }
- } else if (includeEntities.includes('downloads')) {
+ if (item.mediaType === 'podcast' && includeEntities.includes('downloads')) {
const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
item.episodeDownloadsQueued = downloadsInQueue.map(d => d.toJSONForClient())
if (this.podcastManager.currentDownload?.libraryItemId === req.libraryItem.id) {
@@ -56,7 +59,7 @@ class LibraryItemController {
var libraryItem = req.libraryItem
// Item has cover and update is removing cover so purge it from cache
if (libraryItem.media.coverPath && req.body.media && (req.body.media.coverPath === '' || req.body.media.coverPath === null)) {
- await this.cacheManager.purgeCoverCache(libraryItem.id)
+ await CacheManager.purgeCoverCache(libraryItem.id)
}
const hasUpdates = libraryItem.update(req.body)
@@ -71,13 +74,14 @@ class LibraryItemController {
async delete(req, res) {
const hardDelete = req.query.hard == 1 // Delete from file system
const libraryItemPath = req.libraryItem.path
- await this.handleDeleteLibraryItem(req.libraryItem)
+ await this.handleDeleteLibraryItem(req.libraryItem.mediaType, req.libraryItem.id, [req.libraryItem.media.id])
if (hardDelete) {
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
await fs.remove(libraryItemPath).catch((error) => {
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
})
}
+ await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
res.sendStatus(200)
}
@@ -103,7 +107,7 @@ class LibraryItemController {
// 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)
+ await CacheManager.purgeCoverCache(libraryItem.id)
}
// Book specific
@@ -124,7 +128,7 @@ class LibraryItemController {
// Book specific - Get all series being removed from this item
let seriesRemoved = []
if (libraryItem.isBook && mediaPayload.metadata?.series) {
- const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map(se => se.id)
+ const seriesIdsInUpdate = mediaPayload.metadata.series?.map(se => se.id) || []
seriesRemoved = libraryItem.media.metadata.series.filter(se => !seriesIdsInUpdate.includes(se.id))
}
@@ -135,7 +139,7 @@ class LibraryItemController {
if (seriesRemoved.length) {
// Check remove empty series
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
- await this.checkRemoveEmptySeries(seriesRemoved)
+ await this.checkRemoveEmptySeries(libraryItem.media.id, seriesRemoved.map(se => se.id))
}
if (isPodcastAutoDownloadUpdated) {
@@ -164,10 +168,10 @@ class LibraryItemController {
var result = null
if (req.body && req.body.url) {
Logger.debug(`[LibraryItemController] Requesting download cover from url "${req.body.url}"`)
- result = await this.coverManager.downloadCoverFromUrl(libraryItem, req.body.url)
+ result = await CoverManager.downloadCoverFromUrl(libraryItem, req.body.url)
} else if (req.files && req.files.cover) {
Logger.debug(`[LibraryItemController] Handling uploaded cover`)
- result = await this.coverManager.uploadCover(libraryItem, req.files.cover)
+ result = await CoverManager.uploadCover(libraryItem, req.files.cover)
} else {
return res.status(400).send('Invalid request no file or url')
}
@@ -193,7 +197,7 @@ class LibraryItemController {
return res.status(400).send('Invalid request no cover path')
}
- const validationResult = await this.coverManager.validateCoverPath(req.body.cover, libraryItem)
+ const validationResult = await CoverManager.validateCoverPath(req.body.cover, libraryItem)
if (validationResult.error) {
return res.status(500).send(validationResult.error)
}
@@ -213,7 +217,7 @@ class LibraryItemController {
if (libraryItem.media.coverPath) {
libraryItem.updateMediaCover('')
- await this.cacheManager.purgeCoverCache(libraryItem.id)
+ await CacheManager.purgeCoverCache(libraryItem.id)
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
}
@@ -242,7 +246,7 @@ class LibraryItemController {
height: height ? parseInt(height) : null,
width: width ? parseInt(width) : null
}
- return this.cacheManager.handleCoverCache(res, libraryItem, options)
+ return CacheManager.handleCoverCache(res, libraryItem, options)
}
// GET: api/items/:id/stream
@@ -296,7 +300,7 @@ class LibraryItemController {
var libraryItem = req.libraryItem
var options = req.body || {}
- var matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options)
+ var matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options)
res.json(matchResult)
}
@@ -309,18 +313,23 @@ class LibraryItemController {
const hardDelete = req.query.hard == 1 // Delete files from filesystem
const { libraryItemIds } = req.body
- if (!libraryItemIds || !libraryItemIds.length) {
- return res.sendStatus(500)
+ if (!libraryItemIds?.length) {
+ return res.status(400).send('Invalid request body')
}
- const itemsToDelete = Database.libraryItems.filter(li => libraryItemIds.includes(li.id))
+ const itemsToDelete = await Database.libraryItemModel.getAllOldLibraryItems({
+ id: libraryItemIds
+ })
+
if (!itemsToDelete.length) {
return res.sendStatus(404)
}
- for (let i = 0; i < itemsToDelete.length; i++) {
- const libraryItemPath = itemsToDelete[i].path
- Logger.info(`[LibraryItemController] Deleting Library Item "${itemsToDelete[i].media.metadata.title}"`)
- await this.handleDeleteLibraryItem(itemsToDelete[i])
+
+ const libraryId = itemsToDelete[0].libraryId
+ for (const libraryItem of itemsToDelete) {
+ const libraryItemPath = libraryItem.path
+ Logger.info(`[LibraryItemController] Deleting Library Item "${libraryItem.media.metadata.title}"`)
+ await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, [libraryItem.media.id])
if (hardDelete) {
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
await fs.remove(libraryItemPath).catch((error) => {
@@ -328,28 +337,42 @@ class LibraryItemController {
})
}
}
+
+ await Database.resetLibraryIssuesFilterData(libraryId)
res.sendStatus(200)
}
// POST: api/items/batch/update
async batchUpdate(req, res) {
- var updatePayloads = req.body
- if (!updatePayloads || !updatePayloads.length) {
+ const updatePayloads = req.body
+ if (!updatePayloads?.length) {
return res.sendStatus(500)
}
- var itemsUpdated = 0
+ let itemsUpdated = 0
- for (let i = 0; i < updatePayloads.length; i++) {
- var mediaPayload = updatePayloads[i].mediaPayload
- var libraryItem = Database.libraryItems.find(_li => _li.id === updatePayloads[i].id)
+ for (const updatePayload of updatePayloads) {
+ const mediaPayload = updatePayload.mediaPayload
+ const libraryItem = await Database.libraryItemModel.getOldById(updatePayload.id)
if (!libraryItem) return null
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
- var hasUpdates = libraryItem.media.update(mediaPayload)
- if (hasUpdates) {
+ let seriesRemoved = []
+ if (libraryItem.isBook && mediaPayload.metadata?.series) {
+ const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map(se => se.id)
+ seriesRemoved = libraryItem.media.metadata.series.filter(se => !seriesIdsInUpdate.includes(se.id))
+ }
+
+ if (libraryItem.media.update(mediaPayload)) {
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
+
+ if (seriesRemoved.length) {
+ // Check remove empty series
+ Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
+ await this.checkRemoveEmptySeries(libraryItem.media.id, seriesRemoved.map(se => se.id))
+ }
+
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
itemsUpdated++
@@ -368,13 +391,11 @@ class LibraryItemController {
if (!libraryItemIds.length) {
return res.status(403).send('Invalid payload')
}
- const libraryItems = []
- libraryItemIds.forEach((lid) => {
- const li = Database.libraryItems.find(_li => _li.id === lid)
- if (li) libraryItems.push(li.toJSONExpanded())
+ const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
+ id: libraryItemIds
})
res.json({
- libraryItems
+ libraryItems: libraryItems.map(li => li.toJSONExpanded())
})
}
@@ -393,7 +414,9 @@ class LibraryItemController {
return res.sendStatus(400)
}
- const libraryItems = req.body.libraryItemIds.map(lid => Database.getLibraryItem(lid)).filter(li => li)
+ const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
+ id: req.body.libraryItemIds
+ })
if (!libraryItems?.length) {
return res.sendStatus(400)
}
@@ -401,7 +424,7 @@ class LibraryItemController {
res.sendStatus(200)
for (const libraryItem of libraryItems) {
- const matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options)
+ const matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options)
if (matchResult.updated) {
itemsUpdated++
} else if (matchResult.warning) {
@@ -428,23 +451,31 @@ class LibraryItemController {
return res.sendStatus(400)
}
- const libraryItems = req.body.libraryItemIds.map(lid => Database.getLibraryItem(lid)).filter(li => li)
+ const libraryItems = await Database.libraryItemModel.findAll({
+ where: {
+ id: req.body.libraryItemIds
+ },
+ attributes: ['id', 'libraryId', 'isFile']
+ })
if (!libraryItems?.length) {
return res.sendStatus(400)
}
res.sendStatus(200)
+ const libraryId = libraryItems[0].libraryId
for (const libraryItem of libraryItems) {
if (libraryItem.isFile) {
Logger.warn(`[LibraryItemController] Re-scanning file library items not yet supported`)
} else {
- await this.scanner.scanLibraryItemByRequest(libraryItem)
+ await LibraryItemScanner.scanLibraryItem(libraryItem.id)
}
}
+
+ await Database.resetLibraryIssuesFilterData(libraryId)
}
- // POST: api/items/:id/scan (admin)
+ // POST: api/items/:id/scan
async scan(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryItemController] Non-admin user attempted to scan library item`, req.user)
@@ -456,7 +487,8 @@ class LibraryItemController {
return res.sendStatus(500)
}
- const result = await this.scanner.scanLibraryItemByRequest(req.libraryItem)
+ const result = await LibraryItemScanner.scanLibraryItem(req.libraryItem.id)
+ await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
res.json({
result: Object.keys(ScanResult).find(key => ScanResult[key] == result)
})
@@ -529,7 +561,7 @@ class LibraryItemController {
return res.sendStatus(404)
}
- const ffprobeData = await this.scanner.probeAudioFile(audioFile)
+ const ffprobeData = await AudioFileScanner.probeAudioFile(audioFile)
res.json(ffprobeData)
}
@@ -680,7 +712,7 @@ class LibraryItemController {
}
async middleware(req, res, next) {
- req.libraryItem = await Database.models.libraryItem.getOldById(req.params.id)
+ req.libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
if (!req.libraryItem?.media) return res.sendStatus(404)
// Check user can access this library item
diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js
index 8cb6b8bd..b03868a0 100644
--- a/server/controllers/MeController.js
+++ b/server/controllers/MeController.js
@@ -59,7 +59,7 @@ class MeController {
// PATCH: api/me/progress/:id
async createUpdateMediaProgress(req, res) {
- const libraryItem = Database.libraryItems.find(ab => ab.id === req.params.id)
+ const libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
if (!libraryItem) {
return res.status(404).send('Item not found')
}
@@ -75,7 +75,7 @@ class MeController {
// PATCH: api/me/progress/:id/:episodeId
async createUpdateEpisodeMediaProgress(req, res) {
const episodeId = req.params.episodeId
- const libraryItem = Database.libraryItems.find(ab => ab.id === req.params.id)
+ const libraryItem = await Database.libraryItemModel.getOldById(req.params.id)
if (!libraryItem) {
return res.status(404).send('Item not found')
}
@@ -101,7 +101,7 @@ class MeController {
let shouldUpdate = false
for (const itemProgress of itemProgressPayloads) {
- const libraryItem = Database.libraryItems.find(li => li.id === itemProgress.libraryItemId) // Make sure this library item exists
+ const libraryItem = await Database.libraryItemModel.getOldById(itemProgress.libraryItemId)
if (libraryItem) {
if (req.user.createUpdateMediaProgress(libraryItem, itemProgress, itemProgress.episodeId)) {
const mediaProgress = req.user.getMediaProgress(libraryItem.id, itemProgress.episodeId)
@@ -122,10 +122,10 @@ class MeController {
// POST: api/me/item/:id/bookmark
async createBookmark(req, res) {
- var libraryItem = Database.libraryItems.find(li => li.id === req.params.id)
- if (!libraryItem) return res.sendStatus(404)
+ if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
+
const { time, title } = req.body
- var bookmark = req.user.createBookmark(libraryItem.id, time, title)
+ const bookmark = req.user.createBookmark(req.params.id, time, title)
await Database.updateUser(req.user)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
res.json(bookmark)
@@ -133,15 +133,17 @@ class MeController {
// PATCH: api/me/item/:id/bookmark
async updateBookmark(req, res) {
- var libraryItem = Database.libraryItems.find(li => li.id === req.params.id)
- if (!libraryItem) return res.sendStatus(404)
+ if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
+
const { time, title } = req.body
- if (!req.user.findBookmark(libraryItem.id, time)) {
+ if (!req.user.findBookmark(req.params.id, time)) {
Logger.error(`[MeController] updateBookmark not found`)
return res.sendStatus(404)
}
- var bookmark = req.user.updateBookmark(libraryItem.id, time, title)
+
+ const bookmark = req.user.updateBookmark(req.params.id, time, title)
if (!bookmark) return res.sendStatus(500)
+
await Database.updateUser(req.user)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
res.json(bookmark)
@@ -149,16 +151,17 @@ class MeController {
// DELETE: api/me/item/:id/bookmark/:time
async removeBookmark(req, res) {
- var libraryItem = Database.libraryItems.find(li => li.id === req.params.id)
- if (!libraryItem) return res.sendStatus(404)
- var time = Number(req.params.time)
+ if (!await Database.libraryItemModel.checkExistsById(req.params.id)) return res.sendStatus(404)
+
+ const time = Number(req.params.time)
if (isNaN(time)) return res.sendStatus(500)
- if (!req.user.findBookmark(libraryItem.id, time)) {
+ if (!req.user.findBookmark(req.params.id, time)) {
Logger.error(`[MeController] removeBookmark not found`)
return res.sendStatus(404)
}
- req.user.removeBookmark(libraryItem.id, time)
+
+ req.user.removeBookmark(req.params.id, time)
await Database.updateUser(req.user)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
res.sendStatus(200)
@@ -190,7 +193,8 @@ class MeController {
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object`, localProgress)
continue
}
- const libraryItem = Database.getLibraryItem(localProgress.libraryItemId)
+
+ const libraryItem = await Database.libraryItemModel.getOldById(localProgress.libraryItemId)
if (!libraryItem) {
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object no library item`, localProgress)
continue
@@ -242,13 +246,15 @@ class MeController {
}
// GET: api/me/items-in-progress
- getAllLibraryItemsInProgress(req, res) {
+ async getAllLibraryItemsInProgress(req, res) {
const limit = !isNaN(req.query.limit) ? Number(req.query.limit) || 25 : 25
let itemsInProgress = []
+ // TODO: More efficient to do this in a single query
for (const mediaProgress of req.user.mediaProgress) {
if (!mediaProgress.isFinished && (mediaProgress.progress > 0 || mediaProgress.ebookProgress > 0)) {
- const libraryItem = Database.getLibraryItem(mediaProgress.libraryItemId)
+
+ const libraryItem = await Database.libraryItemModel.getOldById(mediaProgress.libraryItemId)
if (libraryItem) {
if (mediaProgress.episodeId && libraryItem.mediaType === 'podcast') {
const episode = libraryItem.media.episodes.find(ep => ep.id === mediaProgress.episodeId)
@@ -278,7 +284,7 @@ class MeController {
// GET: api/me/series/:id/remove-from-continue-listening
async removeSeriesFromContinueListening(req, res) {
- const series = Database.series.find(se => se.id === req.params.id)
+ const series = await Database.seriesModel.getOldById(req.params.id)
if (!series) {
Logger.error(`[MeController] removeSeriesFromContinueListening: Series ${req.params.id} not found`)
return res.sendStatus(404)
@@ -294,7 +300,7 @@ class MeController {
// GET: api/me/series/:id/readd-to-continue-listening
async readdSeriesFromContinueListening(req, res) {
- const series = Database.series.find(se => se.id === req.params.id)
+ const series = await Database.seriesModel.getOldById(req.params.id)
if (!series) {
Logger.error(`[MeController] readdSeriesFromContinueListening: Series ${req.params.id} not found`)
return res.sendStatus(404)
@@ -310,9 +316,19 @@ class MeController {
// GET: api/me/progress/:id/remove-from-continue-listening
async removeItemFromContinueListening(req, res) {
+ const mediaProgress = req.user.mediaProgress.find(mp => mp.id === req.params.id)
+ if (!mediaProgress) {
+ return res.sendStatus(404)
+ }
const hasUpdated = req.user.removeProgressFromContinueListening(req.params.id)
if (hasUpdated) {
- await Database.updateUser(req.user)
+ await Database.mediaProgressModel.update({
+ hideFromContinueListening: true
+ }, {
+ where: {
+ id: mediaProgress.id
+ }
+ })
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
}
res.json(req.user.toJSONForBrowser())
diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js
index 6bf889fb..0fa1c62f 100644
--- a/server/controllers/MiscController.js
+++ b/server/controllers/MiscController.js
@@ -1,12 +1,13 @@
+const Sequelize = require('sequelize')
const Path = require('path')
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
-const filePerms = require('../utils/filePerms')
+const libraryItemFilters = require('../utils/queries/libraryItemFilters')
const patternValidation = require('../libs/nodeCron/pattern-validation')
-const { isObject } = require('../utils/index')
+const { isObject, getTitleIgnorePrefix } = require('../utils/index')
//
// This is a controller for routes that don't have a home yet :(
@@ -14,7 +15,12 @@ const { isObject } = require('../utils/index')
class MiscController {
constructor() { }
- // POST: api/upload
+ /**
+ * POST: /api/upload
+ * Update library item
+ * @param {*} req
+ * @param {*} res
+ */
async handleUpload(req, res) {
if (!req.user.canUpload) {
Logger.warn('User attempted to upload without permission', req.user)
@@ -31,7 +37,7 @@ class MiscController {
const libraryId = req.body.library
const folderId = req.body.folder
- const library = await Database.models.library.getOldById(libraryId)
+ const library = await Database.libraryModel.getOldById(libraryId)
if (!library) {
return res.status(404).send(`Library not found with id ${libraryId}`)
}
@@ -83,12 +89,15 @@ class MiscController {
})
}
- await filePerms.setDefault(firstDirPath)
-
res.sendStatus(200)
}
- // GET: api/tasks
+ /**
+ * GET: /api/tasks
+ * Get tasks for task manager
+ * @param {*} req
+ * @param {*} res
+ */
getTasks(req, res) {
const includeArray = (req.query.include || '').split(',')
@@ -105,7 +114,12 @@ class MiscController {
res.json(data)
}
- // PATCH: api/settings (admin)
+ /**
+ * PATCH: /api/settings
+ * Update server settings
+ * @param {*} req
+ * @param {*} res
+ */
async updateServerSettings(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error('User other than admin attempting to update server settings', req.user)
@@ -113,7 +127,7 @@ class MiscController {
}
const settingsUpdate = req.body
if (!settingsUpdate || !isObject(settingsUpdate)) {
- return res.status(500).send('Invalid settings update object')
+ return res.status(400).send('Invalid settings update object')
}
const madeUpdates = Database.serverSettings.update(settingsUpdate)
@@ -131,6 +145,103 @@ class MiscController {
})
}
+ /**
+ * PATCH: /api/sorting-prefixes
+ *
+ * @param {import('express').Request} req
+ * @param {import('express').Response} res
+ */
+ async updateSortingPrefixes(req, res) {
+ if (!req.user.isAdminOrUp) {
+ Logger.error('User other than admin attempting to update server sorting prefixes', req.user)
+ return res.sendStatus(403)
+ }
+ let sortingPrefixes = req.body.sortingPrefixes
+ if (!sortingPrefixes?.length || !Array.isArray(sortingPrefixes)) {
+ return res.status(400).send('Invalid request body')
+ }
+ sortingPrefixes = [...new Set(sortingPrefixes.map(p => p?.trim?.().toLowerCase()).filter(p => p))]
+ if (!sortingPrefixes.length) {
+ return res.status(400).send('Invalid sortingPrefixes in request body')
+ }
+
+ Logger.debug(`[MiscController] Updating sorting prefixes ${sortingPrefixes.join(', ')}`)
+ Database.serverSettings.sortingPrefixes = sortingPrefixes
+ await Database.updateServerSettings()
+
+ let rowsUpdated = 0
+ // Update titleIgnorePrefix column on books
+ const books = await Database.bookModel.findAll({
+ attributes: ['id', 'title', 'titleIgnorePrefix']
+ })
+ const bulkUpdateBooks = []
+ books.forEach((book) => {
+ const titleIgnorePrefix = getTitleIgnorePrefix(book.title)
+ if (titleIgnorePrefix !== book.titleIgnorePrefix) {
+ bulkUpdateBooks.push({
+ id: book.id,
+ titleIgnorePrefix
+ })
+ }
+ })
+ if (bulkUpdateBooks.length) {
+ Logger.info(`[MiscController] Updating titleIgnorePrefix on ${bulkUpdateBooks.length} books`)
+ rowsUpdated += bulkUpdateBooks.length
+ await Database.bookModel.bulkCreate(bulkUpdateBooks, {
+ updateOnDuplicate: ['titleIgnorePrefix']
+ })
+ }
+
+ // Update titleIgnorePrefix column on podcasts
+ const podcasts = await Database.podcastModel.findAll({
+ attributes: ['id', 'title', 'titleIgnorePrefix']
+ })
+ const bulkUpdatePodcasts = []
+ podcasts.forEach((podcast) => {
+ const titleIgnorePrefix = getTitleIgnorePrefix(podcast.title)
+ if (titleIgnorePrefix !== podcast.titleIgnorePrefix) {
+ bulkUpdatePodcasts.push({
+ id: podcast.id,
+ titleIgnorePrefix
+ })
+ }
+ })
+ if (bulkUpdatePodcasts.length) {
+ Logger.info(`[MiscController] Updating titleIgnorePrefix on ${bulkUpdatePodcasts.length} podcasts`)
+ rowsUpdated += bulkUpdatePodcasts.length
+ await Database.podcastModel.bulkCreate(bulkUpdatePodcasts, {
+ updateOnDuplicate: ['titleIgnorePrefix']
+ })
+ }
+
+ // Update nameIgnorePrefix column on series
+ const allSeries = await Database.seriesModel.findAll({
+ attributes: ['id', 'name', 'nameIgnorePrefix']
+ })
+ const bulkUpdateSeries = []
+ allSeries.forEach((series) => {
+ const nameIgnorePrefix = getTitleIgnorePrefix(series.name)
+ if (nameIgnorePrefix !== series.nameIgnorePrefix) {
+ bulkUpdateSeries.push({
+ id: series.id,
+ nameIgnorePrefix
+ })
+ }
+ })
+ if (bulkUpdateSeries.length) {
+ Logger.info(`[MiscController] Updating nameIgnorePrefix on ${bulkUpdateSeries.length} series`)
+ rowsUpdated += bulkUpdateSeries.length
+ await Database.seriesModel.bulkCreate(bulkUpdateSeries, {
+ updateOnDuplicate: ['nameIgnorePrefix']
+ })
+ }
+
+ res.json({
+ rowsUpdated,
+ serverSettings: Database.serverSettings.toJSONForBrowser()
+ })
+ }
+
/**
* POST: /api/authorize
* Used to authorize an API token
@@ -147,26 +258,55 @@ class MiscController {
res.json(userResponse)
}
- // GET: api/tags
- getAllTags(req, res) {
+ /**
+ * GET: /api/tags
+ * Get all tags
+ * @param {*} req
+ * @param {*} res
+ */
+ async getAllTags(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user attempted to getAllTags`)
return res.sendStatus(404)
}
+
const tags = []
- Database.libraryItems.forEach((li) => {
- if (li.media.tags && li.media.tags.length) {
- li.media.tags.forEach((tag) => {
- if (!tags.includes(tag)) tags.push(tag)
- })
- }
+ const books = await Database.bookModel.findAll({
+ attributes: ['tags'],
+ where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('tags')), {
+ [Sequelize.Op.gt]: 0
+ })
})
+ for (const book of books) {
+ for (const tag of book.tags) {
+ if (!tags.includes(tag)) tags.push(tag)
+ }
+ }
+
+ const podcasts = await Database.podcastModel.findAll({
+ attributes: ['tags'],
+ where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('tags')), {
+ [Sequelize.Op.gt]: 0
+ })
+ })
+ for (const podcast of podcasts) {
+ for (const tag of podcast.tags) {
+ if (!tags.includes(tag)) tags.push(tag)
+ }
+ }
+
res.json({
tags: tags
})
}
- // POST: api/tags/rename
+ /**
+ * POST: /api/tags/rename
+ * Rename tag
+ * Req.body { tag, newTag }
+ * @param {*} req
+ * @param {*} res
+ */
async renameTag(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user attempted to renameTag`)
@@ -183,19 +323,26 @@ class MiscController {
let tagMerged = false
let numItemsUpdated = 0
- for (const li of Database.libraryItems) {
- if (!li.media.tags || !li.media.tags.length) continue
+ // Update filter data
+ Database.replaceTagInFilterData(tag, newTag)
- if (li.media.tags.includes(newTag)) tagMerged = true // new tag is an existing tag so this is a merge
+ const libraryItemsWithTag = await libraryItemFilters.getAllLibraryItemsWithTags([tag, newTag])
+ for (const libraryItem of libraryItemsWithTag) {
+ if (libraryItem.media.tags.includes(newTag)) {
+ tagMerged = true // new tag is an existing tag so this is a merge
+ }
- if (li.media.tags.includes(tag)) {
- li.media.tags = li.media.tags.filter(t => t !== tag) // Remove old tag
- if (!li.media.tags.includes(newTag)) {
- li.media.tags.push(newTag) // Add new tag
+ if (libraryItem.media.tags.includes(tag)) {
+ libraryItem.media.tags = libraryItem.media.tags.filter(t => t !== tag) // Remove old tag
+ if (!libraryItem.media.tags.includes(newTag)) {
+ libraryItem.media.tags.push(newTag)
}
- Logger.debug(`[MiscController] Rename tag "${tag}" to "${newTag}" for item "${li.media.metadata.title}"`)
- await Database.updateLibraryItem(li)
- SocketAuthority.emitter('item_updated', li.toJSONExpanded())
+ Logger.debug(`[MiscController] Rename tag "${tag}" to "${newTag}" for item "${libraryItem.media.title}"`)
+ await libraryItem.media.update({
+ tags: libraryItem.media.tags
+ })
+ const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
+ SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
numItemsUpdated++
}
}
@@ -206,7 +353,13 @@ class MiscController {
})
}
- // DELETE: api/tags/:tag
+ /**
+ * DELETE: /api/tags/:tag
+ * Remove a tag
+ * :tag param is base64 encoded
+ * @param {*} req
+ * @param {*} res
+ */
async deleteTag(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user attempted to deleteTag`)
@@ -215,17 +368,23 @@ class MiscController {
const tag = Buffer.from(decodeURIComponent(req.params.tag), 'base64').toString()
- let numItemsUpdated = 0
- for (const li of Database.libraryItems) {
- if (!li.media.tags || !li.media.tags.length) continue
+ // Get all items with tag
+ const libraryItemsWithTag = await libraryItemFilters.getAllLibraryItemsWithTags([tag])
- if (li.media.tags.includes(tag)) {
- li.media.tags = li.media.tags.filter(t => t !== tag)
- Logger.debug(`[MiscController] Remove tag "${tag}" from item "${li.media.metadata.title}"`)
- await Database.updateLibraryItem(li)
- SocketAuthority.emitter('item_updated', li.toJSONExpanded())
- numItemsUpdated++
- }
+ // Update filterdata
+ Database.removeTagFromFilterData(tag)
+
+ let numItemsUpdated = 0
+ // Remove tag from items
+ for (const libraryItem of libraryItemsWithTag) {
+ Logger.debug(`[MiscController] Remove tag "${tag}" from item "${libraryItem.media.title}"`)
+ libraryItem.media.tags = libraryItem.media.tags.filter(t => t !== tag)
+ await libraryItem.media.update({
+ tags: libraryItem.media.tags
+ })
+ const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
+ SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
+ numItemsUpdated++
}
res.json({
@@ -233,26 +392,54 @@ class MiscController {
})
}
- // GET: api/genres
- getAllGenres(req, res) {
+ /**
+ * GET: /api/genres
+ * Get all genres
+ * @param {*} req
+ * @param {*} res
+ */
+ async getAllGenres(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user attempted to getAllGenres`)
return res.sendStatus(404)
}
const genres = []
- Database.libraryItems.forEach((li) => {
- if (li.media.metadata.genres && li.media.metadata.genres.length) {
- li.media.metadata.genres.forEach((genre) => {
- if (!genres.includes(genre)) genres.push(genre)
- })
- }
+ const books = await Database.bookModel.findAll({
+ attributes: ['genres'],
+ where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('genres')), {
+ [Sequelize.Op.gt]: 0
+ })
})
+ for (const book of books) {
+ for (const tag of book.genres) {
+ if (!genres.includes(tag)) genres.push(tag)
+ }
+ }
+
+ const podcasts = await Database.podcastModel.findAll({
+ attributes: ['genres'],
+ where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('genres')), {
+ [Sequelize.Op.gt]: 0
+ })
+ })
+ for (const podcast of podcasts) {
+ for (const tag of podcast.genres) {
+ if (!genres.includes(tag)) genres.push(tag)
+ }
+ }
+
res.json({
genres
})
}
- // POST: api/genres/rename
+ /**
+ * POST: /api/genres/rename
+ * Rename genres
+ * Req.body { genre, newGenre }
+ * @param {*} req
+ * @param {*} res
+ */
async renameGenre(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user attempted to renameGenre`)
@@ -269,19 +456,26 @@ class MiscController {
let genreMerged = false
let numItemsUpdated = 0
- for (const li of Database.libraryItems) {
- if (!li.media.metadata.genres || !li.media.metadata.genres.length) continue
+ // Update filter data
+ Database.replaceGenreInFilterData(genre, newGenre)
- if (li.media.metadata.genres.includes(newGenre)) genreMerged = true // new genre is an existing genre so this is a merge
+ const libraryItemsWithGenre = await libraryItemFilters.getAllLibraryItemsWithGenres([genre, newGenre])
+ for (const libraryItem of libraryItemsWithGenre) {
+ if (libraryItem.media.genres.includes(newGenre)) {
+ genreMerged = true // new genre is an existing genre so this is a merge
+ }
- if (li.media.metadata.genres.includes(genre)) {
- li.media.metadata.genres = li.media.metadata.genres.filter(g => g !== genre) // Remove old genre
- if (!li.media.metadata.genres.includes(newGenre)) {
- li.media.metadata.genres.push(newGenre) // Add new genre
+ if (libraryItem.media.genres.includes(genre)) {
+ libraryItem.media.genres = libraryItem.media.genres.filter(t => t !== genre) // Remove old genre
+ if (!libraryItem.media.genres.includes(newGenre)) {
+ libraryItem.media.genres.push(newGenre)
}
- Logger.debug(`[MiscController] Rename genre "${genre}" to "${newGenre}" for item "${li.media.metadata.title}"`)
- await Database.updateLibraryItem(li)
- SocketAuthority.emitter('item_updated', li.toJSONExpanded())
+ Logger.debug(`[MiscController] Rename genre "${genre}" to "${newGenre}" for item "${libraryItem.media.title}"`)
+ await libraryItem.media.update({
+ genres: libraryItem.media.genres
+ })
+ const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
+ SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
numItemsUpdated++
}
}
@@ -292,7 +486,13 @@ class MiscController {
})
}
- // DELETE: api/genres/:genre
+ /**
+ * DELETE: /api/genres/:genre
+ * Remove a genre
+ * :genre param is base64 encoded
+ * @param {*} req
+ * @param {*} res
+ */
async deleteGenre(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user attempted to deleteGenre`)
@@ -301,17 +501,23 @@ class MiscController {
const genre = Buffer.from(decodeURIComponent(req.params.genre), 'base64').toString()
- let numItemsUpdated = 0
- for (const li of Database.libraryItems) {
- if (!li.media.metadata.genres || !li.media.metadata.genres.length) continue
+ // Update filter data
+ Database.removeGenreFromFilterData(genre)
- if (li.media.metadata.genres.includes(genre)) {
- li.media.metadata.genres = li.media.metadata.genres.filter(t => t !== genre)
- Logger.debug(`[MiscController] Remove genre "${genre}" from item "${li.media.metadata.title}"`)
- await Database.updateLibraryItem(li)
- SocketAuthority.emitter('item_updated', li.toJSONExpanded())
- numItemsUpdated++
- }
+ // Get all items with genre
+ const libraryItemsWithGenre = await libraryItemFilters.getAllLibraryItemsWithGenres([genre])
+
+ let numItemsUpdated = 0
+ // Remove genre from items
+ for (const libraryItem of libraryItemsWithGenre) {
+ Logger.debug(`[MiscController] Remove genre "${genre}" from item "${libraryItem.media.title}"`)
+ libraryItem.media.genres = libraryItem.media.genres.filter(g => g !== genre)
+ await libraryItem.media.update({
+ genres: libraryItem.media.genres
+ })
+ const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
+ SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
+ numItemsUpdated++
}
res.json({
diff --git a/server/controllers/PlaylistController.js b/server/controllers/PlaylistController.js
index 8c351c78..16514ef8 100644
--- a/server/controllers/PlaylistController.js
+++ b/server/controllers/PlaylistController.js
@@ -7,71 +7,187 @@ const Playlist = require('../objects/Playlist')
class PlaylistController {
constructor() { }
- // POST: api/playlists
+ /**
+ * POST: /api/playlists
+ * Create playlist
+ * @param {*} req
+ * @param {*} res
+ */
async create(req, res) {
- const newPlaylist = new Playlist()
+ const oldPlaylist = new Playlist()
req.body.userId = req.user.id
- const success = newPlaylist.setData(req.body)
+ const success = oldPlaylist.setData(req.body)
if (!success) {
return res.status(400).send('Invalid playlist request data')
}
- const jsonExpanded = newPlaylist.toJSONExpanded(Database.libraryItems)
- await Database.createPlaylist(newPlaylist)
+
+ // Create Playlist record
+ const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist)
+
+ // Lookup all library items in playlist
+ const libraryItemIds = oldPlaylist.items.map(i => i.libraryItemId).filter(i => i)
+ const libraryItemsInPlaylist = await Database.libraryItemModel.findAll({
+ where: {
+ id: libraryItemIds
+ }
+ })
+
+ // Create playlistMediaItem records
+ const mediaItemsToAdd = []
+ let order = 1
+ for (const mediaItemObj of oldPlaylist.items) {
+ const libraryItem = libraryItemsInPlaylist.find(li => li.id === mediaItemObj.libraryItemId)
+ if (!libraryItem) continue
+
+ mediaItemsToAdd.push({
+ mediaItemId: mediaItemObj.episodeId || libraryItem.mediaId,
+ mediaItemType: mediaItemObj.episodeId ? 'podcastEpisode' : 'book',
+ playlistId: oldPlaylist.id,
+ order: order++
+ })
+ }
+ if (mediaItemsToAdd.length) {
+ await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
+ }
+
+ const jsonExpanded = await newPlaylist.getOldJsonExpanded()
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
res.json(jsonExpanded)
}
- // GET: api/playlists
+ /**
+ * GET: /api/playlists
+ * Get all playlists for user
+ * @param {*} req
+ * @param {*} res
+ */
async findAllForUser(req, res) {
- const playlistsForUser = await Database.models.playlist.getPlaylistsForUserAndLibrary(req.user.id)
+ const playlistsForUser = await Database.playlistModel.findAll({
+ where: {
+ userId: req.user.id
+ }
+ })
+ const playlists = []
+ for (const playlist of playlistsForUser) {
+ const jsonExpanded = await playlist.getOldJsonExpanded()
+ playlists.push(jsonExpanded)
+ }
res.json({
- playlists: playlistsForUser.map(p => p.toJSONExpanded(Database.libraryItems))
+ playlists
})
}
- // GET: api/playlists/:id
- findOne(req, res) {
- res.json(req.playlist.toJSONExpanded(Database.libraryItems))
+ /**
+ * GET: /api/playlists/:id
+ * @param {*} req
+ * @param {*} res
+ */
+ async findOne(req, res) {
+ const jsonExpanded = await req.playlist.getOldJsonExpanded()
+ res.json(jsonExpanded)
}
- // PATCH: api/playlists/:id
+ /**
+ * PATCH: /api/playlists/:id
+ * Update playlist
+ * @param {*} req
+ * @param {*} res
+ */
async update(req, res) {
- const playlist = req.playlist
- let wasUpdated = playlist.update(req.body)
- const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
+ const updatedPlaylist = req.playlist.set(req.body)
+ let wasUpdated = false
+ const changed = updatedPlaylist.changed()
+ if (changed?.length) {
+ await req.playlist.save()
+ Logger.debug(`[PlaylistController] Updated playlist ${req.playlist.id} keys [${changed.join(',')}]`)
+ wasUpdated = true
+ }
+
+ // If array of items is passed in then update order of playlist media items
+ const libraryItemIds = req.body.items?.map(i => i.libraryItemId).filter(i => i) || []
+ if (libraryItemIds.length) {
+ const libraryItems = await Database.libraryItemModel.findAll({
+ where: {
+ id: libraryItemIds
+ }
+ })
+ const existingPlaylistMediaItems = await updatedPlaylist.getPlaylistMediaItems({
+ order: [['order', 'ASC']]
+ })
+
+ // Set an array of mediaItemId
+ const newMediaItemIdOrder = []
+ for (const item of req.body.items) {
+ const libraryItem = libraryItems.find(li => li.id === item.libraryItemId)
+ if (!libraryItem) {
+ continue
+ }
+ const mediaItemId = item.episodeId || libraryItem.mediaId
+ newMediaItemIdOrder.push(mediaItemId)
+ }
+
+ // Sort existing playlist media items into new order
+ existingPlaylistMediaItems.sort((a, b) => {
+ const aIndex = newMediaItemIdOrder.findIndex(i => i === a.mediaItemId)
+ const bIndex = newMediaItemIdOrder.findIndex(i => i === b.mediaItemId)
+ return aIndex - bIndex
+ })
+
+ // Update order on playlistMediaItem records
+ let order = 1
+ for (const playlistMediaItem of existingPlaylistMediaItems) {
+ if (playlistMediaItem.order !== order) {
+ await playlistMediaItem.update({
+ order
+ })
+ wasUpdated = true
+ }
+ order++
+ }
+ }
+
+ const jsonExpanded = await updatedPlaylist.getOldJsonExpanded()
if (wasUpdated) {
- await Database.updatePlaylist(playlist)
- SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
+ SocketAuthority.clientEmitter(updatedPlaylist.userId, 'playlist_updated', jsonExpanded)
}
res.json(jsonExpanded)
}
- // DELETE: api/playlists/:id
+ /**
+ * DELETE: /api/playlists/:id
+ * Remove playlist
+ * @param {*} req
+ * @param {*} res
+ */
async delete(req, res) {
- const playlist = req.playlist
- const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
- await Database.removePlaylist(playlist.id)
- SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
+ const jsonExpanded = await req.playlist.getOldJsonExpanded()
+ await req.playlist.destroy()
+ SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
res.sendStatus(200)
}
- // POST: api/playlists/:id/item
+ /**
+ * POST: /api/playlists/:id/item
+ * Add item to playlist
+ * @param {*} req
+ * @param {*} res
+ */
async addItem(req, res) {
- const playlist = req.playlist
+ const oldPlaylist = await Database.playlistModel.getById(req.playlist.id)
const itemToAdd = req.body
if (!itemToAdd.libraryItemId) {
return res.status(400).send('Request body has no libraryItemId')
}
- const libraryItem = Database.libraryItems.find(li => li.id === itemToAdd.libraryItemId)
+ const libraryItem = await Database.libraryItemModel.getOldById(itemToAdd.libraryItemId)
if (!libraryItem) {
return res.status(400).send('Library item not found')
}
- if (libraryItem.libraryId !== playlist.libraryId) {
+ if (libraryItem.libraryId !== oldPlaylist.libraryId) {
return res.status(400).send('Library item in different library')
}
- if (playlist.containsItem(itemToAdd)) {
+ if (oldPlaylist.containsItem(itemToAdd)) {
return res.status(400).send('Item already in playlist')
}
if ((itemToAdd.episodeId && !libraryItem.isPodcast) || (libraryItem.isPodcast && !itemToAdd.episodeId)) {
@@ -81,160 +197,248 @@ class PlaylistController {
return res.status(400).send('Episode not found in library item')
}
- playlist.addItem(itemToAdd.libraryItemId, itemToAdd.episodeId)
-
const playlistMediaItem = {
- playlistId: playlist.id,
+ playlistId: oldPlaylist.id,
mediaItemId: itemToAdd.episodeId || libraryItem.media.id,
mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book',
- order: playlist.items.length
+ order: oldPlaylist.items.length + 1
}
- const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
await Database.createPlaylistMediaItem(playlistMediaItem)
+ const jsonExpanded = await req.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
res.json(jsonExpanded)
}
- // DELETE: api/playlists/:id/item/:libraryItemId/:episodeId?
+ /**
+ * DELETE: /api/playlists/:id/item/:libraryItemId/:episodeId?
+ * Remove item from playlist
+ * @param {*} req
+ * @param {*} res
+ */
async removeItem(req, res) {
- const playlist = req.playlist
- const itemToRemove = {
- libraryItemId: req.params.libraryItemId,
- episodeId: req.params.episodeId || null
- }
- if (!playlist.containsItem(itemToRemove)) {
- return res.sendStatus(404)
+ const oldLibraryItem = await Database.libraryItemModel.getOldById(req.params.libraryItemId)
+ if (!oldLibraryItem) {
+ return res.status(404).send('Library item not found')
}
- playlist.removeItem(itemToRemove.libraryItemId, itemToRemove.episodeId)
+ // Get playlist media items
+ const mediaItemId = req.params.episodeId || oldLibraryItem.media.id
+ const playlistMediaItems = await req.playlist.getPlaylistMediaItems({
+ order: [['order', 'ASC']]
+ })
- const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
+ // Check if media item to delete is in playlist
+ const mediaItemToRemove = playlistMediaItems.find(pmi => pmi.mediaItemId === mediaItemId)
+ if (!mediaItemToRemove) {
+ return res.status(404).send('Media item not found in playlist')
+ }
+
+ // Remove record
+ await mediaItemToRemove.destroy()
+
+ // Update playlist media items order
+ let order = 1
+ for (const mediaItem of playlistMediaItems) {
+ if (mediaItem.mediaItemId === mediaItemId) continue
+ if (mediaItem.order !== order) {
+ await mediaItem.update({
+ order
+ })
+ }
+ order++
+ }
+
+ const jsonExpanded = await req.playlist.getOldJsonExpanded()
// Playlist is removed when there are no items
- if (!playlist.items.length) {
- Logger.info(`[PlaylistController] Playlist "${playlist.name}" has no more items - removing it`)
- await Database.removePlaylist(playlist.id)
- SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
+ if (!jsonExpanded.items.length) {
+ Logger.info(`[PlaylistController] Playlist "${jsonExpanded.name}" has no more items - removing it`)
+ await req.playlist.destroy()
+ SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
} else {
- await Database.updatePlaylist(playlist)
- SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
+ SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', jsonExpanded)
}
res.json(jsonExpanded)
}
- // POST: api/playlists/:id/batch/add
+ /**
+ * POST: /api/playlists/:id/batch/add
+ * Batch add playlist items
+ * @param {*} req
+ * @param {*} res
+ */
async addBatch(req, res) {
- const playlist = req.playlist
- if (!req.body.items || !req.body.items.length) {
- return res.status(500).send('Invalid request body')
+ if (!req.body.items?.length) {
+ return res.status(400).send('Invalid request body')
}
const itemsToAdd = req.body.items
- let hasUpdated = false
- let order = playlist.items.length
- const playlistMediaItems = []
+ const libraryItemIds = itemsToAdd.map(i => i.libraryItemId).filter(i => i)
+ if (!libraryItemIds.length) {
+ return res.status(400).send('Invalid request body')
+ }
+
+ // Find all library items
+ const libraryItems = await Database.libraryItemModel.findAll({
+ where: {
+ id: libraryItemIds
+ }
+ })
+
+ // Get all existing playlist media items
+ const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
+ order: [['order', 'ASC']]
+ })
+
+ const mediaItemsToAdd = []
+
+ // Setup array of playlistMediaItem records to add
+ let order = existingPlaylistMediaItems.length + 1
for (const item of itemsToAdd) {
- if (!item.libraryItemId) {
- return res.status(400).send('Item does not have libraryItemId')
- }
-
- const libraryItem = Database.getLibraryItem(item.libraryItemId)
+ const libraryItem = libraryItems.find(li => li.id === item.libraryItemId)
if (!libraryItem) {
- return res.status(400).send('Item not found with id ' + item.libraryItemId)
- }
-
- if (!playlist.containsItem(item)) {
- playlistMediaItems.push({
- playlistId: playlist.id,
- mediaItemId: item.episodeId || libraryItem.media.id, // podcastEpisodeId or bookId
- mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
- order: order++
- })
- playlist.addItem(item.libraryItemId, item.episodeId)
- hasUpdated = true
+ return res.status(404).send('Item not found with id ' + item.libraryItemId)
+ } else {
+ const mediaItemId = item.episodeId || libraryItem.mediaId
+ if (existingPlaylistMediaItems.some(pmi => pmi.mediaItemId === mediaItemId)) {
+ // Already exists in playlist
+ continue
+ } else {
+ mediaItemsToAdd.push({
+ playlistId: req.playlist.id,
+ mediaItemId,
+ mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
+ order: order++
+ })
+ }
}
}
- const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
- if (hasUpdated) {
- await Database.createBulkPlaylistMediaItems(playlistMediaItems)
- SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
+ let jsonExpanded = null
+ if (mediaItemsToAdd.length) {
+ await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
+ jsonExpanded = await req.playlist.getOldJsonExpanded()
+ SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
+ } else {
+ jsonExpanded = await req.playlist.getOldJsonExpanded()
}
res.json(jsonExpanded)
}
- // POST: api/playlists/:id/batch/remove
+ /**
+ * POST: /api/playlists/:id/batch/remove
+ * Batch remove playlist items
+ * @param {*} req
+ * @param {*} res
+ */
async removeBatch(req, res) {
- const playlist = req.playlist
- if (!req.body.items || !req.body.items.length) {
- return res.status(500).send('Invalid request body')
+ if (!req.body.items?.length) {
+ return res.status(400).send('Invalid request body')
}
+
const itemsToRemove = req.body.items
+ const libraryItemIds = itemsToRemove.map(i => i.libraryItemId).filter(i => i)
+ if (!libraryItemIds.length) {
+ return res.status(400).send('Invalid request body')
+ }
+
+ // Find all library items
+ const libraryItems = await Database.libraryItemModel.findAll({
+ where: {
+ id: libraryItemIds
+ }
+ })
+
+ // Get all existing playlist media items for playlist
+ const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
+ order: [['order', 'ASC']]
+ })
+ let numMediaItems = existingPlaylistMediaItems.length
+
+ // Remove playlist media items
let hasUpdated = false
for (const item of itemsToRemove) {
- if (!item.libraryItemId) {
- return res.status(400).send('Item does not have libraryItemId')
- }
-
- if (playlist.containsItem(item)) {
- playlist.removeItem(item.libraryItemId, item.episodeId)
- hasUpdated = true
- }
+ const libraryItem = libraryItems.find(li => li.id === item.libraryItemId)
+ if (!libraryItem) continue
+ const mediaItemId = item.episodeId || libraryItem.mediaId
+ const existingMediaItem = existingPlaylistMediaItems.find(pmi => pmi.mediaItemId === mediaItemId)
+ if (!existingMediaItem) continue
+ await existingMediaItem.destroy()
+ hasUpdated = true
+ numMediaItems--
}
- const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
+ const jsonExpanded = await req.playlist.getOldJsonExpanded()
if (hasUpdated) {
// Playlist is removed when there are no items
- if (!playlist.items.length) {
- Logger.info(`[PlaylistController] Playlist "${playlist.name}" has no more items - removing it`)
- await Database.removePlaylist(playlist.id)
+ if (!numMediaItems) {
+ Logger.info(`[PlaylistController] Playlist "${req.playlist.name}" has no more items - removing it`)
+ await req.playlist.destroy()
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
} else {
- await Database.updatePlaylist(playlist)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
}
}
res.json(jsonExpanded)
}
- // POST: api/playlists/collection/:collectionId
+ /**
+ * POST: /api/playlists/collection/:collectionId
+ * Create a playlist from a collection
+ * @param {*} req
+ * @param {*} res
+ */
async createFromCollection(req, res) {
- let collection = await Database.models.collection.getById(req.params.collectionId)
+ const collection = await Database.collectionModel.findByPk(req.params.collectionId)
if (!collection) {
return res.status(404).send('Collection not found')
}
// Expand collection to get library items
- collection = collection.toJSONExpanded(Database.libraryItems)
-
- // Filter out library items not accessible to user
- const libraryItems = collection.books.filter(item => req.user.checkCanAccessLibraryItem(item))
-
- if (!libraryItems.length) {
- return res.status(400).send('Collection has no books accessible to user')
+ const collectionExpanded = await collection.getOldJsonExpanded(req.user)
+ if (!collectionExpanded) {
+ // This can happen if the user has no access to all items in collection
+ return res.status(404).send('Collection not found')
}
- const newPlaylist = new Playlist()
+ // Playlists cannot be empty
+ if (!collectionExpanded.books.length) {
+ return res.status(400).send('Collection has no books')
+ }
- const newPlaylistData = {
+ const oldPlaylist = new Playlist()
+ oldPlaylist.setData({
userId: req.user.id,
libraryId: collection.libraryId,
name: collection.name,
- description: collection.description || null,
- items: libraryItems.map(li => ({ libraryItemId: li.id }))
- }
- newPlaylist.setData(newPlaylistData)
+ description: collection.description || null
+ })
- const jsonExpanded = newPlaylist.toJSONExpanded(Database.libraryItems)
- await Database.createPlaylist(newPlaylist)
+ // Create Playlist record
+ const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist)
+
+ // Create PlaylistMediaItem records
+ const mediaItemsToAdd = []
+ let order = 1
+ for (const libraryItem of collectionExpanded.books) {
+ mediaItemsToAdd.push({
+ playlistId: newPlaylist.id,
+ mediaItemId: libraryItem.media.id,
+ mediaItemType: 'book',
+ order: order++
+ })
+ }
+ await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
+
+ const jsonExpanded = await newPlaylist.getOldJsonExpanded()
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
res.json(jsonExpanded)
}
async middleware(req, res, next) {
if (req.params.id) {
- const playlist = await Database.models.playlist.getById(req.params.id)
+ const playlist = await Database.playlistModel.findByPk(req.params.id)
if (!playlist) {
return res.status(404).send('Playlist not found')
}
diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js
index a7d8f3ce..0e1ebcd3 100644
--- a/server/controllers/PodcastController.js
+++ b/server/controllers/PodcastController.js
@@ -6,7 +6,9 @@ const fs = require('../libs/fsExtra')
const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')
const { getFileTimestampsWithIno, filePathToPOSIX } = require('../utils/fileUtils')
-const filePerms = require('../utils/filePerms')
+
+const Scanner = require('../scanner/Scanner')
+const CoverManager = require('../managers/CoverManager')
const LibraryItem = require('../objects/LibraryItem')
@@ -19,7 +21,7 @@ class PodcastController {
}
const payload = req.body
- const library = await Database.models.library.getOldById(payload.libraryId)
+ const library = await Database.libraryModel.getOldById(payload.libraryId)
if (!library) {
Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`)
return res.status(404).send('Library not found')
@@ -34,9 +36,13 @@ class PodcastController {
const podcastPath = filePathToPOSIX(payload.path)
// Check if a library item with this podcast folder exists already
- const existingLibraryItem = Database.libraryItems.find(li => li.path === podcastPath && li.libraryId === library.id)
+ const existingLibraryItem = (await Database.libraryItemModel.count({
+ where: {
+ path: podcastPath
+ }
+ })) > 0
if (existingLibraryItem) {
- Logger.error(`[PodcastController] Podcast already exists with name "${existingLibraryItem.media.metadata.title}" at path "${podcastPath}"`)
+ Logger.error(`[PodcastController] Podcast already exists at path "${podcastPath}"`)
return res.status(400).send('Podcast already exists')
}
@@ -45,7 +51,6 @@ class PodcastController {
return false
})
if (!success) return res.status(400).send('Invalid podcast path')
- await filePerms.setDefault(podcastPath)
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
@@ -71,7 +76,7 @@ class PodcastController {
if (payload.media.metadata.imageUrl) {
// TODO: Scan cover image to library files
// Podcast cover will always go into library item folder
- const coverResponse = await this.coverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl, true)
+ const coverResponse = await CoverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl, true)
if (coverResponse) {
if (coverResponse.error) {
Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`)
@@ -198,7 +203,7 @@ class PodcastController {
}
const overrideDetails = req.query.override === '1'
- const episodesUpdated = await this.scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails })
+ const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails })
if (episodesUpdated) {
await Database.updateLibraryItem(req.libraryItem)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
@@ -268,23 +273,32 @@ class PodcastController {
}
// Update/remove playlists that had this podcast episode
- const playlistsWithEpisode = await Database.models.playlist.getPlaylistsForMediaItemIds([episodeId])
- for (const playlist of playlistsWithEpisode) {
- playlist.removeItem(libraryItem.id, episodeId)
+ const playlistMediaItems = await Database.playlistMediaItemModel.findAll({
+ where: {
+ mediaItemId: episodeId
+ },
+ include: {
+ model: Database.playlistModel,
+ include: Database.playlistMediaItemModel
+ }
+ })
+ for (const pmi of playlistMediaItems) {
+ const numItems = pmi.playlist.playlistMediaItems.length - 1
- // If playlist is now empty then remove it
- if (!playlist.items.length) {
+ if (!numItems) {
Logger.info(`[PodcastController] Playlist "${playlist.name}" has no more items - removing it`)
- await Database.removePlaylist(playlist.id)
- SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', playlist.toJSONExpanded(Database.libraryItems))
+ const jsonExpanded = await pmi.playlist.getOldJsonExpanded()
+ SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_removed', jsonExpanded)
+ await pmi.playlist.destroy()
} else {
- await Database.updatePlaylist(playlist)
- SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', playlist.toJSONExpanded(Database.libraryItems))
+ await pmi.destroy()
+ const jsonExpanded = await pmi.playlist.getOldJsonExpanded()
+ SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_updated', jsonExpanded)
}
}
// Remove media progress for this episode
- const mediaProgressRemoved = await Database.models.mediaProgress.destroy({
+ const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
where: {
mediaItemId: episode.id
}
@@ -298,9 +312,9 @@ class PodcastController {
res.json(libraryItem.toJSON())
}
- middleware(req, res, next) {
- const item = Database.libraryItems.find(li => li.id === req.params.id)
- if (!item || !item.media) return res.sendStatus(404)
+ async middleware(req, res, next) {
+ const item = await Database.libraryItemModel.getOldById(req.params.id)
+ if (!item?.media) return res.sendStatus(404)
if (!item.isPodcast) {
return res.sendStatus(500)
diff --git a/server/controllers/RSSFeedController.js b/server/controllers/RSSFeedController.js
index 82175e4c..9b7acf70 100644
--- a/server/controllers/RSSFeedController.js
+++ b/server/controllers/RSSFeedController.js
@@ -1,14 +1,23 @@
const Logger = require('../Logger')
const Database = require('../Database')
+const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
class RSSFeedController {
constructor() { }
+ async getAll(req, res) {
+ const feeds = await this.rssFeedManager.getFeeds()
+ res.json({
+ feeds: feeds.map(f => f.toJSON()),
+ minified: feeds.map(f => f.toJSONMinified())
+ })
+ }
+
// POST: api/feeds/item/:itemId/open
async openRSSFeedForItem(req, res) {
const options = req.body || {}
- const item = Database.libraryItems.find(li => li.id === req.params.itemId)
+ const item = await Database.libraryItemModel.getOldById(req.params.itemId)
if (!item) return res.sendStatus(404)
// Check user can access this library item
@@ -45,7 +54,7 @@ class RSSFeedController {
async openRSSFeedForCollection(req, res) {
const options = req.body || {}
- const collection = await Database.models.collection.getById(req.params.collectionId)
+ const collection = await Database.collectionModel.findByPk(req.params.collectionId)
if (!collection) return res.sendStatus(404)
// Check request body options exist
@@ -60,7 +69,7 @@ class RSSFeedController {
return res.status(400).send('Slug already in use')
}
- const collectionExpanded = collection.toJSONExpanded(Database.libraryItems)
+ const collectionExpanded = await collection.getOldJsonExpanded()
const collectionItemsWithTracks = collectionExpanded.books.filter(li => li.media.tracks.length)
// Check collection has audio tracks
@@ -79,7 +88,7 @@ class RSSFeedController {
async openRSSFeedForSeries(req, res) {
const options = req.body || {}
- const series = Database.series.find(se => se.id === req.params.seriesId)
+ const series = await Database.seriesModel.getOldById(req.params.seriesId)
if (!series) return res.sendStatus(404)
// Check request body options exist
@@ -95,8 +104,9 @@ class RSSFeedController {
}
const seriesJson = series.toJSON()
+
// Get books in series that have audio tracks
- seriesJson.books = Database.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length)
+ seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter(li => li.media.numTracks)
// Check series has audio tracks
if (!seriesJson.books.length) {
diff --git a/server/controllers/SearchController.js b/server/controllers/SearchController.js
index cc803720..2749016c 100644
--- a/server/controllers/SearchController.js
+++ b/server/controllers/SearchController.js
@@ -1,4 +1,8 @@
const Logger = require("../Logger")
+const BookFinder = require('../finders/BookFinder')
+const PodcastFinder = require('../finders/PodcastFinder')
+const AuthorFinder = require('../finders/AuthorFinder')
+const MusicFinder = require('../finders/MusicFinder')
class SearchController {
constructor() { }
@@ -7,7 +11,7 @@ class SearchController {
const provider = req.query.provider || 'google'
const title = req.query.title || ''
const author = req.query.author || ''
- const results = await this.bookFinder.search(provider, title, author)
+ const results = await BookFinder.search(provider, title, author)
res.json(results)
}
@@ -21,8 +25,8 @@ class SearchController {
}
let results = null
- if (podcast) results = await this.podcastFinder.findCovers(query.title)
- else results = await this.bookFinder.findCovers(query.provider || 'google', query.title, query.author || null)
+ if (podcast) results = await PodcastFinder.findCovers(query.title)
+ else results = await BookFinder.findCovers(query.provider || 'google', query.title, query.author || null)
res.json({
results
})
@@ -30,20 +34,20 @@ class SearchController {
async findPodcasts(req, res) {
const term = req.query.term
- const results = await this.podcastFinder.search(term)
+ const results = await PodcastFinder.search(term)
res.json(results)
}
async findAuthor(req, res) {
const query = req.query.q
- const author = await this.authorFinder.findAuthorByName(query)
+ const author = await AuthorFinder.findAuthorByName(query)
res.json(author)
}
async findChapters(req, res) {
const asin = req.query.asin
const region = (req.query.region || 'us').toLowerCase()
- const chapterData = await this.bookFinder.findChapters(asin, region)
+ const chapterData = await BookFinder.findChapters(asin, region)
if (!chapterData) {
return res.json({ error: 'Chapters not found' })
}
@@ -51,7 +55,7 @@ class SearchController {
}
async findMusicTrack(req, res) {
- const tracks = await this.musicFinder.searchTrack(req.query || {})
+ const tracks = await MusicFinder.searchTrack(req.query || {})
res.json({
tracks
})
diff --git a/server/controllers/SeriesController.js b/server/controllers/SeriesController.js
index 041c0716..38ab3da9 100644
--- a/server/controllers/SeriesController.js
+++ b/server/controllers/SeriesController.js
@@ -1,6 +1,7 @@
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
+const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
class SeriesController {
constructor() { }
@@ -25,7 +26,7 @@ class SeriesController {
const libraryItemsInSeries = req.libraryItemsInSeries
const libraryItemsFinished = libraryItemsInSeries.filter(li => {
const mediaProgress = req.user.getMediaProgress(li.id)
- return mediaProgress && mediaProgress.isFinished
+ return mediaProgress?.isFinished
})
seriesJson.progress = {
libraryItemIds: libraryItemsInSeries.map(li => li.id),
@@ -42,17 +43,6 @@ class SeriesController {
res.json(seriesJson)
}
- async search(req, res) {
- var q = (req.query.q || '').toLowerCase()
- if (!q) return res.json([])
- var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25
- var series = Database.series.filter(se => se.name.toLowerCase().includes(q))
- series = series.slice(0, limit)
- res.json({
- results: series
- })
- }
-
async update(req, res) {
const hasUpdated = req.series.update(req.body)
if (hasUpdated) {
@@ -62,18 +52,17 @@ class SeriesController {
res.json(req.series.toJSON())
}
- middleware(req, res, next) {
- const series = Database.series.find(se => se.id === req.params.id)
+ async middleware(req, res, next) {
+ const series = await Database.seriesModel.getOldById(req.params.id)
if (!series) return res.sendStatus(404)
/**
* 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(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)
+ const libraryItems = await libraryItemsBookFilters.getLibraryItemsForSeries(series, req.user)
+ if (!libraryItems.length) {
+ Logger.warn(`[SeriesController] User attempted to access series "${series.id}" with no accessible books`, req.user)
+ return res.sendStatus(404)
}
if (req.method == 'DELETE' && !req.user.canDelete) {
@@ -85,7 +74,7 @@ class SeriesController {
}
req.series = series
- req.libraryItemsInSeries = libraryItemsAccessible
+ req.libraryItemsInSeries = libraryItems
next()
}
}
diff --git a/server/controllers/SessionController.js b/server/controllers/SessionController.js
index 698f58d7..85baeb27 100644
--- a/server/controllers/SessionController.js
+++ b/server/controllers/SessionController.js
@@ -49,7 +49,7 @@ class SessionController {
return res.sendStatus(404)
}
- const minifiedUserObjects = await Database.models.user.getMinifiedUserObjects()
+ const minifiedUserObjects = await Database.userModel.getMinifiedUserObjects()
const openSessions = this.playbackSessionManager.sessions.map(se => {
return {
...se.toJSON(),
@@ -62,9 +62,9 @@ class SessionController {
})
}
- getOpenSession(req, res) {
- var libraryItem = Database.getLibraryItem(req.session.libraryItemId)
- var sessionForClient = req.session.toJSONForClient(libraryItem)
+ async getOpenSession(req, res) {
+ const libraryItem = await Database.libraryItemModel.getOldById(req.session.libraryItemId)
+ const sessionForClient = req.session.toJSONForClient(libraryItem)
res.json(sessionForClient)
}
diff --git a/server/controllers/ToolsController.js b/server/controllers/ToolsController.js
index 6215dd43..3f81d116 100644
--- a/server/controllers/ToolsController.js
+++ b/server/controllers/ToolsController.js
@@ -66,7 +66,7 @@ class ToolsController {
const libraryItems = []
for (const libraryItemId of libraryItemIds) {
- const libraryItem = Database.getLibraryItem(libraryItemId)
+ const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId)
if (!libraryItem) {
Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not found`)
return res.sendStatus(404)
@@ -99,15 +99,15 @@ class ToolsController {
res.sendStatus(200)
}
- middleware(req, res, next) {
+ async middleware(req, res, next) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryItemController] Non-root user attempted to access tools route`, req.user)
return res.sendStatus(403)
}
if (req.params.id) {
- const item = Database.libraryItems.find(li => li.id === req.params.id)
- if (!item || !item.media) return res.sendStatus(404)
+ const item = await Database.libraryItemModel.getOldById(req.params.id)
+ if (!item?.media) return res.sendStatus(404)
// Check user can access this library item
if (!req.user.checkCanAccessLibraryItem(item)) {
diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js
index 965f6a08..755a60a1 100644
--- a/server/controllers/UserController.js
+++ b/server/controllers/UserController.js
@@ -17,7 +17,7 @@ class UserController {
const includes = (req.query.include || '').split(',').map(i => i.trim())
// Minimal toJSONForBrowser does not include mediaProgress and bookmarks
- const allUsers = await Database.models.user.getOldUsers()
+ const allUsers = await Database.userModel.getOldUsers()
const users = allUsers.map(u => u.toJSONForBrowser(hideRootToken, true))
if (includes.includes('latestSession')) {
@@ -32,20 +32,67 @@ class UserController {
})
}
+ /**
+ * GET: /api/users/:id
+ * Get a single user toJSONForBrowser
+ * Media progress items include: `displayTitle`, `displaySubtitle` (for podcasts), `coverPath` and `mediaUpdatedAt`
+ *
+ * @param {import("express").Request} req
+ * @param {import("express").Response} res
+ */
async findOne(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error('User other than admin attempting to get user', req.user)
return res.sendStatus(403)
}
- res.json(this.userJsonWithItemProgressDetails(req.reqUser, !req.user.isRoot))
+ // Get user media progress with associated mediaItem
+ const mediaProgresses = await Database.mediaProgressModel.findAll({
+ where: {
+ userId: req.reqUser.id
+ },
+ include: [
+ {
+ model: Database.bookModel,
+ attributes: ['id', 'title', 'coverPath', 'updatedAt']
+ },
+ {
+ model: Database.podcastEpisodeModel,
+ attributes: ['id', 'title'],
+ include: {
+ model: Database.podcastModel,
+ attributes: ['id', 'title', 'coverPath', 'updatedAt']
+ }
+ }
+ ]
+ })
+
+ const oldMediaProgresses = mediaProgresses.map(mp => {
+ const oldMediaProgress = mp.getOldMediaProgress()
+ oldMediaProgress.displayTitle = mp.mediaItem?.title
+ if (mp.mediaItem?.podcast) {
+ oldMediaProgress.displaySubtitle = mp.mediaItem.podcast?.title
+ oldMediaProgress.coverPath = mp.mediaItem.podcast?.coverPath
+ oldMediaProgress.mediaUpdatedAt = mp.mediaItem.podcast?.updatedAt
+ } else if (mp.mediaItem) {
+ oldMediaProgress.coverPath = mp.mediaItem.coverPath
+ oldMediaProgress.mediaUpdatedAt = mp.mediaItem.updatedAt
+ }
+ return oldMediaProgress
+ })
+
+ const userJson = req.reqUser.toJSONForBrowser(!req.user.isRoot)
+
+ userJson.mediaProgress = oldMediaProgresses
+
+ res.json(userJson)
}
async create(req, res) {
const account = req.body
const username = account.username
- const usernameExists = await Database.models.user.getUserByUsername(username)
+ const usernameExists = await Database.userModel.getUserByUsername(username)
if (usernameExists) {
return res.status(500).send('Username already taken')
}
@@ -80,7 +127,7 @@ class UserController {
var shouldUpdateToken = false
if (account.username !== undefined && account.username !== user.username) {
- const usernameExists = await Database.models.user.getUserByUsername(account.username)
+ const usernameExists = await Database.userModel.getUserByUsername(account.username)
if (usernameExists) {
return res.status(500).send('Username already taken')
}
@@ -122,9 +169,13 @@ class UserController {
// Todo: check if user is logged in and cancel streams
// Remove user playlists
- const userPlaylists = await Database.models.playlist.getPlaylistsForUserAndLibrary(user.id)
+ const userPlaylists = await Database.playlistModel.findAll({
+ where: {
+ userId: user.id
+ }
+ })
for (const playlist of userPlaylists) {
- await Database.removePlaylist(playlist.id)
+ await playlist.destroy()
}
const userJson = user.toJSONForBrowser()
@@ -182,7 +233,7 @@ class UserController {
}
if (req.params.id) {
- req.reqUser = await Database.models.user.getUserById(req.params.id)
+ req.reqUser = await Database.userModel.getUserById(req.params.id)
if (!req.reqUser) {
return res.sendStatus(404)
}
diff --git a/server/db/libraryItem.db.js b/server/db/libraryItem.db.js
index 3f08bf06..335a52a1 100644
--- a/server/db/libraryItem.db.js
+++ b/server/db/libraryItem.db.js
@@ -5,23 +5,23 @@ const { Sequelize } = require('sequelize')
const Database = require('../Database')
const getLibraryItemMinified = (libraryItemId) => {
- return Database.models.libraryItem.findByPk(libraryItemId, {
+ return Database.libraryItemModel.findByPk(libraryItemId, {
include: [
{
- model: Database.models.book,
+ model: Database.bookModel,
attributes: [
'id', 'title', 'subtitle', 'publishedYear', 'publishedDate', 'publisher', 'description', 'isbn', 'asin', 'language', 'explicit', 'narrators', 'coverPath', 'genres', 'tags'
],
include: [
{
- model: Database.models.author,
+ model: Database.authorModel,
attributes: ['id', 'name'],
through: {
attributes: []
}
},
{
- model: Database.models.series,
+ model: Database.seriesModel,
attributes: ['id', 'name'],
through: {
attributes: ['sequence']
@@ -30,7 +30,7 @@ const getLibraryItemMinified = (libraryItemId) => {
]
},
{
- model: Database.models.podcast,
+ model: Database.podcastModel,
attributes: [
'id', 'title', 'author', 'releaseDate', 'feedURL', 'imageURL', 'description', 'itunesPageURL', 'itunesId', 'itunesArtistId', 'language', 'podcastType', 'explicit', 'autoDownloadEpisodes', 'genres', 'tags',
[Sequelize.literal('(SELECT COUNT(*) FROM "podcastEpisodes" WHERE "podcastEpisodes"."podcastId" = podcast.id)'), 'numPodcastEpisodes']
@@ -41,19 +41,19 @@ const getLibraryItemMinified = (libraryItemId) => {
}
const getLibraryItemExpanded = (libraryItemId) => {
- return Database.models.libraryItem.findByPk(libraryItemId, {
+ return Database.libraryItemModel.findByPk(libraryItemId, {
include: [
{
- model: Database.models.book,
+ model: Database.bookModel,
include: [
{
- model: Database.models.author,
+ model: Database.authorModel,
through: {
attributes: []
}
},
{
- model: Database.models.series,
+ model: Database.seriesModel,
through: {
attributes: ['sequence']
}
@@ -61,10 +61,10 @@ const getLibraryItemExpanded = (libraryItemId) => {
]
},
{
- model: Database.models.podcast,
+ model: Database.podcastModel,
include: [
{
- model: Database.models.podcastEpisode
+ model: Database.podcastEpisodeModel
}
]
},
diff --git a/server/finders/AuthorFinder.js b/server/finders/AuthorFinder.js
index 18fb2223..9c2a3b4f 100644
--- a/server/finders/AuthorFinder.js
+++ b/server/finders/AuthorFinder.js
@@ -4,12 +4,9 @@ const Path = require('path')
const Audnexus = require('../providers/Audnexus')
const { downloadFile } = require('../utils/fileUtils')
-const filePerms = require('../utils/filePerms')
class AuthorFinder {
constructor() {
- this.AuthorPath = Path.join(global.MetadataPath, 'authors')
-
this.audnexus = new Audnexus()
}
@@ -37,12 +34,11 @@ class AuthorFinder {
}
async saveAuthorImage(authorId, url) {
- var authorDir = this.AuthorPath
+ var authorDir = Path.join(global.MetadataPath, 'authors')
var relAuthorDir = Path.posix.join('/metadata', 'authors')
if (!await fs.pathExists(authorDir)) {
await fs.ensureDir(authorDir)
- await filePerms.setDefault(authorDir)
}
var imageExtension = url.toLowerCase().split('.').pop()
@@ -61,4 +57,4 @@ class AuthorFinder {
}
}
}
-module.exports = AuthorFinder
\ No newline at end of file
+module.exports = new AuthorFinder()
\ No newline at end of file
diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js
index 9124e2bd..6452bff0 100644
--- a/server/finders/BookFinder.js
+++ b/server/finders/BookFinder.js
@@ -253,4 +253,4 @@ class BookFinder {
return this.audnexus.getChaptersByASIN(asin, region)
}
}
-module.exports = BookFinder
\ No newline at end of file
+module.exports = new BookFinder()
\ No newline at end of file
diff --git a/server/finders/MusicFinder.js b/server/finders/MusicFinder.js
index 938cae83..3569576f 100644
--- a/server/finders/MusicFinder.js
+++ b/server/finders/MusicFinder.js
@@ -9,4 +9,4 @@ class MusicFinder {
return this.musicBrainz.searchTrack(options)
}
}
-module.exports = MusicFinder
\ No newline at end of file
+module.exports = new MusicFinder()
\ No newline at end of file
diff --git a/server/finders/PodcastFinder.js b/server/finders/PodcastFinder.js
index e0a204bd..52fec15c 100644
--- a/server/finders/PodcastFinder.js
+++ b/server/finders/PodcastFinder.js
@@ -22,4 +22,4 @@ class PodcastFinder {
return results.map(r => r.cover).filter(r => r)
}
}
-module.exports = PodcastFinder
\ No newline at end of file
+module.exports = new PodcastFinder()
\ No newline at end of file
diff --git a/server/managers/AbMergeManager.js b/server/managers/AbMergeManager.js
index 4c041b8b..4ced1390 100644
--- a/server/managers/AbMergeManager.js
+++ b/server/managers/AbMergeManager.js
@@ -5,7 +5,6 @@ const fs = require('../libs/fsExtra')
const workerThreads = require('worker_threads')
const Logger = require('../Logger')
const Task = require('../objects/Task')
-const filePerms = require('../utils/filePerms')
const { writeConcatFile } = require('../utils/ffmpegHelpers')
const toneHelpers = require('../utils/toneHelpers')
@@ -201,10 +200,6 @@ class AbMergeManager {
Logger.debug(`[AbMergeManager] Moving m4b from ${task.data.tempFilepath} to ${task.data.targetFilepath}`)
await fs.move(task.data.tempFilepath, task.data.targetFilepath)
- // Set file permissions and ownership
- await filePerms.setDefault(task.data.targetFilepath)
- await filePerms.setDefault(task.data.itemCachePath)
-
task.setFinished()
await this.removeTask(task, false)
Logger.info(`[AbMergeManager] Ab task finished ${task.id}`)
diff --git a/server/managers/CacheManager.js b/server/managers/CacheManager.js
index f92f0e48..fb0cd26c 100644
--- a/server/managers/CacheManager.js
+++ b/server/managers/CacheManager.js
@@ -1,42 +1,40 @@
const Path = require('path')
const fs = require('../libs/fsExtra')
const stream = require('stream')
-const filePerms = require('../utils/filePerms')
const Logger = require('../Logger')
const { resizeImage } = require('../utils/ffmpegHelpers')
class CacheManager {
constructor() {
+ this.CachePath = null
+ this.CoverCachePath = null
+ this.ImageCachePath = null
+ this.ItemCachePath = null
+ }
+
+ /**
+ * Create cache directory paths if they dont exist
+ */
+ async ensureCachePaths() { // Creates cache paths if necessary and sets owner and permissions
this.CachePath = Path.join(global.MetadataPath, 'cache')
this.CoverCachePath = Path.join(this.CachePath, 'covers')
this.ImageCachePath = Path.join(this.CachePath, 'images')
this.ItemCachePath = Path.join(this.CachePath, 'items')
- }
- async ensureCachePaths() { // Creates cache paths if necessary and sets owner and permissions
- var pathsCreated = false
if (!(await fs.pathExists(this.CachePath))) {
await fs.mkdir(this.CachePath)
- pathsCreated = true
}
if (!(await fs.pathExists(this.CoverCachePath))) {
await fs.mkdir(this.CoverCachePath)
- pathsCreated = true
}
if (!(await fs.pathExists(this.ImageCachePath))) {
await fs.mkdir(this.ImageCachePath)
- pathsCreated = true
}
if (!(await fs.pathExists(this.ItemCachePath))) {
await fs.mkdir(this.ItemCachePath)
- pathsCreated = true
- }
-
- if (pathsCreated) {
- await filePerms.setDefault(this.CachePath)
}
}
@@ -74,9 +72,6 @@ class CacheManager {
const writtenFile = await resizeImage(libraryItem.media.coverPath, path, width, height)
if (!writtenFile) return res.sendStatus(500)
- // Set owner and permissions of cache image
- await filePerms.setDefault(path)
-
if (global.XAccel) {
Logger.debug(`Use X-Accel to serve static file ${writtenFile}`)
return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + writtenFile }).send()
@@ -160,11 +155,8 @@ class CacheManager {
let writtenFile = await resizeImage(author.imagePath, path, width, height)
if (!writtenFile) return res.sendStatus(500)
- // Set owner and permissions of cache image
- await filePerms.setDefault(path)
-
var readStream = fs.createReadStream(writtenFile)
readStream.pipe(res)
}
}
-module.exports = CacheManager
\ No newline at end of file
+module.exports = new CacheManager()
\ No newline at end of file
diff --git a/server/managers/CoverManager.js b/server/managers/CoverManager.js
index eba4f44f..1a83b7cc 100644
--- a/server/managers/CoverManager.js
+++ b/server/managers/CoverManager.js
@@ -3,24 +3,20 @@ const Path = require('path')
const Logger = require('../Logger')
const readChunk = require('../libs/readChunk')
const imageType = require('../libs/imageType')
-const filePerms = require('../utils/filePerms')
const globals = require('../utils/globals')
-const { downloadFile, filePathToPOSIX } = require('../utils/fileUtils')
+const { downloadFile, filePathToPOSIX, checkPathIsFile } = require('../utils/fileUtils')
const { extractCoverArt } = require('../utils/ffmpegHelpers')
+const CacheManager = require('../managers/CacheManager')
class CoverManager {
- constructor(cacheManager) {
- this.cacheManager = cacheManager
-
- this.ItemMetadataPath = Path.posix.join(global.MetadataPath, 'items')
- }
+ constructor() { }
getCoverDirectory(libraryItem) {
- if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile && !libraryItem.isMusic) {
+ if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile) {
return libraryItem.path
} else {
- return Path.posix.join(this.ItemMetadataPath, libraryItem.id)
+ return Path.posix.join(Path.posix.join(global.MetadataPath, 'items'), libraryItem.id)
}
}
@@ -107,11 +103,10 @@ class CoverManager {
}
await this.removeOldCovers(coverDirPath, extname)
- await this.cacheManager.purgeCoverCache(libraryItem.id)
+ await CacheManager.purgeCoverCache(libraryItem.id)
Logger.info(`[CoverManager] Uploaded libraryItem cover "${coverFullPath}" for "${libraryItem.media.metadata.title}"`)
- await filePerms.setDefault(coverFullPath)
libraryItem.updateMediaCover(coverFullPath)
return {
cover: coverFullPath
@@ -146,11 +141,9 @@ class CoverManager {
await fs.rename(temppath, coverFullPath)
await this.removeOldCovers(coverDirPath, '.' + imgtype.ext)
- await this.cacheManager.purgeCoverCache(libraryItem.id)
+ await CacheManager.purgeCoverCache(libraryItem.id)
Logger.info(`[CoverManager] Downloaded libraryItem cover "${coverFullPath}" from url "${url}" for "${libraryItem.media.metadata.title}"`)
-
- await filePerms.setDefault(coverFullPath)
libraryItem.updateMediaCover(coverFullPath)
return {
cover: coverFullPath
@@ -180,6 +173,7 @@ class CoverManager {
updated: false
}
}
+
// Cover path does not exist
if (!await fs.pathExists(coverPath)) {
Logger.error(`[CoverManager] validate cover path does not exist "${coverPath}"`)
@@ -187,8 +181,17 @@ class CoverManager {
error: 'Cover path does not exist'
}
}
+
+ // Cover path is not a file
+ if (!await checkPathIsFile(coverPath)) {
+ Logger.error(`[CoverManager] validate cover path is not a file "${coverPath}"`)
+ return {
+ error: 'Cover path is not a file'
+ }
+ }
+
// Check valid image at path
- var imgtype = await this.checkFileIsValidImage(coverPath, true)
+ var imgtype = await this.checkFileIsValidImage(coverPath, false)
if (imgtype.error) {
return imgtype
}
@@ -212,13 +215,12 @@ class CoverManager {
error: 'Failed to copy cover to dir'
}
}
- await filePerms.setDefault(newCoverPath)
await this.removeOldCovers(coverDirPath, '.' + imgtype.ext)
Logger.debug(`[CoverManager] cover copy success`)
coverPath = newCoverPath
}
- await this.cacheManager.purgeCoverCache(libraryItem.id)
+ await CacheManager.purgeCoverCache(libraryItem.id)
libraryItem.updateMediaCover(coverPath)
return {
@@ -253,12 +255,97 @@ class CoverManager {
const success = await extractCoverArt(audioFileWithCover.metadata.path, coverFilePath)
if (success) {
- await filePerms.setDefault(coverFilePath)
-
libraryItem.updateMediaCover(coverFilePath)
return coverFilePath
}
return false
}
+
+ /**
+ * Extract cover art from audio file and save for library item
+ * @param {import('../models/Book').AudioFileObject[]} audioFiles
+ * @param {string} libraryItemId
+ * @param {string} [libraryItemPath] null for isFile library items
+ * @returns {Promise} returns cover path
+ */
+ async saveEmbeddedCoverArtNew(audioFiles, libraryItemId, libraryItemPath) {
+ let audioFileWithCover = audioFiles.find(af => af.embeddedCoverArt)
+ if (!audioFileWithCover) return null
+
+ let coverDirPath = null
+ if (global.ServerSettings.storeCoverWithItem && libraryItemPath) {
+ coverDirPath = libraryItemPath
+ } else {
+ coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId)
+ }
+ await fs.ensureDir(coverDirPath)
+
+ const coverFilename = audioFileWithCover.embeddedCoverArt === 'png' ? 'cover.png' : 'cover.jpg'
+ const coverFilePath = Path.join(coverDirPath, coverFilename)
+
+ const coverAlreadyExists = await fs.pathExists(coverFilePath)
+ if (coverAlreadyExists) {
+ Logger.warn(`[CoverManager] Extract embedded cover art but cover already exists for "${libraryItemPath}" - bail`)
+ return null
+ }
+
+ const success = await extractCoverArt(audioFileWithCover.metadata.path, coverFilePath)
+ if (success) {
+ return coverFilePath
+ }
+ return null
+ }
+
+ /**
+ *
+ * @param {string} url
+ * @param {string} libraryItemId
+ * @param {string} [libraryItemPath] null if library item isFile or is from adding new podcast
+ * @returns {Promise<{error:string}|{cover:string}>}
+ */
+ async downloadCoverFromUrlNew(url, libraryItemId, libraryItemPath) {
+ try {
+ let coverDirPath = null
+ if (global.ServerSettings.storeCoverWithItem && libraryItemPath) {
+ coverDirPath = libraryItemPath
+ } else {
+ coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId)
+ }
+
+ await fs.ensureDir(coverDirPath)
+
+ const temppath = Path.posix.join(coverDirPath, 'cover')
+ const success = await downloadFile(url, temppath).then(() => true).catch((err) => {
+ Logger.error(`[CoverManager] Download image file failed for "${url}"`, err)
+ return false
+ })
+ if (!success) {
+ return {
+ error: 'Failed to download image from url'
+ }
+ }
+
+ const imgtype = await this.checkFileIsValidImage(temppath, true)
+ if (imgtype.error) {
+ return imgtype
+ }
+
+ const coverFullPath = Path.posix.join(coverDirPath, `cover.${imgtype.ext}`)
+ await fs.rename(temppath, coverFullPath)
+
+ await this.removeOldCovers(coverDirPath, '.' + imgtype.ext)
+ await CacheManager.purgeCoverCache(libraryItemId)
+
+ Logger.info(`[CoverManager] Downloaded libraryItem cover "${coverFullPath}" from url "${url}"`)
+ return {
+ cover: coverFullPath
+ }
+ } catch (error) {
+ Logger.error(`[CoverManager] Fetch cover image from url "${url}" failed`, error)
+ return {
+ error: 'Failed to fetch image from url'
+ }
+ }
+ }
}
-module.exports = CoverManager
\ No newline at end of file
+module.exports = new CoverManager()
\ No newline at end of file
diff --git a/server/managers/CronManager.js b/server/managers/CronManager.js
index b5d17cb1..c44ad70d 100644
--- a/server/managers/CronManager.js
+++ b/server/managers/CronManager.js
@@ -1,10 +1,11 @@
+const Sequelize = require('sequelize')
const cron = require('../libs/nodeCron')
const Logger = require('../Logger')
const Database = require('../Database')
+const LibraryScanner = require('../scanner/LibraryScanner')
class CronManager {
- constructor(scanner, podcastManager) {
- this.scanner = scanner
+ constructor(podcastManager) {
this.podcastManager = podcastManager
this.libraryScanCrons = []
@@ -17,9 +18,9 @@ class CronManager {
* Initialize library scan crons & podcast download crons
* @param {oldLibrary[]} libraries
*/
- init(libraries) {
+ async init(libraries) {
this.initLibraryScanCrons(libraries)
- this.initPodcastCrons()
+ await this.initPodcastCrons()
}
/**
@@ -38,7 +39,7 @@ class CronManager {
Logger.debug(`[CronManager] Init library scan cron for ${library.name} on schedule ${library.settings.autoScanCronExpression}`)
const libScanCron = cron.schedule(library.settings.autoScanCronExpression, () => {
Logger.debug(`[CronManager] Library scan cron executing for ${library.name}`)
- this.scanner.scan(library)
+ LibraryScanner.scan(library)
})
this.libraryScanCrons.push({
libraryId: library.id,
@@ -70,23 +71,34 @@ class CronManager {
}
}
- initPodcastCrons() {
+ /**
+ * Init cron jobs for auto-download podcasts
+ */
+ async initPodcastCrons() {
const cronExpressionMap = {}
- Database.libraryItems.forEach((li) => {
- if (li.mediaType === 'podcast' && li.media.autoDownloadEpisodes) {
- if (!li.media.autoDownloadSchedule) {
- Logger.error(`[CronManager] Podcast auto download schedule is not set for ${li.media.metadata.title}`)
- } else {
- if (!cronExpressionMap[li.media.autoDownloadSchedule]) {
- cronExpressionMap[li.media.autoDownloadSchedule] = {
- expression: li.media.autoDownloadSchedule,
- libraryItemIds: []
- }
- }
- cronExpressionMap[li.media.autoDownloadSchedule].libraryItemIds.push(li.id)
+
+ const podcastsWithAutoDownload = await Database.podcastModel.findAll({
+ where: {
+ autoDownloadEpisodes: true,
+ autoDownloadSchedule: {
+ [Sequelize.Op.not]: null
}
+ },
+ include: {
+ model: Database.libraryItemModel
}
})
+
+ for (const podcast of podcastsWithAutoDownload) {
+ if (!cronExpressionMap[podcast.autoDownloadSchedule]) {
+ cronExpressionMap[podcast.autoDownloadSchedule] = {
+ expression: podcast.autoDownloadSchedule,
+ libraryItemIds: []
+ }
+ }
+ cronExpressionMap[podcast.autoDownloadSchedule].libraryItemIds.push(podcast.libraryItem.id)
+ }
+
if (!Object.keys(cronExpressionMap).length) return
Logger.debug(`[CronManager] Found ${Object.keys(cronExpressionMap).length} podcast episode schedules to start`)
@@ -127,7 +139,7 @@ class CronManager {
// Get podcast library items to check
const libraryItems = []
for (const libraryItemId of libraryItemIds) {
- const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId)
+ const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId)
if (!libraryItem) {
Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`)
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter(lid => lid !== libraryItemId) // Filter it out
diff --git a/server/managers/LogManager.js b/server/managers/LogManager.js
index 789cd877..0b01f32f 100644
--- a/server/managers/LogManager.js
+++ b/server/managers/LogManager.js
@@ -1,6 +1,5 @@
const Path = require('path')
const fs = require('../libs/fsExtra')
-const filePerms = require('../utils/filePerms')
const DailyLog = require('../objects/DailyLog')
@@ -25,13 +24,11 @@ class LogManager {
async ensureLogDirs() {
await fs.ensureDir(this.DailyLogPath)
await fs.ensureDir(this.ScanLogPath)
- await filePerms.setDefault(Path.posix.join(global.MetadataPath, 'logs'), true)
}
async ensureScanLogDir() {
if (!(await fs.pathExists(this.ScanLogPath))) {
await fs.mkdir(this.ScanLogPath)
- await filePerms.setDefault(this.ScanLogPath)
}
}
diff --git a/server/managers/NotificationManager.js b/server/managers/NotificationManager.js
index 5f3ab238..9007261a 100644
--- a/server/managers/NotificationManager.js
+++ b/server/managers/NotificationManager.js
@@ -18,7 +18,7 @@ class NotificationManager {
if (!Database.notificationSettings.isUseable) return
Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.metadata.title}`)
- const library = await Database.models.library.getOldById(libraryItem.libraryId)
+ const library = await Database.libraryModel.getOldById(libraryItem.libraryId)
const eventData = {
libraryItemId: libraryItem.id,
libraryId: libraryItem.libraryId,
diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js
index 7324a7a6..fba813d2 100644
--- a/server/managers/PlaybackSessionManager.js
+++ b/server/managers/PlaybackSessionManager.js
@@ -93,7 +93,7 @@ class PlaybackSessionManager {
}
async syncLocalSession(user, sessionJson, deviceInfo) {
- const libraryItem = Database.getLibraryItem(sessionJson.libraryItemId)
+ const libraryItem = await Database.libraryItemModel.getOldById(sessionJson.libraryItemId)
const episode = (sessionJson.episodeId && libraryItem && libraryItem.isPodcast) ? libraryItem.media.getEpisode(sessionJson.episodeId) : null
if (!libraryItem || (libraryItem.isPodcast && !episode)) {
Logger.error(`[PlaybackSessionManager] syncLocalSession: Media item not found for session "${sessionJson.displayTitle}" (${sessionJson.id})`)
@@ -259,13 +259,13 @@ class PlaybackSessionManager {
}
this.sessions.push(newPlaybackSession)
- SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions, Database.libraryItems))
+ SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions))
return newPlaybackSession
}
async syncSession(user, session, syncData) {
- const libraryItem = Database.libraryItems.find(li => li.id === session.libraryItemId)
+ const libraryItem = await Database.libraryItemModel.getOldById(session.libraryItemId)
if (!libraryItem) {
Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`)
return null
@@ -304,7 +304,7 @@ class PlaybackSessionManager {
await this.saveSession(session)
}
Logger.debug(`[PlaybackSessionManager] closeSession "${session.id}"`)
- SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions, Database.libraryItems))
+ SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions))
SocketAuthority.clientEmitter(session.userId, 'user_session_closed', session.id)
return this.removeSession(session.id)
}
diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js
index 9fe96793..5dec2152 100644
--- a/server/managers/PodcastManager.js
+++ b/server/managers/PodcastManager.js
@@ -6,7 +6,6 @@ const fs = require('../libs/fsExtra')
const { getPodcastFeed } = require('../utils/podcastUtils')
const { removeFile, downloadFile } = require('../utils/fileUtils')
-const filePerms = require('../utils/filePerms')
const { levenshteinDistance } = require('../utils/index')
const opmlParser = require('../utils/parsers/parseOPML')
const opmlGenerator = require('../utils/generators/opmlGenerator')
@@ -96,7 +95,6 @@ class PodcastManager {
if (!(await fs.pathExists(this.currentDownload.libraryItem.path))) {
Logger.warn(`[PodcastManager] Podcast episode download: Podcast folder no longer exists at "${this.currentDownload.libraryItem.path}" - Creating it`)
await fs.mkdir(this.currentDownload.libraryItem.path)
- await filePerms.setDefault(this.currentDownload.libraryItem.path)
}
let success = false
@@ -150,7 +148,7 @@ class PodcastManager {
return false
}
- const libraryItem = Database.libraryItems.find(li => li.id === this.currentDownload.libraryItem.id)
+ const libraryItem = await Database.libraryItemModel.getOldById(this.currentDownload.libraryItem.id)
if (!libraryItem) {
Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`)
return false
@@ -372,8 +370,13 @@ class PodcastManager {
}
}
- generateOPMLFileText(libraryItems) {
- return opmlGenerator.generate(libraryItems)
+ /**
+ * OPML file string for podcasts in a library
+ * @param {import('../models/Podcast')[]} podcasts
+ * @returns {string} XML string
+ */
+ generateOPMLFileText(podcasts) {
+ return opmlGenerator.generate(podcasts)
}
getDownloadQueueDetails(libraryId = null) {
diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js
index 7e4759a2..7eb1cce7 100644
--- a/server/managers/RssFeedManager.js
+++ b/server/managers/RssFeedManager.js
@@ -6,27 +6,28 @@ const Database = require('../Database')
const fs = require('../libs/fsExtra')
const Feed = require('../objects/Feed')
+const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
class RssFeedManager {
constructor() { }
async validateFeedEntity(feedObj) {
if (feedObj.entityType === 'collection') {
- const collection = await Database.models.collection.getById(feedObj.entityId)
+ const collection = await Database.collectionModel.getOldById(feedObj.entityId)
if (!collection) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Collection "${feedObj.entityId}" not found`)
return false
}
} else if (feedObj.entityType === 'libraryItem') {
- if (!Database.libraryItems.some(li => li.id === feedObj.entityId)) {
+ const libraryItemExists = await Database.libraryItemModel.checkExistsById(feedObj.entityId)
+ if (!libraryItemExists) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Library item "${feedObj.entityId}" not found`)
return false
}
} else if (feedObj.entityType === 'series') {
- const series = Database.series.find(s => s.id === feedObj.entityId)
- const hasSeriesBook = series ? Database.libraryItems.some(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length) : false
- if (!hasSeriesBook) {
- Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Series "${feedObj.entityId}" not found or has no audio tracks`)
+ const series = await Database.seriesModel.getOldById(feedObj.entityId)
+ if (!series) {
+ Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Series "${feedObj.entityId}" not found`)
return false
}
} else {
@@ -40,7 +41,7 @@ class RssFeedManager {
* Validate all feeds and remove invalid
*/
async init() {
- const feeds = await Database.models.feed.getOldFeeds()
+ const feeds = await Database.feedModel.getOldFeeds()
for (const feed of feeds) {
// Remove invalid feeds
if (!await this.validateFeedEntity(feed)) {
@@ -51,29 +52,29 @@ class RssFeedManager {
/**
* Find open feed for an entity (e.g. collection id, playlist id, library item id)
- * @param {string} entityId
+ * @param {string} entityId
* @returns {Promise} oldFeed
*/
findFeedForEntityId(entityId) {
- return Database.models.feed.findOneOld({ entityId })
+ return Database.feedModel.findOneOld({ entityId })
}
/**
* Find open feed for a slug
- * @param {string} slug
+ * @param {string} slug
* @returns {Promise} oldFeed
*/
findFeedBySlug(slug) {
- return Database.models.feed.findOneOld({ slug })
+ return Database.feedModel.findOneOld({ slug })
}
/**
* Find open feed for a slug
- * @param {string} slug
+ * @param {string} slug
* @returns {Promise} oldFeed
*/
findFeed(id) {
- return Database.models.feed.findByPkOld(id)
+ return Database.feedModel.findByPkOld(id)
}
async getFeed(req, res) {
@@ -86,7 +87,7 @@ class RssFeedManager {
// Check if feed needs to be updated
if (feed.entityType === 'libraryItem') {
- const libraryItem = Database.getLibraryItem(feed.entityId)
+ const libraryItem = await Database.libraryItemModel.getOldById(feed.entityId)
let mostRecentlyUpdatedAt = libraryItem.updatedAt
if (libraryItem.isPodcast) {
@@ -102,9 +103,9 @@ class RssFeedManager {
await Database.updateFeed(feed)
}
} else if (feed.entityType === 'collection') {
- const collection = await Database.models.collection.getById(feed.entityId)
+ const collection = await Database.collectionModel.findByPk(feed.entityId)
if (collection) {
- const collectionExpanded = collection.toJSONExpanded(Database.libraryItems)
+ const collectionExpanded = await collection.getOldJsonExpanded()
// Find most recently updated item in collection
let mostRecentlyUpdatedAt = collectionExpanded.lastUpdate
@@ -122,11 +123,12 @@ class RssFeedManager {
}
}
} else if (feed.entityType === 'series') {
- const series = Database.series.find(s => s.id === feed.entityId)
+ const series = await Database.seriesModel.getOldById(feed.entityId)
if (series) {
const seriesJson = series.toJSON()
+
// Get books in series that have audio tracks
- seriesJson.books = Database.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length)
+ seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter(li => li.media.numTracks)
// Find most recently updated item in series
let mostRecentlyUpdatedAt = seriesJson.updatedAt
@@ -260,5 +262,11 @@ class RssFeedManager {
if (!feed) return
return this.handleCloseFeed(feed)
}
+
+ async getFeeds() {
+ const feeds = await Database.models.feed.getOldFeeds()
+ Logger.info(`[RssFeedManager] Fetched all feeds`)
+ return feeds
+ }
}
module.exports = RssFeedManager
diff --git a/server/models/Author.js b/server/models/Author.js
index da6189e4..790b35dd 100644
--- a/server/models/Author.js
+++ b/server/models/Author.js
@@ -1,88 +1,171 @@
-const { DataTypes, Model } = require('sequelize')
+const { DataTypes, Model, literal } = require('sequelize')
const oldAuthor = require('../objects/entities/Author')
-module.exports = (sequelize) => {
- class Author extends Model {
- static async getOldAuthors() {
- const authors = await this.findAll()
- return authors.map(au => au.getOldAuthor())
- }
+class Author extends Model {
+ constructor(values, options) {
+ super(values, options)
- getOldAuthor() {
- return new oldAuthor({
- id: this.id,
- asin: this.asin,
- name: this.name,
- description: this.description,
- imagePath: this.imagePath,
- libraryId: this.libraryId,
- addedAt: this.createdAt.valueOf(),
- updatedAt: this.updatedAt.valueOf()
- })
- }
+ /** @type {UUIDV4} */
+ this.id
+ /** @type {string} */
+ this.name
+ /** @type {string} */
+ this.lastFirst
+ /** @type {string} */
+ this.asin
+ /** @type {string} */
+ this.description
+ /** @type {string} */
+ this.imagePath
+ /** @type {UUIDV4} */
+ this.libraryId
+ /** @type {Date} */
+ this.updatedAt
+ /** @type {Date} */
+ this.createdAt
+ }
- static updateFromOld(oldAuthor) {
- const author = this.getFromOld(oldAuthor)
- return this.update(author, {
- where: {
- id: author.id
- }
- })
- }
+ static async getOldAuthors() {
+ const authors = await this.findAll()
+ return authors.map(au => au.getOldAuthor())
+ }
- static createFromOld(oldAuthor) {
- const author = this.getFromOld(oldAuthor)
- return this.create(author)
- }
+ getOldAuthor() {
+ return new oldAuthor({
+ id: this.id,
+ asin: this.asin,
+ name: this.name,
+ description: this.description,
+ imagePath: this.imagePath,
+ libraryId: this.libraryId,
+ addedAt: this.createdAt.valueOf(),
+ updatedAt: this.updatedAt.valueOf()
+ })
+ }
- static createBulkFromOld(oldAuthors) {
- const authors = oldAuthors.map(this.getFromOld)
- return this.bulkCreate(authors)
- }
-
- static getFromOld(oldAuthor) {
- return {
- id: oldAuthor.id,
- name: oldAuthor.name,
- lastFirst: oldAuthor.lastFirst,
- asin: oldAuthor.asin,
- description: oldAuthor.description,
- imagePath: oldAuthor.imagePath,
- libraryId: oldAuthor.libraryId
+ static updateFromOld(oldAuthor) {
+ const author = this.getFromOld(oldAuthor)
+ return this.update(author, {
+ where: {
+ id: author.id
}
- }
+ })
+ }
- static removeById(authorId) {
- return this.destroy({
- where: {
- id: authorId
- }
- })
+ static createFromOld(oldAuthor) {
+ const author = this.getFromOld(oldAuthor)
+ return this.create(author)
+ }
+
+ static createBulkFromOld(oldAuthors) {
+ const authors = oldAuthors.map(this.getFromOld)
+ return this.bulkCreate(authors)
+ }
+
+ static getFromOld(oldAuthor) {
+ return {
+ id: oldAuthor.id,
+ name: oldAuthor.name,
+ lastFirst: oldAuthor.lastFirst,
+ asin: oldAuthor.asin,
+ description: oldAuthor.description,
+ imagePath: oldAuthor.imagePath,
+ libraryId: oldAuthor.libraryId
}
}
- Author.init({
- id: {
- type: DataTypes.UUID,
- defaultValue: DataTypes.UUIDV4,
- primaryKey: true
- },
- name: DataTypes.STRING,
- lastFirst: DataTypes.STRING,
- asin: DataTypes.STRING,
- description: DataTypes.TEXT,
- imagePath: DataTypes.STRING
- }, {
- sequelize,
- modelName: 'author'
- })
+ static removeById(authorId) {
+ return this.destroy({
+ where: {
+ id: authorId
+ }
+ })
+ }
- const { library } = sequelize.models
- library.hasMany(Author, {
- onDelete: 'CASCADE'
- })
- Author.belongsTo(library)
+ /**
+ * Get oldAuthor by id
+ * @param {string} authorId
+ * @returns {Promise}
+ */
+ static async getOldById(authorId) {
+ const author = await this.findByPk(authorId)
+ if (!author) return null
+ return author.getOldAuthor()
+ }
- return Author
-}
\ No newline at end of file
+ /**
+ * Check if author exists
+ * @param {string} authorId
+ * @returns {Promise}
+ */
+ static async checkExistsById(authorId) {
+ return (await this.count({ where: { id: authorId } })) > 0
+ }
+
+ /**
+ * Get old author by name and libraryId. name case insensitive
+ * TODO: Look for authors ignoring punctuation
+ *
+ * @param {string} authorName
+ * @param {string} libraryId
+ * @returns {Promise}
+ */
+ static async getOldByNameAndLibrary(authorName, libraryId) {
+ const author = (await this.findOne({
+ where: [
+ literal(`name = '${authorName}' COLLATE NOCASE`),
+ {
+ libraryId
+ }
+ ]
+ }))?.getOldAuthor()
+ return author
+ }
+
+ /**
+ * Initialize model
+ * @param {import('../Database').sequelize} sequelize
+ */
+ static init(sequelize) {
+ super.init({
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true
+ },
+ name: DataTypes.STRING,
+ lastFirst: DataTypes.STRING,
+ asin: DataTypes.STRING,
+ description: DataTypes.TEXT,
+ imagePath: DataTypes.STRING
+ }, {
+ sequelize,
+ modelName: 'author',
+ indexes: [
+ {
+ fields: [{
+ name: 'name',
+ collate: 'NOCASE'
+ }]
+ },
+ // {
+ // fields: [{
+ // name: 'lastFirst',
+ // collate: 'NOCASE'
+ // }]
+ // },
+ {
+ fields: ['libraryId']
+ }
+ ]
+ })
+
+ const { library } = sequelize.models
+ library.hasMany(Author, {
+ onDelete: 'CASCADE'
+ })
+ Author.belongsTo(library)
+ }
+}
+module.exports = Author
diff --git a/server/models/Book.js b/server/models/Book.js
index b17afc6d..31bcfa3c 100644
--- a/server/models/Book.js
+++ b/server/models/Book.js
@@ -1,178 +1,273 @@
const { DataTypes, Model } = require('sequelize')
const Logger = require('../Logger')
-module.exports = (sequelize) => {
- class Book extends Model {
- static getOldBook(libraryItemExpanded) {
- const bookExpanded = libraryItemExpanded.media
- let authors = []
- if (bookExpanded.authors?.length) {
- authors = bookExpanded.authors.map(au => {
- return {
- id: au.id,
- name: au.name
- }
- })
- } else if (bookExpanded.bookAuthors?.length) {
- authors = bookExpanded.bookAuthors.map(ba => {
- if (ba.author) {
- return {
- id: ba.author.id,
- name: ba.author.name
- }
- } else {
- Logger.error(`[Book] Invalid bookExpanded bookAuthors: no author`, ba)
- return null
- }
- }).filter(a => a)
- }
+/**
+ * @typedef EBookFileObject
+ * @property {string} ino
+ * @property {string} ebookFormat
+ * @property {number} addedAt
+ * @property {number} updatedAt
+ * @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata
+ */
- let series = []
- if (bookExpanded.series?.length) {
- series = bookExpanded.series.map(se => {
- return {
- id: se.id,
- name: se.name,
- sequence: se.bookSeries.sequence
- }
- })
- } else if (bookExpanded.bookSeries?.length) {
- series = bookExpanded.bookSeries.map(bs => {
- if (bs.series) {
- return {
- id: bs.series.id,
- name: bs.series.name,
- sequence: bs.sequence
- }
- } else {
- Logger.error(`[Book] Invalid bookExpanded bookSeries: no series`, bs)
- return null
- }
- }).filter(s => s)
- }
+/**
+ * @typedef ChapterObject
+ * @property {number} id
+ * @property {number} start
+ * @property {number} end
+ * @property {string} title
+ */
- return {
- id: bookExpanded.id,
- libraryItemId: libraryItemExpanded.id,
- coverPath: bookExpanded.coverPath,
- tags: bookExpanded.tags,
- audioFiles: bookExpanded.audioFiles,
- chapters: bookExpanded.chapters,
- ebookFile: bookExpanded.ebookFile,
- metadata: {
- title: bookExpanded.title,
- subtitle: bookExpanded.subtitle,
- authors: authors,
- narrators: bookExpanded.narrators,
- series: series,
- genres: bookExpanded.genres,
- publishedYear: bookExpanded.publishedYear,
- publishedDate: bookExpanded.publishedDate,
- publisher: bookExpanded.publisher,
- description: bookExpanded.description,
- isbn: bookExpanded.isbn,
- asin: bookExpanded.asin,
- language: bookExpanded.language,
- explicit: bookExpanded.explicit,
- abridged: bookExpanded.abridged
+/**
+ * @typedef AudioFileObject
+ * @property {number} index
+ * @property {string} ino
+ * @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata
+ * @property {number} addedAt
+ * @property {number} updatedAt
+ * @property {number} trackNumFromMeta
+ * @property {number} discNumFromMeta
+ * @property {number} trackNumFromFilename
+ * @property {number} discNumFromFilename
+ * @property {boolean} manuallyVerified
+ * @property {string} format
+ * @property {number} duration
+ * @property {number} bitRate
+ * @property {string} language
+ * @property {string} codec
+ * @property {string} timeBase
+ * @property {number} channels
+ * @property {string} channelLayout
+ * @property {ChapterObject[]} chapters
+ * @property {Object} metaTags
+ * @property {string} mimeType
+ */
+
+class Book extends Model {
+ constructor(values, options) {
+ super(values, options)
+
+ /** @type {string} */
+ this.id
+ /** @type {string} */
+ this.title
+ /** @type {string} */
+ this.titleIgnorePrefix
+ /** @type {string} */
+ this.publishedYear
+ /** @type {string} */
+ this.publishedDate
+ /** @type {string} */
+ this.publisher
+ /** @type {string} */
+ this.description
+ /** @type {string} */
+ this.isbn
+ /** @type {string} */
+ this.asin
+ /** @type {string} */
+ this.language
+ /** @type {boolean} */
+ this.explicit
+ /** @type {boolean} */
+ this.abridged
+ /** @type {string} */
+ this.coverPath
+ /** @type {number} */
+ this.duration
+ /** @type {string[]} */
+ this.narrators
+ /** @type {AudioFileObject[]} */
+ this.audioFiles
+ /** @type {EBookFileObject} */
+ this.ebookFile
+ /** @type {ChapterObject[]} */
+ this.chapters
+ /** @type {string[]} */
+ this.tags
+ /** @type {string[]} */
+ this.genres
+ /** @type {Date} */
+ this.updatedAt
+ /** @type {Date} */
+ this.createdAt
+ }
+
+ static getOldBook(libraryItemExpanded) {
+ const bookExpanded = libraryItemExpanded.media
+ let authors = []
+ if (bookExpanded.authors?.length) {
+ authors = bookExpanded.authors.map(au => {
+ return {
+ id: au.id,
+ name: au.name
}
- }
- }
-
- /**
- * @param {object} oldBook
- * @returns {boolean} true if updated
- */
- static saveFromOld(oldBook) {
- const book = this.getFromOld(oldBook)
- return this.update(book, {
- where: {
- id: book.id
- }
- }).then(result => result[0] > 0).catch((error) => {
- Logger.error(`[Book] Failed to save book ${book.id}`, error)
- return false
})
+ } else if (bookExpanded.bookAuthors?.length) {
+ authors = bookExpanded.bookAuthors.map(ba => {
+ if (ba.author) {
+ return {
+ id: ba.author.id,
+ name: ba.author.name
+ }
+ } else {
+ Logger.error(`[Book] Invalid bookExpanded bookAuthors: no author`, ba)
+ return null
+ }
+ }).filter(a => a)
}
- static getFromOld(oldBook) {
- return {
- id: oldBook.id,
- title: oldBook.metadata.title,
- titleIgnorePrefix: oldBook.metadata.titleIgnorePrefix,
- subtitle: oldBook.metadata.subtitle,
- publishedYear: oldBook.metadata.publishedYear,
- publishedDate: oldBook.metadata.publishedDate,
- publisher: oldBook.metadata.publisher,
- description: oldBook.metadata.description,
- isbn: oldBook.metadata.isbn,
- asin: oldBook.metadata.asin,
- language: oldBook.metadata.language,
- explicit: !!oldBook.metadata.explicit,
- abridged: !!oldBook.metadata.abridged,
- narrators: oldBook.metadata.narrators,
- ebookFile: oldBook.ebookFile?.toJSON() || null,
- coverPath: oldBook.coverPath,
- duration: oldBook.duration,
- audioFiles: oldBook.audioFiles?.map(af => af.toJSON()) || [],
- chapters: oldBook.chapters,
- tags: oldBook.tags,
- genres: oldBook.metadata.genres
+ let series = []
+ if (bookExpanded.series?.length) {
+ series = bookExpanded.series.map(se => {
+ return {
+ id: se.id,
+ name: se.name,
+ sequence: se.bookSeries.sequence
+ }
+ })
+ } else if (bookExpanded.bookSeries?.length) {
+ series = bookExpanded.bookSeries.map(bs => {
+ if (bs.series) {
+ return {
+ id: bs.series.id,
+ name: bs.series.name,
+ sequence: bs.sequence
+ }
+ } else {
+ Logger.error(`[Book] Invalid bookExpanded bookSeries: no series`, bs)
+ return null
+ }
+ }).filter(s => s)
+ }
+
+ return {
+ id: bookExpanded.id,
+ libraryItemId: libraryItemExpanded.id,
+ coverPath: bookExpanded.coverPath,
+ tags: bookExpanded.tags,
+ audioFiles: bookExpanded.audioFiles,
+ chapters: bookExpanded.chapters,
+ ebookFile: bookExpanded.ebookFile,
+ metadata: {
+ title: bookExpanded.title,
+ subtitle: bookExpanded.subtitle,
+ authors: authors,
+ narrators: bookExpanded.narrators,
+ series: series,
+ genres: bookExpanded.genres,
+ publishedYear: bookExpanded.publishedYear,
+ publishedDate: bookExpanded.publishedDate,
+ publisher: bookExpanded.publisher,
+ description: bookExpanded.description,
+ isbn: bookExpanded.isbn,
+ asin: bookExpanded.asin,
+ language: bookExpanded.language,
+ explicit: bookExpanded.explicit,
+ abridged: bookExpanded.abridged
}
}
}
- Book.init({
- id: {
- type: DataTypes.UUID,
- defaultValue: DataTypes.UUIDV4,
- primaryKey: true
- },
- title: DataTypes.STRING,
- titleIgnorePrefix: DataTypes.STRING,
- subtitle: DataTypes.STRING,
- publishedYear: DataTypes.STRING,
- publishedDate: DataTypes.STRING,
- publisher: DataTypes.STRING,
- description: DataTypes.TEXT,
- isbn: DataTypes.STRING,
- asin: DataTypes.STRING,
- language: DataTypes.STRING,
- explicit: DataTypes.BOOLEAN,
- abridged: DataTypes.BOOLEAN,
- coverPath: DataTypes.STRING,
- duration: DataTypes.FLOAT,
-
- narrators: DataTypes.JSON,
- audioFiles: DataTypes.JSON,
- ebookFile: DataTypes.JSON,
- chapters: DataTypes.JSON,
- tags: DataTypes.JSON,
- genres: DataTypes.JSON
- }, {
- sequelize,
- modelName: 'book',
- indexes: [
- {
- fields: [{
- name: 'title',
- collate: 'NOCASE'
- }]
- },
- {
- fields: [{
- name: 'titleIgnorePrefix',
- collate: 'NOCASE'
- }]
- },
- {
- fields: ['publishedYear']
- },
- {
- fields: ['duration']
+ /**
+ * @param {object} oldBook
+ * @returns {boolean} true if updated
+ */
+ static saveFromOld(oldBook) {
+ const book = this.getFromOld(oldBook)
+ return this.update(book, {
+ where: {
+ id: book.id
}
- ]
- })
+ }).then(result => result[0] > 0).catch((error) => {
+ Logger.error(`[Book] Failed to save book ${book.id}`, error)
+ return false
+ })
+ }
- return Book
-}
\ No newline at end of file
+ static getFromOld(oldBook) {
+ return {
+ id: oldBook.id,
+ title: oldBook.metadata.title,
+ titleIgnorePrefix: oldBook.metadata.titleIgnorePrefix,
+ subtitle: oldBook.metadata.subtitle,
+ publishedYear: oldBook.metadata.publishedYear,
+ publishedDate: oldBook.metadata.publishedDate,
+ publisher: oldBook.metadata.publisher,
+ description: oldBook.metadata.description,
+ isbn: oldBook.metadata.isbn,
+ asin: oldBook.metadata.asin,
+ language: oldBook.metadata.language,
+ explicit: !!oldBook.metadata.explicit,
+ abridged: !!oldBook.metadata.abridged,
+ narrators: oldBook.metadata.narrators,
+ ebookFile: oldBook.ebookFile?.toJSON() || null,
+ coverPath: oldBook.coverPath,
+ duration: oldBook.duration,
+ audioFiles: oldBook.audioFiles?.map(af => af.toJSON()) || [],
+ chapters: oldBook.chapters,
+ tags: oldBook.tags,
+ genres: oldBook.metadata.genres
+ }
+ }
+
+ /**
+ * Initialize model
+ * @param {import('../Database').sequelize} sequelize
+ */
+ static init(sequelize) {
+ super.init({
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true
+ },
+ title: DataTypes.STRING,
+ titleIgnorePrefix: DataTypes.STRING,
+ subtitle: DataTypes.STRING,
+ publishedYear: DataTypes.STRING,
+ publishedDate: DataTypes.STRING,
+ publisher: DataTypes.STRING,
+ description: DataTypes.TEXT,
+ isbn: DataTypes.STRING,
+ asin: DataTypes.STRING,
+ language: DataTypes.STRING,
+ explicit: DataTypes.BOOLEAN,
+ abridged: DataTypes.BOOLEAN,
+ coverPath: DataTypes.STRING,
+ duration: DataTypes.FLOAT,
+
+ narrators: DataTypes.JSON,
+ audioFiles: DataTypes.JSON,
+ ebookFile: DataTypes.JSON,
+ chapters: DataTypes.JSON,
+ tags: DataTypes.JSON,
+ genres: DataTypes.JSON
+ }, {
+ sequelize,
+ modelName: 'book',
+ indexes: [
+ {
+ fields: [{
+ name: 'title',
+ collate: 'NOCASE'
+ }]
+ },
+ // {
+ // fields: [{
+ // name: 'titleIgnorePrefix',
+ // collate: 'NOCASE'
+ // }]
+ // },
+ {
+ fields: ['publishedYear']
+ },
+ // {
+ // fields: ['duration']
+ // }
+ ]
+ })
+ }
+}
+
+module.exports = Book
\ No newline at end of file
diff --git a/server/models/BookAuthor.js b/server/models/BookAuthor.js
index c425d2fd..9f8860ee 100644
--- a/server/models/BookAuthor.js
+++ b/server/models/BookAuthor.js
@@ -1,41 +1,57 @@
const { DataTypes, Model } = require('sequelize')
-module.exports = (sequelize) => {
- class BookAuthor extends Model {
- static removeByIds(authorId = null, bookId = null) {
- const where = {}
- if (authorId) where.authorId = authorId
- if (bookId) where.bookId = bookId
- return this.destroy({
- where
- })
- }
+class BookAuthor extends Model {
+ constructor(values, options) {
+ super(values, options)
+
+ /** @type {UUIDV4} */
+ this.id
+ /** @type {UUIDV4} */
+ this.bookId
+ /** @type {UUIDV4} */
+ this.authorId
+ /** @type {Date} */
+ this.createdAt
}
- BookAuthor.init({
- id: {
- type: DataTypes.UUID,
- defaultValue: DataTypes.UUIDV4,
- primaryKey: true
- }
- }, {
- sequelize,
- modelName: 'bookAuthor',
- timestamps: true,
- updatedAt: false
- })
+ static removeByIds(authorId = null, bookId = null) {
+ const where = {}
+ if (authorId) where.authorId = authorId
+ if (bookId) where.bookId = bookId
+ return this.destroy({
+ where
+ })
+ }
- // Super Many-to-Many
- // ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship
- const { book, author } = sequelize.models
- book.belongsToMany(author, { through: BookAuthor })
- author.belongsToMany(book, { through: BookAuthor })
+ /**
+ * Initialize model
+ * @param {import('../Database').sequelize} sequelize
+ */
+ static init(sequelize) {
+ super.init({
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true
+ }
+ }, {
+ sequelize,
+ modelName: 'bookAuthor',
+ timestamps: true,
+ updatedAt: false
+ })
- book.hasMany(BookAuthor)
- BookAuthor.belongsTo(book)
+ // Super Many-to-Many
+ // ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship
+ const { book, author } = sequelize.models
+ book.belongsToMany(author, { through: BookAuthor })
+ author.belongsToMany(book, { through: BookAuthor })
- author.hasMany(BookAuthor)
- BookAuthor.belongsTo(author)
+ book.hasMany(BookAuthor)
+ BookAuthor.belongsTo(book)
- return BookAuthor
-}
\ No newline at end of file
+ author.hasMany(BookAuthor)
+ BookAuthor.belongsTo(author)
+ }
+}
+module.exports = BookAuthor
\ No newline at end of file
diff --git a/server/models/BookSeries.js b/server/models/BookSeries.js
index ba6581f2..fe2a07a5 100644
--- a/server/models/BookSeries.js
+++ b/server/models/BookSeries.js
@@ -1,42 +1,65 @@
const { DataTypes, Model } = require('sequelize')
-module.exports = (sequelize) => {
- class BookSeries extends Model {
- static removeByIds(seriesId = null, bookId = null) {
- const where = {}
- if (seriesId) where.seriesId = seriesId
- if (bookId) where.bookId = bookId
- return this.destroy({
- where
- })
- }
+class BookSeries extends Model {
+ constructor(values, options) {
+ super(values, options)
+
+ /** @type {UUIDV4} */
+ this.id
+ /** @type {string} */
+ this.sequence
+ /** @type {UUIDV4} */
+ this.bookId
+ /** @type {UUIDV4} */
+ this.seriesId
+ /** @type {Date} */
+ this.createdAt
}
- BookSeries.init({
- id: {
- type: DataTypes.UUID,
- defaultValue: DataTypes.UUIDV4,
- primaryKey: true
- },
- sequence: DataTypes.STRING
- }, {
- sequelize,
- modelName: 'bookSeries',
- timestamps: true,
- updatedAt: false
- })
+ static removeByIds(seriesId = null, bookId = null) {
+ const where = {}
+ if (seriesId) where.seriesId = seriesId
+ if (bookId) where.bookId = bookId
+ return this.destroy({
+ where
+ })
+ }
- // Super Many-to-Many
- // ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship
- const { book, series } = sequelize.models
- book.belongsToMany(series, { through: BookSeries })
- series.belongsToMany(book, { through: BookSeries })
+ /**
+ * Initialize model
+ * @param {import('../Database').sequelize} sequelize
+ */
+ static init(sequelize) {
+ super.init({
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true
+ },
+ sequence: DataTypes.STRING
+ }, {
+ sequelize,
+ modelName: 'bookSeries',
+ timestamps: true,
+ updatedAt: false
+ })
- book.hasMany(BookSeries)
- BookSeries.belongsTo(book)
+ // Super Many-to-Many
+ // ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship
+ const { book, series } = sequelize.models
+ book.belongsToMany(series, { through: BookSeries })
+ series.belongsToMany(book, { through: BookSeries })
- series.hasMany(BookSeries)
- BookSeries.belongsTo(series)
+ book.hasMany(BookSeries, {
+ onDelete: 'CASCADE'
+ })
+ BookSeries.belongsTo(book)
- return BookSeries
-}
\ No newline at end of file
+ series.hasMany(BookSeries, {
+ onDelete: 'CASCADE'
+ })
+ BookSeries.belongsTo(series)
+ }
+}
+
+module.exports = BookSeries
\ No newline at end of file
diff --git a/server/models/Collection.js b/server/models/Collection.js
index ebe6a597..9d3a8e0a 100644
--- a/server/models/Collection.js
+++ b/server/models/Collection.js
@@ -1,284 +1,342 @@
-const { DataTypes, Model } = require('sequelize')
+const { DataTypes, Model, Sequelize } = require('sequelize')
const oldCollection = require('../objects/Collection')
-const { areEquivalent } = require('../utils/index')
-module.exports = (sequelize) => {
- class Collection extends Model {
- /**
- * Get all old collections
- * @returns {Promise}
- */
- static async getOldCollections() {
- const collections = await this.findAll({
- include: {
- model: sequelize.models.book,
- include: sequelize.models.libraryItem
- },
- order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']]
- })
- return collections.map(c => this.getOldCollection(c))
+
+class Collection extends Model {
+ constructor(values, options) {
+ super(values, options)
+
+ /** @type {UUIDV4} */
+ this.id
+ /** @type {string} */
+ this.name
+ /** @type {string} */
+ this.description
+ /** @type {UUIDV4} */
+ this.libraryId
+ /** @type {Date} */
+ this.updatedAt
+ /** @type {Date} */
+ this.createdAt
+ }
+ /**
+ * Get all old collections
+ * @returns {Promise}
+ */
+ static async getOldCollections() {
+ const collections = await this.findAll({
+ include: {
+ model: this.sequelize.models.book,
+ include: this.sequelize.models.libraryItem
+ },
+ order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
+ })
+ return collections.map(c => this.getOldCollection(c))
+ }
+
+ /**
+ * Get all old collections toJSONExpanded, items filtered for user permissions
+ * @param {[oldUser]} user
+ * @param {[string]} libraryId
+ * @param {[string[]]} include
+ * @returns {Promise |