From 03f5038882c29488b55cf4f80bb0fd2eb0bc0fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mir=C3=B3=20Allard?= Date: Sun, 9 Mar 2025 15:02:31 +0100 Subject: [PATCH 01/38] Translated using Weblate (Swedish) Currently translated at 94.6% (1034 of 1093 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index 5f2385e7..9c85aee2 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -71,7 +71,7 @@ "ButtonQuickMatch": "Snabbmatchning", "ButtonReScan": "Ny skanning", "ButtonRead": "Läs", - "ButtonReadLess": "Visa mindre", + "ButtonReadLess": "Läs mindre", "ButtonReadMore": "Läs mer", "ButtonRefresh": "Uppdatera", "ButtonRemove": "Ta bort", From d6950eab21cb773f1f7941263c1ad1db2f13170c Mon Sep 17 00:00:00 2001 From: Jan Schoenfeld Date: Sun, 9 Mar 2025 18:45:52 +0100 Subject: [PATCH 02/38] Translated using Weblate (German) Currently translated at 100.0% (1093 of 1093 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/de.json b/client/strings/de.json index ddb73a16..a9e91220 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -709,6 +709,7 @@ "MessageBackupsLocationNoEditNote": "Hinweis: Der Sicherungsspeicherort wird über eine Umgebungsvariable festgelegt und kann hier nicht geändert werden.", "MessageBackupsLocationPathEmpty": "Der Backup-Pfad darf nicht leer sein", "MessageBatchEditPopulateMapDetailsAllHelp": "Fülle die aktivierten Felder mit Daten aus allen Elementen. Felder mit mehreren Werten werden zusammengeführt", + "MessageBatchEditPopulateMapDetailsItemHelp": "Aktivierte Felder für Kartendetails mit Daten aus diesem Element füllen", "MessageBatchQuickMatchDescription": "Der Schnellabgleich versucht, fehlende Titelbilder und Metadaten für die ausgewählten Artikel hinzuzufügen. Aktiviere die nachstehenden Optionen, damit der Schnellabgleich vorhandene Titelbilder und/oder Metadaten überschreiben kann.", "MessageBookshelfNoCollections": "Es wurden noch keine Sammlungen erstellt", "MessageBookshelfNoCollectionsHelp": "Sammlungen sind öffentlich. Alle Benutzer mit Zugriff auf die Bibliothek können sie sehen.", From 8820fac6a60de3f061a18b26d2d5ea11408c3fb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=20=D0=93=D0=BE=D1=80?= =?UTF-8?q?=D0=BF=D0=B8=D0=BD=D1=96=D1=87?= Date: Mon, 10 Mar 2025 06:11:23 +0100 Subject: [PATCH 03/38] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1093 of 1093 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/uk.json b/client/strings/uk.json index edf57277..ea9b1bbf 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -578,7 +578,7 @@ "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Пропускати попередні книги у Продовжити серії", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Полиця Продовжити серії на головній сторінці показує найпершу непочату книгу з тих серій, у яких ви завершили хоча б одну книгу та не маєте книг у процесі. Якщо увімкнути це налаштування, то серії продовжуватимуться з останньої завершеної книги, а не з першої непочатої.", "LabelSettingsParseSubtitles": "Дістати підзаголовки", - "LabelSettingsParseSubtitlesHelp": "Дістати підзаголовки з назв тек аудіокниг.
Підзаголовок мусить йти після \" - \"
Наприклад, \"Назва книги - Це підзаголовок\" має підзаголовок \"Це підзаголовок\"", + "LabelSettingsParseSubtitlesHelp": "Витягти субтитри з імен папок аудіокниг.
Підзаголовки мають бути розділені символом \" - \"
тобто. «Назва книги – тут підзаголовок» має підзаголовок «Тут підзаголовок»", "LabelSettingsPreferMatchedMetadata": "Надавати перевагу віднайденим метаданим", "LabelSettingsPreferMatchedMetadataHelp": "Подробиці буде перезаписано віднайденими даними Швидкого пошуку. Без цього Швидкий пошук заповнить лише подробиці, яких бракує.", "LabelSettingsSkipMatchingBooksWithASIN": "Не шукати книги, що мають ASIN", From 3d6e50a09952915464ee1c0450b3091cd3f5591d Mon Sep 17 00:00:00 2001 From: Jan-Eric Myhrgren Date: Mon, 10 Mar 2025 09:40:06 +0100 Subject: [PATCH 04/38] Translated using Weblate (Swedish) Currently translated at 94.5% (1033 of 1093 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index 9c85aee2..cbd4031d 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -128,7 +128,7 @@ "HeaderCollectionItems": "Böcker i samlingen", "HeaderCover": "Omslag", "HeaderCurrentDownloads": "Aktuella nedladdningar", - "HeaderCustomMessageOnLogin": "Anpassat meddelande in inloggning", + "HeaderCustomMessageOnLogin": "Meddelande att visa på sidan för inloggning", "HeaderCustomMetadataProviders": "Egen källa för metadata", "HeaderDetails": "Detaljer", "HeaderDownloadQueue": "Nedladdningskö", From c3ce084aac563eed04b0f4d913723b00b4d59da0 Mon Sep 17 00:00:00 2001 From: Ricky Tigg Date: Mon, 10 Mar 2025 09:38:28 +0100 Subject: [PATCH 05/38] Translated using Weblate (Finnish) Currently translated at 76.3% (835 of 1093 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fi/ --- client/strings/fi.json | 242 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 230 insertions(+), 12 deletions(-) diff --git a/client/strings/fi.json b/client/strings/fi.json index 052a96f3..d1075ae5 100644 --- a/client/strings/fi.json +++ b/client/strings/fi.json @@ -70,7 +70,7 @@ "ButtonQueueRemoveItem": "Poista jonosta", "ButtonQuickEmbed": "Pikaupota", "ButtonQuickEmbedMetadata": "Upota kuvailutiedot nopeasti", - "ButtonQuickMatch": "Pikatäsmää", + "ButtonQuickMatch": "Pikatäsmäys", "ButtonReScan": "Uudelleenskannaa", "ButtonRead": "Lue", "ButtonReadLess": "Lue vähemmän", @@ -135,7 +135,7 @@ "HeaderCustomMetadataProviders": "Mukautetut metadatan tarjoajat", "HeaderDetails": "Yksityiskohdat", "HeaderDownloadQueue": "Latausjono", - "HeaderEbookFiles": "E-kirjatiedostot", + "HeaderEbookFiles": "S-kirjatiedostot", "HeaderEmail": "Sähköposti", "HeaderEmailSettings": "Sähköpostiasetukset", "HeaderEpisodes": "Jaksot", @@ -268,7 +268,7 @@ "LabelChapterCount": "{0} lukua", "LabelChapterTitle": "Luvun nimi", "LabelChapters": "Luvut", - "LabelChaptersFound": "lukua löydetty", + "LabelChaptersFound": "lukuja löydetty", "LabelClickForMoreInfo": "Napsauta saadaksesi lisätietoja", "LabelClickToUseCurrentValue": "Käytä nykyistä arvoa napsauttamalla", "LabelClosePlayer": "Sulje soitin", @@ -310,8 +310,8 @@ "LabelDurationComparisonLonger": "({0} pidempi)", "LabelDurationComparisonShorter": "({0} lyhyempi)", "LabelDurationFound": "Kesto löydetty:", - "LabelEbook": "E-kirja", - "LabelEbooks": "E-kirjat", + "LabelEbook": "S-kirja", + "LabelEbooks": "S-kirjat", "LabelEdit": "Muokkaa", "LabelEmail": "Sähköposti", "LabelEmailSettingsFromAddress": "Osoitteesta", @@ -323,7 +323,7 @@ "LabelEmbeddedCover": "Upotettu kansikuva", "LabelEnable": "Ota käyttöön", "LabelEncodingBackupLocation": "Alkuperäisistä audiotiedostoistasi tallennetaan varmuuskopio osoitteessa:", - "LabelEncodingChaptersNotEmbedded": "Lukuja ei upoteta moniraitaisiin äänikirjoihin.", + "LabelEncodingChaptersNotEmbedded": "Lukuja ei ole upotettu moniraitaisiin äänikirjoihin.", "LabelEncodingClearItemCache": "Varmista, että kohteiden välimuisti tyhjennetään säännöllisesti.", "LabelEncodingFinishedM4B": "Valmistunut M4B tullaan viemään äänikirjakansioosi:", "LabelEncodingInfoEmbedded": "Kuvailutiedot upotetaan äänikirjakansion ääniraitoihin.", @@ -340,6 +340,7 @@ "LabelEpisodeType": "Jakson tyyppi", "LabelEpisodeUrlFromRssFeed": "Jakson URL RSS-syötteestä", "LabelEpisodes": "Jaksot", + "LabelEpisodic": "Jaksollinen", "LabelExample": "Esimerkki", "LabelExpandSeries": "Laajenna sarja", "LabelExpandSubSeries": "Laajenna alisarja", @@ -363,17 +364,22 @@ "LabelFontItalic": "Kursiivi", "LabelFontScale": "Kirjasintyyppien skaalautuminen", "LabelFontStrikethrough": "Yliviivattu", + "LabelFormat": "Muoto", "LabelFull": "Täynnä", "LabelGenre": "Lajityyppi", "LabelGenres": "Lajityypit", - "LabelHasEbook": "Sillä on e-kirja", - "LabelHasSupplementaryEbook": "Sillä on täydentävän e-kirjan", + "LabelHardDeleteFile": "Kova tiedostojen poisto", + "LabelHasEbook": "Sillä on s-kirja", + "LabelHasSupplementaryEbook": "Sillä on täydentävän s-kirjan", + "LabelHideSubtitles": "Piilota tekstitykset", "LabelHighestPriority": "Tärkein", "LabelHost": "Isäntä", + "LabelHour": "Tunti", "LabelHours": "Tunnit", "LabelIcon": "Kuvake", "LabelImageURLFromTheWeb": "Kuvan verkko-osoite", "LabelInProgress": "Kesken", + "LabelIncludeInTracklist": "Sisällytä kappalelistaan", "LabelIncomplete": "Keskeneräinen", "LabelInterval": "Väli", "LabelIntervalCustomDailyWeekly": "Mukautettu päivittäinen/viikoittainen", @@ -384,6 +390,8 @@ "LabelIntervalEvery6Hours": "6 tunnin välein", "LabelIntervalEveryDay": "Joka päivä", "LabelIntervalEveryHour": "Joka tunti", + "LabelIntervalEveryMinute": "Joka minuutti", + "LabelInvert": "Saa käänteiseksi", "LabelItem": "Kohde", "LabelLanguage": "Kieli", "LabelLanguageDefaultServer": "Palvelimen oletuskieli", @@ -391,6 +399,7 @@ "LabelLastBookAdded": "Viimeisin lisätty kirja", "LabelLastBookUpdated": "Viimeisin päivitetty kirja", "LabelLastSeen": "Nähty viimeksi", + "LabelLastTime": "Viimeinen kerta", "LabelLastUpdate": "Viimeisin päivitys", "LabelLayout": "Asettelu", "LabelLayoutSinglePage": "Yksi sivu", @@ -398,15 +407,21 @@ "LabelLess": "Vähemmän", "LabelLibrariesAccessibleToUser": "Käyttäjälle saatavilla olevat kirjastot", "LabelLibrary": "Kirjasto", + "LabelLibraryFilterSublistEmpty": "Ei {0}", + "LabelLibraryItem": "Kirjaston kohde", "LabelLibraryName": "Kirjaston nimi", "LabelLimit": "Raja", "LabelLineSpacing": "Riviväli", "LabelListenAgain": "Kuuntele uudelleen", + "LabelLogLevelDebug": "Viankorjaus", "LabelLogLevelInfo": "Tiedot", "LabelLogLevelWarn": "Varoitus", "LabelLookForNewEpisodesAfterDate": "Etsi uusia jaksoja tämän päivämäärän jälkeen", "LabelLowestPriority": "Vähiten tärkeä", + "LabelMatchExistingUsersBy": "Vastaa olemassa olevia käyttäjiä mukaan", + "LabelMatchExistingUsersByDescription": "Käytetään olemassa olevien käyttäjien yhdistämiseen. Kun yhteys on muodostettu, käyttäjät saavat yksilöllisen tunnuksen SSO-palveluntarjoajaltasi", "LabelMaxEpisodesToDownload": "Jaksojen maksimilatausmäärä. 0 poistaa rajoituksen.", + "LabelMaxEpisodesToDownloadPerCheck": "Enintään # ladattavia uusia jaksoja tarkistusta kohden", "LabelMaxEpisodesToKeep": "Säilytettävien jaksojen enimmäismäärä", "LabelMaxEpisodesToKeepHelp": "Jos arvona on 0, enimmäisrajaa ei ole. Kun uusi jakso ladataan automaattisesti, vanhin jakso poistetaan, jos jaksoja on yli X. Tämä poistaa vain yhden jakson uutta latauskertaa kohden.", "LabelMediaPlayer": "Mediasoitin", @@ -418,7 +433,10 @@ "LabelMinute": "Minuutti", "LabelMinutes": "Minuutit", "LabelMissing": "Puuttuva", - "LabelMissingEbook": "Ei e-kirjaa", + "LabelMissingEbook": "Sillä ei ole s-kirjaa", + "LabelMissingSupplementaryEbook": "Ei täydentävää s-kirjaa", + "LabelMobileRedirectURIs": "Sallitut mobiiliuudelleenohjaus-URI:t", + "LabelMobileRedirectURIsDescription": "Tämä on valkoluettelo kelvollisista uudelleenohjaus-URI:ista mobiilisovelluksille. Oletusarvo on äänikirjahylly://oauth, jonka voit poistaa tai täydentää ylimääräisillä URI:lla kolmannen osapuolen sovellusten integrointia varten. Asteriskin (*) käyttäminen ainoana merkintänä sallii minkä tahansa URI:n.", "LabelMore": "Lisää", "LabelMoreInfo": "Lisätietoja", "LabelName": "Nimi", @@ -437,24 +455,34 @@ "LabelNotes": "Muistiinpanoja", "LabelNotificationAppriseURL": "Apprise osoitteet (URL)", "LabelNotificationAvailableVariables": "Käytettävissä olevat muuttujat", + "LabelNotificationBodyTemplate": "Runkomalli", "LabelNotificationEvent": "Ilmoitustapahtuma", + "LabelNotificationTitleTemplate": "Otsikkomalli", "LabelNotificationsMaxFailedAttempts": "Epäonnistuneiden yritysten enimmäismäärä", "LabelNotificationsMaxFailedAttemptsHelp": "Ilmoitukset poistetaan käytöstä, jos niiden lähettäminen epäonnistuu näin monta kertaa", "LabelNotificationsMaxQueueSize": "Ilmoitustapahtumajonon enimmäispituus", + "LabelNotificationsMaxQueueSizeHelp": "Tapahtumat on rajoitettu ampumaan yksi sekunnissa. Tapahtumat ohitetaan, jos jono on enimmäiskoko. Tämä estää ilmoitusten roskapostin.", "LabelNumberOfBooks": "Kirjojen määrä", "LabelNumberOfEpisodes": "# jaksoja", + "LabelOpenIDAdvancedPermsClaimDescription": "OpenID-vaatimuksen nimi, joka sisältää lisäoikeudet sovelluksen käyttäjän toimiin, joita sovelletaan muihin kuin järjestelmänvalvojan rooleihin (jos määritetty). Jos vaatimus puuttuu vastauksesta, pääsy ABS:iin evätään. Jos yksittäinen vaihtoehto puuttuu, sitä käsitellään false-arvona. Varmista, että identiteetin tarjoajan vaatimus vastaa odotettua rakennetta:", + "LabelOpenIDClaims": "Jätä seuraavat vaihtoehdot tyhjiksi, jos haluat poistaa edistyneen ryhmän ja lupien määrityksen käytöstä ja määrittää sitten automaattisesti käyttäjäryhmän.", + "LabelOpenIDGroupClaimDescription": "Sen OpenID-vaatimuksen nimi, joka sisältää luettelon käyttäjäryhmistä. Kutsutaan yleisesti ryhmiksi. Jos se on määritetty, sovellus jakaa automaattisesti roolit käyttäjän ryhmäjäsenyyksien perusteella, jos näiden ryhmien nimet eivät erota kirjainkoosta \"admin\", \"user\" tai \"guest\" vaatimuksessa. Vaatimuksen tulee sisältää luettelo, ja jos käyttäjä kuuluu useisiin ryhmiin, sovellus määrittää korkeinta pääsytasoa vastaavan roolin. Jos mikään ryhmä ei täsmää, pääsy evätään.", + "LabelOpenRSSFeed": "Avaa RSS-syöte", "LabelOverwrite": "Korvaa", "LabelPaginationPageXOfY": "Sivu {0}/{1}", "LabelPassword": "Salasana", "LabelPath": "Polku", "LabelPermanent": "Pysyvä", "LabelPermissionsAccessAllLibraries": "Käyttöoikeudet kaikkiin kirjastoihin", - "LabelPermissionsAccessAllTags": "Saa käyttää kaikkia tunnisteita", + "LabelPermissionsAccessAllTags": "On pääsy kaikkiin tunnisteihin", "LabelPermissionsAccessExplicitContent": "Saa käyttää aikuisille tarkoitettua sisältöä", + "LabelPermissionsCreateEreader": "Voi luoda e-lukijan", "LabelPermissionsDelete": "Voi poistaa", "LabelPermissionsDownload": "Voi ladata", "LabelPermissionsUpdate": "Voi päivittää", "LabelPermissionsUpload": "Voi lähettää", + "LabelPersonalYearReview": "Vuotesi katsauksessa ({0})", + "LabelPhotoPathURL": "Valokuvan polku/URL-osoite", "LabelPlayMethod": "Toistotapa", "LabelPlayerChapterNumberMarker": "{0}/{1}", "LabelPlaylists": "Soittolistat", @@ -463,56 +491,119 @@ "LabelPodcastType": "Podcastien tyyppi", "LabelPodcasts": "Podcastit", "LabelPort": "Portti", + "LabelPrefixesToIgnore": "Ohitettavat etuliitteet (kirjainkoolla ei väliä)", "LabelPreventIndexing": "Estä syötteesi olemasta iTunesin ja Googlen podcast-hakemistojen indeksoinnin kohteena", - "LabelPrimaryEbook": "Ensisijainen e-kirja", + "LabelPrimaryEbook": "Ensisijainen s-kirja", "LabelProgress": "Edistyminen", "LabelProvider": "Toimittaja", + "LabelProviderAuthorizationValue": "Valtuutusotsikon arvo", "LabelPubDate": "Julkaisupäivä", "LabelPublishYear": "Julkaisuvuosi", "LabelPublishedDate": "Julkaistu {0}", + "LabelPublishedDecade": "Julkaistu vuosikymmen", + "LabelPublishedDecades": "Julkaistu vuosikymmenet", "LabelPublisher": "Julkaisija", "LabelPublishers": "Julkaisijat", "LabelRSSFeedCustomOwnerEmail": "Mukautettu omistajan sähköposti", "LabelRSSFeedCustomOwnerName": "Mukautettu omistajan nimi", + "LabelRSSFeedOpen": "RSS-syöte avoin", "LabelRSSFeedPreventIndexing": "Estä indeksointi", "LabelRSSFeedSlug": "RSS-syöte Slug", + "LabelRSSFeedURL": "RSS-syötteen URL-osoite", "LabelRandomly": "Satunnaisesti", + "LabelReAddSeriesToContinueListening": "Lisää sarja uudelleen jatkaaksesi kuuntelua", "LabelRead": "Lue", "LabelReadAgain": "Lue uudelleen", - "LabelReadEbookWithoutProgress": "Lue e-kirja tallentamatta edistymistietoja", + "LabelReadEbookWithoutProgress": "Lue s-kirja tallentamatta edistymistietoja", "LabelRecentSeries": "Viimeisimmät sarjat", "LabelRecentlyAdded": "Viimeeksi lisätyt", "LabelRecommended": "Suositeltu", "LabelRedo": "Tee uudelleen", "LabelRegion": "Alue", "LabelReleaseDate": "Julkaisupäivä", + "LabelRemoveAllMetadataAbs": "Poista kaikki metadata.abs-tiedostot", + "LabelRemoveAllMetadataJson": "Poista kaikki metadata.json-tiedostot", "LabelRemoveCover": "Poista kansikuva", + "LabelRemoveMetadataFile": "Poista metatietotiedostot kirjaston kohdekansioista", + "LabelRemoveMetadataFileHelp": "Poista kaikki metadata.json- ja metadata.abs-tiedostot {0} kansiostasi.", "LabelRowsPerPage": "Rivejä sivulla", "LabelSearchTerm": "Hakusana", + "LabelSearchTitle": "Etsi otsikko", + "LabelSearchTitleOrASIN": "Etsi otsikko tai ASIN", "LabelSeason": "Kausi", + "LabelSeasonNumber": "Kausi #{0}", "LabelSelectAll": "Valitse kaikki", + "LabelSelectAllEpisodes": "Valitse kaikki jaksot", + "LabelSelectEpisodesShowing": "Valitse {0} näytettävää jaksoa", "LabelSelectUsers": "Valitse käyttäjät", + "LabelSendEbookToDevice": "Lähetä s-kirja kohteeseen...", + "LabelSequence": "Sekvenssi", + "LabelSerial": "Sarja", "LabelSeries": "Sarja", "LabelSeriesName": "Sarjan nimi", + "LabelSeriesProgress": "Sarjan edistyminen", "LabelServerLogLevel": "Palvelimen lokitaso", + "LabelServerYearReview": "Palvelimen vuosi katsauksessa ({0})", "LabelSetEbookAsPrimary": "Aseta ensisijaiseksi", "LabelSetEbookAsSupplementary": "Aseta täydentäväksi", + "LabelSettingsAllowIframe": "Salli upottaminen iframe-kehykseen", "LabelSettingsAudiobooksOnly": "Vain äänikirjat", + "LabelSettingsAudiobooksOnlyHelp": "Tämän asetuksen käyttöönotto ohittaa s-kirjatiedostot, elleivät ne ole äänikirjakansiossa, jolloin ne asetetaan täydentäviksi s-kirjoiksi", + "LabelSettingsBookshelfViewHelp": "Skeuomorfinen muotoilu puisilla hyllyillä", "LabelSettingsChromecastSupport": "Chromecast-tuki", + "LabelSettingsDateFormat": "Päivämäärän muoto", "LabelSettingsDisableWatcher": "Poista kansiotarkkailu käytöstä", "LabelSettingsDisableWatcherForLibrary": "Poista kirjaston kansiotarkkailu käytöstä", "LabelSettingsDisableWatcherHelp": "Poistaa käytöstä kohteiden automaattisen lisäämisen ja päivityksen kun tiedostomuutoksia havaitaan. *Tarvitsee palvelimen uudelleenkäynnistyksen", "LabelSettingsEnableWatcherHelp": "Ottaa käyttöön kohteiden automaattisen lisäämisen ja päivityksen kun tiedostomuutoksia havaitaan. *Tarvitsee palvelimen uudelleenkäynnistyksen", + "LabelSettingsEpubsAllowScriptedContent": "Salli komentosarjamuotoinen sisältö epubissa", + "LabelSettingsEpubsAllowScriptedContentHelp": "Salli epub-tiedostojen suorittaa komentosarjoja. On suositeltavaa pitää tämä asetus pois käytöstä, ellet luota epub-tiedostojen lähteeseen.", "LabelSettingsExperimentalFeatures": "Kokeelliset ominaisuudet", + "LabelSettingsExperimentalFeaturesHelp": "Kehitettävissä olevat ominaisuudet, jotka voivat hyödyntää palautettasi ja auttaa testaamisessa. Napsauta avataksesi github-keskustelun.", "LabelSettingsFindCovers": "Etsi kansikuvia", + "LabelSettingsFindCoversHelp": "Jos äänikirjassasi ei ole kansion sisällä upotettua kantta tai kansikuvaa, skanneri yrittää löytää kannen.
Huomaa: Tämä pidentää skannausaikaa", + "LabelSettingsHideSingleBookSeries": "Piilota yksittäinen kirjasarja", + "LabelSettingsHideSingleBookSeriesHelp": "Sarjat, joissa on yksi kirja, piilotetaan sarjasivulta ja kotisivujen hyllyiltä.", + "LabelSettingsHomePageBookshelfView": "Kotisivu käyttää kirjahyllynäkymää", + "LabelSettingsLibraryBookshelfView": "Kirjasto käyttää kirjahyllynäkymää", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "Valmistumisprosentti on suurempi kuin", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Jäljellä oleva aika on alle (sekuntia)", + "LabelSettingsLibraryMarkAsFinishedWhen": "Merkitse mediakohde valmiiksi, kun", + "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Ohita aiemmat kirjat Jatka sarjassa", + "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Jatka sarja -kotisivun hyllyssä näkyy ensimmäinen kirja, jota ei ole aloitettu sarjoissa, joissa on vähintään yksi kirja valmiina eikä yhtään kirjaa kesken. Tämän asetuksen ottaminen käyttöön jatkaa sarjaa kauimpana valmistuneesta kirjasta ensimmäisen aloittamattoman kirjan sijaan.", + "LabelSettingsParseSubtitles": "Jäsennä tekstitykset", + "LabelSettingsParseSubtitlesHelp": "Pura tekstitykset äänikirjojen kansioiden nimistä.
Tekstitys on erotettava toisistaan merkillä \"-\"
ts. \"Kirjan otsikko - Tekstitys täällä\" on alaotsikko \"Tekstitys täällä\"", + "LabelSettingsPreferMatchedMetadata": "Pidä mieluummin täsmäävät metatiedot", + "LabelSettingsPreferMatchedMetadataHelp": "Täsmäävät tiedot ohittavat kohteen tiedot käytettäessä Pikatäsmäystä. Oletuksena Pikatäsmäys täyttää vain puuttuvat tiedot.", + "LabelSettingsSkipMatchingBooksWithASIN": "Ohita täsmäävät kirjat, joilla on jo ASIN", + "LabelSettingsSkipMatchingBooksWithISBN": "Ohita täsmäävät kirjat, joilla on jo ISBN", + "LabelSettingsSortingIgnorePrefixes": "Jätä etuliitteet huomioimatta lajittelussa", + "LabelSettingsSortingIgnorePrefixesHelp": "eli etuliitteelle \"tämän\" kirjan nimi \"Tämän kirjan nimi\" lajitellaan muodossa \"Kirjan nimi, Tämän\"", + "LabelSettingsSquareBookCovers": "Käytä neliömäisiä kirjankansia", + "LabelSettingsSquareBookCoversHelp": "Käytä mieluummin neliömäisiä kansia kuin tavallisia 1,6:1 kirjankansia", + "LabelSettingsStoreCoversWithItem": "Säilytyskannet esineen kanssa", + "LabelSettingsStoreCoversWithItemHelp": "Oletusarvoisesti kannet tallennetaan kansioon /metadata/items, ja tämän asetuksen ottaminen käyttöön tallentaa kannet kirjaston kohdekansioon. Vain yksi tiedosto nimeltä \"cover\" säilytetään", + "LabelSettingsStoreMetadataWithItem": "Tallenna metatiedot kohteen kanssa", + "LabelSettingsStoreMetadataWithItemHelp": "Oletuksena metatietotiedostot tallennetaan kansioon /metadata/items, ja tämän asetuksen ottaminen käyttöön tallentaa metatietotiedostot kirjastosi kohdekansioihin", + "LabelSettingsTimeFormat": "Aikamuoto", "LabelShare": "Jaa", + "LabelShareDownloadableHelp": "Antaa käyttäjien, joilla on jakolinkki, ladata kirjastokohteen zip-tiedoston.", + "LabelShareOpen": "Jaa Avoin", + "LabelShareURL": "Jaa URL-osoite", "LabelShowAll": "Näytä kaikki", "LabelShowSeconds": "Näytä sekunnit", + "LabelShowSubtitles": "Näytä tekstitykset", "LabelSize": "Koko", "LabelSleepTimer": "Uniajastin", + "LabelSlug": "Slug", + "LabelSortAscending": "Nouseva", + "LabelSortDescending": "Laskeva", "LabelStart": "Aloita", "LabelStartTime": "Aloitusaika", + "LabelStarted": "Aloitettu", + "LabelStartedAt": "Aloitettu", "LabelStatsAudioTracks": "Ääniraidat", + "LabelStatsAuthors": "Tekijät", "LabelStatsBestDay": "Paras päivä", "LabelStatsDailyAverage": "Päivittäinen keskiarvo", "LabelStatsDays": "Päivää", @@ -520,30 +611,151 @@ "LabelStatsHours": "Tunnit", "LabelStatsInARow": "peräjälkeen", "LabelStatsItemsFinished": "Valmiit tuotteet", + "LabelStatsItemsInLibrary": "Kohteet kirjastossa", "LabelStatsMinutes": "minuuttia", "LabelStatsMinutesListening": "Minuuttia kuunneltu", + "LabelStatsOverallDays": "Päivät kokonaisuudessaan", + "LabelStatsOverallHours": "Tunnit kokonaisuudessaan", "LabelStatsWeekListening": "Viikon aikana kuunneltu", + "LabelSubtitle": "Tekstitys", + "LabelSupportedFileTypes": "Tuetut tiedostotyypit", "LabelTag": "Tägi", "LabelTags": "Tägit", + "LabelTagsAccessibleToUser": "Tunnisteet käyttäjän käytettävissä", + "LabelTagsNotAccessibleToUser": "Tunnisteet ei käyttäjien käytettävissä", + "LabelTasks": "Tehtävät käynnissä", + "LabelTextEditorBulletedList": "Luettelomerkitty luettelo", + "LabelTextEditorLink": "Linkki", + "LabelTextEditorNumberedList": "Numeroitu luettelo", + "LabelTextEditorUnlink": "Poista linkitys", "LabelTheme": "Teema", "LabelThemeDark": "Tumma", "LabelThemeLight": "Kirkas", + "LabelTimeBase": "Aika-alusta", + "LabelTimeDurationXHours": "{0} tuntia", + "LabelTimeDurationXMinutes": "{0} minuuttia", + "LabelTimeDurationXSeconds": "{0} sekuntia", + "LabelTimeInMinutes": "Aika minuutteina", + "LabelTimeLeft": "{0} jäljellä", + "LabelTimeListened": "Aika kuunneltu", + "LabelTimeListenedToday": "Kuunneltu aika tänään", "LabelTimeRemaining": "{0} jäljellä", + "LabelTimeToShift": "Vaihtoaika sekunteina", "LabelTitle": "Nimi", + "LabelToolsEmbedMetadata": "Upota metatiedot", + "LabelToolsEmbedMetadataDescription": "Upota metatiedot äänitiedostoihin, mukaan lukien kansikuva ja luvut.", + "LabelToolsM4bEncoder": "M4B Enkooderi", + "LabelToolsMakeM4b": "Tee M4B-äänikirjatiedosto", + "LabelToolsMakeM4bDescription": "Luo .M4B-äänikirjatiedosto, joka sisältää upotetut metatiedot, kansikuvan ja luvut.", + "LabelToolsSplitM4b": "Jaa M4B MP3:ksi", + "LabelToolsSplitM4bDescription": "Luo MP3-tiedostoja M4B:stä, jaettuna lukujen mukaan, upotetulla metatiedolla, kansikuvalla ja luvuilla.", "LabelTotalDuration": "Kokonaiskesto", + "LabelTotalTimeListened": "Yhteensä kuunneltu aika", + "LabelTrackFromFilename": "Raita tiedostonimestä", + "LabelTrackFromMetadata": "Raita metatiedoista", "LabelTracks": "Raidat", + "LabelTracksMultiTrack": "Moniraitainen", + "LabelTracksNone": "Ei raitoja", + "LabelTracksSingleTrack": "Yksiraitainen", + "LabelTrailer": "Traileri", "LabelType": "Tyyppi", + "LabelUnabridged": "Lyhentämätön", + "LabelUndo": "Kumoa", "LabelUnknown": "Tuntematon", + "LabelUnknownPublishDate": "Tuntematon julkaisupäivämäärä", "LabelUpdateCover": "Päivitä kansikuva", + "LabelUpdateCoverHelp": "Salli valittujen kirjojen olemassa olevien kansien päällekirjoittaminen, kun osuma löytyy", + "LabelUpdateDetails": "Päivitä yksityiskohdat", + "LabelUpdateDetailsHelp": "Salli valittujen kirjojen olemassa olevien tietojen korvaaminen, kun osuma löytyy", + "LabelUpdatedAt": "Päivitetty", + "LabelUploaderDragAndDrop": "Vedä ja pudota tiedostoja tai kansioita", + "LabelUploaderDragAndDropFilesOnly": "Vedä ja pudota tiedostoja", + "LabelUploaderDropFiles": "Pudota tiedostot", + "LabelUploaderItemFetchMetadataHelp": "Nouda automaattisesti otsikko, tekijä ja sarja", + "LabelUseAdvancedOptions": "Käytä edistyneitä vaihtoehtoja", + "LabelUseChapterTrack": "Käytä luvunraitaa", + "LabelUseFullTrack": "Käytä täyttä raitaa", + "LabelUseZeroForUnlimited": "Käytä 0 rajatonta varten", "LabelUser": "Käyttäjä", "LabelUsername": "Käyttäjätunnus", "LabelValue": "Arvo", "LabelVersion": "Versio", + "LabelViewBookmarks": "Katso kirjanmerkit", + "LabelViewChapters": "Katso luvut", + "LabelViewPlayerSettings": "Katso soittimen asetukset", + "LabelViewQueue": "Katso soittimen jono", + "LabelVolume": "Äänenvoimakkuus", + "LabelWebRedirectURLsDescription": "Valtuuta nämä URL-osoitteet OAuth-palveluntarjoajassasi sallimaan uudelleenohjauksen takaisin verkkosovellukseen sisäänkirjautumisen jälkeen:", + "LabelWebRedirectURLsSubfolder": "Alikansio URL-osoitteiden uudelleenohjaukselle", + "LabelWeekdaysToRun": "Ajettavat arkipäivät", + "LabelXBooks": "{0} kirjaa", + "LabelXItems": "{0} kohdetta", "LabelYearReviewHide": "Piilota vuosi arvostelussa", "LabelYearReviewShow": "Näytä vuosi arvostelussa", + "LabelYourAudiobookDuration": "Äänikirjan kesto", "LabelYourBookmarks": "Kirjanmerkkisi", + "LabelYourPlaylists": "Soittolistasi", "LabelYourProgress": "Edistymisesi", + "MessageAddToPlayerQueue": "Lisää soittimen jonoon", "MessageAppriseDescription": "Käyttääksesi tätä toimintoa tarvitset Apprise API -instanssin tai rajapinnan joka käsittelee samoja pyyntöjä.
Apprise rajapinnan osoite tulee olla täysi URL polku ilmoituksen lähetykseen, esim. jos rajapinta on osoitteessa http://192.168.1.1:8337,niin arvoksi tulee antaa http://192.168.1.1:8337/notify.", + "MessageBackupsDescription": "Varmuuskopiot sisältävät käyttäjät, käyttäjien edistymisen, kirjastokohteiden tiedot, palvelinasetukset ja /metadata/items- ja /metadata/authors -kansioihin tallennetut kuvat. Varmuuskopiot eivät sisällä kirjastosi kansioihin tallennettuja tiedostoja.", + "MessageBackupsLocationEditNote": "Huomautus: Varmuuskopion sijainnin päivittäminen ei siirrä tai muokkaa olemassa olevia varmuuskopioita", + "MessageBackupsLocationNoEditNote": "Huomautus: Varmuuskopion sijainti asetetaan ympäristömuuttujan kautta, eikä sitä voi muuttaa tässä.", + "MessageBackupsLocationPathEmpty": "Varmuuskopiointisijainnin polku ei voi olla tyhjä", + "MessageBatchEditPopulateMapDetailsAllHelp": "Täytä käytössä olevat kentät tiedoilla kaikista kohteista. Kentät, joilla on useita arvoja, yhdistetään", + "MessageBatchEditPopulateMapDetailsItemHelp": "Täytä käytössä olevat karttayksityiskohtakentät tämän kohteen tiedoilla", + "MessageBatchQuickMatchDescription": "Pikatäsmäys yrittää lisätä puuttuvat kannet ja metatiedot valituille kohteille. Ota käyttöön alla olevat vaihtoehdot, jotta Pikatäsmäys korvaa olemassa olevat kannet ja/tai metatiedot.", + "MessageBookshelfNoCollections": "Et ole vielä tehnyt kokoelmia", + "MessageBookshelfNoCollectionsHelp": "Kokoelmat ovat julkisia. Kaikki käyttäjät, joilla on pääsy kirjastoon, voivat nähdä ne.", + "MessageBookshelfNoRSSFeeds": "RSS-syötteitä ei ole auki", + "MessageBookshelfNoResultsForFilter": "Ei tuloksia suodattimelle \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "Ei tuloksia kyselylle", + "MessageBookshelfNoSeries": "Sinulla ei ole sarjoja", + "MessageChapterEndIsAfter": "Luvun loppu sijaitsee äänikirjan lopun jälkeen", + "MessageChapterErrorFirstNotZero": "Ensimmäisen luvun tulee alkaa nollasta", + "MessageChapterErrorStartGteDuration": "Epäkelvollinen aloitusaika; on oltava lyhyempi kuin äänikirjan kesto", + "MessageChapterErrorStartLtPrev": "Epäkelvollinen aloitusaika; on oltava suurempi tai yhtä suuri kuin edellisen luvun aloitusaika", + "MessageConfirmCloseFeed": "Oletko varma, että haluat sulkea tämän syötteen?", + "MessageConfirmDeleteBackup": "Oletko varma, että haluat poistaa varmuuskopion {0}:lle?", + "MessageConfirmDeleteDevice": "Oletko varma, että haluat poistaa s-lukulaitteen \"{0}\"?", + "MessageConfirmDeleteLibrary": "Oletko varma, että haluat poistaa kirjaston \"{0}\" pysyvästi?", + "MessageConfirmDeleteLibraryItem": "Tämä poistaa kirjastokohteen tietokannasta ja tiedostojärjestelmästäsi. Oletko varma?", + "MessageConfirmDeleteLibraryItems": "Tämä poistaa {0} kirjastokohdetta tietokannasta ja tiedostojärjestelmästäsi. Oletko varma?", + "MessageConfirmDeleteMetadataProvider": "Oletko varma, että haluat poistaa mukautetun metatietojen tarjoajan \"{0}\"?", + "MessageConfirmDeleteNotification": "Oletko varma, että haluat poistaa tämän ilmoituksen?", + "MessageConfirmDeleteSession": "Oletko varma, että haluat poistaa tämän istunnon?", + "MessageConfirmEmbedMetadataInAudioFiles": "Oletko varma, että haluat upottaa metatiedot {0} äänitiedostoihin?", + "MessageConfirmForceReScan": "Oletko varma, että haluat pakottaa uudelleenskannauksen?", + "MessageConfirmMarkAllEpisodesFinished": "Oletko varma, että haluat merkitä kaikki jaksot päättyneiksi?", + "MessageConfirmMarkAllEpisodesNotFinished": "Oletko varma, että haluat merkitä kaikki jaksot ei-valmiiksi?", + "MessageConfirmMarkItemFinished": "Oletko varma, että haluat merkitä \"{0}\":n valmiiksi?", + "MessageConfirmMarkItemNotFinished": "Oletko varma, että haluat merkitä \"{0}\":n ei-valmiiksi?", + "MessageConfirmMarkSeriesFinished": "Oletko varma, että haluat merkitä kaikki tämän sarjan kirjat valmiiksi?", + "MessageConfirmMarkSeriesNotFinished": "Oletko varma, että haluat merkitä kaikki tämän sarjan kirjat ei-valmiiksi?", + "MessageConfirmNotificationTestTrigger": "Käynnistetäänkö tämä ilmoitus testitiedoilla?", + "MessageConfirmPurgeCache": "'Tyhjennä välimuisti' poistaa koko hakemiston sijainnilla /metadata/cache.

Oletko varma, että haluat poistaa välimuistihakemiston?", + "MessageConfirmPurgeItemsCache": "'Tyhjennä kohteiden välimuisti' poistaa koko hakemiston sijainnilla /metadata/cache/items.
Oletko varma?", + "MessageConfirmQuickEmbed": "Varoitus! Pikaupottaminen ei varmuuskopioi äänitiedostojasi. Varmista, että sinulla on varmuuskopio äänitiedostoistasi.

Haluatko jatkaa?", + "MessageConfirmQuickMatchEpisodes": "Jaksojen pikatäsmääminen korvaa tiedot, jos vastaavuus löytyy. Vain täsmäämättömät jaksot päivitetään. Oletko varma?", + "MessageConfirmReScanLibraryItems": "Oletko varma, että haluat skannata uudelleen {0} kohdetta?", + "MessageConfirmRemoveAllChapters": "Oletko varma, että haluat poistaa kaikki jaksot?", + "MessageConfirmRemoveAuthor": "Oletko varma, että haluat poistaa tekijän \"{0}\"?", + "MessageConfirmRemoveCollection": "Oletko varma, että haluat poistaa kokoelman \"{0}\"?", + "MessageConfirmRemoveEpisode": "Oletko varma, että haluat poistaa jakson \"{0}\"?", + "MessageConfirmRemoveEpisodes": "Oletko varma, että haluat poistaa {0} jaksoa?", + "MessageConfirmRemoveListeningSessions": "Oletko varma, että haluat poistaa {0} kuuntelukertaa?", + "MessageConfirmRemoveMetadataFiles": "Oletko varma, että haluat poistaa kaikki metadata.{0}-tiedostot kirjaston kohdekansioista?", + "MessageConfirmRemoveNarrator": "Oletko varma, että haluat poistaa kertojan \"{0}\"?", + "MessageConfirmRemovePlaylist": "Oletko varma, että haluat poistaa soittolistan \"{0}\"?", + "MessageConfirmRenameGenre": "Oletko varma, että haluat nimetä lajityypin \"{0}\" uudelleen \"{1}\":ksi kaikille kohteille?", + "MessageConfirmRenameGenreMergeNote": "Huomautus: Tämä lajityyppi on jo olemassa, joten ne yhdistetään.", + "MessageConfirmRenameGenreWarning": "Varoitus! Samanlainen lajityyppi eri kotelolla on jo olemassa \"{0}\".", + "MessageConfirmRenameTag": "Oletko varma, että haluat nimetä tunnisteen \"{0}\" uudelleen \"{1}\":ksi kaikille kohteille?", + "MessageConfirmRenameTagMergeNote": "Huomautus: Tämä tunniste on jo olemassa, joten ne yhdistetään.", + "MessageConfirmRenameTagWarning": "Varoitus! Samanlainen tunniste eri kotelolla on jo olemassa \"{0}\".", + "MessageConfirmResetProgress": "Oletko varma, että haluat nollata edistymisesi?", + "MessageConfirmSendEbookToDevice": "Oletko varma, että haluat lähettää {0} s-kirjan \"{1}\" laitteeseen \"{2}\"?", + "MessageConfirmUnlinkOpenId": "Oletko varma, että haluat poistaa tämän käyttäjän linkityksen OpenID:stä?", "MessageDownloadingEpisode": "Ladataan jaksoa", "MessageEpisodesQueuedForDownload": "{0} jaksoa on latausjonossa", "MessageFeedURLWillBe": "Syötteen URL tulee olemaan {0}", @@ -564,7 +776,11 @@ "MessageNoUserPlaylists": "Sinulla ei ole soittolistoja", "MessageOr": "tai", "MessagePodcastSearchField": "Syötä hakutermi tai RSS-syötteen URL-osoite", + "MessageQuickMatchAllEpisodes": "Pikatäsmää kaikki jaksot", + "MessageRemoveUserWarning": "Oletko varma, että haluat poistaa käyttäjän \"{0}\" pysyvästi?", "MessageReportBugsAndContribute": "Ilmoita virheistä, toivo ominaisuuksia ja osallistu", + "MessageResetChaptersConfirm": "Oletko varma, että haluat nollata luvut ja kumota tekemäsi muutokset?", + "MessageRestoreBackupConfirm": "Oletko varma, että haluat palauttaa varmuuskopion, joka on luotu", "MessageScheduleLibraryScanNote": "Suurimmalle osaa käyttäjistä on suositeltavaa jättää tämä ominaisuus pois päältä ja säilyttää kansiotarkkailu päällä. Kansiotarkkailu havaitsee automaattisesti tiedostomuutokset kirjaston kansioissa. Kansiotarkkailu ei toimi kaikille tiedostojärjestelmille (kuten NFS), jolloin voidaan käyttää ajastettuja kirjastoskannauksia.", "MessageTaskFailed": "Epäonnistunut", "MessageWatcherIsDisabledGlobally": "Kansiotarkkailu on poistettu käytöstä kaikkialla palvelimen asetuksissa", @@ -573,6 +789,8 @@ "StatsSessions": "istunnot", "ToastAccountUpdateSuccess": "Tili päivitetty", "ToastAppriseUrlRequired": "Arvon tulee olla Apprise URL", + "ToastBatchQuickMatchFailed": "Erän pikatäsmäys epäonnistui!", + "ToastBatchQuickMatchStarted": "{0} kirjan erän pikatäsmäys aloitettu!", "ToastBookmarkCreateFailed": "Kirjanmerkin luominen epäonnistui", "ToastCoverUpdateFailed": "Kansikuvan päivitys epäonnistui", "ToastItemCoverUpdateSuccess": "Kohteen kansikuva päivitetty", From 78031b1a896fb51fddae702534a050fa92d31b5c Mon Sep 17 00:00:00 2001 From: Ricky Tigg Date: Mon, 10 Mar 2025 12:56:36 +0100 Subject: [PATCH 06/38] Translated using Weblate (Finnish) Currently translated at 78.0% (853 of 1093 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fi/ --- client/strings/fi.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/client/strings/fi.json b/client/strings/fi.json index d1075ae5..5157efd5 100644 --- a/client/strings/fi.json +++ b/client/strings/fi.json @@ -715,9 +715,12 @@ "MessageChapterErrorFirstNotZero": "Ensimmäisen luvun tulee alkaa nollasta", "MessageChapterErrorStartGteDuration": "Epäkelvollinen aloitusaika; on oltava lyhyempi kuin äänikirjan kesto", "MessageChapterErrorStartLtPrev": "Epäkelvollinen aloitusaika; on oltava suurempi tai yhtä suuri kuin edellisen luvun aloitusaika", + "MessageChapterStartIsAfter": "Luku alkaa äänikirjan lopun jälkeen", + "MessageCheckingCron": "Tarkistetaan cronia...", "MessageConfirmCloseFeed": "Oletko varma, että haluat sulkea tämän syötteen?", "MessageConfirmDeleteBackup": "Oletko varma, että haluat poistaa varmuuskopion {0}:lle?", "MessageConfirmDeleteDevice": "Oletko varma, että haluat poistaa s-lukulaitteen \"{0}\"?", + "MessageConfirmDeleteFile": "Tämä poistaa tiedoston tiedostojärjestelmästäsi. Oletko varma?", "MessageConfirmDeleteLibrary": "Oletko varma, että haluat poistaa kirjaston \"{0}\" pysyvästi?", "MessageConfirmDeleteLibraryItem": "Tämä poistaa kirjastokohteen tietokannasta ja tiedostojärjestelmästäsi. Oletko varma?", "MessageConfirmDeleteLibraryItems": "Tämä poistaa {0} kirjastokohdetta tietokannasta ja tiedostojärjestelmästäsi. Oletko varma?", @@ -756,12 +759,27 @@ "MessageConfirmResetProgress": "Oletko varma, että haluat nollata edistymisesi?", "MessageConfirmSendEbookToDevice": "Oletko varma, että haluat lähettää {0} s-kirjan \"{1}\" laitteeseen \"{2}\"?", "MessageConfirmUnlinkOpenId": "Oletko varma, että haluat poistaa tämän käyttäjän linkityksen OpenID:stä?", + "MessageDaysListenedInTheLastYear": "{0} kuunneltua päivää viime vuonna", "MessageDownloadingEpisode": "Ladataan jaksoa", + "MessageDragFilesIntoTrackOrder": "Vedä tiedostot oikeaan raitojen järjestykseen", + "MessageEmbedFailed": "Upotus epäonnistui!", + "MessageEmbedFinished": "Upotus valmis!", + "MessageEmbedQueue": "Jonossa metatietojen upottamista varten ({0} jonossa)", "MessageEpisodesQueuedForDownload": "{0} jaksoa on latausjonossa", + "MessageEreaderDevices": "S-kirjojen toimituksen varmistamiseksi sinun on ehkä lisättävä yllä oleva sähköpostiosoite kelvolliseksi lähettäjäksi jokaiselle alla luetellulle laitteelle.", "MessageFeedURLWillBe": "Syötteen URL tulee olemaan {0}", "MessageFetching": "Haetaan...", + "MessageForceReScanDescription": "skannaa kaikki tiedostot uudelleen kuten uusi tarkistus. Äänitiedoston ID3-tunnisteet, OPF-tiedostot ja tekstitiedostot skannataan uusina.", + "MessageImportantNotice": "Tärkeä huomautus!", + "MessageInsertChapterBelow": "Syötä luku alle", + "MessageItemsSelected": "{0} kohdetta valittu", + "MessageItemsUpdated": "{0} kohdetta päivitetty", + "MessageJoinUsOn": "Liity meihin", "MessageLoading": "Ladataan...", + "MessageLoadingFolders": "Ladataan kansioita...", "MessageLogsDescription": "Lokitiedot tallennetaan kansioon /metadata/logs JSON-tiedostoina. Kaatumislokit tallennetaan kansioon /metadata/logs/crash_logs.txt.", + "MessageM4BFailed": "M4B epäonnistui!", + "MessageM4BFinished": "M4B valmis!", "MessageMarkAsFinished": "Merkitse valmiiksi", "MessageNoBookmarks": "Ei kirjanmerkkejä", "MessageNoChapters": "Ei kappaleita", From d2f506eefe9f54ac65f2df12cd86902204f81817 Mon Sep 17 00:00:00 2001 From: Jan-Eric Myhrgren Date: Tue, 11 Mar 2025 15:03:12 +0100 Subject: [PATCH 07/38] Translated using Weblate (Swedish) Currently translated at 94.5% (1033 of 1093 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index cbd4031d..5a4128ee 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -133,7 +133,7 @@ "HeaderDetails": "Detaljer", "HeaderDownloadQueue": "Nedladdningskö", "HeaderEbookFiles": "E-boksfiler", - "HeaderEmail": "E-postadress", + "HeaderEmail": "E-post", "HeaderEmailSettings": "Inställningar för e-post", "HeaderEpisodes": "Avsnitt", "HeaderEreaderDevices": "Enheter för att läsa e-böcker", @@ -305,7 +305,7 @@ "LabelEbook": "E-bok", "LabelEbooks": "E-böcker", "LabelEdit": "Redigera", - "LabelEmail": "E-postadress", + "LabelEmail": "E-post", "LabelEmailSettingsFromAddress": "Från e-postadress", "LabelEmailSettingsRejectUnauthorized": "Avvisa icke-autentiserade certifikat", "LabelEmailSettingsRejectUnauthorizedHelp": "Inaktivering av SSL-certifikatsvalidering kan exponera din anslutning för säkerhetsrisker, såsom man-in-the-middle-attacker. Inaktivera bara denna inställning om du förstår implikationerna och litar på den epostserver du ansluter till.", From 1f609e023dc08b1935641076dad1dfc0e7e53fa6 Mon Sep 17 00:00:00 2001 From: Jan-Eric Myhrgren Date: Wed, 12 Mar 2025 08:21:19 +0100 Subject: [PATCH 08/38] Translated using Weblate (Swedish) Currently translated at 94.5% (1033 of 1093 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index 5a4128ee..0fd96529 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -36,7 +36,7 @@ "ButtonFullPath": "Fullständig sökväg", "ButtonHide": "Dölj", "ButtonHome": "Hem", - "ButtonIssues": "Problem", + "ButtonIssues": "Objekt med problem", "ButtonJumpBackward": "Hoppa bakåt", "ButtonJumpForward": "Hoppa framåt", "ButtonLatest": "Senaste", @@ -780,7 +780,7 @@ "MessageNoEpisodes": "Inga avsnitt", "MessageNoFoldersAvailable": "Inga mappar tillgängliga", "MessageNoGenres": "Inga kategorier", - "MessageNoIssues": "Inga problem", + "MessageNoIssues": "Inga objekt med problem hittades", "MessageNoItems": "Inga objekt", "MessageNoItemsFound": "Inga objekt hittades", "MessageNoListeningSessions": "Inga lyssningstillfällen", From 72f2712a5f5bd050992daf461bab9465749e44e9 Mon Sep 17 00:00:00 2001 From: Xeratone Date: Wed, 12 Mar 2025 08:07:18 +0100 Subject: [PATCH 09/38] Translated using Weblate (Japanese) Currently translated at 0.2% (3 of 1093 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ja/ --- client/strings/ja.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/strings/ja.json b/client/strings/ja.json index 80af12d8..8d1f08c1 100644 --- a/client/strings/ja.json +++ b/client/strings/ja.json @@ -1,3 +1,5 @@ { - "ButtonAdd": "追加" + "ButtonAdd": "追加", + "ButtonAddChapters": "チャプターの追加", + "ButtonCancel": "キャンセル" } From 759efc0d7dddea7ceda668db22db89aec2f78e3e Mon Sep 17 00:00:00 2001 From: ejlaner Date: Wed, 12 Mar 2025 08:52:56 +0100 Subject: [PATCH 10/38] Translated using Weblate (Japanese) Currently translated at 1.7% (19 of 1093 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ja/ --- client/strings/ja.json | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/client/strings/ja.json b/client/strings/ja.json index 8d1f08c1..2dafd3d0 100644 --- a/client/strings/ja.json +++ b/client/strings/ja.json @@ -1,5 +1,21 @@ { "ButtonAdd": "追加", "ButtonAddChapters": "チャプターの追加", - "ButtonCancel": "キャンセル" + "ButtonCancel": "キャンセル", + "ButtonOk": "はい", + "ButtonPlay": "プレイ", + "ButtonPlaying": "プレイ中", + "ButtonPrevious": "先", + "ButtonRead": "野村", + "ButtonYes": "はい", + "HeaderPlayerSettings": "プレーヤーの設定", + "LabelBooks": "ほん", + "LabelLanguage": "言語", + "LabelLanguages": "言語", + "LabelName": "名", + "LabelNew": "新しい", + "LabelNewPassword": "新しいのパスワード", + "LabelPassword": "パスワード", + "LabelPlaylists": "プレイリスト", + "LabelPodcast": "ポッドキャスト" } From de22177dbf7413d8cb128e7c1c0dea941583afbc Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 13 Mar 2025 17:49:05 -0500 Subject: [PATCH 11/38] Update opf parser to support refines meta elements --- server/scanner/OpfFileScanner.js | 16 +++++++------ server/utils/parsers/parseOpfMetadata.js | 29 ++++++++++++++++++++---- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/server/scanner/OpfFileScanner.js b/server/scanner/OpfFileScanner.js index 87c4f565..13f6cc16 100644 --- a/server/scanner/OpfFileScanner.js +++ b/server/scanner/OpfFileScanner.js @@ -2,24 +2,26 @@ const { parseOpfMetadataXML } = require('../utils/parsers/parseOpfMetadata') const { readTextFile } = require('../utils/fileUtils') class OpfFileScanner { - constructor() { } + constructor() {} /** * Parse metadata from .opf file found in library scan and update bookMetadata - * - * @param {import('../models/LibraryItem').LibraryFileObject} opfLibraryFileObj - * @param {Object} bookMetadata + * + * @param {import('../models/LibraryItem').LibraryFileObject} opfLibraryFileObj + * @param {Object} bookMetadata */ async scanBookOpfFile(opfLibraryFileObj, bookMetadata) { const xmlText = await readTextFile(opfLibraryFileObj.metadata.path) const opfMetadata = xmlText ? await parseOpfMetadataXML(xmlText) : null if (opfMetadata) { for (const key in opfMetadata) { - if (key === 'tags') { // Add tags only if tags are empty + if (key === 'tags') { + // Add tags only if tags are empty if (opfMetadata.tags.length) { bookMetadata.tags = opfMetadata.tags } - } else if (key === 'genres') { // Add genres only if genres are empty + } else if (key === 'genres') { + // Add genres only if genres are empty if (opfMetadata.genres.length) { bookMetadata.genres = opfMetadata.genres } @@ -42,4 +44,4 @@ class OpfFileScanner { } } } -module.exports = new OpfFileScanner() \ No newline at end of file +module.exports = new OpfFileScanner() diff --git a/server/utils/parsers/parseOpfMetadata.js b/server/utils/parsers/parseOpfMetadata.js index 8cf768cd..9a55c1f2 100644 --- a/server/utils/parsers/parseOpfMetadata.js +++ b/server/utils/parsers/parseOpfMetadata.js @@ -22,11 +22,22 @@ function parseCreators(metadata) { Object.keys(c['$']) .find((key) => key.startsWith('xmlns:')) ?.split(':')[1] || 'opf' - return { + const creator = { value: c['_'], role: c['$'][`${namespace}:role`] || null, fileAs: c['$'][`${namespace}:file-as`] || null } + + const id = c['$']['id'] + if (id && metadata.meta.refines?.some((r) => r.refines === `#${id}`)) { + const creatorMeta = metadata.meta.refines.filter((r) => r.refines === `#${id}`) + if (creatorMeta) { + creator.role = creatorMeta.find((r) => r.property === 'role')?.value || creator.role || null + creator.fileAs = creatorMeta.find((r) => r.property === 'file-as')?.value || creator.fileAs || null + } + } + + return creator }) } @@ -187,7 +198,6 @@ module.exports.parseOpfMetadataJson = (json) => { const prefix = packageKey.split(':').shift() let metadata = prefix ? json[packageKey][`${prefix}:metadata`] || json[packageKey].metadata : json[packageKey].metadata if (!metadata) return null - if (Array.isArray(metadata)) { if (!metadata.length) return null metadata = metadata[0] @@ -198,12 +208,22 @@ module.exports.parseOpfMetadataJson = (json) => { metadata.meta = {} if (metadataMeta?.length) { metadataMeta.forEach((meta) => { - if (meta && meta['$'] && meta['$'].name) { + if (meta?.['$']?.name) { metadata.meta[meta['$'].name] = [meta['$'].content || ''] + } else if (meta?.['$']?.refines) { + // https://www.w3.org/TR/epub-33/#sec-meta-elem + + if (!metadata.meta.refines) { + metadata.meta.refines = [] + } + metadata.meta.refines.push({ + value: meta._, + refines: meta['$'].refines, + property: meta['$'].property + }) } }) } - const creators = parseCreators(metadata) const authors = (fetchCreators(creators, 'aut') || []).map((au) => au?.trim()).filter((au) => au) const narrators = (fetchNarrators(creators, metadata) || []).map((nrt) => nrt?.trim()).filter((nrt) => nrt) @@ -227,5 +247,6 @@ module.exports.parseOpfMetadataJson = (json) => { module.exports.parseOpfMetadataXML = async (xml) => { const json = await xmlToJSON(xml) if (!json) return null + return this.parseOpfMetadataJson(json) } From 804dafdfcb8acaa635de1d4949d0eefd3727e286 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 14 Mar 2025 17:32:32 -0500 Subject: [PATCH 12/38] Add test for parseOpfMetadata OPF v3 author --- .../utils/parsers/parseOpfMetadata.test.js | 110 ++++++++++-------- 1 file changed, 60 insertions(+), 50 deletions(-) diff --git a/test/server/utils/parsers/parseOpfMetadata.test.js b/test/server/utils/parsers/parseOpfMetadata.test.js index ca033cca..32dae922 100644 --- a/test/server/utils/parsers/parseOpfMetadata.test.js +++ b/test/server/utils/parsers/parseOpfMetadata.test.js @@ -3,8 +3,8 @@ const expect = chai.expect const { parseOpfMetadataXML } = require('../../../../server/utils/parsers/parseOpfMetadata') describe('parseOpfMetadata - test series', async () => { - it('test one series', async () => { - const opf = ` + it('test one series', async () => { + const opf = ` @@ -13,12 +13,12 @@ describe('parseOpfMetadata - test series', async () => { ` - const parsedOpf = await parseOpfMetadataXML(opf) - expect(parsedOpf.series).to.deep.equal([{ "name": "Serie", "sequence": "1" }]) - }) + const parsedOpf = await parseOpfMetadataXML(opf) + expect(parsedOpf.series).to.deep.equal([{ name: 'Serie', sequence: '1' }]) + }) - it('test more then 1 series - in correct order', async () => { - const opf = ` + it('test more then 1 series - in correct order', async () => { + const opf = ` @@ -31,16 +31,16 @@ describe('parseOpfMetadata - test series', async () => { ` - const parsedOpf = await parseOpfMetadataXML(opf) - expect(parsedOpf.series).to.deep.equal([ - { "name": "Serie 1", "sequence": "1" }, - { "name": "Serie 2", "sequence": "2" }, - { "name": "Serie 3", "sequence": "3" }, - ]) - }) + const parsedOpf = await parseOpfMetadataXML(opf) + expect(parsedOpf.series).to.deep.equal([ + { name: 'Serie 1', sequence: '1' }, + { name: 'Serie 2', sequence: '2' }, + { name: 'Serie 3', sequence: '3' } + ]) + }) - it('test messed order of series content and index', async () => { - const opf = ` + it('test messed order of series content and index', async () => { + const opf = ` @@ -52,15 +52,15 @@ describe('parseOpfMetadata - test series', async () => { ` - const parsedOpf = await parseOpfMetadataXML(opf) - expect(parsedOpf.series).to.deep.equal([ - { "name": "Serie 1", "sequence": "1" }, - { "name": "Serie 3", "sequence": null }, - ]) - }) + const parsedOpf = await parseOpfMetadataXML(opf) + expect(parsedOpf.series).to.deep.equal([ + { name: 'Serie 1', sequence: '1' }, + { name: 'Serie 3', sequence: null } + ]) + }) - it('test different values of series content and index', async () => { - const opf = ` + it('test different values of series content and index', async () => { + const opf = ` @@ -73,16 +73,16 @@ describe('parseOpfMetadata - test series', async () => { ` - const parsedOpf = await parseOpfMetadataXML(opf) - expect(parsedOpf.series).to.deep.equal([ - { "name": "Serie 1", "sequence": null }, - { "name": "Serie 2", "sequence": "abc" }, - { "name": "Serie 3", "sequence": null }, - ]) - }) + const parsedOpf = await parseOpfMetadataXML(opf) + expect(parsedOpf.series).to.deep.equal([ + { name: 'Serie 1', sequence: null }, + { name: 'Serie 2', sequence: 'abc' }, + { name: 'Serie 3', sequence: null } + ]) + }) - it('test empty series content', async () => { - const opf = ` + it('test empty series content', async () => { + const opf = ` @@ -91,12 +91,12 @@ describe('parseOpfMetadata - test series', async () => { ` - const parsedOpf = await parseOpfMetadataXML(opf) - expect(parsedOpf.series).to.deep.equal([]) - }) + const parsedOpf = await parseOpfMetadataXML(opf) + expect(parsedOpf.series).to.deep.equal([]) + }) - it('test series and index using an xml namespace', async () => { - const opf = ` + it('test series and index using an xml namespace', async () => { + const opf = ` @@ -105,14 +105,12 @@ describe('parseOpfMetadata - test series', async () => { ` - const parsedOpf = await parseOpfMetadataXML(opf) - expect(parsedOpf.series).to.deep.equal([ - { "name": "Serie 1", "sequence": null } - ]) - }) + const parsedOpf = await parseOpfMetadataXML(opf) + expect(parsedOpf.series).to.deep.equal([{ name: 'Serie 1', sequence: null }]) + }) - it('test series and series index not directly underneath', async () => { - const opf = ` + it('test series and series index not directly underneath', async () => { + const opf = ` @@ -122,9 +120,21 @@ describe('parseOpfMetadata - test series', async () => { ` - const parsedOpf = await parseOpfMetadataXML(opf) - expect(parsedOpf.series).to.deep.equal([ - { "name": "Serie 1", "sequence": "1" } - ]) - }) + const parsedOpf = await parseOpfMetadataXML(opf) + expect(parsedOpf.series).to.deep.equal([{ name: 'Serie 1', sequence: '1' }]) + }) + + it('test author is parsed from refines meta', async () => { + const opf = ` + + + Nevil Shute + aut + Shute, Nevil + + + ` + const parsedOpf = await parseOpfMetadataXML(opf) + expect(parsedOpf.authors).to.deep.equal(['Nevil Shute']) + }) }) From 9f883a501965d2e130f57c441a17f3a82fb3f6ac Mon Sep 17 00:00:00 2001 From: jfrazx Date: Fri, 14 Mar 2025 19:43:09 -0700 Subject: [PATCH 13/38] ci: update actions --- .github/workflows/close_blank_issues.yaml | 6 +++--- .github/workflows/codeql.yml | 2 +- .github/workflows/docker-build.yml | 21 ++++++++++----------- .github/workflows/i18n-integration.yml | 3 ++- .github/workflows/integration-test.yml | 9 +++++---- .github/workflows/lint-openapi.yml | 7 +++++++ .github/workflows/unit-tests.yml | 1 + 7 files changed, 29 insertions(+), 20 deletions(-) diff --git a/.github/workflows/close_blank_issues.yaml b/.github/workflows/close_blank_issues.yaml index c7108827..7190546a 100644 --- a/.github/workflows/close_blank_issues.yaml +++ b/.github/workflows/close_blank_issues.yaml @@ -14,11 +14,11 @@ jobs: steps: - name: Check issue headings - uses: actions/github-script@v6 + uses: actions/github-script@v7 with: script: | const issueBody = context.payload.issue.body || ""; - + // Match Markdown headings (e.g., # Heading, ## Heading) const headingRegex = /^(#{1,6})\s.+/gm; const headings = [...issueBody.matchAll(headingRegex)]; @@ -39,4 +39,4 @@ jobs: issue_number: context.payload.issue.number, state: "closed" }); - } \ No newline at end of file + } diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8d43311b..2e5f4bce 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -43,7 +43,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 846a5563..63735503 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -1,5 +1,4 @@ --- - name: Build and Push Docker Image on: @@ -11,7 +10,7 @@ on: required: true default: 'latest' push: - branches: [main,master] + branches: [main, master] tags: - 'v*.*.*' # Only build when files in these directories have been changed @@ -23,16 +22,16 @@ on: jobs: build: - if: "!contains(github.event.head_commit.message, 'skip ci')" + if: "!contains(github.event.head_commit.message, 'skip ci') && ${{ github.repository == 'advplyr/audiobookshelf' }}" runs-on: ubuntu-20.04 steps: - name: Check out - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Docker meta id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: images: advplyr/audiobookshelf,ghcr.io/${{ github.repository_owner }}/audiobookshelf tags: | @@ -40,13 +39,13 @@ jobs: type=semver,pattern={{version}} - name: Setup QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Cache Docker layers - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: /tmp/.buildx-cache key: ${{ runner.os }}-buildx-${{ github.sha }} @@ -54,20 +53,20 @@ jobs: ${{ runner.os }}-buildx- - name: Login to Dockerhub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} - name: Login to ghcr - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GHCR_PASSWORD }} - name: Build image - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v6 with: tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/i18n-integration.yml b/.github/workflows/i18n-integration.yml index fc844154..8b3a4678 100644 --- a/.github/workflows/i18n-integration.yml +++ b/.github/workflows/i18n-integration.yml @@ -20,7 +20,8 @@ jobs: - name: Set up node uses: actions/setup-node@v4 with: - node-version: '20' + node-version: 20 + cache: 'npm' # The only argument is the `directory`, which is where the i18n files are # stored. diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 580c0f50..18c1d2da 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -18,14 +18,15 @@ jobs: name: build and test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - name: setup nade - uses: actions/setup-node@v3 + - name: setup node + uses: actions/setup-node@v4 with: node-version: 20 + cache: 'npm' - - name: install pkg (using yao-pkg fork for targetting node20) + - name: install pkg (using yao-pkg fork for targeting node20) run: npm install -g @yao-pkg/pkg - name: get client dependencies diff --git a/.github/workflows/lint-openapi.yml b/.github/workflows/lint-openapi.yml index 817e94b9..ec08ecb3 100644 --- a/.github/workflows/lint-openapi.yml +++ b/.github/workflows/lint-openapi.yml @@ -18,15 +18,22 @@ jobs: # Check out the repository - name: Checkout uses: actions/checkout@v4 + # Set up node to run the javascript - name: Set up node uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + # Install Redocly CLI - name: Install Redocly CLI run: npm install -g @redocly/cli@latest + # Perform linting for exploded spec - name: Run linting for exploded spec run: redocly lint docs/root.yaml --format=github-actions + # Perform linting for bundled spec - name: Run linting for bundled spec run: redocly lint docs/openapi.json --format=github-actions diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 695696c6..91a22c71 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -29,6 +29,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20 + cache: 'npm' - name: Install dependencies run: npm ci From ecd782c8a93d3622f2dc3643aa6b97d6befcf6cb Mon Sep 17 00:00:00 2001 From: jfrazx Date: Sat, 15 Mar 2025 00:49:27 -0700 Subject: [PATCH 14/38] fix: docker action --- .github/workflows/docker-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 63735503..f93853f9 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -22,7 +22,7 @@ on: jobs: build: - if: "!contains(github.event.head_commit.message, 'skip ci') && ${{ github.repository == 'advplyr/audiobookshelf' }}" + if: ${{ !contains(github.event.head_commit.message, 'skip ci') && github.repository == 'advplyr/audiobookshelf' }} runs-on: ubuntu-20.04 steps: From e29d3a36727db4cf0358970869d9d5e5695ce0f4 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 15 Mar 2025 17:41:07 -0500 Subject: [PATCH 15/38] Cast OpenLibrary publishYear to string #4114 --- server/providers/OpenLibrary.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/providers/OpenLibrary.js b/server/providers/OpenLibrary.js index 453c919b..10d03378 100644 --- a/server/providers/OpenLibrary.js +++ b/server/providers/OpenLibrary.js @@ -66,10 +66,10 @@ class OpenLibrary { } parsePublishYear(doc, worksData) { - if (doc.first_publish_year && !isNaN(doc.first_publish_year)) return doc.first_publish_year + if (doc.first_publish_year && !isNaN(doc.first_publish_year)) return String(doc.first_publish_year) if (worksData.first_publish_date) { var year = worksData.first_publish_date.split('-')[0] - if (!isNaN(year)) return year + if (!isNaN(year)) return String(year) } return null } From 394bf8cb70dcf8cc6582e5c43a5e45d4bc80b02c Mon Sep 17 00:00:00 2001 From: Gabriel Gavrilov Date: Sun, 16 Mar 2025 07:42:18 -0600 Subject: [PATCH 16/38] Allow number types for payload metadata when updating books. (#4118) * Allow number types for payload metadata * cast numbers to string --- server/models/Book.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/models/Book.js b/server/models/Book.js index 1f4193a2..0dd0b785 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -374,6 +374,10 @@ class Book extends Model { if (payload.metadata) { const metadataStringKeys = ['title', 'subtitle', 'publishedYear', 'publishedDate', 'publisher', 'description', 'isbn', 'asin', 'language'] metadataStringKeys.forEach((key) => { + if (typeof payload.metadata[key] == 'number') { + payload.metadata[key] = String(payload.metadata[key]) + } + if ((typeof payload.metadata[key] === 'string' || payload.metadata[key] === null) && this[key] !== payload.metadata[key]) { this[key] = payload.metadata[key] || null From 7f8de7915cb277ce3c591c3da22a9bdcda3f3497 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 16 Mar 2025 18:02:16 -0500 Subject: [PATCH 17/38] Update remove playlist translations and use our custom confirm modal --- .../components/modals/playlists/EditModal.vue | 39 ++++++++++++------- client/pages/playlist/_id.vue | 38 +++++++++++------- 2 files changed, 49 insertions(+), 28 deletions(-) diff --git a/client/components/modals/playlists/EditModal.vue b/client/components/modals/playlists/EditModal.vue index cd2deffe..78ca6002 100644 --- a/client/components/modals/playlists/EditModal.vue +++ b/client/components/modals/playlists/EditModal.vue @@ -74,21 +74,32 @@ export default { this.newPlaylistDescription = this.playlist.description || '' }, removeClick() { - if (confirm(this.$getString('MessageConfirmRemovePlaylist', [this.playlistName]))) { - this.processing = true - this.$axios - .$delete(`/api/playlists/${this.playlist.id}`) - .then(() => { - this.processing = false - this.show = false - this.$toast.success(this.$strings.ToastPlaylistRemoveSuccess) - }) - .catch((error) => { - console.error('Failed to remove playlist', error) - this.processing = false - this.$toast.error(this.$strings.ToastRemoveFailed) - }) + const payload = { + message: this.$getString('MessageConfirmRemovePlaylist', [this.playlistName]), + callback: (confirmed) => { + if (confirmed) { + this.removePlaylist() + } + }, + type: 'yesNo' } + this.$store.commit('globals/setConfirmPrompt', payload) + }, + removePlaylist() { + this.processing = true + this.$axios + .$delete(`/api/playlists/${this.playlist.id}`) + .then(() => { + this.show = false + this.$toast.success(this.$strings.ToastPlaylistRemoveSuccess) + }) + .catch((error) => { + console.error('Failed to remove playlist', error) + this.$toast.error(this.$strings.ToastRemoveFailed) + }) + .finally(() => { + this.processing = false + }) }, submitForm() { if (this.newPlaylistName === this.playlistName && this.newPlaylistDescription === this.playlist.description) { diff --git a/client/pages/playlist/_id.vue b/client/pages/playlist/_id.vue index 5cd31885..09755324 100644 --- a/client/pages/playlist/_id.vue +++ b/client/pages/playlist/_id.vue @@ -109,21 +109,31 @@ export default { this.$store.commit('globals/setEditPlaylist', this.playlist) }, removeClick() { - if (confirm(`Are you sure you want to remove playlist "${this.playlistName}"?`)) { - this.processingRemove = true - var playlistName = this.playlistName - this.$axios - .$delete(`/api/playlists/${this.playlist.id}`) - .then(() => { - this.processingRemove = false - this.$toast.success(`Playlist "${playlistName}" Removed`) - }) - .catch((error) => { - console.error('Failed to remove playlist', error) - this.processingRemove = false - this.$toast.error(`Failed to remove playlist`) - }) + const payload = { + message: this.$getString('MessageConfirmRemovePlaylist', [this.playlistName]), + callback: (confirmed) => { + if (confirmed) { + this.removePlaylist() + } + }, + type: 'yesNo' } + this.$store.commit('globals/setConfirmPrompt', payload) + }, + removePlaylist() { + this.processingRemove = true + this.$axios + .$delete(`/api/playlists/${this.playlist.id}`) + .then(() => { + this.$toast.success(this.$strings.ToastPlaylistRemoveSuccess) + }) + .catch((error) => { + console.error('Failed to remove playlist', error) + this.$toast.error(this.$strings.ToastRemoveFailed) + }) + .finally(() => { + this.processingRemove = false + }) }, clickPlay() { const queueItems = [] From 5c7865f9457153e7e3e008a007a213ea10165788 Mon Sep 17 00:00:00 2001 From: Jan-Eric Myhrgren Date: Fri, 14 Mar 2025 11:16:13 +0100 Subject: [PATCH 18/38] Translated using Weblate (Swedish) Currently translated at 94.5% (1031 of 1090 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index c2a5e11c..5e984cc8 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -301,6 +301,8 @@ "LabelDownloadable": "Nedladdningsbar", "LabelDuration": "Varaktighet", "LabelDurationComparisonExactMatch": "(exakt matchning)", + "LabelDurationComparisonLonger": "({0} längre)", + "LabelDurationComparisonShorter": "({0} kortare)", "LabelDurationFound": "Varaktighet hittad:", "LabelEbook": "E-bok", "LabelEbooks": "E-böcker", @@ -414,6 +416,7 @@ "LabelLookForNewEpisodesAfterDate": "Sök efter nya avsnitt efter detta datum", "LabelLowestPriority": "Lägst prioritet", "LabelMatchExistingUsersBy": "Matcha befintliga användare med", + "LabelMatchExistingUsersByDescription": "Används för att koppla existerande användare. När kopplingen sker kommer användaren att matchas med ett unikt ID från SSO-leverantören.", "LabelMaxEpisodesToDownload": "Maximalt antal avsnitt att ladda ner (0 = obegränsat).", "LabelMaxEpisodesToDownloadPerCheck": "Maximalt antal nya avsnitt att ladda ner per tillfälle", "LabelMaxEpisodesToKeep": "Maximalt antal avsnitt att behålla", @@ -679,7 +682,7 @@ "MessageAddToPlayerQueue": "Lägg till i spellistan", "MessageAppriseDescription": "För att använda den här funktionen behöver du ha en instans av Apprise API igång eller en API som hanterar dessa begäranden.
Apprise API-urlen bör vara hela URL-sökvägen för att skicka meddelandet, t.ex., om din API-instans är tillgänglig på http://192.168.1.1:8337, bör du ange http://192.168.1.1:8337/notify.", "MessageBackupsDescription": "Säkerhetskopior inkluderar användare, användarnas framsteg, biblioteksobjekt,
serverinställningar och bilder lagrade i /metadata/items & /metadata/authors.
De inkluderar INTE några filer lagrade i dina biblioteksmappar.", - "MessageBackupsLocationEditNote": "OBS: När du ändrar plats för säkerhetskopiorna så flyttas INTE gamla säkerhetskopior dit.", + "MessageBackupsLocationEditNote": "OBS: När du ändrar plats för säkerhetskopiorna så flyttas INTE gamla säkerhetskopior dit", "MessageBackupsLocationNoEditNote": "OBS: Platsen där säkerhetskopiorna lagras bestäms av en central inställning och kan inte ändras här.", "MessageBackupsLocationPathEmpty": "Uppgiften om platsen för lagring av säkerhetskopior kan inte lämnas tom", "MessageBatchEditPopulateMapDetailsAllHelp": "Adderar information från alla objekt nedan i de fält som aktiverats. Om fälten innehåller olika uppgifter kommer informationen att slås samman.", @@ -848,6 +851,7 @@ "MessageTaskScanItemsMissing": "{0} saknades", "MessageTaskScanItemsUpdated": "{0} uppdaterades", "MessageTaskScanNoChangesNeeded": "Inget adderades eller uppdaterades", + "MessageTaskScanningFileChanges": "Söker efter ändrade filer i \"{0}\"", "MessageTaskScanningLibrary": "Biblioteket \"{0}\" har skannats", "MessageTaskTargetDirectoryNotWritable": "Det är inte tillåtet att skriva i den angivna katalogen", "MessageThinking": "Tänker...", From 43706aac6dbd6e8ddb6d355ba38951d2fa591fbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=20=D0=93=D0=BE=D1=80?= =?UTF-8?q?=D0=BF=D0=B8=D0=BD=D1=96=D1=87?= Date: Fri, 14 Mar 2025 17:25:49 +0100 Subject: [PATCH 19/38] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1090 of 1090 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/uk.json b/client/strings/uk.json index 5a88fe0e..faade7e1 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -558,6 +558,8 @@ "LabelSettingsBookshelfViewHelp": "Імітує вигляд дерев'яних полиць", "LabelSettingsChromecastSupport": "Підтримка Chromecast", "LabelSettingsDateFormat": "Формат дати", + "LabelSettingsEnableWatcher": "Автоматично сканувати бібліотеки на наявність змін", + "LabelSettingsEnableWatcherForLibrary": "Автоматично сканувати бібліотеку на наявність змін", "LabelSettingsEnableWatcherHelp": "Вмикає автоматичне додавання/оновлення елементів, коли спостерігаються зміни файлів. *Потребує перезавантаження сервера", "LabelSettingsEpubsAllowScriptedContent": "Дозволити JavaScript-вміст у epub", "LabelSettingsEpubsAllowScriptedContentHelp": "Дозволяти epub-файлам виконувати код. Вмикайте цей параметр лише якщо ви довіряєте джерелу epub-файлів.", From 0f7867a12a38124eea4e173be93ce07ad3b88a3e Mon Sep 17 00:00:00 2001 From: Jan-Eric Myhrgren Date: Sat, 15 Mar 2025 18:28:25 +0100 Subject: [PATCH 20/38] Translated using Weblate (Swedish) Currently translated at 94.5% (1031 of 1090 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index 5e984cc8..57af5df3 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -527,7 +527,7 @@ "LabelSelectEpisodesShowing": "Välj {0} avsnitt som visas", "LabelSelectUsers": "Välj användare", "LabelSendEbookToDevice": "Skicka e-bok till...", - "LabelSequence": "Sekvensnummer", + "LabelSequence": "Ordningsnummer", "LabelSerial": "Seriell", "LabelSeries": "Serier", "LabelSeriesName": "Serienamn", From b2001eca23f47641c2263e0848f346fc9140ae21 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 15 Mar 2025 23:33:46 +0100 Subject: [PATCH 21/38] Added translation using Weblate (Slovak) --- client/strings/sk.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 client/strings/sk.json diff --git a/client/strings/sk.json b/client/strings/sk.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/client/strings/sk.json @@ -0,0 +1 @@ +{} From 3dc2022239d4b5ba40b1c4e6740ede8698c65717 Mon Sep 17 00:00:00 2001 From: biuklija Date: Sun, 16 Mar 2025 13:42:24 +0100 Subject: [PATCH 22/38] Translated using Weblate (Croatian) Currently translated at 100.0% (1090 of 1090 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/client/strings/hr.json b/client/strings/hr.json index 5f0f83ef..3ae06195 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -252,7 +252,7 @@ "LabelBackToUser": "Povratak na korisnika", "LabelBackupAudioFiles": "Sigurnosno kopiranje zvučnih datoteka", "LabelBackupLocation": "Lokacija sigurnosnih kopija", - "LabelBackupsEnableAutomaticBackups": "Omogući automatsku izradu sigurnosnih kopija", + "LabelBackupsEnableAutomaticBackups": "Automatske sigurnosne kopije", "LabelBackupsEnableAutomaticBackupsHelp": "Sigurnosne kopije spremaju se u /metadata/backups", "LabelBackupsMaxBackupSize": "Maksimalna veličina sigurnosne kopije (u GB) (0 za neograničeno)", "LabelBackupsMaxBackupSizeHelp": "U svrhu sprečavanja izrade krive konfiguracije, sigurnosne kopije neće se izraditi ako su veće od zadane veličine.", @@ -403,8 +403,8 @@ "LabelLanguages": "Jezici", "LabelLastBookAdded": "Zadnja dodana knjiga", "LabelLastBookUpdated": "Zadnja ažurirana knjiga", - "LabelLastSeen": "Zadnje gledano", - "LabelLastTime": "Vrijeme zadnjeg slušanja", + "LabelLastSeen": "Zadnji puta viđen", + "LabelLastTime": "Zadnje doslušano vrijeme", "LabelLastUpdate": "Zadnje ažuriranje", "LabelLayout": "Prikaz", "LabelLayoutSinglePage": "Jedna stranica", @@ -558,6 +558,8 @@ "LabelSettingsBookshelfViewHelp": "Skeumorfni dizajn sa drvenim policama", "LabelSettingsChromecastSupport": "Podrška za Chromecast", "LabelSettingsDateFormat": "Format datuma", + "LabelSettingsEnableWatcher": "Automatski pretražuj ima li promjena u knjižnicama", + "LabelSettingsEnableWatcherForLibrary": "Automatski traži promjene u knjižnicama", "LabelSettingsEnableWatcherHelp": "Omogućuje automatsko dodavanje/ažuriranje stavki kada se uoče izmjene datoteka. *Potrebno je ponovno pokretanje poslužitelja", "LabelSettingsEpubsAllowScriptedContent": "Omogući skripte u epub datotekama", "LabelSettingsEpubsAllowScriptedContentHelp": "Omogućuje epub datotekama izvođenje skripti. Preporučamo isključiti ovu mogućnost ukoliko nemate povjerenja u izvore epub datoteka.", From 23d20f4a5c29c96b1472db6961576f4ef0ecd42a Mon Sep 17 00:00:00 2001 From: SunSpring Date: Sun, 16 Mar 2025 05:21:09 +0100 Subject: [PATCH 23/38] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1090 of 1090 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 00a34300..4263e5fa 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -219,6 +219,7 @@ "LabelAccountTypeAdmin": "管理员", "LabelAccountTypeGuest": "来宾", "LabelAccountTypeUser": "用户", + "LabelActivities": "活动", "LabelActivity": "活动", "LabelAddToCollection": "添加到收藏", "LabelAddToCollectionBatch": "批量添加 {0} 个媒体到收藏", @@ -251,7 +252,7 @@ "LabelBackToUser": "返回到用户", "LabelBackupAudioFiles": "备份音频文件", "LabelBackupLocation": "备份位置", - "LabelBackupsEnableAutomaticBackups": "启用自动备份", + "LabelBackupsEnableAutomaticBackups": "自动备份", "LabelBackupsEnableAutomaticBackupsHelp": "备份保存到 /metadata/backups", "LabelBackupsMaxBackupSize": "最大备份大小 (GB) (0 为无限制)", "LabelBackupsMaxBackupSizeHelp": "为了防止错误配置, 如果备份超过配置的大小, 备份将失败.", @@ -283,6 +284,7 @@ "LabelContinueSeries": "继续收听系列", "LabelCover": "封面", "LabelCoverImageURL": "封面图像 URL", + "LabelCoverProvider": "封面提供者", "LabelCreatedAt": "创建时间", "LabelCronExpression": "计划任务表达式", "LabelCurrent": "当前", @@ -391,6 +393,7 @@ "LabelIntervalEvery6Hours": "每 6 小时", "LabelIntervalEveryDay": "每天", "LabelIntervalEveryHour": "每小时", + "LabelIntervalEveryMinute": "每分钟", "LabelInvert": "倒转", "LabelItem": "项目", "LabelJumpBackwardAmount": "向后跳转时间", @@ -555,6 +558,8 @@ "LabelSettingsBookshelfViewHelp": "带有木架子的拟物化设计", "LabelSettingsChromecastSupport": "Chromecast 支持", "LabelSettingsDateFormat": "日期格式", + "LabelSettingsEnableWatcher": "自动扫描库以查找更改", + "LabelSettingsEnableWatcherForLibrary": "自动扫描库以查找更改", "LabelSettingsEnableWatcherHelp": "当检测到文件更改时, 启用项目的自动添加/更新. *需要重新启动服务器", "LabelSettingsEpubsAllowScriptedContent": "允许 epubs 中包含脚本内容", "LabelSettingsEpubsAllowScriptedContentHelp": "允许 epub 文件执行脚本. 建议将此设置保持禁用, 除非你信任 epub 文件的来源.", @@ -840,6 +845,7 @@ "MessageRestoreBackupConfirm": "你确定要恢复创建的这个备份", "MessageRestoreBackupWarning": "恢复备份将覆盖位于 /config 的整个数据库并覆盖 /metadata/items & /metadata/authors 中的图像.

备份不会修改媒体库文件夹中的任何文件. 如果你已启用服务器设置将封面和元数据存储在库文件夹中,则不会备份或覆盖这些内容.

将自动刷新使用服务器的所有客户端.", "MessageScheduleLibraryScanNote": "对于大多数用户, 建议禁用此功能并保持文件夹监视程序设置启用. 文件夹监视程序将自动检测库文件夹中的更改. 文件夹监视程序不适用于每个文件系统 (如 NFS), 因此可以使用计划库扫描.", + "MessageScheduleRunEveryWeekdayAtTime": "每隔 {0} 在 {1} 运行一次", "MessageSearchResultsFor": "搜索结果", "MessageSelected": "{0} 已选择", "MessageServerCouldNotBeReached": "无法访问服务器", From 5d8a88dc082b42ead6011935e9745c5eca66b0b0 Mon Sep 17 00:00:00 2001 From: peter cerny Date: Sun, 16 Mar 2025 10:10:10 +0100 Subject: [PATCH 24/38] Translated using Weblate (Slovak) Currently translated at 7.5% (82 of 1090 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sk/ --- client/strings/sk.json | 85 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/client/strings/sk.json b/client/strings/sk.json index 0967ef42..ee8184fb 100644 --- a/client/strings/sk.json +++ b/client/strings/sk.json @@ -1 +1,84 @@ -{} +{ + "ButtonAdd": "Pridať", + "ButtonAddChapters": "Pridať kapitoly", + "ButtonAddDevice": "Pridať zariadenie", + "ButtonAddLibrary": "Pridať knižnicu", + "ButtonAddPodcasts": "Pridať podcasty", + "ButtonAddUser": "Pridať užívateľa", + "ButtonAddYourFirstLibrary": "Pridajte vašu prvú knižnicu", + "ButtonApply": "Použiť", + "ButtonApplyChapters": "Použiť kapitoly", + "ButtonAuthors": "Autori", + "ButtonBack": "Späť", + "ButtonBatchEditPopulateFromExisting": "Vytvoriť z existujúcej", + "ButtonBatchEditPopulateMapDetails": "Vyplniť detaily na mape", + "ButtonBrowseForFolder": "Prehľadávať adresáre", + "ButtonCancel": "Zrušiť", + "ButtonCancelEncode": "Zrušiť kódovanie", + "ButtonChangeRootPassword": "Zmeniť Root heslo", + "ButtonCheckAndDownloadNewEpisodes": "Skontrolovať a stiahnuť nové epizódy", + "ButtonChooseAFolder": "Vyberte adresár", + "ButtonChooseFiles": "Vyberte súbory", + "ButtonClearFilter": "Zrušiť filter", + "ButtonCloseFeed": "Zatvoriť zdroj", + "ButtonCloseSession": "Ukončiť otvorené pripojenie", + "ButtonCollections": "Zbierky", + "ButtonConfigureScanner": "Nastaviť skener", + "ButtonCreate": "Vytvoriť", + "ButtonCreateBackup": "Vytvoriť zálohu", + "ButtonDelete": "Zmazať", + "ButtonDownloadQueue": "Poradie", + "ButtonEdit": "Upraviť", + "ButtonEditChapters": "Upraviť kapitoly", + "ButtonEditPodcast": "Upraviť podcast", + "ButtonEnable": "Povoliť", + "ButtonForceReScan": "Vynútiť preskenovanie", + "ButtonFullPath": "Zobraziť cestu", + "ButtonHide": "Skryť", + "ButtonHome": "Domov", + "ButtonIssues": "Problémy", + "ButtonJumpBackward": "Posun späť", + "ButtonJumpForward": "Posun vpred", + "ButtonLatest": "Najnovšie", + "ButtonLibrary": "Knižnica", + "ButtonLogout": "Odhlásenie", + "ButtonLookup": "Vyhľadať", + "ButtonManageTracks": "Spravovať stopy", + "ButtonMapChapterTitles": "Mapovať názvy kapitol", + "ButtonMatchAllAuthors": "Vyhľadať všetkých autorov", + "ButtonMatchBooks": "Vyhľadať knihy", + "ButtonNevermind": "Nevadí", + "ButtonNext": "Ďalšie", + "ButtonNextChapter": "Ďalšia kapitola", + "ButtonNextItemInQueue": "Ďalšia položka v poradí", + "ButtonOk": "OK", + "ButtonOpenFeed": "Otvoriť zdroj", + "ButtonOpenManager": "Otvoriť správcu", + "ButtonPause": "Zastaviť", + "ButtonPlay": "Prehrať", + "ButtonPlayAll": "Prehrať všetko", + "ButtonPlaying": "Prehráva sa", + "ButtonPlaylists": "Playlisty", + "ButtonPrevious": "Predchádzajúci", + "ButtonPreviousChapter": "Predchádzajúca kapitola", + "ButtonProbeAudioFile": "Preskúmaj zvukový súbor", + "ButtonPurgeAllCache": "Vymaž celú medzipamäť", + "ButtonPurgeItemsCache": "Vymaž medzipamäť položiek", + "ButtonQueueAddItem": "Pridať do poradia", + "ButtonQueueRemoveItem": "Vymazať z poradia", + "ButtonQuickEmbed": "Rýchle vloženie", + "ButtonQuickEmbedMetadata": "Rýchle vloženie metadát", + "ButtonQuickMatch": "Rýchle vyhľadanie", + "ButtonReScan": "Preskenovať", + "ButtonRead": "Načítať", + "ButtonReadLess": "Načítať menej", + "ButtonReadMore": "Načítať viac", + "ButtonRefresh": "Obnoviť", + "ButtonRemove": "Odstrániť", + "ButtonRemoveAll": "Odstrániť všetko", + "ButtonRemoveAllLibraryItems": "Odstrániť všetky položky knižnice", + "ButtonRemoveFromContinueListening": "Odstrániť z nedokončených podcastov", + "ButtonRemoveFromContinueReading": "Odtrániť z nedokončených audiokníh", + "HeaderMatch": "Spárovať", + "LabelBackupsNumberToKeepHelp": "Týmto spôsobom odstránite vždy iba jednu zálohu. V prípade, ak chcete odtrániť viacero záloh, mali by ste ich odstrániť manuálne." +} From 9fedab738ffc61481bd68c2d8259c9159fa9a1de Mon Sep 17 00:00:00 2001 From: peter cerny Date: Sun, 16 Mar 2025 14:42:24 +0100 Subject: [PATCH 25/38] Translated using Weblate (Slovak) Currently translated at 7.7% (84 of 1090 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sk/ --- client/strings/sk.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/sk.json b/client/strings/sk.json index ee8184fb..39936c53 100644 --- a/client/strings/sk.json +++ b/client/strings/sk.json @@ -79,6 +79,8 @@ "ButtonRemoveAllLibraryItems": "Odstrániť všetky položky knižnice", "ButtonRemoveFromContinueListening": "Odstrániť z nedokončených podcastov", "ButtonRemoveFromContinueReading": "Odtrániť z nedokončených audiokníh", + "ButtonRemoveSeriesFromContinueSeries": "Odstrániť z nedokončených sérií", + "ButtonReset": "Resetovať", "HeaderMatch": "Spárovať", "LabelBackupsNumberToKeepHelp": "Týmto spôsobom odstránite vždy iba jednu zálohu. V prípade, ak chcete odtrániť viacero záloh, mali by ste ich odstrániť manuálne." } From e76fbda9e0641717175a45ffd404416395b709ee Mon Sep 17 00:00:00 2001 From: peter cerny Date: Sun, 16 Mar 2025 14:44:04 +0100 Subject: [PATCH 26/38] Translated using Weblate (Slovak) Currently translated at 8.3% (91 of 1090 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sk/ --- client/strings/sk.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/strings/sk.json b/client/strings/sk.json index 39936c53..ed13e9a4 100644 --- a/client/strings/sk.json +++ b/client/strings/sk.json @@ -81,6 +81,13 @@ "ButtonRemoveFromContinueReading": "Odtrániť z nedokončených audiokníh", "ButtonRemoveSeriesFromContinueSeries": "Odstrániť z nedokončených sérií", "ButtonReset": "Resetovať", + "ButtonResetToDefault": "Resetovať do predvolené", + "ButtonRestore": "Obnoviť zo zálohy", + "ButtonSave": "Uložiť", + "ButtonSaveAndClose": "Uložiť a zavrieť", + "ButtonSaveTracklist": "Uložiť zoznam", + "ButtonScan": "Skenovať", + "ButtonScanLibrary": "Skenovať knižnicu", "HeaderMatch": "Spárovať", "LabelBackupsNumberToKeepHelp": "Týmto spôsobom odstránite vždy iba jednu zálohu. V prípade, ak chcete odtrániť viacero záloh, mali by ste ich odstrániť manuálne." } From 8fa733e144c15f1fb5afd0b8db3096536b881aeb Mon Sep 17 00:00:00 2001 From: "J. Lavoie" Date: Mon, 17 Mar 2025 02:11:23 +0100 Subject: [PATCH 27/38] Translated using Weblate (French) Currently translated at 99.3% (1083 of 1090 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index c8e02b07..64cd3972 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -76,7 +76,7 @@ "ButtonReadLess": "Lire moins", "ButtonReadMore": "Lire la suite", "ButtonRefresh": "Rafraîchir", - "ButtonRemove": "Supprimer", + "ButtonRemove": "Retirer", "ButtonRemoveAll": "Supprimer tout", "ButtonRemoveAllLibraryItems": "Supprimer tous les éléments de la bibliothèque", "ButtonRemoveFromContinueListening": "Ne plus continuer à écouter", From 6c968bfca49fdcfec3523c7ffc0980199ced9e62 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Mon, 17 Mar 2025 13:13:57 +0100 Subject: [PATCH 28/38] Translated using Weblate (Slovenian) Currently translated at 100.0% (1090 of 1090 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/sl.json b/client/strings/sl.json index 8329dba6..1996c33f 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -558,6 +558,8 @@ "LabelSettingsBookshelfViewHelp": "Skeuomorfna oblika z lesenimi policami", "LabelSettingsChromecastSupport": "Podpora za Chromecast", "LabelSettingsDateFormat": "Oblika datuma", + "LabelSettingsEnableWatcher": "Samodejno preglej knjižnice za spremembe", + "LabelSettingsEnableWatcherForLibrary": "Samodejno preglej knjižnico za spremembe", "LabelSettingsEnableWatcherHelp": "Omogoča samodejno dodajanje/posodabljanje elementov, ko so zaznane spremembe datoteke. *Potreben je ponovni zagon strežnika", "LabelSettingsEpubsAllowScriptedContent": "Dovoli skriptirano vsebino v epubih", "LabelSettingsEpubsAllowScriptedContentHelp": "Dovoli datotekam epub izvajanje skript. Priporočljivo je, da to nastavitev pustite onemogočeno, razen če zaupate viru datotek epub.", From 0123dacb292ba73bbc9f8bfe73d2e94e82c1fadb Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Mon, 17 Mar 2025 19:35:59 +0100 Subject: [PATCH 29/38] download multiple items --- server/controllers/LibraryController.js | 48 +++++++++++++++- server/routers/ApiRouter.js | 1 + server/utils/zipHelpers.js | 73 +++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 3585dc51..f51b4974 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -23,6 +23,7 @@ const RssFeedManager = require('../managers/RssFeedManager') const libraryFilters = require('../utils/queries/libraryFilters') const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters') const authorFilters = require('../utils/queries/authorFilters') +const zipHelpers = require('../utils/zipHelpers') /** * @typedef RequestUserObject @@ -528,7 +529,7 @@ class LibraryController { Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to delete library`) return res.sendStatus(403) } - + // Remove library watcher Watcher.removeLibrary(req.library) @@ -1419,6 +1420,51 @@ class LibraryController { }) } + /** + * GET: /api/library/:id/download + * Downloads multiple library items + * + * @param {LibraryItemControllerRequest} req + * @param {Response} res + */ + async downloadMultiple(req, res) { + if (!req.user.canDownload) { + Logger.warn(`User "${req.user.username}" attempted to download without permission`) + return res.sendStatus(403) + } + + if(req.query.ids === undefined) { + res.status(400).send('Library not found') + } + + const itemIds = req.query.ids.split(',') + + const libraryItems = await Database.libraryItemModel.findAll({ + attributes: ['id', 'libraryId', 'path'], + where: { + id: itemIds, + libraryId: req.params.id, + mediaType: 'book' + } + }) + + Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for items "${itemIds}"`) + + const filename = `LibraryItems-${Date.now()}.zip` + const libraryItemPaths = libraryItems.map((li) => li.path) + + console.log(libraryItemPaths) + + try { + await zipHelpers.zipDirectoriesPipe(libraryItemPaths, filename, res) + Logger.info(`[LibraryItemController] Downloaded item "${filename}" at "${libraryItemPaths}"`) + } catch (error) { + Logger.error(`[LibraryItemController] Download failed for item "${filename}" at "${libraryItemPaths}"`, error) + //LibraryItemController.handleDownloadError(error, res) + } + } + + /** * * @param {RequestWithUser} req diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 6779d0af..78a5291d 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -94,6 +94,7 @@ class ApiRouter { this.router.post('/libraries/order', LibraryController.reorder.bind(this)) this.router.post('/libraries/:id/remove-metadata', LibraryController.middleware.bind(this), LibraryController.removeAllMetadataFiles.bind(this)) this.router.get('/libraries/:id/podcast-titles', LibraryController.middleware.bind(this), LibraryController.getPodcastTitles.bind(this)) + this.router.get('/libraries/:id/download', LibraryController.middleware.bind(this), LibraryController.downloadMultiple.bind(this)) // // Item Routes diff --git a/server/utils/zipHelpers.js b/server/utils/zipHelpers.js index 44b65296..1145f3a0 100644 --- a/server/utils/zipHelpers.js +++ b/server/utils/zipHelpers.js @@ -1,5 +1,6 @@ const Logger = require('../Logger') const archiver = require('../libs/archiver') +const { lstatSync } = require('node:fs') module.exports.zipDirectoryPipe = (path, filename, res) => { return new Promise((resolve, reject) => { @@ -50,3 +51,75 @@ module.exports.zipDirectoryPipe = (path, filename, res) => { archive.finalize() }) } + +/** + * Creates a zip archive containing multiple directories and streams it to the response. + * + * @param {string[]} paths - Array of directory paths to include in the zip archive. + * @param {string} filename - Name of the zip file to be sent as attachment. + * @param {object} res - Response object to pipe the archive data to. + * @returns {Promise} - Promise that resolves when the zip operation completes. + */ +module.exports.zipDirectoriesPipe = (paths, filename, res) => { + return new Promise((resolve, reject) => { + // create a file to stream archive data to + res.attachment(filename) + + const archive = archiver('zip', { + zlib: { level: 0 } // Sets the compression level. + }) + + // listen for all archive data to be written + // 'close' event is fired only when a file descriptor is involved + res.on('close', () => { + Logger.info(archive.pointer() + ' total bytes') + Logger.debug('archiver has been finalized and the output file descriptor has closed.') + resolve() + }) + + // This event is fired when the data source is drained no matter what was the data source. + // It is not part of this library but rather from the NodeJS Stream API. + // @see: https://nodejs.org/api/stream.html#stream_event_end + res.on('end', () => { + Logger.debug('Data has been drained') + }) + + // good practice to catch warnings (ie stat failures and other non-blocking errors) + archive.on('warning', function (err) { + if (err.code === 'ENOENT') { + // log warning + Logger.warn(`[DownloadManager] Archiver warning: ${err.message}`) + } else { + // throw error + Logger.error(`[DownloadManager] Archiver error: ${err.message}`) + // throw err + reject(err) + } + }) + archive.on('error', function (err) { + Logger.error(`[DownloadManager] Archiver error: ${err.message}`) + reject(err) + }) + + // pipe archive data to the file + archive.pipe(res) + + // Add each path as a directory in the zip + paths.forEach(path => { + + const paths = path.split('/') + + // Check if path is file or directory + if (lstatSync(path).isDirectory()) { + const dirName = path.split('/').pop() + + // Add the directory to the archive with its name as the root folder + archive.directory(path, dirName); + } else { + archive.file(path, { name: paths[paths.length - 1] }); + } + }); + + archive.finalize() + }) +} From 0a9a846a332480eeb08bb8f999960e6432c16b99 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Mon, 17 Mar 2025 19:56:42 +0100 Subject: [PATCH 30/38] added download to frontend --- client/components/app/Appbar.vue | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue index bb452526..6fd2bc3e 100644 --- a/client/components/app/Appbar.vue +++ b/client/components/app/Appbar.vue @@ -180,6 +180,15 @@ export default { action: 'rescan' }) + // The limit of 50 is introduced because of the URL length. Each id has 36 chars, so 36 * 40 = 1440 + // + 40 , separators = 1480 chars + base path 280 chars = 1760 chars. This keeps the URL under 2000 chars even with longer domains + if(this.selectedMediaItems.length <= 40) { + options.push({ + text: this.$strings.LabelDownload, + action: 'download' + }) + } + return options } }, @@ -215,6 +224,8 @@ export default { this.batchAutoMatchClick() } else if (action === 'rescan') { this.batchRescan() + } else if (action === 'download') { + this.batchDownload() } }, async batchRescan() { @@ -241,6 +252,11 @@ export default { } this.$store.commit('globals/setConfirmPrompt', payload) }, + async batchDownload() { + const libraryItemIds = this.selectedMediaItems.map((i) => i.id) + console.log('Downloading library items', libraryItemIds) + this.$downloadFile(`/api/libraries/${this.$store.state.libraries.currentLibraryId}/download?token=${this.$store.getters['user/getToken']}&ids=${libraryItemIds.join(',')}`, null, true) + }, async playSelectedItems() { this.$store.commit('setProcessingBatch', true) From 9b79aab4d5b8604f8535d32340b965d65f665818 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Mon, 17 Mar 2025 19:58:55 +0100 Subject: [PATCH 31/38] logging --- server/controllers/LibraryController.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index f51b4974..5ca80115 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -1452,15 +1452,24 @@ class LibraryController { const filename = `LibraryItems-${Date.now()}.zip` const libraryItemPaths = libraryItems.map((li) => li.path) - - console.log(libraryItemPaths) + try { await zipHelpers.zipDirectoriesPipe(libraryItemPaths, filename, res) Logger.info(`[LibraryItemController] Downloaded item "${filename}" at "${libraryItemPaths}"`) } catch (error) { Logger.error(`[LibraryItemController] Download failed for item "${filename}" at "${libraryItemPaths}"`, error) - //LibraryItemController.handleDownloadError(error, res) + LibraryController.handleDownloadError(error, res) + } + } + + static handleDownloadError(error, res) { + if (!res.headersSent) { + if (error.code === 'ENOENT') { + return res.status(404).send('File not found') + } else { + return res.status(500).send('Download failed') + } } } From 3c9966e84917196fac1578d5519662339effeb2a Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Mon, 17 Mar 2025 20:04:01 +0100 Subject: [PATCH 32/38] clean up --- server/controllers/LibraryController.js | 22 ++++++++-------------- server/utils/zipHelpers.js | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 5ca80115..673c5cf2 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -1433,8 +1433,8 @@ class LibraryController { return res.sendStatus(403) } - if(req.query.ids === undefined) { - res.status(400).send('Library not found') + if(req.query.ids === undefined || req.query.ids === '') { + res.status(400).send('Library items not found') } const itemIds = req.query.ids.split(',') @@ -1452,24 +1452,18 @@ class LibraryController { const filename = `LibraryItems-${Date.now()}.zip` const libraryItemPaths = libraryItems.map((li) => li.path) - + + if (!libraryItemPaths.length) { + Logger.warn(`[LibraryItemController] No library items found for ids "${itemIds}"`) + return res.status(404).send('Library items not found') + } try { await zipHelpers.zipDirectoriesPipe(libraryItemPaths, filename, res) Logger.info(`[LibraryItemController] Downloaded item "${filename}" at "${libraryItemPaths}"`) } catch (error) { Logger.error(`[LibraryItemController] Download failed for item "${filename}" at "${libraryItemPaths}"`, error) - LibraryController.handleDownloadError(error, res) - } - } - - static handleDownloadError(error, res) { - if (!res.headersSent) { - if (error.code === 'ENOENT') { - return res.status(404).send('File not found') - } else { - return res.status(500).send('Download failed') - } + zipHelpers.handleDownloadError(error, res) } } diff --git a/server/utils/zipHelpers.js b/server/utils/zipHelpers.js index 1145f3a0..6849df5d 100644 --- a/server/utils/zipHelpers.js +++ b/server/utils/zipHelpers.js @@ -123,3 +123,20 @@ module.exports.zipDirectoriesPipe = (paths, filename, res) => { archive.finalize() }) } + +/** + * Handles errors that occur during the download process. + * + * @param error + * @param res + * @returns {*} + */ +module.exports.handleDownloadError = (error, res) => { + if (!res.headersSent) { + if (error.code === 'ENOENT') { + return res.status(404).send('File not found') + } else { + return res.status(500).send('Download failed') + } + } +} From 40504da4d7f8318e1827f464867ce5f9c266d2fd Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 18 Mar 2025 00:09:49 +0200 Subject: [PATCH 33/38] Improve book library page query performance for author sort order (#4080) * Add migration to create authorNames* columns, in libraryItems including update triggers and indices * Add authorNames columns and indices to LibraryItem model * Add database triggers for updating author names in libraryItems (for new databases) * Populate authorNames during book scanning * Update book sorting to use new authorNames columns * Add an index on podcastEpisodes.publishedAt * Fix group_concat order by and update to sqlite 3.44.2 --------- Co-authored-by: advplyr --- package-lock.json | 536 +++++++++++++----- package.json | 2 +- server/Database.js | 69 +++ server/migrations/changelog.md | 1 + .../v2.20.0-improve-author-sort-queries.js | 272 +++++++++ server/models/LibraryItem.js | 14 +- server/models/PodcastEpisode.js | 4 + server/scanner/BookScanner.js | 2 + .../utils/queries/libraryItemsBookFilters.js | 15 +- .../queries/libraryItemsPodcastFilters.js | 8 +- ...2.20.0-improve-author-sort-queries.test.js | 361 ++++++++++++ 11 files changed, 1133 insertions(+), 151 deletions(-) create mode 100644 server/migrations/v2.20.0-improve-author-sort-queries.js create mode 100644 test/server/migrations/v2.20.0-improve-author-sort-queries.test.js diff --git a/package-lock.json b/package-lock.json index 26ea6578..f3d83f73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "semver": "^7.6.3", "sequelize": "^6.35.2", "socket.io": "^4.5.4", - "sqlite3": "^5.1.6", + "sqlite3": "^5.1.7", "ssrf-req-filter": "^1.1.0", "xml2js": "^0.5.0" }, @@ -587,39 +587,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@mapbox/node-pre-gyp": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz", - "integrity": "sha512-4ySo4CjzStuprMwk35H5pPbkymjv1SF3jGLj6rAHp/xT/RF7TL7bd9CTm1xDY49K2qF7jmR/g7k+SkLETP6opA==", - "dependencies": { - "detect-libc": "^2.0.0", - "https-proxy-agent": "^5.0.0", - "make-dir": "^3.1.0", - "node-fetch": "^2.6.7", - "nopt": "^5.0.0", - "npmlog": "^5.0.1", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.11" - }, - "bin": { - "node-pre-gyp": "bin/node-pre-gyp" - } - }, - "node_modules/@mapbox/node-pre-gyp/node_modules/nopt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", - "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "dependencies": { - "abbrev": "1" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/@npmcli/fs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", @@ -741,7 +708,8 @@ "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "devOptional": true }, "node_modules/accepts": { "version": "1.3.8", @@ -759,6 +727,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "optional": true, "dependencies": { "debug": "4" }, @@ -770,6 +739,7 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "optional": true, "dependencies": { "ms": "2.1.2" }, @@ -785,7 +755,8 @@ "node_modules/agent-base/node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "optional": true }, "node_modules/agentkeepalive": { "version": "4.3.0", @@ -850,6 +821,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "devOptional": true, "engines": { "node": ">=8" } @@ -897,7 +869,8 @@ "node_modules/aproba": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", - "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", + "optional": true }, "node_modules/archy": { "version": "1.0.0", @@ -905,18 +878,6 @@ "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", "dev": true }, - "node_modules/are-we-there-yet": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", - "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -957,7 +918,28 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "devOptional": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" }, "node_modules/base64id": { "version": "2.0.0", @@ -976,6 +958,26 @@ "node": ">=8" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", @@ -1003,6 +1005,7 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "devOptional": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1058,6 +1061,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -1326,6 +1353,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "optional": true, "bin": { "color-support": "bin.js" } @@ -1350,12 +1378,14 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "devOptional": true }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "optional": true }, "node_modules/content-disposition": { "version": "0.5.4", @@ -1461,6 +1491,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-eql": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", @@ -1473,6 +1518,15 @@ "node": ">=6" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/default-require-extensions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz", @@ -1499,7 +1553,8 @@ "node_modules/delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "optional": true }, "node_modules/depd": { "version": "2.0.0", @@ -1519,9 +1574,10 @@ } }, "node_modules/detect-libc": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz", - "integrity": "sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "license": "Apache-2.0", "engines": { "node": ">=8" } @@ -1613,7 +1669,8 @@ "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "devOptional": true }, "node_modules/encodeurl": { "version": "1.0.2", @@ -1644,6 +1701,15 @@ "node": ">=0.10.0" } }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/engine.io": { "version": "6.5.4", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.4.tgz", @@ -1777,6 +1843,15 @@ "node": ">= 0.6" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/express": { "version": "4.18.2", "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", @@ -1844,6 +1919,12 @@ "node": ">= 0.6" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -1993,6 +2074,12 @@ } ] }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -2007,7 +2094,8 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "devOptional": true }, "node_modules/function-bind": { "version": "1.1.2", @@ -2017,25 +2105,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gauge": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", - "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.2", - "console-control-strings": "^1.0.0", - "has-unicode": "^2.0.1", - "object-assign": "^4.1.1", - "signal-exit": "^3.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.2" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2085,10 +2154,17 @@ "node": ">=8.0.0" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "devOptional": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -2164,7 +2240,8 @@ "node_modules/has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "optional": true }, "node_modules/hasha": { "version": "5.2.2", @@ -2277,6 +2354,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "optional": true, "dependencies": { "agent-base": "6", "debug": "4" @@ -2289,6 +2367,7 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "optional": true, "dependencies": { "ms": "2.1.2" }, @@ -2304,7 +2383,8 @@ "node_modules/https-proxy-agent/node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "optional": true }, "node_modules/humanize-ms": { "version": "1.2.1", @@ -2326,6 +2406,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore-by-default": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", @@ -2368,6 +2468,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "devOptional": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -2378,6 +2479,12 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/ip": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", @@ -2417,6 +2524,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "devOptional": true, "engines": { "node": ">=8" } @@ -2885,6 +2993,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, "dependencies": { "semver": "^6.0.0" }, @@ -2899,6 +3008,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -2993,10 +3103,23 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "devOptional": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -3004,6 +3127,15 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", @@ -3103,6 +3235,12 @@ "node": ">=10" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/mocha": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.2.0.tgz", @@ -3399,6 +3537,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -3438,30 +3582,24 @@ "isarray": "0.0.1" } }, - "node_modules/node-addon-api": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", - "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==" - }, - "node_modules/node-fetch": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", - "integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==", + "node_modules/node-abi": { + "version": "3.74.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.74.0.tgz", + "integrity": "sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==", + "license": "MIT", "dependencies": { - "whatwg-url": "^5.0.0" + "semver": "^7.3.5" }, "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } + "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-gyp": { "version": "8.4.1", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", @@ -3658,17 +3796,6 @@ "node": ">=0.10.0" } }, - "node_modules/npmlog": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", - "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", - "dependencies": { - "are-we-there-yet": "^2.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^3.0.0", - "set-blocking": "^2.0.0" - } - }, "node_modules/nyc": { "version": "15.1.0", "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz", @@ -3962,6 +4089,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -4029,6 +4157,32 @@ "node": ">=8" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/process-on-spawn": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", @@ -4078,6 +4232,16 @@ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", "dev": true }, + "node_modules/pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -4131,6 +4295,30 @@ "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -4210,6 +4398,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "devOptional": true, "dependencies": { "glob": "^7.1.3" }, @@ -4404,7 +4593,8 @@ "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "devOptional": true }, "node_modules/setprototypeof": { "version": "1.2.0", @@ -4448,7 +4638,53 @@ "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "devOptional": true + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } }, "node_modules/simple-update-notifier": { "version": "1.1.0", @@ -4701,13 +4937,15 @@ "dev": true }, "node_modules/sqlite3": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.6.tgz", - "integrity": "sha512-olYkWoKFVNSSSQNvxVUfjiVbz3YtBwTJj+mfV5zpHmqW3sELx2Cf4QCdirMelhM5Zh+KDVaKgQHqCxrqiWHybw==", + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", + "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", "hasInstallScript": true, + "license": "BSD-3-Clause", "dependencies": { - "@mapbox/node-pre-gyp": "^1.0.0", - "node-addon-api": "^4.2.0", + "bindings": "^1.5.0", + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1", "tar": "^6.1.11" }, "optionalDependencies": { @@ -4770,6 +5008,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "devOptional": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -4783,6 +5022,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "devOptional": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -4839,6 +5079,40 @@ "node": ">=10" } }, + "node_modules/tar-fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", + "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tar/node_modules/minipass": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", @@ -4907,10 +5181,17 @@ "nodetouch": "bin/nodetouch.js" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } }, "node_modules/type-detect": { "version": "4.0.8", @@ -5061,20 +5342,6 @@ "node": ">= 0.8" } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5100,6 +5367,7 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "optional": true, "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } diff --git a/package.json b/package.json index 639393d6..beb96b22 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "semver": "^7.6.3", "sequelize": "^6.35.2", "socket.io": "^4.5.4", - "sqlite3": "^5.1.6", + "sqlite3": "^5.1.7", "ssrf-req-filter": "^1.1.0", "xml2js": "^0.5.0" }, diff --git a/server/Database.js b/server/Database.js index 498e9e5e..52827e3f 100644 --- a/server/Database.js +++ b/server/Database.js @@ -782,6 +782,7 @@ class Database { await this.addTriggerIfNotExists('books', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId') await this.addTriggerIfNotExists('podcasts', 'title', 'id', 'libraryItems', 'title', 'mediaId') await this.addTriggerIfNotExists('podcasts', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId') + await this.addAuthorNamesTriggersIfNotExist() } async addTriggerIfNotExists(sourceTable, sourceColumn, sourceIdColumn, targetTable, targetColumn, targetIdColumn) { @@ -806,6 +807,74 @@ class Database { `) } + async addAuthorNamesTriggersIfNotExist() { + const libraryItems = 'libraryItems' + const bookAuthors = 'bookAuthors' + const authors = 'authors' + const columns = [ + { name: 'authorNamesFirstLast', source: `${authors}.name`, spec: { type: Sequelize.STRING, allowNull: true } }, + { name: 'authorNamesLastFirst', source: `${authors}.lastFirst`, spec: { type: Sequelize.STRING, allowNull: true } } + ] + const authorsSort = `${bookAuthors}.createdAt ASC` + const columnNames = columns.map((column) => column.name).join(', ') + const columnSourcesExpression = columns.map((column) => `GROUP_CONCAT(${column.source}, ', ' ORDER BY ${authorsSort})`).join(', ') + const authorsJoin = `${authors} JOIN ${bookAuthors} ON ${authors}.id = ${bookAuthors}.authorId` + + const addBookAuthorsTriggerIfNotExists = async (action) => { + const modifiedRecord = action === 'delete' ? 'OLD' : 'NEW' + const triggerName = this.convertToSnakeCase(`update_${libraryItems}_authorNames_on_${bookAuthors}_${action}`) + const authorNamesSubQuery = ` + SELECT ${columnSourcesExpression} + FROM ${authorsJoin} + WHERE ${bookAuthors}.bookId = ${modifiedRecord}.bookId + ` + const [[{ count }]] = await this.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='${triggerName}'`) + if (count > 0) return // Trigger already exists + + Logger.info(`[Database] Adding trigger ${triggerName}`) + + await this.sequelize.query(` + CREATE TRIGGER ${triggerName} + AFTER ${action} ON ${bookAuthors} + FOR EACH ROW + BEGIN + UPDATE ${libraryItems} + SET (${columnNames}) = (${authorNamesSubQuery}) + WHERE mediaId = ${modifiedRecord}.bookId; + END; + `) + } + + const addAuthorsUpdateTriggerIfNotExists = async () => { + const triggerName = this.convertToSnakeCase(`update_${libraryItems}_authorNames_on_authors_update`) + const authorNamesSubQuery = ` + SELECT ${columnSourcesExpression} + FROM ${authorsJoin} + WHERE ${bookAuthors}.bookId = ${libraryItems}.mediaId + ` + + const [[{ count }]] = await this.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='${triggerName}'`) + if (count > 0) return // Trigger already exists + + Logger.info(`[Database] Adding trigger ${triggerName}`) + + await this.sequelize.query(` + CREATE TRIGGER ${triggerName} + AFTER UPDATE OF name ON ${authors} + FOR EACH ROW + BEGIN + UPDATE ${libraryItems} + SET (${columnNames}) = (${authorNamesSubQuery}) + WHERE mediaId IN (SELECT bookId FROM ${bookAuthors} WHERE authorId = NEW.id); + END; + `) + } + + await addBookAuthorsTriggerIfNotExists('insert') + await addBookAuthorsTriggerIfNotExists('delete') + await addAuthorsUpdateTriggerIfNotExists() + } + convertToSnakeCase(str) { return str.replace(/([A-Z])/g, '_$1').toLowerCase() } diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index b447970f..0fcbe675 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -15,3 +15,4 @@ Please add a record of every database migration that you create to this file. Th | v2.17.7 | v2.17.7-add-indices | Adds indices to the libraryItems and books tables to reduce query times | | v2.19.1 | v2.19.1-copy-title-to-library-items | Copies title and titleIgnorePrefix to the libraryItems table, creates update triggers and indices | | v2.19.4 | v2.19.4-improve-podcast-queries | Adds numEpisodes to podcasts, adds podcastId to mediaProgresses, copies podcast title to libraryItems | +| v2.20.0 | v2.20.0-improve-author-sort-queries | Adds AuthorNames(FirstLast\|LastFirst) to libraryItems to improve author sort queries | diff --git a/server/migrations/v2.20.0-improve-author-sort-queries.js b/server/migrations/v2.20.0-improve-author-sort-queries.js new file mode 100644 index 00000000..53016c70 --- /dev/null +++ b/server/migrations/v2.20.0-improve-author-sort-queries.js @@ -0,0 +1,272 @@ +const util = require('util') +const { Sequelize } = require('sequelize') + +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.20.0' +const migrationName = `${migrationVersion}-improve-author-sort-queries` +const loggerPrefix = `[${migrationVersion} migration]` + +// Migration constants +const libraryItems = 'libraryItems' +const bookAuthors = 'bookAuthors' +const authors = 'authors' +const podcastEpisodes = 'podcastEpisodes' +const columns = [ + { name: 'authorNamesFirstLast', source: `${authors}.name`, spec: { type: Sequelize.STRING, allowNull: true } }, + { name: 'authorNamesLastFirst', source: `${authors}.lastFirst`, spec: { type: Sequelize.STRING, allowNull: true } } +] +const authorsSort = `${bookAuthors}.createdAt ASC` +const columnNames = columns.map((column) => column.name).join(', ') +const columnSourcesExpression = columns.map((column) => `GROUP_CONCAT(${column.source}, ', ' ORDER BY ${authorsSort})`).join(', ') +const authorsJoin = `${authors} JOIN ${bookAuthors} ON ${authors}.id = ${bookAuthors}.authorId` + +/** + * This upward migration adds an authorNames column to the libraryItems table and populates it. + * It also creates triggers to update the authorNames column when the corresponding bookAuthors and authors records are updated. + * It also creates an index on the authorNames column. + * + * It also adds an index on publishedAt to the podcastEpisodes table. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + const helper = new MigrationHelper(queryInterface, logger) + + // Upwards migration script + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + // Add authorNames columns to libraryItems table + await helper.addColumns() + + // Populate authorNames columns with the author names for each libraryItem + await helper.populateColumnsFromSource() + + // Create triggers to update the authorNames column when the corresponding bookAuthors and authors records are updated + await helper.addTriggers() + + // Create indexes on the authorNames columns + await helper.addIndexes() + + // Add index on publishedAt to the podcastEpisodes table + await helper.addIndex(podcastEpisodes, ['publishedAt']) + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This downward migration removes the authorNames column from the libraryItems table, + * the triggers on the bookAuthors and authors tables, and the index on the authorNames column. + * + * It also removes the index on publishedAt from the podcastEpisodes table. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + // Downward migration script + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + const helper = new MigrationHelper(queryInterface, logger) + + // Remove triggers to update authorNames columns + await helper.removeTriggers() + + // Remove index on publishedAt from the podcastEpisodes table + await helper.removeIndex(podcastEpisodes, ['publishedAt']) + + // Remove indexes on the authorNames columns + await helper.removeIndexes() + + // Remove authorNames columns from libraryItems table + await helper.removeColumns() + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +class MigrationHelper { + constructor(queryInterface, logger) { + this.queryInterface = queryInterface + this.logger = logger + } + + async addColumn(table, column, options) { + this.logger.info(`${loggerPrefix} adding column "${column}" to table "${table}"`) + const tableDescription = await this.queryInterface.describeTable(table) + if (!tableDescription[column]) { + await this.queryInterface.addColumn(table, column, options) + this.logger.info(`${loggerPrefix} added column "${column}" to table "${table}"`) + } else { + this.logger.info(`${loggerPrefix} column "${column}" already exists in table "${table}"`) + } + } + + async addColumns() { + this.logger.info(`${loggerPrefix} adding ${columnNames} columns to ${libraryItems} table`) + for (const column of columns) { + await this.addColumn(libraryItems, column.name, column.spec) + } + this.logger.info(`${loggerPrefix} added ${columnNames} columns to ${libraryItems} table`) + } + + async removeColumn(table, column) { + this.logger.info(`${loggerPrefix} removing column "${column}" from table "${table}"`) + const tableDescription = await this.queryInterface.describeTable(table) + if (tableDescription[column]) { + await this.queryInterface.sequelize.query(`ALTER TABLE ${table} DROP COLUMN ${column}`) + this.logger.info(`${loggerPrefix} removed column "${column}" from table "${table}"`) + } else { + this.logger.info(`${loggerPrefix} column "${column}" does not exist in table "${table}"`) + } + } + + async removeColumns() { + this.logger.info(`${loggerPrefix} removing ${columnNames} columns from ${libraryItems} table`) + for (const column of columns) { + await this.removeColumn(libraryItems, column.name) + } + this.logger.info(`${loggerPrefix} removed ${columnNames} columns from ${libraryItems} table`) + } + + async populateColumnsFromSource() { + this.logger.info(`${loggerPrefix} populating ${columnNames} columns in ${libraryItems} table`) + const authorNamesSubQuery = ` + SELECT ${columnSourcesExpression} + FROM ${authorsJoin} + WHERE ${bookAuthors}.bookId = ${libraryItems}.mediaId + ` + await this.queryInterface.sequelize.query(` + UPDATE ${libraryItems} + SET (${columnNames}) = (${authorNamesSubQuery}) + WHERE mediaType = 'book'; + `) + this.logger.info(`${loggerPrefix} populated ${columnNames} columns in ${libraryItems} table`) + } + + async addBookAuthorsTrigger(action) { + this.logger.info(`${loggerPrefix} adding trigger to update ${libraryItems} ${columnNames} on ${bookAuthors} ${action}`) + const modifiedRecord = action === 'delete' ? 'OLD' : 'NEW' + const triggerName = convertToSnakeCase(`update_${libraryItems}_authorNames_on_${bookAuthors}_${action}`) + const authorNamesSubQuery = ` + SELECT ${columnSourcesExpression} + FROM ${authorsJoin} + WHERE ${bookAuthors}.bookId = ${modifiedRecord}.bookId + ` + await this.queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`) + + await this.queryInterface.sequelize.query(` + CREATE TRIGGER ${triggerName} + AFTER ${action} ON ${bookAuthors} + FOR EACH ROW + BEGIN + UPDATE ${libraryItems} + SET (${columnNames}) = (${authorNamesSubQuery}) + WHERE mediaId = ${modifiedRecord}.bookId; + END; + `) + this.logger.info(`${loggerPrefix} added trigger to update ${libraryItems} ${columnNames} on ${bookAuthors} ${action}`) + } + + async addAuthorsUpdateTrigger() { + this.logger.info(`${loggerPrefix} adding trigger to update ${libraryItems} ${columnNames} on ${authors} update`) + const triggerName = convertToSnakeCase(`update_${libraryItems}_authorNames_on_authors_update`) + const authorNamesSubQuery = ` + SELECT ${columnSourcesExpression} + FROM ${authorsJoin} + WHERE ${bookAuthors}.bookId = ${libraryItems}.mediaId + ` + + await this.queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`) + + await this.queryInterface.sequelize.query(` + CREATE TRIGGER ${triggerName} + AFTER UPDATE OF name ON ${authors} + FOR EACH ROW + BEGIN + UPDATE ${libraryItems} + SET (${columnNames}) = (${authorNamesSubQuery}) + WHERE mediaId IN (SELECT bookId FROM ${bookAuthors} WHERE authorId = NEW.id); + END; + `) + this.logger.info(`${loggerPrefix} added trigger to update ${libraryItems} ${columnNames} on ${authors} update`) + } + + async addTriggers() { + await this.addBookAuthorsTrigger('insert') + await this.addBookAuthorsTrigger('delete') + await this.addAuthorsUpdateTrigger() + } + + async removeBookAuthorsTrigger(action) { + this.logger.info(`${loggerPrefix} removing trigger to update ${libraryItems} ${columnNames} on ${bookAuthors} ${action}`) + const triggerName = convertToSnakeCase(`update_${libraryItems}_authorNames_on_${bookAuthors}_${action}`) + await this.queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`) + this.logger.info(`${loggerPrefix} removed trigger to update ${libraryItems} ${columnNames} on ${bookAuthors} ${action}`) + } + + async removeAuthorsUpdateTrigger() { + this.logger.info(`${loggerPrefix} removing trigger to update ${libraryItems} ${columnNames} on ${authors} update`) + const triggerName = convertToSnakeCase(`update_${libraryItems}_authorNames_on_authors_update`) + await this.queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`) + this.logger.info(`${loggerPrefix} removed trigger to update ${libraryItems} ${columnNames} on ${authors} update`) + } + + async removeTriggers() { + await this.removeBookAuthorsTrigger('insert') + await this.removeBookAuthorsTrigger('delete') + await this.removeAuthorsUpdateTrigger() + } + + async addIndex(tableName, columns) { + const columnString = columns.map((column) => util.inspect(column)).join(', ') + const indexName = convertToSnakeCase(`${tableName}_${columns.map((column) => (typeof column === 'string' ? column : column.name)).join('_')}`) + try { + this.logger.info(`${loggerPrefix} adding index on [${columnString}] to table ${tableName}. index name: ${indexName}"`) + await this.queryInterface.addIndex(tableName, columns) + this.logger.info(`${loggerPrefix} added index on [${columnString}] to table ${tableName}. index name: ${indexName}"`) + } catch (error) { + if (error.name === 'SequelizeDatabaseError' && error.message.includes('already exists')) { + this.logger.info(`${loggerPrefix} index [${columnString}] for table "${tableName}" already exists`) + } else { + throw error + } + } + } + + async addIndexes() { + for (const column of columns) { + await this.addIndex(libraryItems, ['libraryId', 'mediaType', { name: column.name, collate: 'NOCASE' }]) + } + } + + async removeIndex(tableName, columns) { + this.logger.info(`${loggerPrefix} removing index [${columns.join(', ')}] from table "${tableName}"`) + await this.queryInterface.removeIndex(tableName, columns) + this.logger.info(`${loggerPrefix} removed index [${columns.join(', ')}] from table "${tableName}"`) + } + + async removeIndexes() { + for (const column of columns) { + await this.removeIndex(libraryItems, ['libraryId', 'mediaType', column.name]) + } + } +} +/** + * Utility function to convert a string to snake case, e.g. "titleIgnorePrefix" -> "title_ignore_prefix" + * + * @param {string} str - the string to convert to snake case. + * @returns {string} - the string in snake case. + */ +function convertToSnakeCase(str) { + return str.replace(/([A-Z])/g, '_$1').toLowerCase() +} + +module.exports = { up, down } diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 5d23bc8f..bf561d5e 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -77,6 +77,10 @@ class LibraryItem extends Model { this.title // Only used for sorting /** @type {string} */ this.titleIgnorePrefix // Only used for sorting + /** @type {string} */ + this.authorNamesFirstLast // Only used for sorting + /** @type {string} */ + this.authorNamesLastFirst // Only used for sorting } /** @@ -683,7 +687,9 @@ class LibraryItem extends Model { libraryFiles: DataTypes.JSON, extraData: DataTypes.JSON, title: DataTypes.STRING, - titleIgnorePrefix: DataTypes.STRING + titleIgnorePrefix: DataTypes.STRING, + authorNamesFirstLast: DataTypes.STRING, + authorNamesLastFirst: DataTypes.STRING }, { sequelize, @@ -710,6 +716,12 @@ class LibraryItem extends Model { { fields: ['libraryId', 'mediaType', { name: 'titleIgnorePrefix', collate: 'NOCASE' }] }, + { + fields: ['libraryId', 'mediaType', { name: 'authorNamesFirstLast', collate: 'NOCASE' }] + }, + { + fields: ['libraryId', 'mediaType', { name: 'authorNamesLastFirst', collate: 'NOCASE' }] + }, { fields: ['libraryId', 'mediaId', 'mediaType'] }, diff --git a/server/models/PodcastEpisode.js b/server/models/PodcastEpisode.js index 4746f315..e6d62916 100644 --- a/server/models/PodcastEpisode.js +++ b/server/models/PodcastEpisode.js @@ -122,6 +122,10 @@ class PodcastEpisode extends Model { { name: 'podcastEpisode_createdAt_podcastId', fields: ['createdAt', 'podcastId'] + }, + { + name: 'podcast_episodes_published_at', + fields: ['publishedAt'] } ] } diff --git a/server/scanner/BookScanner.js b/server/scanner/BookScanner.js index 210f20f9..1ef6cea9 100644 --- a/server/scanner/BookScanner.js +++ b/server/scanner/BookScanner.js @@ -523,6 +523,8 @@ class BookScanner { libraryItemObj.extraData = {} libraryItemObj.title = bookMetadata.title libraryItemObj.titleIgnorePrefix = getTitleIgnorePrefix(bookMetadata.title) + libraryItemObj.authorNamesFirstLast = bookMetadata.authors.join(', ') + libraryItemObj.authorNamesLastFirst = bookMetadata.authors.map((author) => Database.authorModel.getLastFirst(author)).join(', ') // Set isSupplementary flag on ebook library files for (const libraryFile of libraryItemObj.libraryFiles) { diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 7839651b..50872397 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -264,9 +264,9 @@ module.exports = { } else if (sortBy === 'media.metadata.publishedYear') { return [[Sequelize.literal(`CAST(\`book\`.\`publishedYear\` AS INTEGER)`), dir]] } else if (sortBy === 'media.metadata.authorNameLF') { - return [[Sequelize.literal('author_name COLLATE NOCASE'), dir]] + return [[Sequelize.literal('`libraryItem`.`authorNamesLastFirst` COLLATE NOCASE'), dir]] } else if (sortBy === 'media.metadata.authorName') { - return [[Sequelize.literal('author_name COLLATE NOCASE'), dir]] + return [[Sequelize.literal('`libraryItem`.`authorNamesFirstLast` COLLATE NOCASE'), dir]] } else if (sortBy === 'media.metadata.title') { if (collapseseries) { return [[Sequelize.literal('display_title COLLATE NOCASE'), dir]] @@ -397,18 +397,7 @@ module.exports = { const includeRSSFeed = include.includes('rssfeed') const includeMediaItemShare = !!user?.isAdminOrUp && include.includes('share') - // For sorting by author name an additional attribute must be added - // with author names concatenated let bookAttributes = null - if (sortBy === 'media.metadata.authorNameLF') { - bookAttributes = { - include: [[Sequelize.literal(`(SELECT group_concat(lastFirst, ", ") FROM (SELECT a.lastFirst FROM authors AS a, bookAuthors as ba WHERE ba.authorId = a.id AND ba.bookId = book.id ORDER BY ba.createdAt ASC))`), 'author_name']] - } - } else if (sortBy === 'media.metadata.authorName') { - bookAttributes = { - include: [[Sequelize.literal(`(SELECT group_concat(name, ", ") FROM (SELECT a.name FROM authors AS a, bookAuthors as ba WHERE ba.authorId = a.id AND ba.bookId = book.id ORDER BY ba.createdAt ASC))`), 'author_name']] - } - } const libraryItemWhere = { libraryId diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index 6527cfbd..70400f87 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -465,7 +465,7 @@ module.exports = { async getRecentEpisodes(user, library, limit, offset) { const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user) - const episodes = await Database.podcastEpisodeModel.findAll({ + const findOptions = { where: { '$mediaProgresses.isFinished$': { [Sequelize.Op.or]: [null, false] @@ -496,7 +496,11 @@ module.exports = { subQuery: false, limit, offset - }) + } + + const findtAll = process.env.QUERY_PROFILING ? profile(Database.podcastEpisodeModel.findAll.bind(Database.podcastEpisodeModel)) : Database.podcastEpisodeModel.findAll.bind(Database.podcastEpisodeModel) + + const episodes = await findtAll(findOptions) const episodeResults = episodes.map((ep) => { ep.podcast.podcastEpisodes = [] // Not needed diff --git a/test/server/migrations/v2.20.0-improve-author-sort-queries.test.js b/test/server/migrations/v2.20.0-improve-author-sort-queries.test.js new file mode 100644 index 00000000..76cf704a --- /dev/null +++ b/test/server/migrations/v2.20.0-improve-author-sort-queries.test.js @@ -0,0 +1,361 @@ +const chai = require('chai') +const sinon = require('sinon') +const { expect } = chai + +const { DataTypes, Sequelize } = require('sequelize') +const Logger = require('../../../server/Logger') + +const { up, down } = require('../../../server/migrations/v2.20.0-improve-author-sort-queries') + +const normalizeWhitespaceAndBackticks = (str) => str.replace(/\s+/g, ' ').trim().replace(/`/g, '') + +describe('Migration v2.20.0-improve-author-sort-queries', () => { + let sequelize + let queryInterface + let loggerInfoStub + + beforeEach(async () => { + sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + queryInterface = sequelize.getQueryInterface() + loggerInfoStub = sinon.stub(Logger, 'info') + + await queryInterface.createTable('libraryItems', { + id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true }, + mediaId: { type: DataTypes.INTEGER, allowNull: false }, + mediaType: { type: DataTypes.STRING, allowNull: false }, + libraryId: { type: DataTypes.INTEGER, allowNull: false } + }) + + await queryInterface.createTable('authors', { + id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true }, + name: { type: DataTypes.STRING, allowNull: false }, + lastFirst: { type: DataTypes.STRING, allowNull: false } + }) + + await queryInterface.createTable('bookAuthors', { + id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true }, + bookId: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'libraryItems', key: 'id', onDelete: 'CASCADE' } }, + authorId: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'authors', key: 'id', onDelete: 'CASCADE' } }, + createdAt: { type: DataTypes.DATE, allowNull: false } + }) + + await queryInterface.createTable('podcastEpisodes', { + id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true }, + publishedAt: { type: DataTypes.DATE, allowNull: true } + }) + + await queryInterface.bulkInsert('libraryItems', [ + { id: 1, mediaId: 1, mediaType: 'book', libraryId: 1 }, + { id: 2, mediaId: 2, mediaType: 'book', libraryId: 1 } + ]) + + await queryInterface.bulkInsert('authors', [ + { id: 1, name: 'John Doe', lastFirst: 'Doe, John' }, + { id: 2, name: 'Jane Smith', lastFirst: 'Smith, Jane' }, + { id: 3, name: 'John Smith', lastFirst: 'Smith, John' } + ]) + + await queryInterface.bulkInsert('bookAuthors', [ + { id: 1, bookId: 1, authorId: 1, createdAt: '2025-01-01 00:00:00.000 +00:00' }, + { id: 2, bookId: 2, authorId: 2, createdAt: '2025-01-02 00:00:00.000 +00:00' }, + { id: 3, bookId: 1, authorId: 3, createdAt: '2024-12-31 00:00:00.000 +00:00' } + ]) + + await queryInterface.bulkInsert('podcastEpisodes', [ + { id: 1, publishedAt: '2025-01-01 00:00:00.000 +00:00' }, + { id: 2, publishedAt: '2025-01-02 00:00:00.000 +00:00' }, + { id: 3, publishedAt: '2025-01-03 00:00:00.000 +00:00' } + ]) + }) + + afterEach(() => { + sinon.restore() + }) + + describe('up', () => { + it('should add the authorNamesFirstLast and authorNamesLastFirst columns to the libraryItems table', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + const libraryItems = await queryInterface.describeTable('libraryItems') + expect(libraryItems.authorNamesFirstLast).to.exist + expect(libraryItems.authorNamesLastFirst).to.exist + }) + + it('should populate the authorNamesFirstLast and authorNamesLastFirst columns with the author names for each libraryItem', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + const [libraryItems] = await queryInterface.sequelize.query('SELECT * FROM libraryItems') + expect(libraryItems).to.deep.equal([ + { id: 1, mediaId: 1, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'John Smith, John Doe', authorNamesLastFirst: 'Smith, John, Doe, John' }, + { id: 2, mediaId: 2, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'Jane Smith', authorNamesLastFirst: 'Smith, Jane' } + ]) + }) + + it('should create triggers to update the authorNamesFirstLast and authorNamesLastFirst columns when the corresponding bookAuthors and authors records are updated', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_insert'`) + expect(count).to.equal(1) + + const [[{ sql }]] = await queryInterface.sequelize.query(`SELECT sql FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_insert'`) + expect(normalizeWhitespaceAndBackticks(sql)).to.equal( + normalizeWhitespaceAndBackticks(` + CREATE TRIGGER update_library_items_author_names_on_book_authors_insert + AFTER insert ON bookAuthors + FOR EACH ROW + BEGIN + UPDATE libraryItems + SET (authorNamesFirstLast, authorNamesLastFirst) = ( + SELECT GROUP_CONCAT(authors.name, ', ' ORDER BY bookAuthors.createdAt ASC), GROUP_CONCAT(authors.lastFirst, ', ' ORDER BY bookAuthors.createdAt ASC) + FROM authors JOIN bookAuthors ON authors.id = bookAuthors.authorId + WHERE bookAuthors.bookId = NEW.bookId + ) + WHERE mediaId = NEW.bookId; + END + `) + ) + + const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_delete'`) + expect(count2).to.equal(1) + + const [[{ sql: sql2 }]] = await queryInterface.sequelize.query(`SELECT sql FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_delete'`) + expect(normalizeWhitespaceAndBackticks(sql2)).to.equal( + normalizeWhitespaceAndBackticks(` + CREATE TRIGGER update_library_items_author_names_on_book_authors_delete + AFTER delete ON bookAuthors + FOR EACH ROW + BEGIN + UPDATE libraryItems + SET (authorNamesFirstLast, authorNamesLastFirst) = ( + SELECT GROUP_CONCAT(authors.name, ', ' ORDER BY bookAuthors.createdAt ASC), GROUP_CONCAT(authors.lastFirst, ', ' ORDER BY bookAuthors.createdAt ASC) + FROM authors JOIN bookAuthors ON authors.id = bookAuthors.authorId + WHERE bookAuthors.bookId = OLD.bookId + ) + WHERE mediaId = OLD.bookId; + END + `) + ) + + const [[{ count: count3 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_authors_update'`) + expect(count3).to.equal(1) + + const [[{ sql: sql3 }]] = await queryInterface.sequelize.query(`SELECT sql FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_authors_update'`) + expect(normalizeWhitespaceAndBackticks(sql3)).to.equal( + normalizeWhitespaceAndBackticks(` + CREATE TRIGGER update_library_items_author_names_on_authors_update + AFTER UPDATE OF name ON authors + FOR EACH ROW + BEGIN + UPDATE libraryItems + SET (authorNamesFirstLast, authorNamesLastFirst) = ( + SELECT GROUP_CONCAT(authors.name, ', ' ORDER BY bookAuthors.createdAt ASC), GROUP_CONCAT(authors.lastFirst, ', ' ORDER BY bookAuthors.createdAt ASC) + FROM authors JOIN bookAuthors ON authors.id = bookAuthors.authorId + WHERE bookAuthors.bookId = libraryItems.mediaId + ) + WHERE mediaId IN (SELECT bookId FROM bookAuthors WHERE authorId = NEW.id); + END + `) + ) + }) + + it('should create indexes on the authorNamesFirstLast and authorNamesLastFirst columns', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_first_last'`) + expect(count).to.equal(1) + + const [[{ sql }]] = await queryInterface.sequelize.query(`SELECT sql FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_first_last'`) + expect(normalizeWhitespaceAndBackticks(sql)).to.equal( + normalizeWhitespaceAndBackticks(` + CREATE INDEX library_items_library_id_media_type_author_names_first_last ON libraryItems (libraryId, mediaType, authorNamesFirstLast COLLATE NOCASE) + `) + ) + + const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_last_first'`) + expect(count2).to.equal(1) + + const [[{ sql: sql2 }]] = await queryInterface.sequelize.query(`SELECT sql FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_last_first'`) + expect(normalizeWhitespaceAndBackticks(sql2)).to.equal( + normalizeWhitespaceAndBackticks(` + CREATE INDEX library_items_library_id_media_type_author_names_last_first ON libraryItems (libraryId, mediaType, authorNamesLastFirst COLLATE NOCASE) + `) + ) + }) + + it('should trigger after update on authors', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + // update author name + await queryInterface.sequelize.query(`UPDATE authors SET (name, lastFirst) = ('John Wayne', 'Wayne, John') WHERE id = 1`) + + // check that the libraryItems table was updated + const [libraryItems] = await queryInterface.sequelize.query(`SELECT * FROM libraryItems`) + expect(libraryItems).to.deep.equal([ + { id: 1, mediaId: 1, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'John Smith, John Wayne', authorNamesLastFirst: 'Smith, John, Wayne, John' }, + { id: 2, mediaId: 2, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'Jane Smith', authorNamesLastFirst: 'Smith, Jane' } + ]) + }) + + it('should trigger after insert on bookAuthors', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + // insert a new author + await queryInterface.sequelize.query(`INSERT INTO authors (id, name, lastFirst) VALUES (4, 'John Wayne', 'Wayne, John')`) + + // insert a new bookAuthor + await queryInterface.sequelize.query(`INSERT INTO bookAuthors (id, bookId, authorId, createdAt) VALUES (4, 1, 4, '2025-01-04 00:00:00.000 +00:00')`) + + // check that the libraryItems table was updated + const [libraryItems] = await queryInterface.sequelize.query(`SELECT * FROM libraryItems`) + expect(libraryItems).to.deep.equal([ + { id: 1, mediaId: 1, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'John Smith, John Doe, John Wayne', authorNamesLastFirst: 'Smith, John, Doe, John, Wayne, John' }, + { id: 2, mediaId: 2, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'Jane Smith', authorNamesLastFirst: 'Smith, Jane' } + ]) + }) + + it('should trigger after delete on bookAuthors', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + // delete a bookAuthor + await queryInterface.sequelize.query(`DELETE FROM bookAuthors WHERE id = 1`) + + // check that the libraryItems table was updated + const [libraryItems] = await queryInterface.sequelize.query(`SELECT * FROM libraryItems`) + expect(libraryItems).to.deep.equal([ + { id: 1, mediaId: 1, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'John Smith', authorNamesLastFirst: 'Smith, John' }, + { id: 2, mediaId: 2, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'Jane Smith', authorNamesLastFirst: 'Smith, Jane' } + ]) + }) + + it('should add an index on publishedAt to the podcastEpisodes table', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='podcast_episodes_published_at'`) + expect(count).to.equal(1) + + const [[{ sql }]] = await queryInterface.sequelize.query(`SELECT sql FROM sqlite_master WHERE type='index' AND name='podcast_episodes_published_at'`) + expect(normalizeWhitespaceAndBackticks(sql)).to.equal( + normalizeWhitespaceAndBackticks(` + CREATE INDEX podcast_episodes_published_at ON podcastEpisodes (publishedAt) + `) + ) + }) + + it('should be idempotent', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await up({ context: { queryInterface, logger: Logger } }) + + const libraryItemsTable = await queryInterface.describeTable('libraryItems') + expect(libraryItemsTable.authorNamesFirstLast).to.exist + expect(libraryItemsTable.authorNamesLastFirst).to.exist + + const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_insert'`) + expect(count).to.equal(1) + + const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_delete'`) + expect(count2).to.equal(1) + + const [[{ count: count3 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_authors_update'`) + expect(count3).to.equal(1) + + const [[{ count: count4 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_first_last'`) + expect(count4).to.equal(1) + + const [[{ count: count5 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_last_first'`) + expect(count5).to.equal(1) + + const [[{ count: count6 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='podcast_episodes_published_at'`) + expect(count6).to.equal(1) + + const [libraryItems] = await queryInterface.sequelize.query(`SELECT * FROM libraryItems`) + expect(libraryItems).to.deep.equal([ + { id: 1, mediaId: 1, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'John Smith, John Doe', authorNamesLastFirst: 'Smith, John, Doe, John' }, + { id: 2, mediaId: 2, mediaType: 'book', libraryId: 1, authorNamesFirstLast: 'Jane Smith', authorNamesLastFirst: 'Smith, Jane' } + ]) + }) + }) + + describe('down', () => { + it('should remove the authorNamesFirstLast and authorNamesLastFirst columns from the libraryItems table', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + + const libraryItemsTable = await queryInterface.describeTable('libraryItems') + expect(libraryItemsTable.authorNamesFirstLast).to.not.exist + expect(libraryItemsTable.authorNamesLastFirst).to.not.exist + + const [libraryItems] = await queryInterface.sequelize.query(`SELECT * FROM libraryItems`) + expect(libraryItems).to.deep.equal([ + { id: 1, mediaId: 1, mediaType: 'book', libraryId: 1 }, + { id: 2, mediaId: 2, mediaType: 'book', libraryId: 1 } + ]) + }) + + it('should remove the triggers from the libraryItems table', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + + const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_insert'`) + expect(count).to.equal(0) + + const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_delete'`) + expect(count2).to.equal(0) + + const [[{ count: count3 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_authors_update'`) + expect(count3).to.equal(0) + }) + + it('should remove the indexes from the libraryItems table', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + + const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_first_last'`) + expect(count).to.equal(0) + + const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_last_first'`) + expect(count2).to.equal(0) + }) + + it('should remove the index on publishedAt from the podcastEpisodes table', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + + const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='podcast_episodes_published_at'`) + expect(count).to.equal(0) + }) + + it('should be idempotent', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + + const libraryItemsTable = await queryInterface.describeTable('libraryItems') + expect(libraryItemsTable.authorNamesFirstLast).to.not.exist + expect(libraryItemsTable.authorNamesLastFirst).to.not.exist + + const [libraryItems] = await queryInterface.sequelize.query(`SELECT * FROM libraryItems`) + expect(libraryItems).to.deep.equal([ + { id: 1, mediaId: 1, mediaType: 'book', libraryId: 1 }, + { id: 2, mediaId: 2, mediaType: 'book', libraryId: 1 } + ]) + + const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_insert'`) + expect(count).to.equal(0) + + const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_book_authors_delete'`) + expect(count2).to.equal(0) + + const [[{ count: count3 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_author_names_on_authors_update'`) + expect(count3).to.equal(0) + + const [[{ count: count4 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_first_last'`) + expect(count4).to.equal(0) + + const [[{ count: count5 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_author_names_last_first'`) + expect(count5).to.equal(0) + + const [[{ count: count6 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='podcast_episodes_published_at'`) + expect(count6).to.equal(0) + }) + }) +}) From 38f05a857ff3cec50bafb594b5d0ab49d6c585ae Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 17 Mar 2025 17:11:01 -0500 Subject: [PATCH 34/38] Version bump v2.20.0 --- client/package-lock.json | 4 ++-- client/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index b7f75917..77759a7d 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.19.5", + "version": "2.20.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.19.5", + "version": "2.20.0", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index b5b2f982..3e35df33 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.19.5", + "version": "2.20.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index f3d83f73..b73ce8f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.19.5", + "version": "2.20.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.19.5", + "version": "2.20.0", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index beb96b22..10e54d31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.19.5", + "version": "2.20.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 1def32aa5053bb0a431920ffdcafb1aa29f7c1ab Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 18 Mar 2025 17:03:43 -0500 Subject: [PATCH 35/38] Fix req.query check and response --- server/controllers/LibraryController.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 673c5cf2..1d2d5bfa 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -1424,7 +1424,7 @@ class LibraryController { * GET: /api/library/:id/download * Downloads multiple library items * - * @param {LibraryItemControllerRequest} req + * @param {LibraryControllerRequest} req * @param {Response} res */ async downloadMultiple(req, res) { @@ -1433,8 +1433,9 @@ class LibraryController { return res.sendStatus(403) } - if(req.query.ids === undefined || req.query.ids === '') { - res.status(400).send('Library items not found') + if (!req.query.ids || typeof req.query.ids !== 'string') { + res.status(400).send('Invalid request. ids must be a string') + return } const itemIds = req.query.ids.split(',') @@ -1467,7 +1468,6 @@ class LibraryController { } } - /** * * @param {RequestWithUser} req From ff36a9327cab943672d053f857443fd89f0bd88b Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 18 Mar 2025 17:28:49 -0500 Subject: [PATCH 36/38] Fix multiple download for podcasts & cleanup --- server/controllers/LibraryController.js | 20 ++++++++--------- server/utils/zipHelpers.js | 29 ++++++++++--------------- 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 1d2d5bfa..f9aeba3f 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -1441,29 +1441,27 @@ class LibraryController { const itemIds = req.query.ids.split(',') const libraryItems = await Database.libraryItemModel.findAll({ - attributes: ['id', 'libraryId', 'path'], + attributes: ['id', 'libraryId', 'path', 'isFile'], where: { - id: itemIds, - libraryId: req.params.id, - mediaType: 'book' + id: itemIds } }) - Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for items "${itemIds}"`) + Logger.info(`[LibraryController] User "${req.user.username}" requested download for items "${itemIds}"`) const filename = `LibraryItems-${Date.now()}.zip` - const libraryItemPaths = libraryItems.map((li) => li.path) + const pathObjects = libraryItems.map((li) => ({ path: li.path, isFile: li.isFile })) - if (!libraryItemPaths.length) { - Logger.warn(`[LibraryItemController] No library items found for ids "${itemIds}"`) + if (!pathObjects.length) { + Logger.warn(`[LibraryController] No library items found for ids "${itemIds}"`) return res.status(404).send('Library items not found') } try { - await zipHelpers.zipDirectoriesPipe(libraryItemPaths, filename, res) - Logger.info(`[LibraryItemController] Downloaded item "${filename}" at "${libraryItemPaths}"`) + await zipHelpers.zipDirectoriesPipe(pathObjects, filename, res) + Logger.info(`[LibraryController] Downloaded ${pathObjects.length} items "${filename}"`) } catch (error) { - Logger.error(`[LibraryItemController] Download failed for item "${filename}" at "${libraryItemPaths}"`, error) + Logger.error(`[LibraryController] Download failed for items "${filename}" at ${pathObjects.map((po) => po.path).join(', ')}`, error) zipHelpers.handleDownloadError(error, res) } } diff --git a/server/utils/zipHelpers.js b/server/utils/zipHelpers.js index 6849df5d..421ca73c 100644 --- a/server/utils/zipHelpers.js +++ b/server/utils/zipHelpers.js @@ -1,6 +1,7 @@ +const Path = require('path') +const { Response } = require('express') const Logger = require('../Logger') const archiver = require('../libs/archiver') -const { lstatSync } = require('node:fs') module.exports.zipDirectoryPipe = (path, filename, res) => { return new Promise((resolve, reject) => { @@ -55,12 +56,12 @@ module.exports.zipDirectoryPipe = (path, filename, res) => { /** * Creates a zip archive containing multiple directories and streams it to the response. * - * @param {string[]} paths - Array of directory paths to include in the zip archive. + * @param {{ path: string, isFile: boolean }[]} pathObjects * @param {string} filename - Name of the zip file to be sent as attachment. - * @param {object} res - Response object to pipe the archive data to. + * @param {Response} res - Response object to pipe the archive data to. * @returns {Promise} - Promise that resolves when the zip operation completes. */ -module.exports.zipDirectoriesPipe = (paths, filename, res) => { +module.exports.zipDirectoriesPipe = (pathObjects, filename, res) => { return new Promise((resolve, reject) => { // create a file to stream archive data to res.attachment(filename) @@ -105,20 +106,14 @@ module.exports.zipDirectoriesPipe = (paths, filename, res) => { archive.pipe(res) // Add each path as a directory in the zip - paths.forEach(path => { - - const paths = path.split('/') - - // Check if path is file or directory - if (lstatSync(path).isDirectory()) { - const dirName = path.split('/').pop() - + pathObjects.forEach((pathObject) => { + if (!pathObject.isFile) { // Add the directory to the archive with its name as the root folder - archive.directory(path, dirName); + archive.directory(pathObject.path, Path.basename(pathObject.path)) } else { - archive.file(path, { name: paths[paths.length - 1] }); + archive.file(pathObject.path, { name: Path.basename(pathObject.path) }) } - }); + }) archive.finalize() }) @@ -127,8 +122,8 @@ module.exports.zipDirectoriesPipe = (paths, filename, res) => { /** * Handles errors that occur during the download process. * - * @param error - * @param res + * @param {*} error + * @param {Response} res * @returns {*} */ module.exports.handleDownloadError = (error, res) => { From 3b7db82bf0e2ff6679643589a8b4489655299cc3 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 18 Mar 2025 17:33:46 -0500 Subject: [PATCH 37/38] Update bulk download to not open in new tab --- client/components/app/Appbar.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue index 6fd2bc3e..d40794c3 100644 --- a/client/components/app/Appbar.vue +++ b/client/components/app/Appbar.vue @@ -182,7 +182,7 @@ export default { // The limit of 50 is introduced because of the URL length. Each id has 36 chars, so 36 * 40 = 1440 // + 40 , separators = 1480 chars + base path 280 chars = 1760 chars. This keeps the URL under 2000 chars even with longer domains - if(this.selectedMediaItems.length <= 40) { + if (this.selectedMediaItems.length <= 40) { options.push({ text: this.$strings.LabelDownload, action: 'download' @@ -255,7 +255,7 @@ export default { async batchDownload() { const libraryItemIds = this.selectedMediaItems.map((i) => i.id) console.log('Downloading library items', libraryItemIds) - this.$downloadFile(`/api/libraries/${this.$store.state.libraries.currentLibraryId}/download?token=${this.$store.getters['user/getToken']}&ids=${libraryItemIds.join(',')}`, null, true) + this.$downloadFile(`/api/libraries/${this.$store.state.libraries.currentLibraryId}/download?token=${this.$store.getters['user/getToken']}&ids=${libraryItemIds.join(',')}`) }, async playSelectedItems() { this.$store.commit('setProcessingBatch', true) From 92bb3527dea5af48dee90df5b10fc5372195d4cc Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 19 Mar 2025 17:39:23 -0500 Subject: [PATCH 38/38] Add logs when sanitizing filename and update podcast episode download to set targetFilename on init #4121 --- server/managers/PodcastManager.js | 2 +- server/objects/PodcastEpisodeDownload.js | 26 +++++++++++++++++++----- server/utils/fileUtils.js | 10 +++++++++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 5ed0fb89..086238d6 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -108,7 +108,7 @@ class PodcastManager { // e.g. "/tagesschau 20 Uhr.mp3" becomes "/tagesschau 20 Uhr (ep_asdfasdf).mp3" // this handles podcasts where every title is the same (ref https://github.com/advplyr/audiobookshelf/issues/1802) if (await fs.pathExists(this.currentDownload.targetPath)) { - this.currentDownload.appendRandomId = true + this.currentDownload.setAppendRandomId(true) } // Ignores all added files to this dir diff --git a/server/objects/PodcastEpisodeDownload.js b/server/objects/PodcastEpisodeDownload.js index 7ff89395..3c1d82ac 100644 --- a/server/objects/PodcastEpisodeDownload.js +++ b/server/objects/PodcastEpisodeDownload.js @@ -20,6 +20,8 @@ class PodcastEpisodeDownload { this.appendRandomId = false + this.targetFilename = null + this.startedAt = null this.createdAt = null this.finishedAt = null @@ -74,11 +76,6 @@ class PodcastEpisodeDownload { get episodeTitle() { return this.rssPodcastEpisode.title } - get targetFilename() { - const appendage = this.appendRandomId ? ` (${this.id})` : '' - const filename = `${this.rssPodcastEpisode.title}${appendage}.${this.fileExtension}` - return sanitizeFilename(filename) - } get targetPath() { return filePathToPOSIX(Path.join(this.libraryItem.path, this.targetFilename)) } @@ -93,6 +90,23 @@ class PodcastEpisodeDownload { return new Date(this.rssPodcastEpisode.publishedAt).getFullYear() } + /** + * @param {string} title + */ + getSanitizedFilename(title) { + const appendage = this.appendRandomId ? ` (${this.id})` : '' + const filename = `${title.trim()}${appendage}.${this.fileExtension}` + return sanitizeFilename(filename) + } + + /** + * @param {boolean} appendRandomId + */ + setAppendRandomId(appendRandomId) { + this.appendRandomId = appendRandomId + this.targetFilename = this.getSanitizedFilename(this.rssPodcastEpisode.title || '') + } + /** * * @param {import('../utils/podcastUtils').RssPodcastEpisode} rssPodcastEpisode - from rss feed @@ -112,6 +126,8 @@ class PodcastEpisodeDownload { this.url = encodeURI(url) } + this.targetFilename = this.getSanitizedFilename(this.rssPodcastEpisode.title || '') + this.libraryItem = libraryItem this.isAutoDownload = isAutoDownload this.createdAt = Date.now() diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index 4b6915b7..2c935557 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -362,6 +362,9 @@ module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => { return false } + // Normalize the string first to ensure consistent byte calculations + filename = filename.normalize('NFC') + // Most file systems use number of bytes for max filename // to support most filesystems we will use max of 255 bytes in utf-16 // Ref: https://doc.owncloud.com/server/next/admin_manual/troubleshooting/path_filename_length.html @@ -390,8 +393,11 @@ module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => { const ext = Path.extname(sanitized) // separate out file extension const basename = Path.basename(sanitized, ext) const extByteLength = Buffer.byteLength(ext, 'utf16le') + const basenameByteLength = Buffer.byteLength(basename, 'utf16le') if (basenameByteLength + extByteLength > MAX_FILENAME_BYTES) { + Logger.debug(`[fileUtils] Filename "${filename}" is too long (${basenameByteLength + extByteLength} bytes), trimming basename to ${MAX_FILENAME_BYTES - extByteLength} bytes.`) + const MaxBytesForBasename = MAX_FILENAME_BYTES - extByteLength let totalBytes = 0 let trimmedBasename = '' @@ -407,6 +413,10 @@ module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => { sanitized = trimmedBasename + ext } + if (filename !== sanitized) { + Logger.debug(`[fileUtils] Sanitized filename "${filename}" to "${sanitized}" (${Buffer.byteLength(sanitized, 'utf16le')} bytes)`) + } + return sanitized }