diff --git a/client/package-lock.json b/client/package-lock.json index 71bcf26a..57d2da71 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.33.1", + "version": "2.33.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.33.1", + "version": "2.33.2", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 782f411b..f747cf90 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.33.1", + "version": "2.33.2", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/client/players/LocalAudioPlayer.js b/client/players/LocalAudioPlayer.js index 7fc17e7a..a0384d54 100644 --- a/client/players/LocalAudioPlayer.js +++ b/client/players/LocalAudioPlayer.js @@ -46,7 +46,20 @@ export default class LocalAudioPlayer extends EventEmitter { this.player.addEventListener('loadedmetadata', this.evtLoadedMetadata.bind(this)) this.player.addEventListener('timeupdate', this.evtTimeupdate.bind(this)) - var mimeTypes = ['audio/flac', 'audio/mpeg', 'audio/mp4', 'audio/ogg', 'audio/aac', 'audio/x-ms-wma', 'audio/x-aiff', 'audio/webm'] + var mimeTypes = [ + 'audio/flac', + 'audio/mpeg', + 'audio/mp4', + 'audio/ogg', + 'audio/aac', + 'audio/x-ms-wma', + 'audio/x-aiff', + 'audio/webm', + // `audio/matroska` is the correct mimetype, but the server still uses `audio/x-matroska` + // ref: https://www.iana.org/assignments/media-types/media-types.xhtml + 'audio/matroska', + 'audio/x-matroska' + ] var mimeTypeCanPlayMap = {} mimeTypes.forEach((mt) => { var canPlay = this.player.canPlayType(mt) diff --git a/client/strings/be.json b/client/strings/be.json index d598ef7b..43668acb 100644 --- a/client/strings/be.json +++ b/client/strings/be.json @@ -16,7 +16,7 @@ "ButtonBrowseForFolder": "Агляд папак", "ButtonCancel": "Скасаваць", "ButtonCancelEncode": "Скасаваць кадзіраванне", - "ButtonChangeRootPassword": "Зменіце Root пароль", + "ButtonChangeRootPassword": "Змяніць пароль root", "ButtonCheckAndDownloadNewEpisodes": "Праверыць і спампаваць новыя выпускі", "ButtonChooseAFolder": "Выбраць папку", "ButtonChooseFiles": "Выбраць файлы", @@ -81,7 +81,7 @@ "ButtonRemove": "Выдаліць", "ButtonRemoveAll": "Выдаліць усе", "ButtonRemoveAllLibraryItems": "Выдаліць усе элементы бібліятэкі", - "ButtonRemoveFromContinueListening": "Выдаліць з Працягнуць праслухоўванне", + "ButtonRemoveFromContinueListening": "Выдаліць з Працяг праслухоўвання", "ButtonRemoveFromContinueReading": "Выдаліць з Працягваць чытанне", "ButtonRemoveSeriesFromContinueSeries": "Выдаліць серыю з Працягваць серыю", "ButtonReset": "Скінуць", @@ -252,8 +252,8 @@ "LabelAudioChannels": "Аўдыяканалы (1 або 2)", "LabelAudioCodec": "Аўдыякодэк", "LabelAuthor": "Аўтар", - "LabelAuthorFirstLast": "Аўтар (Імя Прозвішча)", - "LabelAuthorLastFirst": "Аўтар (Прозвішча, Імя)", + "LabelAuthorFirstLast": "Аўтар (імя, прозвішча)", + "LabelAuthorLastFirst": "Аўтар (прозвішча, імя)", "LabelAuthors": "Аўтары", "LabelAutoDownloadEpisodes": "Аўтаматычна спампоўваць выпускі", "LabelAutoFetchMetadata": "Аўтаматычнае атрыманне метаданых", @@ -292,7 +292,7 @@ "LabelCollections": "Калекцыі", "LabelComplete": "Завяршыць", "LabelConfirmPassword": "Пацвердзіце пароль", - "LabelContinueListening": "Працягнуць праслухоўванне", + "LabelContinueListening": "Працяг праслухоўвання", "LabelContinueReading": "Працягнуць чытанне", "LabelContinueSeries": "Працягнуць серыі", "LabelCorsAllowed": "Дазволеныя крыніцы CORS", @@ -424,7 +424,7 @@ "LabelLastBookAdded": "Апошняя дададзеная кніга", "LabelLastBookUpdated": "Апошняя абноўленая кніга", "LabelLastProgressDate": "Апошні прагрэс: {0}", - "LabelLastSeen": "Апошні прагляд", + "LabelLastSeen": "Апошняя актыўнасць", "LabelLastTime": "Апошні раз", "LabelLastUpdate": "Апошняе абнаўленне", "LabelLayout": "Знешні выгляд", @@ -545,7 +545,7 @@ "LabelRSSFeedSlug": "Ідэнтыфікатар RSS-стужкі", "LabelRSSFeedURL": "URL RSS-стужкі", "LabelRandomly": "Выпадкова", - "LabelReAddSeriesToContinueListening": "Дадаць серыю зноў у Працягнуць праслухоўванне", + "LabelReAddSeriesToContinueListening": "Дадаць серыю зноў у Працяг праслухоўвання", "LabelRead": "Чытаць", "LabelReadAgain": "Чытаць зноў", "LabelReadEbookWithoutProgress": "Чытаць электронную кнігу без захавання прагрэсу", @@ -634,12 +634,12 @@ "LabelSortAscending": "Па ўзрастанні", "LabelSortDescending": "Па ўбыванні", "LabelSortPubDate": "Сартаваць па даце публікацыі", - "LabelStart": "Пачаць", + "LabelStart": "Пачатак", "LabelStartTime": "Час пачатку", "LabelStarted": "Пачата", "LabelStartedAt": "Пачата ў", "LabelStartedDate": "Пачата {0}", - "LabelStatsAudioTracks": "Аўдыятрэкаў", + "LabelStatsAudioTracks": "Аўдыятрэкі", "LabelStatsAuthors": "Аўтараў", "LabelStatsBestDay": "Найлепшы дзень", "LabelStatsDailyAverage": "У сярэднім за дзень", diff --git a/client/strings/bg.json b/client/strings/bg.json index dacd6208..460f0ff8 100644 --- a/client/strings/bg.json +++ b/client/strings/bg.json @@ -436,7 +436,7 @@ "LabelLibraryFilterSublistEmpty": "Не {0}", "LabelLibraryItem": "Елемент на Библиотека", "LabelLibraryName": "Име на Библиотека", - "LabelLibrarySortByProgress": "Прогрес: Последно Обновен", + "LabelLibrarySortByProgress": "Прогрес: Последно обновление", "LabelLibrarySortByProgressFinished": "Прогрес: Приключено", "LabelLibrarySortByProgressStarted": "Прогрес: Започнато", "LabelLimit": "Лимит", @@ -892,7 +892,7 @@ "MessageScheduleRunEveryWeekdayAtTime": "Изпълни всеки {0} в {1}", "MessageSearchResultsFor": "Резултати от търсенето за", "MessageSelected": "{0} избрани", - "MessageSeriesSequenceCannotContainSpaces": "Подредбата в серия не може да съдържа шпации.", + "MessageSeriesSequenceCannotContainSpaces": "Подредбата в серия не може да съдържа шпации", "MessageServerCouldNotBeReached": "Сървърът не може да бъде достигнат", "MessageSetChaptersFromTracksDescription": "Задайте глави, като използвате всеки аудио файл като глава и заглавие на главата като име на аудио файла", "MessageShareExpirationWillBe": "Изтичането ще бъде на {0}", @@ -956,6 +956,8 @@ "NotificationOnEpisodeDownloadedDescription": "Изпълнява се при автоматично изтегляне на подкаст епизод", "NotificationOnRSSFeedDisabledDescription": "Изпълнява се, когато автоматичното изтегляне на епизодите е деактивирано, поради твърде много неуспешни опити", "NotificationOnRSSFeedFailedDescription": "Пуска се когато заявката за RSS фийд е неуспешна за автоматично сваляне на епизод", + "NotificationOnTestDescription": "Event за тестване на системата за нотификации", + "PlaceholderBulkChapterInput": "Въведете име на глава или използвайте номериране (прим. 'Епизод 1', 'Глава 10', '1.')", "PlaceholderNewCollection": "Ново име на колекцията", "PlaceholderNewFolderPath": "Нов път на папката", "PlaceholderNewPlaylist": "Ново име на плейлиста", @@ -963,26 +965,58 @@ "PlaceholderSearchEpisode": "Търсене на Епизоди...", "StatsAuthorsAdded": "добаврени автори", "StatsBooksAdded": "добавени книги", + "StatsBooksAdditional": "Някой от вкючените добавки…", "StatsBooksFinished": "завършени книги", + "StatsBooksFinishedThisYear": "Някой от книгите приключени тази година…", + "StatsBooksListenedTo": "слушани книги", + "StatsCollectionGrewTo": "Твоята книжна колекция израсна до…", + "StatsSessions": "сесии", + "StatsSpentListening": "прекарано в слушане", + "StatsTopAuthor": "ТОП АВТОР", + "StatsTopAuthors": "ТОП АВТОРИ", + "StatsTopGenre": "ТОП ЖАНР", + "StatsTopGenres": "ТОП ЖАНРА", + "StatsTopMonth": "ТОП МЕСЕЦ", + "StatsTopNarrator": "ТОП РАЗКАЗВАЧ", + "StatsTopNarrators": "ТОП РАЗКАЗВАЧИ", + "StatsTotalDuration": "С пълно времетраене…", + "StatsYearInReview": "ГОДИНАТА В ПРЕГЛЕД", "ToastAccountUpdateSuccess": "Успешно обновяване на акаунта", + "ToastAppriseUrlRequired": "Трябва да въведете Apprise URL", + "ToastAsinRequired": "ASIN-а е задължителен", "ToastAuthorImageRemoveSuccess": "Авторската снимка е премахната", + "ToastAuthorNotFound": "Автор \"{0}\" не е намерен", + "ToastAuthorRemoveSuccess": "Арторът е премахнат", + "ToastAuthorSearchNotFound": "Авторът не е намерен", "ToastAuthorUpdateMerged": "Обновяване на автора сливано", "ToastAuthorUpdateSuccess": "Автора обновен", "ToastAuthorUpdateSuccessNoImageFound": "Автор обновен (не е намерена снимка)", + "ToastBackupAppliedSuccess": "Архивът е приложен", "ToastBackupCreateFailed": "Неуспешно създаване на архив", "ToastBackupCreateSuccess": "Архивът е създаден", "ToastBackupDeleteFailed": "Неуспешно изтриване на архив", "ToastBackupDeleteSuccess": "Архивът е изтрит", + "ToastBackupInvalidMaxKeep": "Невалиден брой за архиви за запазване", + "ToastBackupInvalidMaxSize": "Невалиден максимален рамер на архив", "ToastBackupRestoreFailed": "Неуспешно възстановяване на архив", "ToastBackupUploadFailed": "Неуспешно качване на архив", "ToastBackupUploadSuccess": "Архивът е качен", + "ToastBatchApplyDetailsToItemsSuccess": "Детайли приложени на предмети", + "ToastBatchDeleteFailed": "Груповото изтриване се провали", + "ToastBatchDeleteSuccess": "Успешно групово изтриване", + "ToastBatchQuickMatchFailed": "Груповото Бързо Съвпадение се провали!", + "ToastBatchQuickMatchStarted": "Груповото Бързо Съвпадение на {0} книги започна!", "ToastBatchUpdateFailed": "Неуспешно групово актуализиране", "ToastBatchUpdateSuccess": "Успешно групово актуализиране", "ToastBookmarkCreateFailed": "Неуспешно създаване на отметка", "ToastBookmarkCreateSuccess": "Отметката е създадена", "ToastBookmarkRemoveSuccess": "Отметката е премахната", + "ToastBulkChapterInvalidCount": "Въведете число между 1 и 150", "ToastCachePurgeFailed": "Неуспешно изчистване на кеша", "ToastCachePurgeSuccess": "Успешно изчистване на кеша", + "ToastChapterLocked": "Главата е заключена.", + "ToastChapterStartTimeAdjusted": "Начално време на главате е настоено с {0} секунди", + "ToastChaptersAllLocked": "Всички глави са заключени. Оключете някой глави за да преместите техните времена.", "ToastChaptersHaveErrors": "Главите имат грешки", "ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия", "ToastCollectionRemoveSuccess": "Колекцията е премахната", diff --git a/client/strings/de.json b/client/strings/de.json index ed0cae52..4d9ecd41 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -116,7 +116,7 @@ "ButtonViewAll": "Alles anzeigen", "ButtonYes": "Ja", "ErrorUploadFetchMetadataAPI": "Fehler beim Abrufen der Metadaten", - "ErrorUploadFetchMetadataNoResults": "Metadaten konnten nicht abgerufen werden. Versuche, den Titel und/oder den Autor zu aktualisieren.", + "ErrorUploadFetchMetadataNoResults": "Metadaten konnten nicht abgerufen werden - versuche den Titel und/oder den Autor zu aktualisieren", "ErrorUploadLacksTitle": "Es muss ein Titel eingegeben werden", "HeaderAccount": "Konto", "HeaderAddCustomMetadataProvider": "Benutzerdefinierten Metadatenanbieter hinzufügen", @@ -622,7 +622,7 @@ "LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet", "LabelSettingsTimeFormat": "Zeitformat", "LabelShare": "Freigeben", - "LabelShareDownloadableHelp": "Erlaubt es einem Nutzer, mit dem Link, die Dateien des Mediums als ZIP herunterzuladen.", + "LabelShareDownloadableHelp": "Erlaubt es einem Nutzer, mit dem Link die Dateien des Mediums als ZIP herunterzuladen.", "LabelShareOpen": "Freigeben", "LabelShareURL": "Freigabe URL", "LabelShowAll": "Alles anzeigen", @@ -737,7 +737,7 @@ "MessageAddToPlayerQueue": "Zur Abspielwarteliste hinzufügen", "MessageAppriseDescription": "Um diese Funktion nutzen zu können, musst du eine Instanz von Apprise API laufen haben oder eine API verwenden welche dieselbe Anfragen bearbeiten kann.
Die Apprise API Url muss der vollständige URL-Pfad sein, an den die Benachrichtigung gesendet werden soll, z.B. wenn Ihre API-Instanz unter http://192.168.1.1:8337 läuft, würdest du http://192.168.1.1:8337/notify eingeben.", "MessageAsinCheck": "Stelle sicher, dass die ASIN aus der richtigen Audible Region verwendet wird, nicht Amazon.", - "MessageAuthenticationLegacyTokenWarning": "Alte API-Token werden in Zukunft entfernt. Benutze stattdessen API Keys.", + "MessageAuthenticationLegacyTokenWarning": "Nicht mehr unterstützte API tokens werden in der Zukunft entfernt. Nutze stattdessen API Schlüssel.", "MessageAuthenticationOIDCChangesRestart": "Nach dem Speichern muss der Server neugestartet werden um die OIDC Änderungen zu übernehmen.", "MessageAuthenticationSecurityMessage": "Die Anmeldung wurde abgesichert. Benutzersitzungen werden getrennt, alle Benutzer müssen sich erneut anmelden.", "MessageBackupsDescription": "In einer Sicherung werden Benutzer, Benutzerfortschritte, Details zu den Bibliotheksobjekten, Servereinstellungen und Bilder welche in /metadata/items & /metadata/authors gespeichert sind gespeichert. Sicherungen enthalten keine Dateien welche in den einzelnen Bibliotheksordnern (Medien-Ordnern) gespeichert sind.", @@ -816,7 +816,7 @@ "MessageFeedURLWillBe": "Feed-URL wird {0} sein", "MessageFetching": "Wird abgerufen …", "MessageForceReScanDescription": "Durchsucht alle Dateien erneut, wie bei einem frischen Scan. ID3-Tags von Audiodateien, OPF-Dateien und Textdateien werden neu durchsucht.", - "MessageHeatmapListeningTimeTooltip": "{0} auf {1} gehört", + "MessageHeatmapListeningTimeTooltip": "{0} gehört auf {1}", "MessageHeatmapNoListeningSessions": "Keine Hörsitzungen am {0}", "MessageImportantNotice": "Wichtiger Hinweis!", "MessageInsertChapterBelow": "Kapitel unten einfügen", @@ -1103,7 +1103,7 @@ "ToastPodcastCreateFailed": "Podcast konnte nicht erstellt werden", "ToastPodcastCreateSuccess": "Podcast erstellt", "ToastPodcastEpisodeUpdated": "Podcast-Folge aktualisiert", - "ToastPodcastGetFeedFailed": "Fehler beim abrufen des Podcast-Feeds", + "ToastPodcastGetFeedFailed": "Fehler beim Abrufen des Podcast Feeds", "ToastPodcastNoEpisodesInFeed": "Keine Episoden in RSS Feed gefunden", "ToastPodcastNoRssFeed": "Podcast enthält keinen RSS Feed", "ToastProgressIsNotBeingSynced": "Fortschritt wird nicht synchronisiert, Wiedergabe wird neu gestartet", diff --git a/client/strings/es.json b/client/strings/es.json index eae32bd9..e9655563 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -40,7 +40,7 @@ "ButtonFullPath": "Ruta completa", "ButtonHide": "Ocultar", "ButtonHome": "Inicio", - "ButtonIssues": "Cuestiones", + "ButtonIssues": "Incidencias", "ButtonJumpBackward": "Retroceder", "ButtonJumpForward": "Adelantar", "ButtonLatest": "Más recientes", @@ -850,7 +850,7 @@ "MessageNoEpisodes": "Ningún episodio", "MessageNoFoldersAvailable": "Ninguna carpeta disponible", "MessageNoGenres": "Ningún género", - "MessageNoIssues": "Ningún número", + "MessageNoIssues": "Sin incidencias", "MessageNoItems": "Ningún elemento", "MessageNoItemsFound": "Ningún elemento encontrado", "MessageNoListeningSessions": "Ninguna sesión de escucha", @@ -1116,8 +1116,8 @@ "ToastRemoveFailed": "Error al eliminar", "ToastRemoveItemFromCollectionFailed": "Error al eliminar el elemento de la colección", "ToastRemoveItemFromCollectionSuccess": "Elemento eliminado de la colección", - "ToastRemoveItemsWithIssuesFailed": "Error en la eliminación de artículos de biblioteca incorrectos", - "ToastRemoveItemsWithIssuesSuccess": "Se eliminaron artículos de biblioteca incorrectos", + "ToastRemoveItemsWithIssuesFailed": "Error en la eliminación de artículos de biblioteca con incidencias", + "ToastRemoveItemsWithIssuesSuccess": "Se eliminaron artículos de biblioteca con incidencias", "ToastRenameFailed": "Error al cambiar el nombre", "ToastRescanFailed": "Error al volver a escanear para {0}", "ToastRescanRemoved": "Se eliminó el elemento reescaneado", diff --git a/client/strings/it.json b/client/strings/it.json index d2178cbf..4b392162 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -34,7 +34,7 @@ "ButtonEditChapters": "Modifica Capitoli", "ButtonEditPodcast": "Modifica Podcast", "ButtonEnable": "Abilita", - "ButtonFireAndFail": "Fire and Fail", + "ButtonFireAndFail": "Centro e fallimento", "ButtonFireOnTest": "Fire onTest event", "ButtonForceReScan": "Forza Re-Scan", "ButtonFullPath": "Percorso Completo", @@ -182,7 +182,7 @@ "HeaderPlaylist": "Playlist", "HeaderPlaylistItems": "Elementi della playlist", "HeaderPodcastsToAdd": "Podcasts da Aggiungere", - "HeaderPresets": "Presets", + "HeaderPresets": "Preimpostazioni", "HeaderPreviewCover": "Anteprima Cover", "HeaderRSSFeedGeneral": "Dettagli RSS", "HeaderRSSFeedIsOpen": "RSS Feed è aperto", @@ -306,7 +306,7 @@ "LabelCustomCronExpression": "Espressione Cron personalizzata:", "LabelDatetime": "Data & Ora", "LabelDays": "Giorni", - "LabelDeleteFromFileSystemCheckbox": "Elimina dal file system (togli la spunta per eliminarla solo dal DB)", + "LabelDeleteFromFileSystemCheckbox": "Elimina dal file system (despunta per rimuoverla solo dal database)", "LabelDescription": "Descrizione", "LabelDeselectAll": "Deseleziona Tutto", "LabelDetectedPattern": "Trovato pattern:", @@ -436,9 +436,9 @@ "LabelLibraryFilterSublistEmpty": "Nessuno {0}", "LabelLibraryItem": "Elementi della biblioteca", "LabelLibraryName": "Nome della biblioteca", - "LabelLibrarySortByProgress": "Progressi: Ultimi aggiornamenti", - "LabelLibrarySortByProgressFinished": "Progressi: Completati", - "LabelLibrarySortByProgressStarted": "Progressi: Iniziati", + "LabelLibrarySortByProgress": "Progresso: ultimo aggiornamento", + "LabelLibrarySortByProgressFinished": "Progresso: finito", + "LabelLibrarySortByProgressStarted": "Progresso: iniziato", "LabelLimit": "Limiti", "LabelLineSpacing": "Interlinea", "LabelListenAgain": "Ascolta ancora", @@ -497,7 +497,7 @@ "LabelNumberOfBooks": "Numero di libri", "LabelNumberOfChapters": "Numero di capitoli:", "LabelNumberOfEpisodes": "Numero di episodi", - "LabelOpenIDAdvancedPermsClaimDescription": "Nome dell'attestazione OpenID che contiene autorizzazioni avanzate per le azioni dell'utente all'interno dell'applicazione che verranno applicate ai ruoli non amministratori (se configurato). Se il reclamo manca nella risposta, l'accesso ad ABS verrà negato. Se manca una singola opzione, verrà trattata comefalsa. Assicurati che l'attestazione del provider di identità corrisponda alla struttura prevista:", + "LabelOpenIDAdvancedPermsClaimDescription": "Nome dell'attestazione OpenID che contiene autorizzazioni avanzate per le azioni dell'utente all'interno dell'applicazione che verranno applicate ai ruoli non amministrativi (se configurato). Se il reclamo manca nella risposta, l'accesso ad ABS verrà negato. Se manca una singola opzione, verrà trattata come falso. Assicurati che l'attestazione del provider di identità corrisponda alla struttura prevista:", "LabelOpenIDClaims": "Lasciare vuote le seguenti opzioni per disabilitare l'assegnazione avanzata di gruppi e autorizzazioni, assegnando quindi automaticamente il gruppo \"Utente\".", "LabelOpenIDGroupClaimDescription": "Nome dell'attestazione OpenID che contiene un elenco dei gruppi dell'utente. Comunemente indicato come gruppo. se configurato, l'applicazione assegnerà automaticamente i ruoli in base alle appartenenze ai gruppi dell'utente, a condizione che tali gruppi siano denominati \"admin\", \"utente\" o \"ospite\" senza distinzione tra maiuscole e minuscole nell'attestazione. L'attestazione deve contenere un elenco e, se un utente appartiene a più gruppi, l'applicazione assegnerà il ruolo corrispondente al livello di accesso più alto. Se nessun gruppo corrisponde, l'accesso verrà negato.", "LabelOpenRSSFeed": "Apri RSS Feed", @@ -530,7 +530,7 @@ "LabelPrimaryEbook": "Libro principale", "LabelProgress": "Cominciati", "LabelProvider": "Fornitore", - "LabelProviderAuthorizationValue": "Authorization Header Value", + "LabelProviderAuthorizationValue": "Valore intestazione di autorizzazione", "LabelPubDate": "Data di pubblicazione", "LabelPublishYear": "Anno di pubblicazione", "LabelPublishedDate": "Pubblicati {0}", @@ -674,7 +674,7 @@ "LabelTimeDurationXMinutes": "{0} minuti", "LabelTimeDurationXSeconds": "{0} secondi", "LabelTimeInMinutes": "Tempo in minuti", - "LabelTimeLeft": "{0} sinistra", + "LabelTimeLeft": "{0} rimasti", "LabelTimeListened": "Tempo di Ascolto", "LabelTimeListenedToday": "Tempo di Ascolto Oggi", "LabelTimeRemaining": "{0} rimanente", @@ -682,7 +682,7 @@ "LabelTitle": "Titolo", "LabelToolsEmbedMetadata": "Incorpora Metadata", "LabelToolsEmbedMetadataDescription": "Incorpora i metadati nei file audio, inclusi l'immagine di copertina e i capitoli.", - "LabelToolsM4bEncoder": "M4B Encoder", + "LabelToolsM4bEncoder": "Codificatore M4B", "LabelToolsMakeM4b": "Crea un file M4B", "LabelToolsMakeM4bDescription": "Genera un file audiolibro M4B con metadati incorporati, immagine di copertina e capitoli.", "LabelToolsSplitM4b": "Converti M4B in MP3", @@ -854,7 +854,7 @@ "MessageNoItems": "Nessun oggetto", "MessageNoItemsFound": "Nessun oggetto trovato", "MessageNoListeningSessions": "Nessuna sessione di ascolto", - "MessageNoLogs": "Nessun Log", + "MessageNoLogs": "Nessun rapporto", "MessageNoMediaProgress": "Nessun progresso multimediale", "MessageNoNotifications": "Nessuna notifica", "MessageNoPodcastFeed": "Podcast non valido: nessun feed", @@ -1109,7 +1109,7 @@ "ToastProgressIsNotBeingSynced": "L'avanzamento non è sincronizzato, riavviare la riproduzione", "ToastProviderCreatedFailed": "Impossibile aggiungere il provider", "ToastProviderCreatedSuccess": "Aggiunto nuovo provider", - "ToastProviderNameAndUrlRequired": "Nome e URL richiesti", + "ToastProviderNameAndUrlRequired": "Nome e Url richiesti", "ToastProviderRemoveSuccess": "Provider rimosso", "ToastRSSFeedCloseFailed": "Errore chiusura flusso RSS", "ToastRSSFeedCloseSuccess": "Flusso RSS chiuso", diff --git a/client/strings/ru.json b/client/strings/ru.json index c84fe9dc..ed3f18b8 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -392,7 +392,7 @@ "LabelGenre": "Жанр", "LabelGenres": "Жанры", "LabelHardDeleteFile": "Жесткое удаление файла", - "LabelHasEbook": "Есть e-книга", + "LabelHasEbook": "Есть электронная книга", "LabelHasSupplementaryEbook": "Есть дополнительная e-книга", "LabelHideSubtitles": "Скрыть серии", "LabelHighestPriority": "Наивысший приоритет", @@ -437,8 +437,8 @@ "LabelLibraryItem": "Элемент библиотеки", "LabelLibraryName": "Имя библиотеки", "LabelLibrarySortByProgress": "Прогресс: Последнее обновление", - "LabelLibrarySortByProgressFinished": "Прогресс: Завершено", - "LabelLibrarySortByProgressStarted": "Прогресс: Начато", + "LabelLibrarySortByProgressFinished": "Прогресс: Закончена", + "LabelLibrarySortByProgressStarted": "Прогресс: Начата", "LabelLimit": "Лимит", "LabelLineSpacing": "Межстрочный интервал", "LabelListenAgain": "Послушать снова", diff --git a/client/strings/sk.json b/client/strings/sk.json index 6101eba7..23ed276e 100644 --- a/client/strings/sk.json +++ b/client/strings/sk.json @@ -275,7 +275,7 @@ "LabelBonus": "Bonus", "LabelBooks": "Knihy", "LabelButtonText": "Text tlačidla", - "LabelByAuthor": "od", + "LabelByAuthor": "od {0}", "LabelChangePassword": "Zmeniť heslo", "LabelChannels": "Kanály", "LabelChapterCount": "{0} kapitol", diff --git a/package-lock.json b/package-lock.json index d8cc43bc..250390c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.33.1", + "version": "2.33.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.33.1", + "version": "2.33.2", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index d44df116..810863f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.33.1", + "version": "2.33.2", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", diff --git a/server/controllers/AuthorController.js b/server/controllers/AuthorController.js index 82ed3e50..80471ec4 100644 --- a/server/controllers/AuthorController.js +++ b/server/controllers/AuthorController.js @@ -10,7 +10,7 @@ const CacheManager = require('../managers/CacheManager') const CoverManager = require('../managers/CoverManager') const AuthorFinder = require('../finders/AuthorFinder') -const { reqSupportsWebp, isValidASIN } = require('../utils/index') +const { reqSupportsWebp, isValidASIN, clampPositiveInt } = require('../utils/index') const naturalSort = createNewSortInstance({ comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare @@ -412,8 +412,8 @@ class AuthorController { const options = { format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'), - height: height ? parseInt(height) : null, - width: width ? parseInt(width) : null + height: clampPositiveInt(height ? parseInt(height) : null, 4096), + width: clampPositiveInt(width ? parseInt(width) : null, 4096) } return CacheManager.handleAuthorCache(res, authorId, options) } diff --git a/server/controllers/FileSystemController.js b/server/controllers/FileSystemController.js index 4b0a94b3..41e082fd 100644 --- a/server/controllers/FileSystemController.js +++ b/server/controllers/FileSystemController.js @@ -117,7 +117,7 @@ class FileSystemController { filepath = fileUtils.filePathToPOSIX(filepath) // Ensure filepath is inside library folder (prevents directory traversal) - if (!filepath.startsWith(libraryFolder.path)) { + if (!fileUtils.isSameOrSubPath(libraryFolder.path, filepath)) { Logger.error(`[FileSystemController] Filepath is not inside library folder: ${filepath}`) return res.sendStatus(400) } diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 55ef4569..73b3d5c6 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -462,7 +462,7 @@ class LibraryController { } } Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`) - await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) + await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, req.library.id) } if (authorIds.length) { @@ -563,7 +563,7 @@ class LibraryController { mediaItemIds.push(libraryItem.mediaId) } Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`) - await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) + await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, req.library.id) } // Set PlaybackSessions libraryId to null @@ -714,7 +714,7 @@ class LibraryController { } } Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`) - await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) + await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, req.library.id) } if (authorIds.length) { @@ -1435,10 +1435,15 @@ class LibraryController { const libraryItems = await Database.libraryItemModel.findAll({ attributes: ['id', 'libraryId', 'path', 'isFile'], where: { - id: itemIds + id: itemIds, + libraryId: req.library.id } }) + if (libraryItems.length < itemIds.length) { + Logger.warn(`[LibraryController] User "${req.user.username}" requested ${itemIds.length} items but only ${libraryItems.length} are in library "${req.library.id}"`) + } + Logger.info(`[LibraryController] User "${req.user.username}" requested download for items "${itemIds}"`) const filename = `LibraryItems-${Date.now()}.zip` diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 5247dbb0..1a6b8ac1 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -7,7 +7,7 @@ const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') const zipHelpers = require('../utils/zipHelpers') -const { reqSupportsWebp } = require('../utils/index') +const { reqSupportsWebp, clampPositiveInt } = require('../utils/index') const { ScanResult, AudioMimeType } = require('../utils/constants') const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils') const LibraryItemScanner = require('../scanner/LibraryItemScanner') @@ -111,7 +111,7 @@ class LibraryItemController { } } - await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds) + await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds, req.libraryItem.libraryId) if (hardDelete) { Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`) await fs.remove(libraryItemPath).catch((error) => { @@ -398,8 +398,8 @@ class LibraryItemController { const options = { format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'), - height: height ? parseInt(height) : null, - width: width ? parseInt(width) : null + height: clampPositiveInt(height ? parseInt(height) : null, 4096), + width: clampPositiveInt(width ? parseInt(width) : null, 4096) } return CacheManager.handleCoverCache(res, libraryItemId, options) } @@ -565,7 +565,7 @@ class LibraryItemController { authorIds.push(...libraryItem.media.authors.map((au) => au.id)) } } - await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) + await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, libraryItem.libraryId) if (hardDelete) { Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`) await fs.remove(libraryItemPath).catch((error) => { diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js index c7028760..f099d05e 100644 --- a/server/controllers/PodcastController.js +++ b/server/controllers/PodcastController.js @@ -7,7 +7,7 @@ const Database = require('../Database') const fs = require('../libs/fsExtra') const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils') -const { getFileTimestampsWithIno, filePathToPOSIX } = require('../utils/fileUtils') +const { getFileTimestampsWithIno, filePathToPOSIX, isSameOrSubPath } = require('../utils/fileUtils') const { validateUrl } = require('../utils/index') const htmlSanitizer = require('../utils/htmlSanitizer') @@ -58,8 +58,18 @@ class PodcastController { return res.status(404).send('Folder not found') } + if (typeof payload.path !== 'string' || !payload.path.trim()) { + return res.status(400).send('Invalid request body. "path" must be a non-empty string') + } + + const libraryFolderPath = filePathToPOSIX(folder.path) const podcastPath = filePathToPOSIX(payload.path) + if (!isSameOrSubPath(libraryFolderPath, podcastPath)) { + Logger.error(`[PodcastController] Create: Podcast path is outside library folder "${libraryFolderPath}": "${podcastPath}"`) + return res.status(400).send('Podcast path must be inside the selected library folder') + } + // Check if a library item with this podcast folder exists already const existingLibraryItem = (await Database.libraryItemModel.count({ @@ -83,7 +93,7 @@ class PodcastController { const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath) - let relPath = payload.path.replace(folder.fullPath, '') + let relPath = podcastPath.replace(libraryFolderPath, '') if (relPath.startsWith('/')) relPath = relPath.slice(1) let newLibraryItem = null diff --git a/server/managers/BackupManager.js b/server/managers/BackupManager.js index 2697f94e..a7b531e6 100644 --- a/server/managers/BackupManager.js +++ b/server/managers/BackupManager.js @@ -126,13 +126,31 @@ class BackupManager { } catch (error) { // Not a valid zip file Logger.error('[BackupManager] Failed to read backup file - backup might not be a valid .zip file', tempPath, error) + await zip.close().catch(() => {}) + await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err)) return res.status(400).send('Failed to read backup file - backup might not be a valid .zip file') } - if (!Object.keys(entries).includes('absdatabase.sqlite')) { + if (!entries['absdatabase.sqlite']) { Logger.error(`[BackupManager] Invalid backup with no absdatabase.sqlite file - might be a backup created on an old Audiobookshelf server.`) + await zip.close().catch(() => {}) + await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err)) return res.status(500).send('Invalid backup with no absdatabase.sqlite file - might be a backup created on an old Audiobookshelf server.') } + const detailsEntry = entries['details'] + if (!detailsEntry) { + Logger.error('[BackupManager] Invalid backup - missing details entry') + await zip.close().catch(() => {}) + await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err)) + return res.status(400).send('Invalid backup file - missing details entry') + } + if (detailsEntry.size > 1024 * 1024) { + Logger.error(`[BackupManager] Backup details entry too large: ${detailsEntry.size} bytes`) + await zip.close().catch(() => {}) + await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err)) + return res.status(400).send('Invalid backup file - details entry too large') + } + const data = await zip.entryData('details') const details = data.toString('utf8').split('\n') @@ -140,9 +158,13 @@ class BackupManager { if (!backup.serverVersion) { Logger.error(`[BackupManager] Invalid backup with no server version - might be a backup created before version 2.0.0`) + await zip.close().catch(() => {}) + await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err)) return res.status(500).send('Invalid backup. Might be a backup created before version 2.0.0.') } + await zip.close().catch(() => {}) + backup.fileSize = await getFileSize(backup.fullPath) const existingBackupIndex = this.backups.findIndex((b) => b.id === backup.id) @@ -257,9 +279,24 @@ class BackupManager { let data = null try { zip = new StreamZip.async({ file: fullFilePath }) + const entries = await zip.entries() + + const detailsEntry = entries['details'] + if (!detailsEntry) { + Logger.error(`[BackupManager] Backup "${fullFilePath}" missing details entry - skipping`) + await zip.close().catch(() => {}) + continue + } + if (detailsEntry.size > 1024 * 1024) { + Logger.error(`[BackupManager] Backup "${fullFilePath}" details entry too large (${detailsEntry.size} bytes) - skipping`) + await zip.close().catch(() => {}) + continue + } + data = await zip.entryData('details') } catch (error) { Logger.error(`[BackupManager] Failed to unzip backup "${fullFilePath}"`, error) + if (zip) await zip.close().catch(() => {}) continue } diff --git a/server/models/Author.js b/server/models/Author.js index 287b6697..65561e21 100644 --- a/server/models/Author.js +++ b/server/models/Author.js @@ -111,16 +111,17 @@ class Author extends Model { * * @param {string} name * @param {string} libraryId - * @returns {Promise} + * @returns {Promise<{ author: Author, created: boolean }>} */ static async findOrCreateByNameAndLibrary(name, libraryId) { const author = await this.getByNameAndLibrary(name, libraryId) - if (author) return author - return this.create({ + if (author) return { author, created: false } + const newAuthor = await this.create({ name, lastFirst: this.getLastFirst(name), libraryId }) + return { author: newAuthor, created: true } } /** diff --git a/server/models/Book.js b/server/models/Book.js index 96371f3a..d9f2ff13 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -4,6 +4,7 @@ const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils') const parseNameString = require('../utils/parsers/parseNameString') const htmlSanitizer = require('../utils/htmlSanitizer') const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters') +const SocketAuthority = require('../SocketAuthority') /** * @typedef EBookFileObject @@ -470,13 +471,23 @@ class Book extends Model { for (const author of authorsRemoved) { await bookAuthorModel.removeByIds(author.id, this.id) + const numBooks = await bookAuthorModel.getCountForAuthor(author.id) + if (numBooks > 0) { + SocketAuthority.emitter('author_updated', author.toOldJSONExpanded(numBooks)) + } Logger.debug(`[Book] "${this.title}" Removed author "${author.name}"`) this.authors = this.authors.filter((au) => au.id !== author.id) } const authorsAdded = [] for (const authorName of newAuthorNames) { - const author = await authorModel.findOrCreateByNameAndLibrary(authorName, libraryId) + const { author, created } = await authorModel.findOrCreateByNameAndLibrary(authorName, libraryId) await bookAuthorModel.create({ bookId: this.id, authorId: author.id }) + if (created) { + SocketAuthority.emitter('author_added', author.toOldJSON()) + } else { + const numBooks = await bookAuthorModel.getCountForAuthor(author.id) + SocketAuthority.emitter('author_updated', author.toOldJSONExpanded(numBooks)) + } Logger.debug(`[Book] "${this.title}" Added author "${author.name}"`) this.authors.push(author) authorsAdded.push(author) diff --git a/server/objects/PlaybackSession.js b/server/objects/PlaybackSession.js index ba031b66..ace0256e 100644 --- a/server/objects/PlaybackSession.js +++ b/server/objects/PlaybackSession.js @@ -110,7 +110,8 @@ class PlaybackSession { startedAt: this.startedAt, updatedAt: this.updatedAt, audioTracks: this.audioTracks.map((at) => at.toJSON?.() || { ...at }), - libraryItem: libraryItem?.toOldJSONExpanded() || null + libraryItem: libraryItem?.toOldJSONExpanded() || null, + coverAspectRatio: this.coverAspectRatio !== null ? this.coverAspectRatio : undefined // Used for share sessions } } diff --git a/server/objects/Stream.js b/server/objects/Stream.js index 5aa013e8..70361463 100644 --- a/server/objects/Stream.js +++ b/server/objects/Stream.js @@ -73,7 +73,7 @@ class Stream extends EventEmitter { return [AudioMimeType.FLAC, AudioMimeType.OPUS, AudioMimeType.WMA, AudioMimeType.AIFF, AudioMimeType.WEBM, AudioMimeType.WEBMA, AudioMimeType.AWB, AudioMimeType.CAF] } get codecsToForceAAC() { - return ['alac', 'ac3', 'eac3'] + return ['alac', 'ac3', 'eac3', 'opus'] } get userToken() { return this.user.token diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index db04bf5e..e89b364f 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -363,8 +363,9 @@ class ApiRouter { * Remove library item and associated entities * @param {string} libraryItemId * @param {string[]} mediaItemIds array of bookId or podcastEpisodeId + * @param {string} libraryId */ - async handleDeleteLibraryItem(libraryItemId, mediaItemIds) { + async handleDeleteLibraryItem(libraryItemId, mediaItemIds, libraryId) { const numProgressRemoved = await Database.mediaProgressModel.destroy({ where: { mediaItemId: mediaItemIds @@ -395,7 +396,8 @@ class ApiRouter { await Database.libraryItemModel.removeById(libraryItemId) SocketAuthority.emitter('item_removed', { - id: libraryItemId + id: libraryItemId, + libraryId }) } diff --git a/server/utils/constants.js b/server/utils/constants.js index cc5217f4..925035e1 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -48,6 +48,8 @@ module.exports.AudioMimeType = { AIF: 'audio/x-aiff', WEBM: 'audio/webm', WEBMA: 'audio/webm', + // TODO: Switch to `audio/matroska`? marked as deprecated in IANA registry + // ref: https://datatracker.ietf.org/doc/html/rfc9559 MKA: 'audio/x-matroska', AWB: 'audio/amr-wb', CAF: 'audio/x-caf', diff --git a/server/utils/index.js b/server/utils/index.js index c7700a78..49a7c8e6 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -54,6 +54,16 @@ module.exports.isNullOrNaN = (num) => { return num === null || isNaN(num) } +/** + * @param {number|null|undefined} value + * @param {number} max + * @returns {number|null} + */ +module.exports.clampPositiveInt = (value, max) => { + if (value == null || !Number.isFinite(value) || value <= 0) return null + return Math.min(Math.floor(value), max) +} + const xmlToJSON = (xml) => { return new Promise((resolve, reject) => { parseString(xml, (err, results) => { diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 2042a8e3..1cb0c4cb 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -217,6 +217,10 @@ function extractEpisodeData(item) { episode[cleanKey] = extractFirstArrayItemString(item, key) }) + if (episode.subtitle) { + episode.subtitle = htmlSanitizer.sanitize(episode.subtitle.trim()) + } + // Extract psc:chapters if duration is set episode.durationSeconds = episode.duration ? timestampToSeconds(episode.duration) : null