Compare commits

...

46 Commits

Author SHA1 Message Date
advplyr
6e8547f0c8 Version bump 2.2.15 2023-02-11 16:30:06 -06:00
advplyr
96930d7ecc Fix:Scrollable config side nav and mobile ui 2023-02-11 16:19:04 -06:00
advplyr
23f2c8a251 Fix:Replacing item cover remove old covers case insensitive #1391 2023-02-11 15:56:18 -06:00
advplyr
c5372d1405 Fix:Series,Collection,Playlist title scaling #1440 2023-02-11 15:51:23 -06:00
advplyr
dcfbed5f30 Update:Add inode value to log #1447 2023-02-11 15:39:34 -06:00
advplyr
895ab8d18a Fix:Audio track hover timestamp bubble z-index 2023-02-11 15:29:39 -06:00
advplyr
8b5d05739f Fix:Adding new podcast when folder already exists #1462 2023-02-11 15:25:54 -06:00
advplyr
a8f6202302 Remove Gentium Book font, reduce appbar icon and title font size 2023-02-11 15:02:56 -06:00
advplyr
5d40fdf277 Merge pull request #1487 from Nab0y/master
FantLab.ru BookFinder
2023-02-11 14:29:38 -06:00
advplyr
f35c96e118 FantLab minor refactor 2023-02-11 14:25:25 -06:00
advplyr
8f8d6f81ab Fix:Upload API endpoint crashing on invalid request with no files #1473 2023-02-10 17:25:19 -06:00
advplyr
e195eec1c5 Fix:OPF parser supporting attributes on tags #1478 2023-02-10 17:22:23 -06:00
advplyr
33846e46fa Fix:Handle podcast RSS feeds with iso-8859-1 encoding #1489 2023-02-10 17:07:25 -06:00
advplyr
2ad03bcb9a Fix:Bad backup files and backing up playlists, feeds #1485 2023-02-10 15:33:42 -06:00
Dmitry
371cd3b2e5 Update server/providers/FantLab.js
Co-authored-by: advplyr <67830747+advplyr@users.noreply.github.com>
2023-02-09 23:09:44 +03:00
Dmitry Naboychenko
b9307143bd FantLab match provider fixes after code review 2023-02-08 22:32:27 +03:00
Dmitry
36e44e902a Merge branch 'advplyr:master' into master 2023-02-08 17:31:19 +03:00
advplyr
4529fc0124 Merge pull request #1484 from magnww/fix_oom_crash
Reduce memory usage when scanning large folders
2023-02-07 16:59:33 -06:00
gefan
ba07761de3 Revert "kill zombie processes to reduce memory usage"
This reverts commit 19e39f6321.
2023-02-07 12:33:33 +08:00
Dmitry
3b7ce69327 Merge branch 'advplyr:master' into master 2023-02-07 00:25:45 +03:00
Dmitry Naboychenko
cf927f61a0 Add FantLab.ru Book Finder 2023-02-07 00:25:18 +03:00
gefan
61c32d99e7 scan media files in batches 2023-02-07 00:18:57 +08:00
gefan
19e39f6321 kill zombie processes to reduce memory usage 2023-02-07 00:18:48 +08:00
advplyr
f9e6655359 Update:API endpoint for syncing multiple local sessions. New API endpoint to get current user. Deprecate /me/sync-local-progress endpoint 2023-02-05 16:52:17 -06:00
advplyr
debf0f495d Fix:OPML upload path separator #1476 2023-02-04 13:34:50 -06:00
advplyr
3383ec2046 Add logs to playback session manager 2023-02-04 13:23:13 -06:00
advplyr
b957e1a36b Update:API endpoints for library and library item scan updated to POST requests 2023-02-03 17:50:42 -06:00
advplyr
c93f17051a Fix:Event sent when changing languages to rehydrate page 2023-02-03 14:50:48 -06:00
advplyr
5983f0262f Merge pull request #1472 from Nab0y/master
Russian localization
2023-02-03 14:49:14 -06:00
Dmitry
96a8e74d38 Merge branch 'advplyr:master' into master 2023-02-03 23:09:09 +03:00
Dmitry Naboychenko
6f3d488c3d Add russian localization 2023-02-03 23:08:38 +03:00
advplyr
74dcc4f9e4 Merge pull request #1470 from tomazed/patch-localization
Patch localization Item Metadata Utils Header
2023-02-03 04:27:41 -06:00
Tomazed
8c4d3b93c8 revert formatting 2023-02-03 11:25:02 +01:00
Tomazed
c411cf04cc ItemMetaDataUtils Header localized 2023-02-03 11:16:45 +01:00
advplyr
d1b25da408 Merge pull request #1469 from yuuzhan/adding-tags-to-metadata.abs
Adding tags to metadata.abs
2023-02-02 17:19:30 -06:00
advplyr
08f765fa51 Update parsing and using tags from abmetadata file 2023-02-02 17:13:22 -06:00
advplyr
337cf90c4b Add debug logs to playback sessions 2023-02-02 16:24:34 -06:00
advplyr
17b930e13d Merge pull request #1463 from Machou/patch-1
Update fr.json
2023-02-02 16:17:23 -06:00
yuuzhan
639b600570 Updated parseAndCheckForUpdates to pass in LibraryItem instead of Metadata Object 2023-02-02 12:47:12 -05:00
yuuzhan
7a751b8f91 Updated function parseAndCheckForUpdates to pass Library Item rather then just the metadata object 2023-02-02 12:46:22 -05:00
yuuzhan
68621e0c07 Update abmetadataGenerator.js 2023-02-02 12:43:48 -05:00
advplyr
5abb02e93a Version bump 2.2.14 2023-02-01 16:17:36 -06:00
advplyr
573079c5a1 Fix:Downgrade to axios 0.27.2 for pkg #1466 2023-02-01 15:58:58 -06:00
advplyr
5bde320ac7 Update:Remove X-Powered-By express response headers 2023-02-01 14:34:01 -06:00
Machou
5f63d97e59 Update fr.json 2023-02-01 03:40:41 +01:00
Machou
bf2fe3faea Update fr.json
fr_FR update
fr_FR reworked
fr_FR cleaned
2023-02-01 03:37:53 +01:00
94 changed files with 1492 additions and 549 deletions

View File

@@ -48,14 +48,6 @@
font-size: 1.5rem;
}
@font-face {
font-family: 'Gentium Book Basic';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(~static/fonts/GentiumBookBasic.woff2) format('woff2');
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';

View File

@@ -3,11 +3,11 @@
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60">
<div class="flex h-full items-center">
<nuxt-link to="/">
<img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-12 sm:min-w-12 sm:h-12 sm:mr-4" />
<img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-10 sm:min-w-10 sm:h-10 sm:mr-4" />
</nuxt-link>
<nuxt-link to="/">
<h1 class="text-2xl font-book mr-6 hidden lg:block hover:underline">audiobookshelf <span v-if="showExperimentalFeatures" class="material-icons text-lg text-warning pr-1">logo_dev</span></h1>
<h1 class="text-xl mr-6 hidden lg:block hover:underline">audiobookshelf <span v-if="showExperimentalFeatures" class="material-icons text-lg text-warning pr-1">logo_dev</span></h1>
</nuxt-link>
<ui-libraries-dropdown class="mr-2" />

View File

@@ -4,14 +4,14 @@
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" />
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
<p class="text-center text-2xl mb-4 py-4">{{ libraryName }} Library is empty!</p>
<div v-if="userIsAdminOrUp" class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
</div>
</div>
<div v-else-if="loaded && !shelves.length && search" class="w-full h-40 flex items-center justify-center">
<p class="text-center text-xl font-book py-4">No results for query</p>
<p class="text-center text-xl py-4">No results for query</p>
</div>
<!-- Alternate plain view -->
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">

View File

@@ -44,7 +44,7 @@
</div>
</div>
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-px left-4 md:left-8 w-44 rounded-md" style="height: 22px">
<div class="absolute text-center categoryPlacard transform z-30 bottom-px left-4 md:left-8 w-44 rounded-md" style="height: 22px">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border">
<p class="transform text-sm">{{ $strings[shelf.labelStringKey] }}</p>
</div>

View File

@@ -42,7 +42,7 @@
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">
<!-- Series books page -->
<template v-if="selectedSeries">
<p class="pl-2 font-book text-base md:text-lg">
<p class="pl-2 text-base md:text-lg">
{{ seriesName }}
</p>
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
@@ -60,7 +60,7 @@
</template>
<!-- library & collections page -->
<template v-else-if="page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && !isHome">
<p class="font-book hidden md:block">{{ numShowing }} {{ entityName }}</p>
<p class="hidden md:block">{{ numShowing }} {{ entityName }}</p>
<div class="flex-grow hidden sm:inline-block" />

View File

@@ -1,15 +1,19 @@
<template>
<div class="w-44 fixed left-0 top-16 h-full bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform" :class="wrapperClass" v-click-outside="clickOutside">
<div v-show="isMobilePortrait" class="flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
<span class="material-icons text-2xl">arrow_back</span>
<div>
<div class="w-44 fixed left-0 top-16 bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform mb-12 overflow-y-auto" :class="wrapperClass + ' ' + (streamLibraryItem ? 'h-[calc(100%-270px)]' : 'h-[calc(100%-110px)]')" v-click-outside="clickOutside">
<div v-show="isMobilePortrait" class="flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
<span class="material-icons text-2xl">arrow_back</span>
</div>
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-4 h-12 border-b border-primary border-opacity-30 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>{{ route.title }}</p>
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version" />
</div>
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-4 h-12 border-b border-primary border-opacity-30 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>{{ route.title }}</p>
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<div class="w-full h-12 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }">
<div class="w-44 h-12 px-4 border-t bg-bg border-black border-opacity-20 fixed left-0 flex flex-col justify-center" :class="wrapperClass" :style="{ bottom: streamLibraryItem ? '160px' : '0px' }">
<div class="flex justify-between">
<p class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</p>
@@ -17,8 +21,6 @@
</div>
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
</div>
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version" />
</div>
</template>

View File

@@ -7,7 +7,7 @@
</template>
<div v-if="initialized && !totalShelves && !hasFilter && entityName === 'items'" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">{{ $getString('MessageXLibraryIsEmpty', [libraryName]) }}</p>
<p class="text-center text-2xl mb-4 py-4">{{ $getString('MessageXLibraryIsEmpty', [libraryName]) }}</p>
<div v-if="userIsAdminOrUp" class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2">{{ $strings.ButtonConfigureScanner }}</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">{{ $strings.ButtonScanLibrary }}</ui-btn>

View File

@@ -8,7 +8,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonHome }}</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonHome }}</p>
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
@@ -16,7 +16,7 @@
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons text-2xl">format_list_bulleted</span>
<p class="font-book pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLatest }}</p>
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLatest }}</p>
<div v-show="isPodcastLatestPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
@@ -26,7 +26,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLibrary }}</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLibrary }}</p>
<div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
@@ -36,7 +36,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSeries }}</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSeries }}</p>
<div v-show="isSeriesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
@@ -44,7 +44,7 @@
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons-outlined text-2xl">collections_bookmark</span>
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCollections }}</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCollections }}</p>
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
@@ -57,7 +57,7 @@
/>
</svg>
<p class="font-book pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAuthors }}</p>
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAuthors }}</p>
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
@@ -65,7 +65,7 @@
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="abs-icons icon-podcast text-xl"></span>
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSearch }}</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSearch }}</p>
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
@@ -73,7 +73,7 @@
<nuxt-link v-if="isMusicLibrary" :to="`/library/${currentLibraryId}/bookshelf/albums`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isMusicAlbumsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons-outlined text-xl">album</span>
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">Albums</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">Albums</p>
<div v-show="isMusicAlbumsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
@@ -81,7 +81,7 @@
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons text-2.5xl">queue_music</span>
<p class="font-book pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
<p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
@@ -89,7 +89,7 @@
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'">
<span class="material-icons text-2xl">warning</span>
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 1rem">{{ $strings.ButtonIssues }}</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 1rem">{{ $strings.ButtonIssues }}</p>
<div v-show="showingIssues" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
<div class="absolute top-1 right-1 w-4 h-4 rounded-full bg-white bg-opacity-30 flex items-center justify-center">

View File

@@ -6,7 +6,7 @@
<covers-group-cover ref="groupcover" :id="groupEncode" :name="groupName" :type="groupType" :book-items="bookItems" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity z-30" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ groupName }}</p>
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ groupName }}</p>
</div>
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ bookItems.length }}</div>

View File

@@ -5,7 +5,7 @@
<covers-preview-cover ref="cover" :src="coverSrc" :width="width" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>

View File

@@ -23,7 +23,7 @@
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
<div v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="font-book text-gray-300 text-center">{{ title }}</p>
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="text-gray-300 text-center">{{ title }}</p>
</div>
<!-- Cover Image -->
@@ -32,13 +32,13 @@
<!-- Placeholder Cover Title & Author -->
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div>
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">
<p class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">
{{ titleCleaned }}
</p>
</div>
</div>
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
<p class="text-center font-book" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
<p class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
</div>
</div>
@@ -662,7 +662,7 @@ export default {
const axios = this.$axios || this.$nuxt.$axios
this.processing = true
axios
.$get(`/api/items/${this.libraryItemId}/scan`)
.$post(`/api/items/${this.libraryItemId}/scan`)
.then((data) => {
var result = data.result
if (!result) {

View File

@@ -12,13 +12,13 @@
<span v-if="!isHovering && rssFeed" class="absolute z-10 material-icons text-success" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem', fontSize: 1.5 * sizeMultiplier + 'rem' }">rss_feed</span>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
</div>
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
<p class="truncate" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ title }}</p>
</div>
</div>
</template>

View File

@@ -9,13 +9,13 @@
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
</div>
</div>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
</div>
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
<p class="truncate" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ title }}</p>
</div>
</div>
</template>
@@ -50,8 +50,8 @@ export default {
return 0.875
},
sizeMultiplier() {
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
return this.width / 240
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6)
return this.width / 120
},
title() {
return this.playlist ? this.playlist.name : ''

View File

@@ -10,18 +10,18 @@
<div v-if="isSeriesFinished" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b bg-success w-full" />
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
</div>
<span v-if="!isHovering && rssFeed" class="absolute z-10 material-icons text-success" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem', fontSize: 1.5 * sizeMultiplier + 'rem' }">rss_feed</span>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
</div>
</div>
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
<p class="truncate" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
</div>
</div>

View File

@@ -54,7 +54,7 @@ export default {
},
folderPath() {
if (!this.libraryFolderPath) return ''
return `${this.libraryFolderPath}\\${this.$sanitizeFilename(this.title)}`
return `${this.libraryFolderPath}/${this.$sanitizeFilename(this.title)}`
},
detailsWidth() {
return this.width - 85

View File

@@ -7,7 +7,7 @@
<img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
<div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
<p class="text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
<div class="absolute top-2 right-2">
<widgets-loading-spinner />
</div>
@@ -17,17 +17,17 @@
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
<img src="/Logo.png" loading="lazy" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
<p class="text-center font-book text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
<p class="text-center text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
</div>
</div>
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div>
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
<p class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
</div>
</div>
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
<p class="text-center font-book" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
<p class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
</div>
</div>
</template>

View File

@@ -19,7 +19,7 @@
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
<p class="font-book text-white text-opacity-60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
<p class="text-white text-opacity-60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
</div>
</div>
</template>

View File

@@ -138,7 +138,7 @@ export default {
var innerP = document.createElement('p')
innerP.textContent = this.name
innerP.className = 'text-sm font-book text-white'
innerP.className = 'text-sm text-white'
imgdiv.appendChild(innerP)
return imgdiv

View File

@@ -14,7 +14,7 @@
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
<img src="/Logo.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
<p class="text-center font-book text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
<p class="text-center text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
</div>
</div>

View File

@@ -2,7 +2,7 @@
<modals-modal ref="modal" v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<form @submit.prevent="submitForm">

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="backup-scheduler" :width="700" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ $strings.HeaderSetBackupSchedule }}</p>
<p class="text-3xl text-white truncate">{{ $strings.HeaderSetBackupSchedule }}</p>
</div>
</template>
<div v-if="show && newCronExpression" class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="batchQuickMatch" :processing="processing" :width="500" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="bookmarks" :width="500" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ $strings.LabelYourBookmarks }}</p>
<p class="text-3xl text-white truncate">{{ $strings.LabelYourBookmarks }}</p>
</div>
</template>
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="listening-session-modal" :processing="processing" :width="700" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ $strings.HeaderSession }} {{ _session.id }}</p>
<p class="text-3xl text-white truncate">{{ $strings.HeaderSession }} {{ _session.id }}</p>
</div>
</template>
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="sleep-timer" :width="350" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="font-book text-3xl text-white truncate pointer-events-none">{{ $strings.HeaderSleepTimer }}</p>
<p class="text-3xl text-white truncate pointer-events-none">{{ $strings.HeaderSleepTimer }}</p>
</div>
</template>

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="edit-author" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="changelog" :width="800" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">Changelog</p>
<p class="text-3xl text-white truncate">Changelog</p>
</div>
</template>
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="collections" :processing="processing" :width="500" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="edit-collection" :width="700" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ $strings.HeaderCollection }}</p>
<p class="text-3xl text-white truncate">{{ $strings.HeaderCollection }}</p>
</div>
</template>
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">

View File

@@ -2,12 +2,12 @@
<modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="marginTop">
<template #outer>
<div class="absolute top-0 left-0 p-4 landscape:px-4 landscape:py-2 md:portrait:p-5 lg:p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="font-book text-xl md:portrait:text-3xl md:landscape:text-lg lg:text-3xl text-white truncate pointer-events-none">{{ title }}</p>
<p class="text-xl md:portrait:text-3xl md:landscape:text-lg lg:text-3xl text-white truncate pointer-events-none">{{ title }}</p>
</div>
</template>
<div class="absolute -top-10 left-0 z-10 w-full flex">
<template v-for="tab in availableTabs">
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
</template>
</div>

View File

@@ -129,7 +129,7 @@ export default {
rescan() {
this.rescanning = true
this.$axios
.$get(`/api/items/${this.libraryItemId}/scan`)
.$post(`/api/items/${this.libraryItemId}/scan`)
.then((data) => {
this.rescanning = false
var result = data.result

View File

@@ -19,7 +19,7 @@
</div>
<div v-if="!episodes.length" class="flex my-4 text-center justify-center text-xl">{{ $strings.MessageNoEpisodes }}</div>
<table v-else class="text-sm tracksTable">
<tr class="font-book">
<tr>
<th class="text-left">Sort #</th>
<th class="text-left whitespace-nowrap">{{ $strings.LabelEpisode }}</th>
<th class="text-left">{{ $strings.EpisodeTitle }}</th>
@@ -33,7 +33,7 @@
<td class="text-left">
<p class="px-4">{{ episode.episode }}</p>
</td>
<td class="font-book">
<td>
{{ episode.title }}
</td>
<td class="font-mono text-center">

View File

@@ -2,12 +2,12 @@
<modals-modal v-model="show" name="edit-library" :width="700" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-xl md:text-3xl text-white truncate">{{ title }}</p>
<p class="text-xl md:text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div class="absolute -top-10 left-0 z-10 w-full flex">
<template v-for="tab in tabs">
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
</template>
</div>

View File

@@ -2,7 +2,7 @@
<modals-modal ref="modal" v-model="show" name="notification-edit" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<form @submit.prevent="submitForm">

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="queue-items" :width="800" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ $strings.HeaderPlayerQueue }}</p>
<p class="text-3xl text-white truncate">{{ $strings.HeaderPlayerQueue }}</p>
</div>
</template>
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden py-4" style="max-height: 80vh">

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="playlists" :processing="processing" :width="500" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="edit-playlist" :width="700" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ $strings.HeaderPlaylist }}</p>
<p class="text-3xl text-white truncate">{{ $strings.HeaderPlaylist }}</p>
</div>
</template>
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">

View File

@@ -2,12 +2,12 @@
<modals-modal v-model="show" name="podcast-episode-edit-modal" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div class="absolute -top-10 left-0 z-10 w-full flex">
<template v-for="tab in tabs">
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
</template>
</div>

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="podcast-episodes-modal" :width="1200" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div ref="wrapper" id="podcast-wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="new-podcast-modal" :width="1000" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-3/4 overflow-hidden">
<p class="font-book text-xl md:text-3xl text-white truncate">{{ title }}</p>
<p class="text-xl md:text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div ref="wrapper" id="podcast-wrapper" class="p-2 md:p-8 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-x-hidden overflow-y-auto" style="max-height: 80vh">

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="opml-feeds-modal" :width="1000" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div ref="wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
@@ -97,7 +97,7 @@ export default {
},
methods: {
toFeedMetadata(feed) {
var metadata = feed.metadata
const metadata = feed.metadata
return {
title: metadata.title,
author: metadata.author,
@@ -122,9 +122,9 @@ export default {
},
async submit() {
this.processing = true
var newFeedPayloads = this.feedMetadata.map((metadata) => {
const newFeedPayloads = this.feedMetadata.map((metadata) => {
return {
path: `${this.selectedFolderPath}\\${this.$sanitizeFilename(metadata.title)}`,
path: `${this.selectedFolderPath}/${this.$sanitizeFilename(metadata.title)}`,
folderId: this.selectedFolderId,
libraryId: this.currentLibrary.id,
media: {

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="podcast-episode-remove-modal" :width="500" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="podcast-episode-view-modal" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ $strings.LabelEpisode }}</p>
<p class="text-3xl text-white truncate">{{ $strings.LabelEpisode }}</p>
</div>
</template>
<div ref="wrapper" class="p-4 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh">

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="rss-feed-modal" :width="600" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">

View File

@@ -15,7 +15,7 @@
</div>
<!-- Hover timestamp -->
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none z-10">
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap">00:00</p>
</div>
<div ref="hoverTimestampArrow" class="absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
@@ -83,9 +83,9 @@ export default {
var offsetX = e.offsetX
var perc = offsetX / this.trackWidth
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0;
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration;
const time = baseTime + (perc * duration);
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
const time = baseTime + perc * duration
if (isNaN(time) || time === null) {
console.error('Invalid time', perc, time)
return
@@ -143,10 +143,10 @@ export default {
mousemoveTrack(e) {
var offsetX = e.offsetX
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0;
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration;
const progressTime = (offsetX / this.trackWidth) * duration;
const totalTime = baseTime + progressTime;
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
const progressTime = (offsetX / this.trackWidth) * duration
const totalTime = baseTime + progressTime
if (this.$refs.hoverTimestamp) {
var width = this.$refs.hoverTimestamp.clientWidth

View File

@@ -4,7 +4,7 @@
<span class="material-icons cursor-pointer text-4xl" @click="close">close</span>
</div>
<div class="absolute top-4 left-4 font-book">
<div class="absolute top-4 left-4">
<h1 class="text-2xl mb-1">{{ abTitle }}</h1>
<p v-if="abAuthor">by {{ abAuthor }}</p>
</div>

View File

@@ -1,6 +1,6 @@
<template>
<div class="w-96 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">{{ $strings.HeaderStatsMinutesListeningChart }}</h1>
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsMinutesListeningChart }}</h1>
<div class="relative w-96 h-72">
<div class="absolute top-0 left-0">
<template v-for="lbl in yAxisLabels">
@@ -27,7 +27,7 @@
<div class="absolute -bottom-2 left-0 flex ml-6">
<template v-for="dayObj in last7Days">
<div :key="dayObj.date" :style="{ width: daySpacing + daySpacing / 14 + 'px' }">
<p class="text-sm font-book">{{ dayObj.dayOfWeekAbbr }}</p>
<p class="text-sm">{{ dayObj.dayOfWeekAbbr }}</p>
</div>
</template>
</div>

View File

@@ -6,7 +6,7 @@
</svg>
<div class="px-2">
<p class="text-4xl md:text-5xl font-bold">{{ totalItems }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsItemsInLibrary }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsItemsInLibrary }}</p>
</div>
</div>
@@ -14,7 +14,7 @@
<span class="material-icons text-7xl">show_chart</span>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalTime }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ useOverallHours ? $strings.LabelStatsOverallHours : $strings.LabelStatsOverallDays }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ useOverallHours ? $strings.LabelStatsOverallHours : $strings.LabelStatsOverallDays }}</p>
</div>
</div>
@@ -24,7 +24,7 @@
</svg>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalAuthors }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAuthors }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAuthors }}</p>
</div>
</div>
@@ -32,7 +32,7 @@
<span class="material-icons-outlined text-6xl pt-1">insert_drive_file</span>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalSizeNum }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelSize }} ({{ totalSizeMod }})</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelSize }} ({{ totalSizeMod }})</p>
</div>
</div>
@@ -40,7 +40,7 @@
<span class="material-icons-outlined text-6xl pt-1">audio_file</span>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ numAudioTracks }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAudioTracks }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAudioTracks }}</p>
</div>
</div>
</div>

View File

@@ -11,7 +11,7 @@
</div>
<transition name="slide">
<table class="text-sm tracksTable" v-show="expanded || keepOpen">
<tr class="font-book">
<tr>
<th class="text-left w-16"><span class="px-4">Id</span></th>
<th class="text-left">{{ $strings.LabelTitle }}</th>
<th class="text-center">{{ $strings.LabelStart }}</th>
@@ -21,7 +21,7 @@
<td class="text-left">
<p class="px-4">{{ chapter.id }}</p>
</td>
<td class="font-book">
<td>
{{ chapter.title }}
</td>
<td class="font-mono text-center hover:underline cursor-pointer" @click.stop="goToTimestamp(chapter.start)">

View File

@@ -14,7 +14,7 @@
<transition name="slide">
<div class="w-full" v-show="showFiles">
<table class="text-sm tracksTable">
<tr class="font-book">
<tr>
<th class="text-left px-4">{{ $strings.LabelPath }}</th>
<th class="text-left w-24 min-w-24">{{ $strings.LabelSize }}</th>
<th class="text-left px-4 w-24">{{ $strings.LabelType }}</th>
@@ -22,7 +22,7 @@
</tr>
<template v-for="file in files">
<tr :key="file.path">
<td class="font-book px-4">
<td class="px-4">
{{ showFullPath ? file.metadata.path : file.metadata.relPath }}
</td>
<td class="font-mono">

View File

@@ -18,7 +18,7 @@
<transition name="slide">
<div class="w-full" v-show="showTracks">
<table class="text-sm tracksTable">
<tr class="font-book">
<tr>
<th class="w-10">#</th>
<th class="text-left">{{ $strings.LabelFilename }}</th>
<th class="text-left w-20">{{ $strings.LabelSize }}</th>

View File

@@ -11,20 +11,20 @@
<transition name="slide">
<div class="w-full" v-show="expand">
<table class="text-sm tracksTable">
<tr class="font-book">
<tr>
<th class="text-left">{{ $strings.LabelFilename }}</th>
<th class="text-left">{{ $strings.LabelSize }}</th>
<th class="text-left">{{ $strings.LabelType }}</th>
</tr>
<template v-for="file in files">
<tr :key="file.path">
<td class="font-book pl-2">
<td class="pl-2">
{{ file.name }}
</td>
<td class="font-mono">
{{ $bytesPretty(file.size) }}
</td>
<td class="font-book">
<td>
{{ file.filetype }}
</td>
</tr>

View File

@@ -5,7 +5,7 @@
<svg v-else viewBox="0 0 24 24" class="h-6 w-6 text-white text-opacity-50 animate-spin">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
<p class="text-base md:text-xl font-book pl-2 md:pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
<p class="text-base md:text-xl pl-2 md:pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
<div class="flex-grow" />

View File

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

View File

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

View File

@@ -136,7 +136,7 @@
<modals-modal v-model="showFindChaptersModal" name="edit-book" :width="500" :processing="findingChapters">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="font-book text-3xl text-white truncate pointer-events-none">{{ $strings.HeaderFindChapters }}</p>
<p class="text-3xl text-white truncate pointer-events-none">{{ $strings.HeaderFindChapters }}</p>
</div>
</template>
<div class="w-full h-full max-h-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative">

View File

@@ -37,23 +37,23 @@
<draggable v-model="files" v-bind="dragOptions" class="list-group border border-gray-600" draggable=".item" tag="ul" @start="drag = true" @end="drag = false" @update="draggableUpdate">
<transition-group type="transition" :name="!drag ? 'flip-list' : null">
<li v-for="(audio, index) in files" :key="audio.ino" :class="audio.include ? 'item' : 'exclude'" class="w-full list-group-item flex items-center relative">
<div class="font-book text-center px-4 py-1 w-12 min-w-12">
<div class="text-center px-4 py-1 w-12 min-w-12">
{{ audio.include ? index - numExcluded + 1 : -1 }}
</div>
<div class="font-book text-center px-4 w-24 min-w-24">{{ audio.index }}</div>
<div class="font-book text-center px-2 w-32 min-w-32">
<div class="text-center px-4 w-24 min-w-24">{{ audio.index }}</div>
<div class="text-center px-2 w-32 min-w-32">
{{ audio.trackNumFromFilename }}
</div>
<div class="font-book text-center w-32 min-w-32">
<div class="text-center w-32 min-w-32">
{{ audio.trackNumFromMeta }}
</div>
<div class="font-book truncate px-4 w-20 min-w-20">
<div class="truncate px-4 w-20 min-w-20">
{{ audio.discNumFromFilename }}
</div>
<div class="font-book truncate px-4 w-20 min-w-20">
<div class="truncate px-4 w-20 min-w-20">
{{ audio.discNumFromMeta }}
</div>
<div class="font-book truncate px-4 flex-grow">
<div class="truncate px-4 flex-grow">
{{ audio.metadata.filename }}
</div>

View File

@@ -10,7 +10,7 @@
<ui-toggle-switch labeledBy="settings-store-cover-with-items" v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
<ui-tooltip :text="$strings.LabelSettingsStoreCoversWithItemHelp">
<p class="pl-4">
<span id="settings-store-cover-with-items">{{ $strings.LabelSettingsStoreCoversWithItem }}</span>
<span id="settings-store-cover-with-items">{{ $strings.LabelSettingsStoreCoversWithItem }}</span>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
@@ -206,7 +206,7 @@
<div class="flex items-center py-4">
<div class="flex-grow" />
<p class="pr-2 text-sm font-book text-yellow-400">
<p class="pr-2 text-sm text-yellow-400">
{{ $strings.MessageReportBugsAndContribute }}
<a class="underline" href="https://github.com/advplyr/audiobookshelf" target="_blank">github</a>
</p>
@@ -217,7 +217,7 @@
/>
</svg>
</a>
<p class="pl-4 pr-2 text-sm font-book text-yellow-400">
<p class="pl-4 pr-2 text-sm text-yellow-400">
{{ $strings.MessageJoinUsOn }}
<a class="underline" href="https://discord.gg/pJsjuNCKRq" target="_blank">discord</a>
</p>

View File

@@ -1,6 +1,6 @@
<template>
<div>
<app-settings-content :header-text="'Item Metadata Utils'">
<app-settings-content :header-text="$strings.HeaderItemMetadataUtils">
<nuxt-link to="/config/item-metadata-utils/tags" class="block w-full rounded bg-primary/40 hover:bg-primary/60 text-gray-300 hover:text-white p-4 mt-6 mb-2">
<div class="flex justify-between">
<p>{{ $strings.HeaderManageTags }}</p>

View File

@@ -5,14 +5,14 @@
<div class="flex lg:flex-row flex-wrap justify-between flex-col mt-8">
<div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">{{ $strings.HeaderStatsTop5Genres }}</h1>
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsTop5Genres }}</h1>
<p v-if="!top5Genres.length">{{ $strings.MessageNoGenres }}</p>
<template v-for="genre in top5Genres">
<div :key="genre.genre" class="w-full py-2">
<div class="flex items-end mb-1">
<p class="text-2xl font-bold">{{ Math.round((100 * genre.count) / totalItems) }}&nbsp;%</p>
<div class="flex-grow" />
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=genres.${$encode(genre.genre)}`" class="text-base font-book text-white text-opacity-70 hover:underline">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=genres.${$encode(genre.genre)}`" class="text-base text-white text-opacity-70 hover:underline">
{{ genre.genre }}
</nuxt-link>
</div>
@@ -23,12 +23,12 @@
</template>
</div>
<div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">{{ $strings.HeaderStatsTop10Authors }}</h1>
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsTop10Authors }}</h1>
<p v-if="!top10Authors.length">{{ $strings.MessageNoAuthors }}</p>
<template v-for="(author, index) in top10Authors">
<div :key="author.id" class="w-full py-2">
<div class="flex items-center mb-1">
<p class="text-sm font-book text-white text-opacity-70 w-36 pr-2 truncate">
<p class="text-sm text-white text-opacity-70 w-36 pr-2 truncate">
{{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">{{ author.name }}</nuxt-link>
</p>
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
@@ -42,12 +42,12 @@
</template>
</div>
<div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">{{ $strings.HeaderStatsLongestItems }}</h1>
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsLongestItems }}</h1>
<p v-if="!top10LongestItems.length">{{ $strings.MessageNoItems }}</p>
<template v-for="(ab, index) in top10LongestItems">
<div :key="index" class="w-full py-2">
<div class="flex items-center mb-1">
<p class="text-sm font-book text-white text-opacity-70 w-44 pr-2 truncate">
<p class="text-sm text-white text-opacity-70 w-44 pr-2 truncate">
{{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/item/${ab.id}`" class="hover:underline">{{ ab.title }}</nuxt-link>
</p>
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">

View File

@@ -11,7 +11,7 @@
</svg>
<div class="px-3">
<p class="text-4xl md:text-5xl font-bold">{{ userItemsFinished.length }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsItemsFinished }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsItemsFinished }}</p>
</div>
</div>
@@ -21,7 +21,7 @@
</div>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalDaysListened }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsDaysListened }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsDaysListened }}</p>
</div>
</div>
@@ -31,7 +31,7 @@
</div>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalMinutesListening }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsMinutesListening }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsMinutesListening }}</p>
</div>
</div>
</div>
@@ -39,7 +39,7 @@
<stats-daily-listening-chart :listening-stats="listeningStats" class="origin-top-left transform scale-75 lg:scale-100" />
<div class="w-80 my-6 mx-auto">
<div class="flex mb-4 items-center">
<h1 class="text-2xl font-book">{{ $strings.HeaderStatsRecentSessions }}</h1>
<h1 class="text-2xl">{{ $strings.HeaderStatsRecentSessions }}</h1>
<div class="flex-grow" />
<ui-btn :to="`/config/users/${user.id}/sessions`" class="text-xs" :padding-x="1.5" :padding-y="1">{{ $strings.ButtonViewAll }}</ui-btn>
</div>
@@ -47,9 +47,9 @@
<template v-for="(item, index) in mostRecentListeningSessions">
<div :key="item.id" class="w-full py-0.5">
<div class="flex items-center mb-1">
<p class="text-sm font-book text-white text-opacity-70 w-8">{{ index + 1 }}.&nbsp;</p>
<p class="text-sm text-white text-opacity-70 w-8">{{ index + 1 }}.&nbsp;</p>
<div class="w-56">
<p class="text-sm font-book text-white text-opacity-80 truncate">{{ item.mediaMetadata ? item.mediaMetadata.title : '' }}</p>
<p class="text-sm text-white text-opacity-80 truncate">{{ item.mediaMetadata ? item.mediaMetadata.title : '' }}</p>
<p class="text-xs text-white text-opacity-50">{{ $dateDistanceFromNow(item.updatedAt) }}</p>
</div>
<div class="flex-grow" />

View File

@@ -65,7 +65,7 @@
<td>
<covers-book-cover :width="50" :library-item="item" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</td>
<td class="font-book">
<td>
<template v-if="item.media && item.media.metadata && item.episode">
<p>{{ item.episode.title || 'Unknown' }}</p>
<p class="text-white text-opacity-50 text-sm font-sans">{{ item.media.metadata.title }}</p>

View File

@@ -12,6 +12,7 @@ const languageCodeMap = {
'hr': { label: 'Hrvatski', dateFnsLocale: 'hr' },
'it': { label: 'Italiano', dateFnsLocale: 'it' },
'pl': { label: 'Polski', dateFnsLocale: 'pl' },
'ru': { label: 'Русский', dateFnsLocale: 'ru' },
'zh-cn': { label: '简体中文 (Simplified Chinese)', dateFnsLocale: 'zhCN' },
}
Vue.prototype.$languageCodeOptions = Object.keys(languageCodeMap).map(code => {
@@ -77,7 +78,7 @@ async function loadi18n(code) {
Vue.prototype.$setDateFnsLocale(languageCodeMap[code].dateFnsLocale)
console.log('i18n strings=', Vue.prototype.$strings)
Vue.prototype.$eventBus.$emit('change-lang', code)
this.$eventBus.$emit('change-lang', code)
return true
}

View File

Binary file not shown.

View File

@@ -66,7 +66,7 @@ export const getters = {
export const actions = {
requestLibraryScan({ state, commit }, { libraryId, force }) {
return this.$axios.$get(`/api/libraries/${libraryId}/scan`, { params: { force } })
return this.$axios.$post(`/api/libraries/${libraryId}/scan?force=${force ? 1 : 0}`)
},
loadFolders({ state, commit }) {
if (state.folders.length) {

View File

@@ -52,6 +52,10 @@ export const state = () => ({
{
text: 'Audible.es',
value: 'audible.es'
},
{
text: 'FantLab.ru',
value: 'fantlab'
}
],
podcastProviders: [

View File

@@ -1,70 +1,70 @@
{
"ButtonAdd": "Ajouter",
"ButtonAddChapters": "Ajouter Chapitre",
"ButtonAddPodcasts": "Ajouter Podcasts",
"ButtonAddYourFirstLibrary": "Ajouter votre Première Bibliothèque",
"ButtonAddChapters": "Ajouter le chapitre",
"ButtonAddPodcasts": "Ajouter des podcasts",
"ButtonAddYourFirstLibrary": "Ajouter votre première bibliothèque",
"ButtonApply": "Appliquer",
"ButtonApplyChapters": "Appliquer les Chapitres",
"ButtonApplyChapters": "Appliquer les chapitres",
"ButtonAuthors": "Auteurs",
"ButtonBrowseForFolder": "Naviguer vers le Répertoire",
"ButtonBrowseForFolder": "Naviguer vers le répertoire",
"ButtonCancel": "Annuler",
"ButtonCancelEncode": "Annuler l'encodage",
"ButtonChangeRootPassword": "Changer le mot de passe Administrateur",
"ButtonCheckAndDownloadNewEpisodes": "Vérifier & Télécharger de Nouveaux Episodes",
"ButtonChooseAFolder": "Choisir un Dossier",
"ButtonChooseFiles": "Choisir les Fichiers",
"ButtonClearFilter": "Effacer le Filtre",
"ButtonCloseFeed": "Fermer le Flux",
"ButtonCheckAndDownloadNewEpisodes": "Vérifier & télécharger de nouveaux épisodes",
"ButtonChooseAFolder": "Choisir un dossier",
"ButtonChooseFiles": "Choisir les fichiers",
"ButtonClearFilter": "Effacer le filtre",
"ButtonCloseFeed": "Fermer le flux",
"ButtonCollections": "Collections",
"ButtonConfigureScanner": "Configurer le Scan",
"ButtonConfigureScanner": "Configurer l'analyse",
"ButtonCreate": "Créer",
"ButtonCreateBackup": "Créer une Sauvegarde",
"ButtonCreateBackup": "Créer une sauvegarde",
"ButtonDelete": "Effacer",
"ButtonEdit": "Editer",
"ButtonEditChapters": "Editer Chapitre",
"ButtonEditPodcast": "Editer Podcast",
"ButtonForceReScan": "Forcer un Re-Scan",
"ButtonFullPath": "Chemin Complet",
"ButtonEdit": "Modifier",
"ButtonEditChapters": "Modifier les chapitres",
"ButtonEditPodcast": "Modifier les podcasts",
"ButtonForceReScan": "Forcer une nouvelle analyse",
"ButtonFullPath": "Chemin complet",
"ButtonHide": "Cacher",
"ButtonHome": "Accueil",
"ButtonIssues": "Parutions",
"ButtonLatest": "Dernière Version",
"ButtonLatest": "Dernière version",
"ButtonLibrary": "Bibliothèque",
"ButtonLogout": "Se Déconnecter",
"ButtonLookup": "Rechercher",
"ButtonManageTracks": "Gérer les pistes",
"ButtonMapChapterTitles": "Correspondance des titres de chapitres",
"ButtonMatchAllAuthors": "Rechercher tous les Auteurs",
"ButtonMatchAllAuthors": "Rechercher tous les auteurs",
"ButtonMatchBooks": "Rechercher les Livres",
"ButtonNevermind": "Oubliez cela",
"ButtonOk": "Ok",
"ButtonOpenFeed": "Ouvrir le Flux",
"ButtonOpenManager": "Ouvrir le Gestionnaire",
"ButtonPlay": "Ecouter",
"ButtonPlaying": "En Lecture",
"ButtonPlaylists": "Listes de Lecture",
"ButtonPurgeAllCache": "Purger Tout le Cache",
"ButtonPurgeItemsCache": "Purger le Cache des Articles",
"ButtonPurgeMediaProgress": "Purger la Progression des Médias",
"ButtonQueueAddItem": "Ajouter à la Liste de Lecture",
"ButtonQueueRemoveItem": "Supprimer de la Liste de Lecture",
"ButtonQuickMatch": "Recherche Rapide",
"ButtonPlay": "Écouter",
"ButtonPlaying": "En lecture",
"ButtonPlaylists": "Listes de lecture",
"ButtonPurgeAllCache": "Purger le cache",
"ButtonPurgeItemsCache": "Purger le cache des articles",
"ButtonPurgeMediaProgress": "Purger la progression des médias",
"ButtonQueueAddItem": "Ajouter à la liste de lecture",
"ButtonQueueRemoveItem": "Supprimer de la liste de lecture",
"ButtonQuickMatch": "Recherche rapide",
"ButtonRead": "Lire",
"ButtonRemove": "Supprimer",
"ButtonRemoveAll": "Supprimer tout",
"ButtonRemoveAllLibraryItems": "Supprimer tous les Articles de la Bibliothèque",
"ButtonRemoveAllLibraryItems": "Supprimer tous les articles de la bibliothèque",
"ButtonRemoveFromContinueListening": "Ne plus continuer à écouter",
"ButtonRemoveSeriesFromContinueSeries": "Ne plus continuer à écouter la Série",
"ButtonReScan": "Re-Scan",
"ButtonRemoveSeriesFromContinueSeries": "Ne plus continuer à écouter la série",
"ButtonReScan": "Nouvelle analyse",
"ButtonReset": "Réinitialiser",
"ButtonRestore": "Rétablir",
"ButtonSave": "Sauvegarder",
"ButtonSaveAndClose": "Sauvegarder & Fermer",
"ButtonSaveTracklist": "Sauvegarder la liste de lecture",
"ButtonScan": "Scanner",
"ButtonScanLibrary": "Scanner la Bibliothèque",
"ButtonScan": "Analyser",
"ButtonScanLibrary": "Analyser la bibliothèque",
"ButtonSearch": "Rechercher",
"ButtonSelectFolderPath": "Sélectionner le Chemin du Dossier",
"ButtonSelectFolderPath": "Sélectionner le Chemin du dossier",
"ButtonSeries": "Séries",
"ButtonSetChaptersFromTracks": "Positionner les Chapitre par rapports aux Pistes",
"ButtonShiftTimes": "Décaler le Temps",
@@ -78,88 +78,88 @@
"ButtonUploadOPMLFile": "Téléverser un Fichier OPML",
"ButtonUserDelete": "Effacer l'utilisateur {0}",
"ButtonUserEdit": "Modifier l'utilisateur {0}",
"ButtonViewAll": "Afficher Tout",
"ButtonViewAll": "Afficher tout",
"ButtonYes": "Oui",
"HeaderAccount": "Compte",
"HeaderAdvanced": "Avancé",
"HeaderAppriseNotificationSettings": "Configuration des Notifications Apprise",
"HeaderAudiobookTools": "Outils de Gestion de Fichier Audiobook",
"HeaderAudioTracks": "Pistes Audio",
"HeaderAudioTracks": "Pistes zudio",
"HeaderBackups": "Sauvegardes",
"HeaderChangePassword": "Chager le mot de passe",
"HeaderChapters": "Chapitres",
"HeaderChooseAFolder": "Choisir un Dossier",
"HeaderChooseAFolder": "Choisir un dossier",
"HeaderCollection": "Collection",
"HeaderCollectionItems": "Entrées de la Collection",
"HeaderCover": "Couverture",
"HeaderDetails": "Détails",
"HeaderEpisodes": "Episodes",
"HeaderEpisodes": "Épisodes",
"HeaderFiles": "Fichiers",
"HeaderFindChapters": "Trouver les Chapitres",
"HeaderFindChapters": "Trouver les chapitres",
"HeaderIgnoredFiles": "Fichiers Ignorés",
"HeaderItemFiles": "Fichiers des Articles",
"HeaderItemMetadataUtils": "Outils de Gestion des Métadonnées",
"HeaderLastListeningSession": "Dernière Session d'Ecoute",
"HeaderLatestEpisodes": "Dernier Episodes",
"HeaderItemMetadataUtils": "Outils de gestion des métadonnées",
"HeaderLastListeningSession": "Dernière Session d'écoute",
"HeaderLatestEpisodes": "Dernier épisodes",
"HeaderLibraries": "Bibliothèque",
"HeaderLibraryFiles": "Fichier de Bibliothèque",
"HeaderLibraryStats": "Statistiques de Bibliothèque",
"HeaderListeningSessions": "Sessions d'Ecoute",
"HeaderListeningStats": "Statistiques d'Ecoute",
"HeaderLibraryFiles": "Fichier de bibliothèque",
"HeaderLibraryStats": "Statistiques de bibliothèque",
"HeaderListeningSessions": "Sessions d'écoute",
"HeaderListeningStats": "Statistiques d'écoute",
"HeaderLogin": "Connexion",
"HeaderLogs": "Fichiers Journaux",
"HeaderManageGenres": "Gérer les Genres",
"HeaderManageTags": "Gérer les Etiquettes",
"HeaderMapDetails": "Edition en Masse",
"HeaderManageGenres": "Gérer les genres",
"HeaderManageTags": "Gérer les étiquettes",
"HeaderMapDetails": "Édition en Masse",
"HeaderMatch": "Rechercher",
"HeaderMetadataToEmbed": "Métadonnée à Intégrer",
"HeaderNewAccount": "Nouveau Compte",
"HeaderNewLibrary": "Nouvelle Bibliothèque",
"HeaderNotifications": "Notifications",
"HeaderOpenRSSFeed": "Ouvrir Flux RSS",
"HeaderOtherFiles": "Autres Fichiers",
"HeaderOtherFiles": "Autres fichiers",
"HeaderPermissions": "Permissions",
"HeaderPlayerQueue": "Liste d'Ecoute",
"HeaderPlaylist": "Liste de Lecture",
"HeaderPlaylistItems": "Elements de la Liste de Lecture",
"HeaderPodcastsToAdd": "Podcasts à Ajouter",
"HeaderPreviewCover": "Prévisualiser la Couverture",
"HeaderRemoveEpisode": "Supprimer l'Episode",
"HeaderRemoveEpisodes": "Suppression de {0} Episodes",
"HeaderPlayerQueue": "Liste d'écoute",
"HeaderPlaylist": "Liste de lecture",
"HeaderPlaylistItems": "Éléments de la liste de lecture",
"HeaderPodcastsToAdd": "Podcasts à ajouter",
"HeaderPreviewCover": "Prévisualiser la couverture",
"HeaderRemoveEpisode": "Supprimer l'épisode",
"HeaderRemoveEpisodes": "Suppression de {0} épisodes",
"HeaderRSSFeedIsOpen": "Le Flux RSS et Ouvert",
"HeaderSavedMediaProgress": "Progression de la Sauvegarde des Médias",
"HeaderSavedMediaProgress": "Progression de la sauvegarde des médias",
"HeaderSchedule": "Programmation",
"HeaderScheduleLibraryScans": "Scan Automatique de la Bibliothèque",
"HeaderScheduleLibraryScans": "Analyse automatique de la bibliothèque",
"HeaderSession": "Session",
"HeaderSetBackupSchedule": "Activer la Sauvegarde Automatique",
"HeaderSettings": "Paramètres",
"HeaderSettingsDisplay": "Affichage",
"HeaderSettingsExperimental": "Fonctionnalités Expérimentales",
"HeaderSettingsExperimental": "Fonctionnalités expérimentales",
"HeaderSettingsGeneral": "Général",
"HeaderSettingsScanner": "Scanneur",
"HeaderSleepTimer": "Minuterie",
"HeaderStatsLongestItems": "Articles les Plus Long (heures)",
"HeaderStatsMinutesListeningChart": "Minutes d'Ecoute (7 derniers jours)",
"HeaderStatsRecentSessions": "Sessions Récentes",
"HeaderStatsLongestItems": "Articles les plus long (heures)",
"HeaderStatsMinutesListeningChart": "Minutes d'écoute (7 derniers jours)",
"HeaderStatsRecentSessions": "Sessions récentes",
"HeaderStatsTop10Authors": "Top 10 Auteurs",
"HeaderStatsTop5Genres": "Top 5 Genres",
"HeaderTools": "Outils",
"HeaderUpdateAccount": "Mettre à jour le Compte",
"HeaderUpdateAuthor": "Mettre à jour l'Auteur",
"HeaderUpdateDetails": "Mettre à jour les Détails",
"HeaderUpdateLibrary": "Mettre à jour la Bibliothèque",
"HeaderUpdateAccount": "Mettre à jour le compte",
"HeaderUpdateAuthor": "Mettre à jour l'auteur",
"HeaderUpdateDetails": "Mettre à jour les détails",
"HeaderUpdateLibrary": "Mettre à jour la bibliothèque",
"HeaderUsers": "Utilisateurs",
"HeaderYourStats": "Vos Statistiques",
"LabelAccountType": "Type de Compte",
"HeaderYourStats": "Vos statistiques",
"LabelAccountType": "Type de compte",
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Invité",
"LabelAccountTypeUser": "Utilisateur",
"LabelActivity": "Activité",
"LabelAddedAt": "Date d'Ajout",
"LabelAddToCollection": "Ajouter à la Collection",
"LabelAddToCollectionBatch": "Ajout de {0} Livres à la Collection",
"LabelAddToPlaylist": "Ajouter à la Liste de Lecture",
"LabelAddToPlaylistBatch": "{0} Elements Ajoutés à la Liste de Lecture",
"LabelAddedAt": "Date d'ajout",
"LabelAddToCollection": "Ajouter à la collection",
"LabelAddToCollectionBatch": "Ajout de {0} livres à la lollection",
"LabelAddToPlaylist": "Ajouter à la liste de lecture",
"LabelAddToPlaylistBatch": "{0} éléments ajoutés à la liste de lecture",
"LabelAll": "Tout",
"LabelAllUsers": "Tous les Utilisateurs",
"LabelAppend": "Ajouter",
@@ -167,7 +167,7 @@
"LabelAuthorFirstLast": "Auteur (Prénom Nom)",
"LabelAuthorLastFirst": "Auteur (Nom, Prénom)",
"LabelAuthors": "Auteurs",
"LabelAutoDownloadEpisodes": "Téléchargement Automatique d'Episode",
"LabelAutoDownloadEpisodes": "Téléchargement automatique d'épisode",
"LabelBackToUser": "Revenir à l'Utilisateur",
"LabelBackupsEnableAutomaticBackups": "Activer les Sauvegardes Automatiques",
"LabelBackupsEnableAutomaticBackupsHelp": "Sauvegardes Enregistrées dans /metadata/backups",
@@ -176,60 +176,60 @@
"LabelBackupsNumberToKeep": "Nombre de Sauvegardes à maintenir",
"LabelBackupsNumberToKeepHelp": "Une seule sauvegarde sera effacée à la fois. Si vous avez plus de sauvegardes à effacer, vous devrez le faire manuellement.",
"LabelBooks": "Livres",
"LabelChangePassword": "Changer le Mot de Passe",
"LabelChaptersFound": "Chapitres Trouvés",
"LabelChapterTitle": "Titres du Chapitre",
"LabelClosePlayer": "Fermer le Lecteur",
"LabelCollapseSeries": "Réduire les Séries",
"LabelChangePassword": "Changer le mot de passe",
"LabelChaptersFound": "Chapitres trouvés",
"LabelChapterTitle": "Titres du chapitre",
"LabelClosePlayer": "Fermer le lecteur",
"LabelCollapseSeries": "Réduire les séries",
"LabelCollections": "Collections",
"LabelComplete": "Complet",
"LabelConfirmPassword": "Confirmer le Mot de Passe",
"LabelContinueListening": "Continuer la Lecture",
"LabelContinueSeries": "Continuer la Série",
"LabelConfirmPassword": "Confirmer le mot de passe",
"LabelContinueListening": "Continuer la lecture",
"LabelContinueSeries": "Continuer la série",
"LabelCover": "Couverture",
"LabelCoverImageURL": "URL vers l'image de Couverture",
"LabelCoverImageURL": "URL vers l'image de couverture",
"LabelCreatedAt": "Créé le",
"LabelCronExpression": "Expression Cron",
"LabelCurrent": "Courrant",
"LabelCurrently": "En ce Moment:",
"LabelCurrently": "En ce moment :",
"LabelDatetime": "Datetime",
"LabelDescription": "Description",
"LabelDeselectAll": "Tout Déselectionner",
"LabelDevice": "Appareil",
"LabelDeviceInfo": "Détail de l'Appareil",
"LabelDeviceInfo": "Détail de l'appareil",
"LabelDirectory": "Répertoire",
"LabelDiscFromFilename": "Disque depuis le Fichier",
"LabelDiscFromMetadata": "Disque depuis les Métadonnées",
"LabelDiscFromFilename": "Disque depuis le fichier",
"LabelDiscFromMetadata": "Disque depuis les métadonnées",
"LabelDownload": "Téléchargement",
"LabelDuration": "Durée",
"LabelDurationFound": "Durée Trouvée:",
"LabelEdit": "Editer",
"LabelDurationFound": "Durée trouvée :",
"LabelEdit": "Modifier",
"LabelEnable": "Activer",
"LabelEnd": "Fin",
"LabelEpisode": "Episode",
"LabelEpisodeTitle": "Titre de l'Episode",
"LabelEpisodeType": "Type de l'Episode",
"LabelEpisode": "Épisode",
"LabelEpisodeTitle": "Titre de l'épisode",
"LabelEpisodeType": "Type de l'épisode",
"LabelExplicit": "Restriction",
"LabelFeedURL": "URL de Flux",
"LabelFeedURL": "URL deu flux",
"LabelFile": "Fichier",
"LabelFileBirthtime": "Creation du Fichier",
"LabelFileModified": "Modification du Fichier",
"LabelFileBirthtime": "Creation du fichier",
"LabelFileModified": "Modification du fichier",
"LabelFilename": "Nom de Fichier",
"LabelFilterByUser": "Filtrer par l'Utilisateur",
"LabelFindEpisodes": "Trouver des Episodes",
"LabelFilterByUser": "Filtrer par l'utilisateur",
"LabelFindEpisodes": "Trouver des épisodes",
"LabelFinished": "Fini(e)",
"LabelFolder": "Dossier",
"LabelFolders": "Dossiers",
"LabelGenre": "Genre",
"LabelGenres": "Genres",
"LabelHardDeleteFile": "Effacement du Fichier",
"LabelHardDeleteFile": "Suppression du fichier",
"LabelHour": "Heure",
"LabelIcon": "Icone",
"LabelIncludeInTracklist": "Inclure dans la Liste des Pistes",
"LabelIncludeInTracklist": "Inclure dans la liste des pistes",
"LabelIncomplete": "Incomplet",
"LabelInProgress": "En Cours",
"LabelInProgress": "En cours",
"LabelInterval": "Interval",
"LabelIntervalCustomDailyWeekly": "Journalier/Hebdomadaire Personnalisé",
"LabelIntervalCustomDailyWeekly": "Journalier / Hebdomadaire personnalisé",
"LabelIntervalEvery12Hours": "Toutes les 12 heures",
"LabelIntervalEvery15Minutes": "Toutes les 15 minutes",
"LabelIntervalEvery2Hours": "Toutes les 2 heures",
@@ -237,46 +237,46 @@
"LabelIntervalEvery6Hours": "Toutes les 6 heures",
"LabelIntervalEveryDay": "Tous les jours",
"LabelIntervalEveryHour": "Toutes les heures",
"LabelInvalidParts": "Parties Invalides",
"LabelInvalidParts": "Parties invalides",
"LabelItem": "Article",
"LabelLanguage": "Langue",
"LabelLanguageDefaultServer": "Langue par Défaut",
"LabelLastSeen": "Vu Dernièrement",
"LabelLanguageDefaultServer": "Langue par défaut",
"LabelLastSeen": "Vu dernièrement",
"LabelLastTime": "Progression",
"LabelLastUpdate": "Dernière Mise à Jour",
"LabelLastUpdate": "Dernière mise à jour",
"LabelLess": "Moins",
"LabelLibrariesAccessibleToUser": "Bibliothèque Accessible à l'Utilisateur",
"LabelLibrariesAccessibleToUser": "Bibliothèque accessible à l'utilisateur",
"LabelLibrary": "Bibliothèque",
"LabelLibraryItem": "Article de Bibliothèque",
"LabelLibraryName": "Nom de Bibliothèque",
"LabelLibraryItem": "Article de bibliothèque",
"LabelLibraryName": "Nom de bibliothèque",
"LabelLimit": "Limite",
"LabelListenAgain": "Ecouter à Nouveau",
"LabelListenAgain": "Écouter à nouveau",
"LabelLogLevelDebug": "Debug",
"LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Rechercher de Nouveaux Episode après cette Date",
"LabelMediaPlayer": "Lecteur Multimédia",
"LabelMediaType": "Type de Média",
"LabelMetadataProvider": "Fournisseur de Métadonnées",
"LabelMetaTag": "Etiquette de Métadonnée",
"LabelLookForNewEpisodesAfterDate": "Rechercher de nouveaux épisode après cette date",
"LabelMediaPlayer": "Lecteur multimédia",
"LabelMediaType": "Type de média",
"LabelMetadataProvider": "Fournisseur de métadonnées",
"LabelMetaTag": "Etiquette de métadonnée",
"LabelMinute": "Minute",
"LabelMissing": "Manquant",
"LabelMissingParts": "Parties Manquantes",
"LabelMissingParts": "Parties manquantes",
"LabelMore": "Plus",
"LabelName": "Nom",
"LabelNarrator": "Narrateur",
"LabelNarrators": "Narrateurs",
"LabelNew": "Nouveau",
"LabelNewestAuthors": "Nouveaux Auteurs",
"LabelNewestEpisodes": "Derniers Episodes",
"LabelNewPassword": "Nouveau Mot de Passe",
"LabelNewestAuthors": "Nouveaux auteurs",
"LabelNewestEpisodes": "Derniers épisodes",
"LabelNewPassword": "Nouveau mot de passe",
"LabelNotes": "Notes",
"LabelNotFinished": "Non Terminé(e)",
"LabelNotificationAppriseURL": "URL(s) d'Apprise",
"LabelNotificationAvailableVariables": "Variables Disponibles",
"LabelNotFinished": "Non terminé(e)",
"LabelNotificationAppriseURL": "URL(s) d'apprise",
"LabelNotificationAvailableVariables": "Variables disponibles",
"LabelNotificationBodyTemplate": "Modèle de Message",
"LabelNotificationEvent": "Evènement de Notification",
"LabelNotificationsMaxFailedAttempts": "Nombres de Tentatives d'Envoi",
"LabelNotificationsMaxFailedAttempts": "Nombres de tentatives d'envoi",
"LabelNotificationsMaxFailedAttemptsHelp": "La notification est abandonnée une fois ce seuil atteint",
"LabelNotificationsMaxQueueSize": "Nombres de notifications maximum à mettre en attente",
"LabelNotificationsMaxQueueSizeHelp": "La limite de notification est de un évènement par seconde. Le notification seront ignorées si la file d'attente est à son maximum. Cela empêche un flot trop important.",
@@ -288,58 +288,58 @@
"LabelOverwrite": "Ecraser",
"LabelPassword": "Mot de Passe",
"LabelPath": "Chemin",
"LabelPermissionsAccessAllLibraries": "Peut Acceder à Toutes les Bibliothèque",
"LabelPermissionsAccessAllTags": "Peut Acceder à Toutes les Etiquettes",
"LabelPermissionsAccessExplicitContent": "Peut Acceter au Contenu Restreint",
"LabelPermissionsDelete": "Peut Supprimer",
"LabelPermissionsDownload": "Peut Télécharger",
"LabelPermissionsUpdate": "Peut Mettre à Jour",
"LabelPermissionsUpload": "Peut Téléverser",
"LabelPhotoPathURL": "Chemin/URL des photos",
"LabelPlaylists": "Listes de Lecture",
"LabelPlayMethod": "Méthode d'Ecoute",
"LabelPermissionsAccessAllLibraries": "Peut accéder à toutes les bibliothèque",
"LabelPermissionsAccessAllTags": "Peut accéder à toutes les étiquettes",
"LabelPermissionsAccessExplicitContent": "Peut acceter au contenu restreint",
"LabelPermissionsDelete": "Peut supprimer",
"LabelPermissionsDownload": "Peut télécharger",
"LabelPermissionsUpdate": "Peut mettre à Jour",
"LabelPermissionsUpload": "Peut téléverser",
"LabelPhotoPathURL": "Chemin / URL des photos",
"LabelPlaylists": "Listes de lecture",
"LabelPlayMethod": "Méthode d'écoute",
"LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts",
"LabelPrefixesToIgnore": "Préfixes à Ignorer (Insensible à la Casse)",
"LabelProgress": "Progression",
"LabelProvider": "Fournisseur",
"LabelPubDate": "Date de Publication",
"LabelPublisher": "Editeur",
"LabelPublishYear": "Année d'Edition",
"LabelRecentlyAdded": "Derniers Ajouts",
"LabelRecentSeries": "Séries Récentes",
"LabelRecommended": "Recommended",
"LabelPubDate": "Date de publication",
"LabelPublisher": "Éditeur",
"LabelPublishYear": "Année d'édition",
"LabelRecentlyAdded": "Derniers ajouts",
"LabelRecentSeries": "Séries récentes",
"LabelRecommended": "Recommandé",
"LabelRegion": "Région",
"LabelReleaseDate": "Date de Parution",
"LabelRemoveCover": "Supprimer la Couverture",
"LabelRSSFeedOpen": "Flux RSS Ouvert",
"LabelRSSFeedSlug": "Flux RSS Slug",
"LabelRSSFeedURL": "URL du Flux RSS",
"LabelSearchTerm": "Terme de Recherche",
"LabelSearchTitle": "Titre de Recherche",
"LabelSearchTitleOrASIN": "Recherche du Titre ou ASIN",
"LabelReleaseDate": "Date de parution",
"LabelRemoveCover": "Supprimer la couverture",
"LabelRSSFeedOpen": "Flux RSS ouvert",
"LabelRSSFeedSlug": "Identificateur d'adresse du Flux RSS ",
"LabelRSSFeedURL": "Adresse du flux RSS",
"LabelSearchTerm": "Terme de recherche",
"LabelSearchTitle": "Titre de recherche",
"LabelSearchTitleOrASIN": "Recherche du titre ou ASIN",
"LabelSeason": "Saison",
"LabelSequence": "Séquence",
"LabelSeries": "Séries",
"LabelSeriesName": "Nom de la Série",
"LabelSeriesProgress": "Progression de Séries",
"LabelSettingsBookshelfViewHelp": "Design Skeumorphic avec une Etagère en Bois",
"LabelSeriesName": "Nom de la série",
"LabelSeriesProgress": "Progression de séries",
"LabelSettingsBookshelfViewHelp": "Design Skeuomorphic avec une étagère en bois",
"LabelSettingsChromecastSupport": "Support Chromecast",
"LabelSettingsDateFormat": "Format de Date",
"LabelSettingsDisableWatcher": "Désactiver la Surveillance",
"LabelSettingsDisableWatcherForLibrary": "Désactiver la surveillance du dossier pour la Bibliothèque",
"LabelSettingsDateFormat": "Format de date",
"LabelSettingsDisableWatcher": "Désactiver la surveillance",
"LabelSettingsDisableWatcherForLibrary": "Désactiver la surveillance du dossier pour la bibliothèque",
"LabelSettingsDisableWatcherHelp": "Désactive la mise à jour automatique lorsque les fichiers changent. *Nécessite un redémarrage*",
"LabelSettingsEnableEReader": "Active E-reader pour tous les utilisateurs",
"LabelSettingsEnableEReaderHelp": "E-reader est toujours en cours de développement, mais ce paramètre l'active pour tous les utilisateurs (ou utiliser l'interrupteur \"Fonctionnalités Expérimentales\" pour l'activer seulement pour vous)",
"LabelSettingsExperimentalFeatures": "Fonctionnalités Expérimentales",
"LabelSettingsExperimentalFeaturesHelp": "Fonctionnalités en cours de développement sur lesquels nous attendons votre retour et expérience. Cliquer pour ouvrir la discussion Github.",
"LabelSettingsFindCovers": "Rechercher des Couvertures",
"LabelSettingsFindCoversHelp": "Si votre Livre Audio ne possède pas de couverture intégrée ou une image de couverture dans le dossier, le scanner tentera de récupérer une couverture.<br>Attention, cela peut augmenter le temps de scan.",
"LabelSettingsHomePageBookshelfView": "La page d'Accueil utilise la vue étagère",
"LabelSettingsFindCoversHelp": "Si votre livre audio ne possède pas de couverture intégrée ou une image de couverture dans le dossier, l'analyser tentera de récupérer une couverture.<br>Attention, cela peut augmenter le temps d'analyse.",
"LabelSettingsHomePageBookshelfView": "La page d'accueil utilise la vue étagère",
"LabelSettingsLibraryBookshelfView": "La bibliothèque utilise la vue étagère",
"LabelSettingsOverdriveMediaMarkers": "Utiliser Overdrive Media Marker pour les chapitres",
"LabelSettingsOverdriveMediaMarkersHelp": "Les fichiers MP3 d'Overdrive viennent avec les minutages des chapitres intégrés en métadonnées. Activer ce paramètre utilisera ces minutages pour les chapitres automatiquement.",
"LabelSettingsParseSubtitles": "Analyse des Sous-titres",
"LabelSettingsParseSubtitles": "Analyse des sous-titres",
"LabelSettingsParseSubtitlesHelp": "Extrait les sous-titres depuis le dossier du Livre Audio.<br>Les sous-titres doivent être séparés par \" - \"<br>i.e. \"Titre du Livre - Ceci est un sous-titre\" aura le sous-titre \"Ceci est un sous-titre\"",
"LabelSettingsPreferAudioMetadata": "Préférer les Métadonnées Audio",
"LabelSettingsPreferAudioMetadataHelp": "Les méta étiquettes ID3 des fichiers audios seront utilisés à la place des noms de dossier pour les détails du livre audio",
@@ -378,12 +378,12 @@
"LabelStatsMinutesListening": "Minutes d'écoute",
"LabelStatsOverallDays": "Jours au total",
"LabelStatsOverallHours": "Heures au total",
"LabelStatsWeekListening": "Ecoute de la Semaine",
"LabelStatsWeekListening": "Écoute de la semaine",
"LabelSubtitle": "Sous-Titre",
"LabelSupportedFileTypes": "Types de Fichiers Supportés",
"LabelTag": "Etiquette",
"LabelTags": "Etiquettes",
"LabelTagsAccessibleToUser": "Etiquettes Accessibles à l'Utilisateur",
"LabelSupportedFileTypes": "Types de fichiers Supportés",
"LabelTag": "Étiquette",
"LabelTags": "Étiquettes",
"LabelTagsAccessibleToUser": "Étiquettes accessibles à l'utilisateur",
"LabelTimeListened": "Temps d'écoute",
"LabelTimeListenedToday": "Nombres d'écoutes Aujourd'hui",
"LabelTimeRemaining": "{0} restantes",
@@ -396,9 +396,9 @@
"LabelToolsSplitM4b": "Scinde le fichier M4B en fichiers MP3",
"LabelToolsSplitM4bDescription": "Créer plusieurs fichier MP3 à partir du découpage par chapitre, en incluant les métadonnées, l'image de couverture et les chapitres.",
"LabelTotalDuration": "Durée Totale",
"LabelTotalTimeListened": "Temps d'Ecoute Total",
"LabelTrackFromFilename": "Piste depuis le Fichier",
"LabelTrackFromMetadata": "Piste depuis les Métadonnées",
"LabelTotalTimeListened": "Temps d'écoute total",
"LabelTrackFromFilename": "Piste depuis le fichier",
"LabelTrackFromMetadata": "Piste depuis les métadonnées",
"LabelTracks": "Pistes",
"LabelTracksMultiTrack": "Piste Multiple",
"LabelTracksSingleTrack": "Piste Simple",
@@ -409,8 +409,8 @@
"LabelUpdatedAt": "Mis à jour à",
"LabelUpdateDetails": "Mettre à jours les Détails",
"LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsqu'une correspondance est trouvée",
"LabelUploaderDragAndDrop": "Glisser & Déposer des Fichiers ou Dossiers",
"LabelUploaderDropFiles": "Déposer des Fichiers",
"LabelUploaderDragAndDrop": "Glisser & Déposer des fichiers ou dossiers",
"LabelUploaderDropFiles": "Déposer des fichiers",
"LabelUseChapterTrack": "Utiliser la Piste du Chapitre",
"LabelUseFullTrack": "Utiliser la Piste Complète",
"LabelUser": "Utilisateur",
@@ -419,14 +419,14 @@
"LabelVersion": "Version",
"LabelViewBookmarks": "Afficher les Signets",
"LabelViewChapters": "Afficher les Chapitres",
"LabelViewQueue": "Afficher la Liste de Lecture",
"LabelViewQueue": "Afficher la liste de lecture",
"LabelVolume": "Volume",
"LabelWeekdaysToRun": "Jours de la semaine à exécuter",
"LabelYourAudiobookDuration": "Durée de vos Livres Audios",
"LabelYourBookmarks": "Vos Signets",
"LabelYourPlaylists": "Vos Listes de Lecture",
"LabelYourProgress": "Votre Progression",
"MessageAddToPlayerQueue": "Ajouter en Queue d'Ecoute",
"LabelYourPlaylists": "Vos listes de lecture",
"LabelYourProgress": "Votre progression",
"MessageAddToPlayerQueue": "Ajouter en file d'attente",
"MessageAppriseDescription": "Nécessite une instance d'<a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API Apprise</a> pour utiliser cette fonctionnalité ou une api qui prend en charge les mêmes requêtes. <br />L'URL de l'API Apprise doit comprendre le chemin complet pour envoyer la notification. Par exemple, si votre instance écoute sur <code>http://192.168.1.1:8337</code> alors vous devez mettre <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "Les Sauvegardes incluent les utilisateurs, la progression de lecture par utilisateur, les détails des articles des bibliothèques, les paramètres du serveur et les images sauvegardées. Les Sauvegardes n'incluent pas les fichiers de votre bibliothèque.",
"MessageBatchQuickMatchDescription": "La Recherche par Correspondance Rapide tentera d'ajouter les couvertures et les métadonnées manquantes pour les articles sélectionnés. Activer l'option suivante pour autoriser la Recherche par Correspondance à écraser les données existantes.",
@@ -440,108 +440,108 @@
"MessageChapterErrorStartLtPrev": "Horodatage invalide car il doit débuter au moins après le précédent chapitre",
"MessageChapterStartIsAfter": "Le Chapitre Début est situé au début de votre Livre Audio",
"MessageCheckingCron": "Vérification du cron...",
"MessageConfirmDeleteBackup": "Etes vous certain de vouloir supprimer la Sauvegarde de {0}?",
"MessageConfirmDeleteLibrary": "Etes vous certain de vouloir supprimer définitivement la bibliothèque \"{0}\"?",
"MessageConfirmDeleteSession": "Etes vous certain de vouloir supprimer cette session?",
"MessageConfirmForceReScan": "Etes vous certain de vouloir lancer une Analyse Forcée?",
"MessageConfirmMarkSeriesFinished": "Etes vous certain de vouloir marquer comme terminé tous les livres de cette série?",
"MessageConfirmMarkSeriesNotFinished": "Etes vous certain de vouloir marquer comme non terminé tous les livres de cette série?",
"MessageConfirmRemoveCollection": "Etes vous certain de vouloir supprimer la collection \"{0}\"?",
"MessageConfirmRemoveEpisode": "Etes vous certain de vouloir supprimer l'épisode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Etes vous certain de vouloir supprimer {0} épisodes?",
"MessageConfirmRemovePlaylist": "Etes vous certain de vouloir supprimer la liste de lecture \"{0}\"?",
"MessageConfirmRenameGenre": "Etes vous certain de vouloir renommer le genre \"{0}\" vers \"{1}\" pour tous les articles?",
"MessageConfirmDeleteBackup": "Êtes-vous sûr de vouloir supprimer la Sauvegarde de {0} ?",
"MessageConfirmDeleteLibrary": "Êtes-vous sûr de vouloir supprimer définitivement la bibliothèque \"{0}\" ?",
"MessageConfirmDeleteSession": "Êtes-vous sûr de vouloir supprimer cette session ?",
"MessageConfirmForceReScan": "Êtes-vous sûr de vouloir lancer une Analyse Forcée ?",
"MessageConfirmMarkSeriesFinished": "Êtes-vous sûr de vouloir marquer comme terminé tous les livres de cette série ?",
"MessageConfirmMarkSeriesNotFinished": "Êtes-vous sûr de vouloir marquer comme non terminé tous les livres de cette série ?",
"MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection \"{0}\" ?",
"MessageConfirmRemoveEpisode": "Êtes-vous sûr de vouloir supprimer l'épisode \"{0}\" ?",
"MessageConfirmRemoveEpisodes": "Êtes-vous sûr de vouloir supprimer {0} épisodes ?",
"MessageConfirmRemovePlaylist": "Êtes-vous sûr de vouloir supprimer la liste de lecture \"{0}\" ?",
"MessageConfirmRenameGenre": "Êtes-vous sûr de vouloir renommer le genre \"{0}\" vers \"{1}\" pour tous les articles ?",
"MessageConfirmRenameGenreMergeNote": "Information: Ce genre existe déjà et sera fusionné.",
"MessageConfirmRenameGenreWarning": "Attention! Un genre similaire avec une casse différente existe déjà \"{0}\".",
"MessageConfirmRenameTag": "Etes vous certain de vouloir renommer l'étiquette \"{0}\" vers \"{1}\" pour tous les articles?",
"MessageConfirmRenameGenreWarning": "Attention ! Un genre similaire avec une casse différente existe déjà \"{0}\".",
"MessageConfirmRenameTag": "Êtes-vous sûr de vouloir renommer l'étiquette \"{0}\" vers \"{1}\" pour tous les articles ?",
"MessageConfirmRenameTagMergeNote": "Information: Cette étiquette existe déjà et sera fusionnée.",
"MessageConfirmRenameTagWarning": "Attention! Une étiquette similaire avec une casse différente existe déjà \"{0}\".",
"MessageConfirmRenameTagWarning": "Attention ! Une étiquette similaire avec une casse différente existe déjà \"{0}\".",
"MessageDownloadingEpisode": "Téléchargement de l'épisode",
"MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans l'ordre correct",
"MessageEmbedFinished": "Intégration Terminée!",
"MessageEpisodesQueuedForDownload": "{0} Episode(s) mis en file pour téléchargement",
"MessageEmbedFinished": "Intégration Terminée !",
"MessageEpisodesQueuedForDownload": "{0} épisode(s) mis en file pour téléchargement",
"MessageFeedURLWillBe": "L'URL du Flux sera {0}",
"MessageFetching": "Récupération...",
"MessageForceReScanDescription": "Analysera tous les fichiers de nouveau. Les étiquettes ID3 des fichiers audios, Fichiers OPF, et les fichiers textes seront analysés comme s'ils étaient nouveaux.",
"MessageImportantNotice": "Information Importante!",
"MessageForceReScanDescription": "Analysera tous les fichiers de nouveau. Les étiquettes ID3 des fichiers audios, fichiers OPF, et les fichiers textes seront analysés comme s'ils étaient nouveaux.",
"MessageImportantNotice": "Information Importante !",
"MessageInsertChapterBelow": "Insérer le chapitre ci-dessous",
"MessageItemsSelected": "{0} Articles Sélectionnés",
"MessageItemsUpdated": "{0} Articles Mis à Jour",
"MessageItemsSelected": "{0} articles sélectionnés",
"MessageItemsUpdated": "{0} articles mis à jour",
"MessageJoinUsOn": "Rejoignez-nous sur",
"MessageListeningSessionsInTheLastYear": "{0} sessions d'écoute l'an dernier",
"MessageLoading": "Chargement...",
"MessageLoadingFolders": "Chargement des Dossiers...",
"MessageM4BFailed": "M4B en échec!",
"MessageM4BFinished": "M4B terminé!",
"MessageMapChapterTitles": "Faire correspondre les titres des chapitres aux chapitres existants de votre Livre Audio sans ajuster l'horodatage.",
"MessageMarkAsFinished": "Marquer comme Terminé",
"MessageLoadingFolders": "Chargement des dossiers...",
"MessageM4BFailed": "M4B en échec !",
"MessageM4BFinished": "M4B terminé !",
"MessageMapChapterTitles": "Faire correspondre les titres des chapitres aux chapitres existants de votre livre audio sans ajuster l'horodatage.",
"MessageMarkAsFinished": "Marquer comme terminé",
"MessageMarkAsNotFinished": "Marquer comme non Terminé",
"MessageMatchBooksDescription": "tentera de faire correspondre les livres de la bibliothèque avec les livres du fournisseur sélectionné pour combler les détails et couverture manquants. N'écrase pas les données existantes.",
"MessageNoAudioTracks": "Pas de pistes audio",
"MessageNoAuthors": "Pas d'Auteurs",
"MessageNoBackups": "Pas de Sauvegardes",
"MessageNoBookmarks": "Pas de Signets",
"MessageNoChapters": "Pas de Chapitres",
"MessageNoCollections": "Pas de Collections",
"MessageNoCoversFound": "Pas de Couvertures Trouvées",
"MessageNoDescription": "Pas de Description",
"MessageNoBookmarks": "Pas de signets",
"MessageNoChapters": "Pas de chapitres",
"MessageNoCollections": "Pas de collections",
"MessageNoCoversFound": "Aucune couverture trouvée",
"MessageNoDescription": "Pas de description",
"MessageNoEpisodeMatchesFound": "Pas de correspondance d'épisode trouvée",
"MessageNoEpisodes": "Pas d'Episodes",
"MessageNoFoldersAvailable": "Pas de Dossiers Disponibles",
"MessageNoGenres": "Pas de Genres",
"MessageNoIssues": "Pas de Parution",
"MessageNoEpisodes": "Aucun épisode",
"MessageNoFoldersAvailable": "Aucun dossier disponible",
"MessageNoGenres": "Pas de genres",
"MessageNoIssues": "Pas de parution",
"MessageNoItems": "Pas d'Articles",
"MessageNoItemsFound": "Pas d'Articles Trouvés",
"MessageNoListeningSessions": "Pas de Sessions d'Ecoutes",
"MessageNoLogs": "Pas de Journaux",
"MessageNoListeningSessions": "Pas de sessions d'écoutes",
"MessageNoLogs": "Pas de journaux",
"MessageNoMediaProgress": "Pas de Média en cours",
"MessageNoNotifications": "Pas de Notifications",
"MessageNoPodcastsFound": "Pas de podcasts trouvés",
"MessageNoResults": "Pas de Résultats",
"MessageNoResults": "Pas de résultats",
"MessageNoSearchResultsFor": "Pas de résultats de recherche pour \"{0}\"",
"MessageNoSeries": "Pas de Séries",
"MessageNoTags": "Pas d'Etiquettes",
"MessageNoSeries": "Pas de séries",
"MessageNoTags": "Pas d'étiquettes",
"MessageNotYetImplemented": "Non implémenté",
"MessageNoUpdateNecessary": "Pas de mise à jour nécessaire",
"MessageNoUpdatesWereNecessary": "Aucune mise à jour n'était nécessaire",
"MessageNoUserPlaylists": "Vous n'avez aucune liste de lecture",
"MessageOr": "ou",
"MessagePauseChapter": "Suspendre la lecture du chapitre",
"MessagePlayChapter": "Ecouter depuis le début du chapitre",
"MessagePlayChapter": "Écouter depuis le début du chapitre",
"MessagePlaylistCreateFromCollection": "Créer une liste de lecture depuis la collection",
"MessagePodcastHasNoRSSFeedForMatching": "Le Podcast n'a pas d'URL de flux RSS à utiliser pour la correspondance",
"MessageQuickMatchDescription": "Renseigne les détails manquants ainsi que la couverture avec la première correspondance de '{0}'. N'écrase pas les données présentes à moins que le paramètre 'Préférer les Métadonnées par correspondance' soit activé.",
"MessageRemoveAllItemsWarning": "ATTENTION! Cette action supprimera toute la base de données de la bibliothèque ainsi que les mises à jour ou correspondances qui auraient été effectuées. Cela n'a aucune incidence sur les fichiers de la bibliothèque. Voulez-vous continuer?",
"MessageRemoveAllItemsWarning": "ATTENTION ! Cette action supprimera toute la base de données de la bibliothèque ainsi que les mises à jour ou correspondances qui auraient été effectuées. Cela n'a aucune incidence sur les fichiers de la bibliothèque. Souhaitez-vous continuer ?",
"MessageRemoveChapter": "Supprimer le chapitre",
"MessageRemoveEpisodes": "Suppression de {0} épisode(s)",
"MessageRemoveFromPlayerQueue": "Supprimer de la liste d'écoute",
"MessageRemoveUserWarning": "Etes-vous certain de vouloir supprimer définitivement l'utilisateur \"{0}\"?",
"MessageRemoveUserWarning": "Êtes-vous certain de vouloir supprimer définitivement l'utilisateur \"{0}\" ?",
"MessageReportBugsAndContribute": "Remonter des anomalies, demander des fonctionnalités et contribuer sur",
"MessageResetChaptersConfirm": "Etes-vous certain de vouloir réinitialiser les chapitres et annuler les changements effectués?",
"MessageRestoreBackupConfirm": "Etes-vous certain de vouloir restaurer la sauvegarde créée le",
"MessageResetChaptersConfirm": "Êtes-vous certain de vouloir réinitialiser les chapitres et annuler les changements effectués ?",
"MessageRestoreBackupConfirm": "Êtes-vous certain de vouloir restaurer la sauvegarde créée le",
"MessageRestoreBackupWarning": "Restaurer la sauvegarde écrasera la base de donnée située dans le dossier /config ainsi que les images sur /metadata/items & /metadata/authors.<br /><br />Les sauvegardes ne touchent pas aux fichiers de la bibliothèque. Si vous avez activé le paramètre pour sauvegarder les métadonnées et les images de couverture dans le même dossier que les fichiers, ceux-ci ne ni sauvegardés, ni écrasés lors de la restauration.<br /><br />Tous les clients utilisant votre serveur seront automatiquement mis à jour.",
"MessageSearchResultsFor": "Résultats de recherche pour",
"MessageServerCouldNotBeReached": "Serveur inaccessible",
"MessageSetChaptersFromTracksDescription": "Positionne un chapitre par fichier audio, avec le titre du fichier comme titre de chapitre",
"MessageStartPlaybackAtTime": "Démarrer la lecture pour \"{0}\" à {1}?",
"MessageStartPlaybackAtTime": "Démarrer la lecture pour \"{0}\" à {1} ?",
"MessageThinking": "On réfléchit...",
"MessageUploaderItemFailed": "Échec du téléversement",
"MessageUploaderItemSuccess": "Téléversement effectué!",
"MessageUploaderItemSuccess": "Téléversement effectué !",
"MessageUploading": "Téléversement...",
"MessageValidCronExpression": "Expression cron valide",
"MessageWatcherIsDisabledGlobally": "La Surveillance est désactivée par un paramètre global du serveur",
"MessageXLibraryIsEmpty": "La Bibliothèque {0} est vide!",
"MessageXLibraryIsEmpty": "La bibliothèque {0} est vide !",
"MessageYourAudiobookDurationIsLonger": "La durée de votre Livre Audio est plus longue que la durée trouvée",
"MessageYourAudiobookDurationIsShorter": "La durée de votre Livre Audio est plus courte que la durée trouvée",
"NoteChangeRootPassword": "L'utilisateur Root est le seul a pouvoir utiliser un mote de passe vide",
"NoteChapterEditorTimes": "Information: L'horodatage du premier chapitre doit être à 0:00 et celui du dernier chapitre ne peut se situer au-delà de la durée du Livre Audio.",
"NoteFolderPicker": "Information: Les dossiers déjà surveillés ne sont pas affichés",
"NoteFolderPickerDebian": "Information: La sélection de dossier sur une installation debian n'est pas finalisée. Merci de renseigner le chemin complet vers votre bibliothèque manuellement.",
"NoteRSSFeedPodcastAppsHttps": "Attention: La majorité des application de podcast nécessite une URL de flux en HTTPS.",
"NoteRSSFeedPodcastAppsPubDate": "Warning: Un ou plus de vos épisodes ne possèdent pas de Pub Date. Certaines applications de podcast le requièrent.",
"NoteUploaderFoldersWithMediaFiles": "Les dossiers avec des fichiers médias seront traités en tant qu'articles séparés.",
"NoteUploaderOnlyAudioFiles": "En téléversant seulement des fichiers audio, chaque fichier sera traité comme un Livre Audio séparé.",
"NoteUploaderUnsupportedFiles": "Les fichiers non-supportés seront ignorés. En sélectionnant ou déponsant un dossier, les autres fichiers qui ne sont pas un dossier contenant un article seront ignorés.",
"NoteRSSFeedPodcastAppsHttps": "Attention : la majorité des application de podcast nécessite une adresse de flux en HTTPS.",
"NoteRSSFeedPodcastAppsPubDate": "Attention : un ou plusieurs de vos épisodes ne possèdent pas de date de publication. Certaines applications de podcast le requièrent.",
"NoteUploaderFoldersWithMediaFiles": "Les dossiers contenant des fichiers multimédias seront traités comme des éléments distincts de la bibliothèque.",
"NoteUploaderOnlyAudioFiles": "Si vous téléverser uniquement des fichiers audio, chaque fichier audio sera traité comme un livre audio distinct.",
"NoteUploaderUnsupportedFiles": "Les fichiers non pris en charge sont ignorés. Lorsque vous choisissez ou déposez un dossier, les autres fichiers qui ne sont pas dans un dossier d'élément sont ignorés.",
"PlaceholderNewCollection": "Nom de la nouvelle collection",
"PlaceholderNewFolderPath": "Nouveau chemin de dossier",
"PlaceholderNewPlaylist": "Nouveau nom de liste de lecture",
@@ -615,4 +615,4 @@
"ToastSocketFailedToConnect": "Échec de la connexion WebSocket",
"ToastUserDeleteFailed": "Échec de la suppression de l'utilisateur",
"ToastUserDeleteSuccess": "Utilisateur supprimé"
}
}

618
client/strings/ru.json Normal file
View File

@@ -0,0 +1,618 @@
{
"ButtonAdd": "Добавить",
"ButtonAddChapters": "Добавить Главы",
"ButtonAddPodcasts": "Добавить Подкасты",
"ButtonAddYourFirstLibrary": "Добавьте Вашу первую библиотеку",
"ButtonApply": "Применить",
"ButtonApplyChapters": "Применить Главы",
"ButtonAuthors": "Авторы",
"ButtonBrowseForFolder": "Выбрать Папку",
"ButtonCancel": "Отмена",
"ButtonCancelEncode": "Отменить Кодирование",
"ButtonChangeRootPassword": "Поменять Мастер Пароль",
"ButtonCheckAndDownloadNewEpisodes": "Проверка и Загрузка Новых Эпизодов",
"ButtonChooseAFolder": "Выбор папки",
"ButtonChooseFiles": "Выбор файлов",
"ButtonClearFilter": "Очистить Фильтр",
"ButtonCloseFeed": "Закрыть Канал",
"ButtonCollections": "Коллекции",
"ButtonConfigureScanner": "Конфигурация Сканера",
"ButtonCreate": "Создать",
"ButtonCreateBackup": "Создать бэкап",
"ButtonDelete": "Удалить",
"ButtonEdit": "Редактировать",
"ButtonEditChapters": "Редактировать Главы",
"ButtonEditPodcast": "Редактировать Подкаст",
"ButtonForceReScan": "Принудительно Пере сканировать",
"ButtonFullPath": "Полный Путь",
"ButtonHide": "Скрыть",
"ButtonHome": "Домой",
"ButtonIssues": "Проблемы",
"ButtonLatest": "Последнее",
"ButtonLibrary": "Библиотека",
"ButtonLogout": "Выход",
"ButtonLookup": "Найти",
"ButtonManageTracks": "Управление Треками",
"ButtonMapChapterTitles": "Найти Названия Глав",
"ButtonMatchAllAuthors": "Найти Всех Авторов",
"ButtonMatchBooks": "Найти Книги",
"ButtonNevermind": "Не важно",
"ButtonOk": "Ok",
"ButtonOpenFeed": "Открыть Канал",
"ButtonOpenManager": "Открыть Менеджер",
"ButtonPlay": "Слушать",
"ButtonPlaying": "Проигрывается",
"ButtonPlaylists": "Плейлисты",
"ButtonPurgeAllCache": "Очистить Весь Кэш",
"ButtonPurgeItemsCache": "Очистить Кэш Элементов",
"ButtonPurgeMediaProgress": "Очистить Прогресс Медиа",
"ButtonQueueAddItem": "Добавить в очередь",
"ButtonQueueRemoveItem": "Удалить из очереди",
"ButtonQuickMatch": "Быстрый Поиск",
"ButtonRead": "Читать",
"ButtonRemove": "Удалить",
"ButtonRemoveAll": "Удалить Всё",
"ButtonRemoveAllLibraryItems": "Удалить Все Элементы Библиотеки",
"ButtonRemoveFromContinueListening": "Удалить из Продолжить Слушать",
"ButtonRemoveSeriesFromContinueSeries": "Удалить Серию из Продолжить Серию",
"ButtonReScan": "Пере сканировать",
"ButtonReset": "Сбросить",
"ButtonRestore": "Восстановить",
"ButtonSave": "Сохранить",
"ButtonSaveAndClose": "Сохранить и Закрыть",
"ButtonSaveTracklist": "Сохранить Список треков",
"ButtonScan": "Сканировать",
"ButtonScanLibrary": "Сканировать Библиотеку",
"ButtonSearch": "Поиск",
"ButtonSelectFolderPath": "Выберите Путь Папки",
"ButtonSeries": "Серии",
"ButtonSetChaptersFromTracks": "Установить главы из треков",
"ButtonShiftTimes": "Смещение",
"ButtonShow": "Показать",
"ButtonStartM4BEncode": "Начать Кодирование M4B",
"ButtonStartMetadataEmbed": "Начать Встраивание Метаданных",
"ButtonSubmit": "Применить",
"ButtonUpload": "Загрузить",
"ButtonUploadBackup": "Загрузить Бэкап",
"ButtonUploadCover": "Загрузить Обложку",
"ButtonUploadOPMLFile": "Загрузить Файл OPML",
"ButtonUserDelete": "Удалить пользователя {0}",
"ButtonUserEdit": "Редактировать пользователя {0}",
"ButtonViewAll": "Посмотреть Все",
"ButtonYes": "Да",
"HeaderAccount": "Учетная запись",
"HeaderAdvanced": "Дополнительно",
"HeaderAppriseNotificationSettings": "Настройки Оповещений",
"HeaderAudiobookTools": "Инструменты Файлов Аудиокниг",
"HeaderAudioTracks": "Аудио Треки",
"HeaderBackups": "Бэкапы",
"HeaderChangePassword": "Изменить Пароль",
"HeaderChapters": "Главы",
"HeaderChooseAFolder": "Выберите Папку",
"HeaderCollection": "Коллекция",
"HeaderCollectionItems": "Элементы Коллекции",
"HeaderCover": "Обложка",
"HeaderDetails": "Подробности",
"HeaderEpisodes": "Эпизоды",
"HeaderFiles": "Файлы",
"HeaderFindChapters": "Найти Главы",
"HeaderIgnoredFiles": "Игнорируемые Файлы",
"HeaderItemFiles": "Файлы Элемента",
"HeaderItemMetadataUtils": "Утилиты",
"HeaderLastListeningSession": "Последний Сеанс Прослушивания",
"HeaderLatestEpisodes": "Последние эпизоды",
"HeaderLibraries": "Библиотеки",
"HeaderLibraryFiles": "Файлы Библиотеки",
"HeaderLibraryStats": "Статистика Библиотеки",
"HeaderListeningSessions": "Сеансы",
"HeaderListeningStats": "Статистика Прослушивания",
"HeaderLogin": "Логин",
"HeaderLogs": "Логи",
"HeaderManageGenres": "Редактировать Жанры",
"HeaderManageTags": "Редактировать Теги",
"HeaderMapDetails": "Найти подробности",
"HeaderMatch": "Поиск",
"HeaderMetadataToEmbed": "Метаинформация для встраивания",
"HeaderNewAccount": "Новая Учетная запись",
"HeaderNewLibrary": "Новая Библиотека",
"HeaderNotifications": "Уведомления",
"HeaderOpenRSSFeed": "Открыть RSS-канал",
"HeaderOtherFiles": "Другие Файлы",
"HeaderPermissions": "Разрешения",
"HeaderPlayerQueue": "Очередь Воспроизведения",
"HeaderPlaylist": "Плейлист",
"HeaderPlaylistItems": "Элементы Списка Воспроизведения",
"HeaderPodcastsToAdd": "Подкасты для Добавления",
"HeaderPreviewCover": "Предпросмотр Обложки",
"HeaderRemoveEpisode": "Удалить Эпизод",
"HeaderRemoveEpisodes": "Удалить {0} Эпизодов",
"HeaderRSSFeedIsOpen": "RSS-канал Открыт",
"HeaderSavedMediaProgress": "Прогресс Медиа Сохранен",
"HeaderSchedule": "Планировщик",
"HeaderScheduleLibraryScans": "Планировщик Автоматического Сканирования Библиотеки",
"HeaderSession": "Сеансы",
"HeaderSetBackupSchedule": "Установить Планировщик Бэкапов",
"HeaderSettings": "Настройки",
"HeaderSettingsDisplay": "Дисплей",
"HeaderSettingsExperimental": "Экспериментальные Функции",
"HeaderSettingsGeneral": "Основные",
"HeaderSettingsScanner": "Сканер",
"HeaderSleepTimer": "Таймер Сна",
"HeaderStatsLongestItems": "Самые Длинные Книги (часов)",
"HeaderStatsMinutesListeningChart": "Минут прослушивания (последние 7 дней)",
"HeaderStatsRecentSessions": "Последние Сеансы",
"HeaderStatsTop10Authors": "Топ 10 Авторов",
"HeaderStatsTop5Genres": "Топ 5 Жанров",
"HeaderTools": "Инструменты",
"HeaderUpdateAccount": "Обновить Учетную запись",
"HeaderUpdateAuthor": "Обновить Автора",
"HeaderUpdateDetails": "Обновить Детали",
"HeaderUpdateLibrary": "Обновить Библиотеку",
"HeaderUsers": "Пользователи",
"HeaderYourStats": "Ваша Статистика",
"LabelAccountType": "Тип Учетной записи",
"LabelAccountTypeAdmin": "Администратор",
"LabelAccountTypeGuest": "Гость",
"LabelAccountTypeUser": "Пользователь",
"LabelActivity": "Активность",
"LabelAddedAt": "Добавить В",
"LabelAddToCollection": "Добавить в Коллекцию",
"LabelAddToCollectionBatch": "Добавить {0} Книг в Коллекцию",
"LabelAddToPlaylist": "Добавить в Плейлист",
"LabelAddToPlaylistBatch": "Добавить {0} Элементов в Плейлист",
"LabelAll": "Все",
"LabelAllUsers": "Все пользователи",
"LabelAppend": "Добавить",
"LabelAuthor": "Автор",
"LabelAuthorFirstLast": "Автор (Имя Фамилия)",
"LabelAuthorLastFirst": "Автор (Фамилия, Имя)",
"LabelAuthors": "Авторы",
"LabelAutoDownloadEpisodes": "Скачивать Эпизоды Автоматически",
"LabelBackToUser": "Назад к Пользователю",
"LabelBackupsEnableAutomaticBackups": "Включить автоматическое бэкапирование",
"LabelBackupsEnableAutomaticBackupsHelp": "Бэкапы сохраняются в /metadata/backups",
"LabelBackupsMaxBackupSize": "Максимальный размер бэкапа (в GB)",
"LabelBackupsMaxBackupSizeHelp": "В качестве защиты процесс бэкапирования будет завершаться ошибкой, если будет превышен настроенный размер.",
"LabelBackupsNumberToKeep": "Сохранять бэкапов",
"LabelBackupsNumberToKeepHelp": "За один раз только 1 бэкап будет удален, так что если у вас будет больше бэкапов, то их нужно удалить вручную.",
"LabelBooks": "Книги",
"LabelChangePassword": "Изменить Пароль",
"LabelChaptersFound": "глав найдено",
"LabelChapterTitle": "Название Главы",
"LabelClosePlayer": "Закрыть проигрыватель",
"LabelCollapseSeries": "Свернуть Серии",
"LabelCollections": "Коллекции",
"LabelComplete": "Завершить",
"LabelConfirmPassword": "Подтвердить Пароль",
"LabelContinueListening": "Продолжить Слушать",
"LabelContinueSeries": "Продолжить Серию",
"LabelCover": "Обложка",
"LabelCoverImageURL": "URL Изображения Обложки",
"LabelCreatedAt": "Создано",
"LabelCronExpression": "Выражение Cron",
"LabelCurrent": "Текущий",
"LabelCurrently": "Текущее:",
"LabelDatetime": "Дата и время",
"LabelDescription": "Описание",
"LabelDeselectAll": "Снять Выделение",
"LabelDevice": "Устройство",
"LabelDeviceInfo": "Информация об Устройстве",
"LabelDirectory": "Каталог",
"LabelDiscFromFilename": "Диск из Имени файла",
"LabelDiscFromMetadata": "Диск из Метаданных",
"LabelDownload": "Скачать",
"LabelDuration": "Длина",
"LabelDurationFound": "Найденная длина:",
"LabelEdit": "Редактировать",
"LabelEnable": "Включить",
"LabelEnd": "Конец",
"LabelEpisode": "Эпизод",
"LabelEpisodeTitle": "Имя Эпизода",
"LabelEpisodeType": "Тип Эпизода",
"LabelExplicit": "Явный",
"LabelFeedURL": "URL Канала",
"LabelFile": "Файл",
"LabelFileBirthtime": "Дата Создания",
"LabelFileModified": "Дата Модификации",
"LabelFilename": "Имя файла",
"LabelFilterByUser": "Фильтр по Пользователю",
"LabelFindEpisodes": "Найти Эпизоды",
"LabelFinished": "Закончен",
"LabelFolder": "Папка",
"LabelFolders": "Папки",
"LabelGenre": "Жанр",
"LabelGenres": "Жанры",
"LabelHardDeleteFile": "Жесткое удаление файла",
"LabelHour": "Часы",
"LabelIcon": "Иконка",
"LabelIncludeInTracklist": "Включать в Список воспроизведения",
"LabelIncomplete": "Не завершен",
"LabelInProgress": "В процессе",
"LabelInterval": "Интервал",
"LabelIntervalCustomDailyWeekly": "Пользовательские ежедневно/еженедельно",
"LabelIntervalEvery12Hours": "Каждые 12 часов",
"LabelIntervalEvery15Minutes": "Каждые 15 минут",
"LabelIntervalEvery2Hours": "Каждые 2 часа",
"LabelIntervalEvery30Minutes": "Каждые 30 минут",
"LabelIntervalEvery6Hours": "Каждые 6 часов",
"LabelIntervalEveryDay": "Каждый день",
"LabelIntervalEveryHour": "Каждый час",
"LabelInvalidParts": "Неверные Части",
"LabelItem": "Элемент",
"LabelLanguage": "Язык",
"LabelLanguageDefaultServer": "Язык Сервера по Умолчанию",
"LabelLastSeen": "Последнее Сканирование",
"LabelLastTime": "Последний по Времени",
"LabelLastUpdate": "Последний Обновленный",
"LabelLess": "Менее",
"LabelLibrariesAccessibleToUser": "Библиотеки Доступные для Пользователя",
"LabelLibrary": "Библиотека",
"LabelLibraryItem": "Элемент Библиотеки",
"LabelLibraryName": "Имя Библиотеки",
"LabelLimit": "Лимит",
"LabelListenAgain": "Послушать Снова",
"LabelLogLevelDebug": "Debug",
"LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Искать новые эпизоды после этой даты",
"LabelMediaPlayer": "Медиа Проигрыватель",
"LabelMediaType": "Тип Медиа",
"LabelMetadataProvider": "Провайдер",
"LabelMetaTag": "Мета Тег",
"LabelMinute": "Минуты",
"LabelMissing": "Потеряно",
"LabelMissingParts": "Потерянные Части",
"LabelMore": "Еще",
"LabelName": "Имя",
"LabelNarrator": "Читает",
"LabelNarrators": "Чтецы",
"LabelNew": "Новый",
"LabelNewestAuthors": "Новые Авторы",
"LabelNewestEpisodes": "Новые Эпизоды",
"LabelNewPassword": "Новый Пароль",
"LabelNotes": "Заметки",
"LabelNotFinished": "Не Завершено",
"LabelNotificationAppriseURL": "URL(ы) для извещений",
"LabelNotificationAvailableVariables": "Доступные переменные",
"LabelNotificationBodyTemplate": "Шаблон Тела",
"LabelNotificationEvent": "Событие Оповещения",
"LabelNotificationsMaxFailedAttempts": "Макс. попыток",
"LabelNotificationsMaxFailedAttemptsHelp": "Уведомления будут выключены если произойдет ошибка отправки данное количество раз",
"LabelNotificationsMaxQueueSize": "Макс. размер очереди для событий уведомлений",
"LabelNotificationsMaxQueueSizeHelp": "События ограничены 1 в секунду. События будут игнорированы если в очереди максимальное количество. Это предотвращает спам сообщениями.",
"LabelNotificationTitleTemplate": "Шаблон Заголовка",
"LabelNotStarted": "Не Запущено",
"LabelNumberOfBooks": "Количество Книг",
"LabelNumberOfEpisodes": "# Эпизодов",
"LabelOpenRSSFeed": "Открыть RSS-канал",
"LabelOverwrite": "Перезаписать",
"LabelPassword": "Пароль",
"LabelPath": "Путь",
"LabelPermissionsAccessAllLibraries": "Есть Доступ ко всем Библиотекам",
"LabelPermissionsAccessAllTags": "Есть Доступ ко всем Тегам",
"LabelPermissionsAccessExplicitContent": "Есть Доступ к Явному Содержимому",
"LabelPermissionsDelete": "Может Удалять",
"LabelPermissionsDownload": "Может Скачивать",
"LabelPermissionsUpdate": "Может Обновлять",
"LabelPermissionsUpload": "Может Закачивать",
"LabelPhotoPathURL": "Путь к Фото/URL",
"LabelPlaylists": "Плейлисты",
"LabelPlayMethod": "Метод Воспроизведения",
"LabelPodcast": "Подкаст",
"LabelPodcasts": "Подкасты",
"LabelPrefixesToIgnore": "Игнорируемые Префиксы (без учета регистра)",
"LabelProgress": "Прогресс",
"LabelProvider": "Провайдер",
"LabelPubDate": "Дата Публикации",
"LabelPublisher": "Издатель",
"LabelPublishYear": "Год Публикации",
"LabelRecentlyAdded": "Недавно Добавленные",
"LabelRecentSeries": "Последние Серии",
"LabelRecommended": "Рекомендованное",
"LabelRegion": "Регион",
"LabelReleaseDate": "Дата Выхода",
"LabelRemoveCover": "Удалить обложку",
"LabelRSSFeedOpen": "Открыть RSS-канал",
"LabelRSSFeedSlug": "Встроить RSS-канал",
"LabelRSSFeedURL": "URL RSS-канала",
"LabelSearchTerm": "Поисковый Запрос",
"LabelSearchTitle": "Поиск по Названию",
"LabelSearchTitleOrASIN": "Поиск по Названию или ASIN",
"LabelSeason": "Сезон",
"LabelSequence": "Последовательность",
"LabelSeries": "Серия",
"LabelSeriesName": "Имя Серии",
"LabelSeriesProgress": "Прогресс Серии",
"LabelSettingsBookshelfViewHelp": "Конструкция с деревянными полками",
"LabelSettingsChromecastSupport": "Поддержка Chromecast",
"LabelSettingsDateFormat": "Формат Даты",
"LabelSettingsDisableWatcher": "Отключить Отслеживание",
"LabelSettingsDisableWatcherForLibrary": "Отключить отслеживание для библиотеки",
"LabelSettingsDisableWatcherHelp": "Отключает автоматическое добавление/обновление элементов, когда обнаружено изменение файлов. *Требуется перезапуск сервера",
"LabelSettingsEnableEReader": "Включить e-reader для всех пользователей",
"LabelSettingsEnableEReaderHelp": "E-reader все еще находится в стадии разработки, используйте эту настройку, чтобы открыть его для всех ваших пользователей (Только для Вас используйте переключатель \"Экспериментальные Функции\")",
"LabelSettingsExperimentalFeatures": "Экспериментальные функции",
"LabelSettingsExperimentalFeaturesHelp": "Функционал в разработке на который Вы могли бы дать отзыв или помочь в тестировании. Нажмите для открытия обсуждения на github.",
"LabelSettingsFindCovers": "Найти обложки",
"LabelSettingsFindCoversHelp": "Если у Ваших аудиокниг нет встроенной обложки или файла обложки в папке книги, то сканер попробует найти обложку.<br>Примечание: Это увеличит время сканирования",
"LabelSettingsHomePageBookshelfView": "Вид книжной полки на Домашней Странице",
"LabelSettingsLibraryBookshelfView": "Вид книжной полки в Библиотеке",
"LabelSettingsOverdriveMediaMarkers": "Overdrive Media Markers для глав",
"LabelSettingsOverdriveMediaMarkersHelp": "MP3 файлы из Overdrive поставляется с таймингами глав, встроенными в виде пользовательских метаданных. При включении этого параметра эти теги будут автоматически использоваться для таймингов глав",
"LabelSettingsParseSubtitles": "Разбор подзаголовков",
"LabelSettingsParseSubtitlesHelp": "Извлечение подзаголовков из имен папок аудиокниг.<br>Подзаголовок должны быть отделен \" - \"<br>например \"Название Книги - Тут Подзаголовок\" подзаголовок будет \"Тут Подзаголовок\"",
"LabelSettingsPreferAudioMetadata": "Предпочитать аудио метаданные",
"LabelSettingsPreferAudioMetadataHelp": "ID3 мета теги будут использоваться для данных книг вместо имен папок",
"LabelSettingsPreferMatchedMetadata": "Предпочитать метаданные поиска",
"LabelSettingsPreferMatchedMetadataHelp": "Данные поиска будут перезаписывать данные книг при использовании Быстрого Поиска. По умолчанию Быстрый Поиск будет использоваться только при отсутствии данных",
"LabelSettingsPreferOPFMetadata": "Предпочитать OPF метаданные",
"LabelSettingsPreferOPFMetadataHelp": "Метаданные из файла OPF будут использованы для данных книги вместо имен папок",
"LabelSettingsSkipMatchingBooksWithASIN": "Пропускать Поиск книг у которых уже заполнен ASIN",
"LabelSettingsSkipMatchingBooksWithISBN": "Пропускать Поиск книг у которых уже заполнен ISBN",
"LabelSettingsSortingIgnorePrefixes": "Игнорировать префиксы при сортировке",
"LabelSettingsSortingIgnorePrefixesHelp": "Например \"the\", книга с названием \"The Book Title\" будет сортироваться как \"Book Title, The\"",
"LabelSettingsSquareBookCovers": "Использовать квадратные обложки книг",
"LabelSettingsSquareBookCoversHelp": "Использовать квадратные обложки вместо стандартных для книг 1.6:1 обложек",
"LabelSettingsStoreCoversWithItem": "Хранить обложки с элементом",
"LabelSettingsStoreCoversWithItemHelp": "По умолчанию обложки сохраняются в папке /metadata/items, при включении этой настройки обложка будет храниться в папке элемента. Будет сохраняться только один файл с именем \"cover\"",
"LabelSettingsStoreMetadataWithItem": "Хранить метаинформацию с элементом",
"LabelSettingsStoreMetadataWithItemHelp": "По умолчанию метаинформация сохраняется в папке /metadata/items, при включении этой настройки метаинформация будет храниться в папке элемента. Используется расширение файла .abs",
"LabelShowAll": "Показать Все",
"LabelSize": "Размер",
"LabelSleepTimer": "Таймер сна",
"LabelStart": "Начало",
"LabelStarted": "Начат",
"LabelStartedAt": "Начато В",
"LabelStartTime": "Время Начала",
"LabelStatsAudioTracks": "Аудио Треки",
"LabelStatsAuthors": "Авторы",
"LabelStatsBestDay": "Лучший День",
"LabelStatsDailyAverage": "В среднем в День",
"LabelStatsDays": "Дней",
"LabelStatsDaysListened": "Дней Прослушано",
"LabelStatsHours": "Часов",
"LabelStatsInARow": "в строке",
"LabelStatsItemsFinished": "Элементов Завершено",
"LabelStatsItemsInLibrary": "Элементов в Библиотеке",
"LabelStatsMinutes": "минут",
"LabelStatsMinutesListening": "Минут Прослушано",
"LabelStatsOverallDays": "Всего Дней",
"LabelStatsOverallHours": "Всего Часов",
"LabelStatsWeekListening": "Недель Прослушано",
"LabelSubtitle": "Подзаголовок",
"LabelSupportedFileTypes": "Поддерживаемые типы файлов",
"LabelTag": "Тег",
"LabelTags": "Теги",
"LabelTagsAccessibleToUser": "Теги Доступные для Пользователя",
"LabelTimeListened": "Время Прослушивания",
"LabelTimeListenedToday": "Время Прослушивания Сегодня",
"LabelTimeRemaining": "{0} осталось",
"LabelTimeToShift": "Время смещения в сек.",
"LabelTitle": "Название",
"LabelToolsEmbedMetadata": "Встроить Метаданные",
"LabelToolsEmbedMetadataDescription": "Встроить метаданные в аудио файлы, включая обложку и главы.",
"LabelToolsMakeM4b": "Создать M4B Файл Аудиокниги",
"LabelToolsMakeM4bDescription": "Создает .M4B файл аудиокниги с встроенными метаданными, обложкой и главами.",
"LabelToolsSplitM4b": "Разделить M4B на MP3 файлы",
"LabelToolsSplitM4bDescription": "Создает MP3 файла из M4B, разделяет на главы с встроенными метаданными, обложкой и главами.",
"LabelTotalDuration": "Общая Длина",
"LabelTotalTimeListened": "Всего Прослушано",
"LabelTrackFromFilename": "Трек из Имени файла",
"LabelTrackFromMetadata": "Трек из Метаданных",
"LabelTracks": "Треков",
"LabelTracksMultiTrack": "Мультитрек",
"LabelTracksSingleTrack": "Один трек",
"LabelType": "Тип",
"LabelUnknown": "Неизвестно",
"LabelUpdateCover": "Обновить Обложку",
"LabelUpdateCoverHelp": "Позволяет перезаписывать существующие обложки для выбранных книг если будут найдены",
"LabelUpdatedAt": "Обновлено в",
"LabelUpdateDetails": "Обновить Подробности",
"LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены",
"LabelUploaderDragAndDrop": "Перетащите файлы или каталоги",
"LabelUploaderDropFiles": "Перетащите файлы",
"LabelUseChapterTrack": "Показывать время главы",
"LabelUseFullTrack": "Показывать время книги",
"LabelUser": "Пользователь",
"LabelUsername": "Имя пользователя",
"LabelValue": "Значение",
"LabelVersion": "Версия",
"LabelViewBookmarks": "Закладки",
"LabelViewChapters": "Главы",
"LabelViewQueue": "Очередь воспроизведения",
"LabelVolume": "Громкость",
"LabelWeekdaysToRun": "Дни недели для запуска",
"LabelYourAudiobookDuration": "Продолжительность Вашей Книги",
"LabelYourBookmarks": "Ваши Закладки",
"LabelYourPlaylists": "Ваши Плейлисты",
"LabelYourProgress": "Ваш Прогресс",
"MessageAddToPlayerQueue": "Добавить в очередь проигрывателя",
"MessageAppriseDescription": "Для использования этой функции необходимо иметь запущенный экземпляр <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> или api которое обрабатывает те же самые запросы. <br />URL-адрес API Apprise должен быть полным URL-адресом для отправки уведомления, т.е., если API запущено по адресу <code>http://192.168.1.1:8337</code> тогда нужно указать <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "Бэкап включает пользователей, прогресс пользователей, данные элементов библиотеки, настройки сервера и изображения хранящиеся в <code>/metadata/items</code> и <code>/metadata/authors</code>. Бэкапы <strong>НЕ</strong> сохраняют файлы из папок библиотек.",
"MessageBatchQuickMatchDescription": "Быстрый Поиск попытается добавить отсутствующие обложки и метаданные для выбранных элементов. Включите параметры ниже, чтобы разрешить Быстрому Поиску перезаписывать существующие обложки и/или метаданные.",
"MessageBookshelfNoCollections": "Вы еще не создали ни одной коллекции",
"MessageBookshelfNoResultsForFilter": "Нет Результатов для фильтра \"{0}: {1}\"",
"MessageBookshelfNoRSSFeeds": "Нет открытых RSS-каналов",
"MessageBookshelfNoSeries": "У вас нет серий",
"MessageChapterEndIsAfter": "Конец главы после окончания вашей аудиокниги",
"MessageChapterErrorFirstNotZero": "Первая глава должна начинаться с 0",
"MessageChapterErrorStartGteDuration": "Неверное время начала, должно быть меньше продолжительности аудиокниги",
"MessageChapterErrorStartLtPrev": "Неверное время начала, должно быть больше или равно времени начала предыдущей главы",
"MessageChapterStartIsAfter": "Глава начинается после окончания аудиокниги",
"MessageCheckingCron": "Проверка cron...",
"MessageConfirmDeleteBackup": "Вы уверены, что хотите удалить бэкап для {0}?",
"MessageConfirmDeleteLibrary": "Вы уверены, что хотите навсегда удалить библиотеку \"{0}\"?",
"MessageConfirmDeleteSession": "Вы уверены, что хотите удалить этот сеанс?",
"MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?",
"MessageConfirmMarkSeriesFinished": "Вы уверены, что хотите отметить все книги этой серии как законченные?",
"MessageConfirmMarkSeriesNotFinished": "Вы уверены, что хотите отметить все книги этой серии как незаконченные?",
"MessageConfirmRemoveCollection": "Вы уверены, что хотите удалить коллекцию \"{0}\"?",
"MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Вы уверены, что хотите удалить {0} эпизодов?",
"MessageConfirmRemovePlaylist": "Вы уверены, что хотите удалить плейлист \"{0}\"?",
"MessageConfirmRenameGenre": "Вы уверены, что хотите переименовать жанр \"{0}\" в \"{1}\" для всех элементов?",
"MessageConfirmRenameGenreMergeNote": "Примечание: Этот жанр уже существует, поэтому они будут объединены.",
"MessageConfirmRenameGenreWarning": "Предупреждение! Похожий жанр с другими начальными буквами уже существует \"{0}\".",
"MessageConfirmRenameTag": "Вы уверены, что хотите переименовать тег \"{0}\" в \"{1}\" для всех элементов?",
"MessageConfirmRenameTagMergeNote": "Примечание: Этот тег уже существует, поэтому они будут объединены.",
"MessageConfirmRenameTagWarning": "Предупреждение! Похожий тег с другими начальными буквами уже существует \"{0}\".",
"MessageDownloadingEpisode": "Эпизод скачивается",
"MessageDragFilesIntoTrackOrder": "Перетащите файлы для исправления порядка треков",
"MessageEmbedFinished": "Встраивание завершено!",
"MessageEpisodesQueuedForDownload": "{0} Эпизод(ов) запланировано для закачки",
"MessageFeedURLWillBe": "URL канала будет {0}",
"MessageFetching": "Завершается...",
"MessageForceReScanDescription": "будет сканировать все файлы снова, как свежее сканирование. Теги ID3 аудиофайлов, OPF-файлы и текстовые файлы будут сканироваться как новые.",
"MessageImportantNotice": "Важное замечание!",
"MessageInsertChapterBelow": "Вставить главу ниже",
"MessageItemsSelected": "{0} Элементов Выделено",
"MessageItemsUpdated": "{0} Элементов Обновлено",
"MessageJoinUsOn": "Присоединяйтесь к нам в",
"MessageListeningSessionsInTheLastYear": "{0} сеансов прослушивания в прошлом году",
"MessageLoading": "Загрузка...",
"MessageLoadingFolders": "Загрузка каталогов...",
"MessageM4BFailed": "M4B Ошибка!",
"MessageM4BFinished": "M4B Завершено!",
"MessageMapChapterTitles": "Сопоставление названий глав с существующими главами аудиокниги без корректировки временных меток",
"MessageMarkAsFinished": "Отметить, как Завершенную",
"MessageMarkAsNotFinished": "Отметить, как Не Завершенную",
"MessageMatchBooksDescription": "попытается сопоставить книги в библиотеке с книгой из выбранного поставщика поиска и заполнить пустые детали и обложку. Не перезаписывает сведения.",
"MessageNoAudioTracks": "Нет аудио треков",
"MessageNoAuthors": "Нет Авторов",
"MessageNoBackups": "Нет Бэкапов",
"MessageNoBookmarks": "Нет Закладок",
"MessageNoChapters": "Нет Глав",
"MessageNoCollections": "Нет Коллекций",
"MessageNoCoversFound": "Обложек не найдено",
"MessageNoDescription": "Нет описания",
"MessageNoEpisodeMatchesFound": "Совпадения эпизодов не найдены",
"MessageNoEpisodes": "Нет Эпизодов",
"MessageNoFoldersAvailable": "Нет доступных папок",
"MessageNoGenres": "Нет Жанров",
"MessageNoIssues": "Нет Проблем",
"MessageNoItems": "Нет Элементов",
"MessageNoItemsFound": "Элементы не найдены",
"MessageNoListeningSessions": "Нет Сеансов Прослушивания",
"MessageNoLogs": "Нет Логов",
"MessageNoMediaProgress": "Нет Прогресса Медиа",
"MessageNoNotifications": "Нет Уведомлений",
"MessageNoPodcastsFound": "Подкасты не найдены",
"MessageNoResults": "Нет Результатов",
"MessageNoSearchResultsFor": "Нет результатов поиска для \"{0}\"",
"MessageNoSeries": "Нет Серий",
"MessageNoTags": "Нет Тегов",
"MessageNotYetImplemented": "Пока не реализовано",
"MessageNoUpdateNecessary": "Обновление не требуется",
"MessageNoUpdatesWereNecessary": "Обновления не требовались",
"MessageNoUserPlaylists": "У вас нет плейлистов",
"MessageOr": "или",
"MessagePauseChapter": "Пауза воспроизведения главы",
"MessagePlayChapter": "Прослушать начало главы",
"MessagePlaylistCreateFromCollection": "Создать плейлист из коллекции",
"MessagePodcastHasNoRSSFeedForMatching": "Подкаст не имеет URL-адреса RSS-канала, который можно использовать для поиска",
"MessageQuickMatchDescription": "Заполняет пустые детали элемента и обложку первым результатом поиска из «{0}». Не перезаписывает сведения, если не включен параметр сервера 'Предпочитать метаданные поиска'.",
"MessageRemoveAllItemsWarning": "ПРЕДУПРЕЖДЕНИЕ! Это действие удалит все элементы библиотеки из базы данных, включая все сделанные обновления или совпадения. Ничего не произойдет с вашими фактическими файлами. Уверены?",
"MessageRemoveChapter": "Удалить главу",
"MessageRemoveEpisodes": "Удалить {0} эпизод(ов)",
"MessageRemoveFromPlayerQueue": "Удалить из очереди воспроизведения",
"MessageRemoveUserWarning": "Вы уверены, что хотите навсегда удалить пользователя \"{0}\"?",
"MessageReportBugsAndContribute": "Сообщайте об ошибках, запрашивайте функции и вносите свой вклад на",
"MessageResetChaptersConfirm": "Вы уверены, что хотите сбросить главы и отменить внесенные изменения?",
"MessageRestoreBackupConfirm": "Вы уверены, что хотите восстановить резервную копию, созданную",
"MessageRestoreBackupWarning": "Восстановление резервной копии перезапишет всю базу данных, расположенную в /config, и обложки изображений в /metadata/items и /metadata/authors.<br/><br/>Бэкапы не изменяют файлы в папках библиотеки. Если вы включили параметры сервера для хранения обложек и метаданных в папках библиотеки, то они не резервируются и не перезаписываются.<br/><br/>Все клиенты, использующие ваш сервер, будут автоматически обновлены.",
"MessageSearchResultsFor": "Результаты поиска для",
"MessageServerCouldNotBeReached": "Не удалось связаться с сервером",
"MessageSetChaptersFromTracksDescription": "Установка глав с использованием каждого аудиофайла в качестве главы и заголовка главы в качестве имени аудиофайла",
"MessageStartPlaybackAtTime": "Начать воспроизведение для \"{0}\" с {1}?",
"MessageThinking": "Думаю...",
"MessageUploaderItemFailed": "Не удалось загрузить",
"MessageUploaderItemSuccess": "Успешно Загружено!",
"MessageUploading": "Загрузка...",
"MessageValidCronExpression": "Верное cron выражение",
"MessageWatcherIsDisabledGlobally": "Наблюдатель отключен глобально в настройках сервера",
"MessageXLibraryIsEmpty": "{0} Библиотека пуста!",
"MessageYourAudiobookDurationIsLonger": "Продолжительность аудиокниги больше найденной продолжительности",
"MessageYourAudiobookDurationIsShorter": "Продолжительность аудиокниги короче найденной продолжительности",
"NoteChangeRootPassword": "Пользователь root — единственный пользователь, который может иметь пустой пароль",
"NoteChapterEditorTimes": "Примечание: Время начала первой главы должно оставаться в 0:00, а время начала последней главы не может превышать продолжительность этой аудиокниги.",
"NoteFolderPicker": "Примечание: папки, уже сопоставленные, не будут отображаться",
"NoteFolderPickerDebian": "Примечание: Выбор папок debian не реализован полностью. Необходимо ввести путь к библиотеке напрямую.",
"NoteRSSFeedPodcastAppsHttps": "Предупреждение: Большинству приложений подкастов потребуется, чтобы URL-адрес RSS-канала использовал HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Предупреждение: 1 или более эпизодов не имеют даты публикации. Некоторые приложения для подкастов требуют этого.",
"NoteUploaderFoldersWithMediaFiles": "Папки с медиафайлами будут обрабатываться как отдельные элементы библиотеки.",
"NoteUploaderOnlyAudioFiles": "Если загружать только аудиофайлы, то каждый аудиофайл будет обрабатываться как отдельная аудиокнига.",
"NoteUploaderUnsupportedFiles": "Неподдерживаемые файлы игнорируются. При выборе или удалении папки другие файлы, не находящиеся в папке элемента, игнорируются.",
"PlaceholderNewCollection": "Новое имя коллекции",
"PlaceholderNewFolderPath": "Путь к новой папке",
"PlaceholderNewPlaylist": "Новое название плейлиста",
"PlaceholderSearch": "Поиск...",
"ToastAccountUpdateFailed": "Не удалось обновить учетную запись",
"ToastAccountUpdateSuccess": "Учетная запись обновлена",
"ToastAuthorImageRemoveFailed": "Не удалось удалить изображение",
"ToastAuthorImageRemoveSuccess": "Изображение автора удалено",
"ToastAuthorUpdateFailed": "Не удалось обновить автора",
"ToastAuthorUpdateMerged": "Автор объединен",
"ToastAuthorUpdateSuccess": "Автор обновлен",
"ToastAuthorUpdateSuccessNoImageFound": "Автор обновлен (изображение не найдено)",
"ToastBackupCreateFailed": "Не удалось создать бэкап",
"ToastBackupCreateSuccess": "Бэкап создан",
"ToastBackupDeleteFailed": "Не удалось удалить бэкап",
"ToastBackupDeleteSuccess": "Бэкап удален",
"ToastBackupRestoreFailed": "Не удалось восстановить из бэкапа",
"ToastBackupUploadFailed": "Не удалось загрузить бэкап",
"ToastBackupUploadSuccess": "Бэкап загружен",
"ToastBatchUpdateFailed": "Сбой пакетного обновления",
"ToastBatchUpdateSuccess": "Успешное пакетное обновление",
"ToastBookmarkCreateFailed": "Не удалось создать закладку",
"ToastBookmarkCreateSuccess": "Добавлена закладка",
"ToastBookmarkRemoveFailed": "Не удалось удалить закладку",
"ToastBookmarkRemoveSuccess": "Закладка удалена",
"ToastBookmarkUpdateFailed": "Не удалось обновить закладку",
"ToastBookmarkUpdateSuccess": "Закладка обновлена",
"ToastChaptersHaveErrors": "Главы имеют ошибки",
"ToastChaptersMustHaveTitles": "Главы должны содержать названия",
"ToastCollectionItemsRemoveFailed": "Не удалось удалить элемент(ы) из коллекции",
"ToastCollectionItemsRemoveSuccess": "Элемент(ы), удалены из коллекции",
"ToastCollectionRemoveFailed": "Не удалось удалить коллекцию",
"ToastCollectionRemoveSuccess": "Коллекция удалена",
"ToastCollectionUpdateFailed": "Не удалось обновить коллекцию",
"ToastCollectionUpdateSuccess": "Коллекция обновлена",
"ToastItemCoverUpdateFailed": "Не удалось обновить обложку элемента",
"ToastItemCoverUpdateSuccess": "Обложка элемента обновлена",
"ToastItemDetailsUpdateFailed": "Не удалось обновить сведения об элементе",
"ToastItemDetailsUpdateSuccess": "Обновлены сведения об элементе",
"ToastItemDetailsUpdateUnneeded": "Для сведений об элементе не требуется никаких обновлений",
"ToastItemMarkedAsFinishedFailed": "Не удалось пометить как Завершенный",
"ToastItemMarkedAsFinishedSuccess": "Элемент помечен как Завершенный",
"ToastItemMarkedAsNotFinishedFailed": "Не удалось пометить как Незавершенный",
"ToastItemMarkedAsNotFinishedSuccess": "Элемент помечен как Незавершенный",
"ToastLibraryCreateFailed": "Не удалось создать библиотеку",
"ToastLibraryCreateSuccess": "Библиотека \"{0}\" создана",
"ToastLibraryDeleteFailed": "Не удалось удалить библиотеку",
"ToastLibraryDeleteSuccess": "Библиотека удалена",
"ToastLibraryScanFailedToStart": "Не удалось запустить сканирование",
"ToastLibraryScanStarted": "Запущено сканирование библиотеки",
"ToastLibraryUpdateFailed": "Не удалось обновить библиотеку",
"ToastLibraryUpdateSuccess": "Библиотека \"{0}\" обновлена",
"ToastPlaylistCreateFailed": "Не удалось создать плейлист",
"ToastPlaylistCreateSuccess": "Плейлист создан",
"ToastPlaylistRemoveFailed": "Не удалось удалить плейлист",
"ToastPlaylistRemoveSuccess": "Плейлист удален",
"ToastPlaylistUpdateFailed": "Не удалось обновить плейлист",
"ToastPlaylistUpdateSuccess": "Плейлист обновлен",
"ToastPodcastCreateFailed": "Не удалось создать подкаст",
"ToastPodcastCreateSuccess": "Подкаст успешно создан",
"ToastRemoveItemFromCollectionFailed": "Не удалось удалить элемент из коллекции",
"ToastRemoveItemFromCollectionSuccess": "Элемент удален из коллекции",
"ToastRSSFeedCloseFailed": "Не удалось закрыть RSS-канал",
"ToastRSSFeedCloseSuccess": "RSS-канал закрыт",
"ToastSeriesUpdateFailed": "Не удалось обновить серию",
"ToastSeriesUpdateSuccess": "Успешное обновление серии",
"ToastSessionDeleteFailed": "Не удалось удалить сеанс",
"ToastSessionDeleteSuccess": "Сеанс удален",
"ToastSocketConnected": "Сокет подключен",
"ToastSocketDisconnected": "Сокет отключен",
"ToastSocketFailedToConnect": "Не удалось подключить сокет",
"ToastUserDeleteFailed": "Не удалось удалить пользователя",
"ToastUserDeleteSuccess": "Пользователь удален"
}

View File

@@ -91,8 +91,7 @@ module.exports = {
},
fontFamily: {
sans: ['Source Sans Pro'],
mono: ['Ubuntu Mono'],
book: ['Gentium Book Basic', 'serif']
mono: ['Ubuntu Mono']
},
fontSize: {
xxs: '0.625rem',

38
package-lock.json generated
View File

@@ -1,15 +1,15 @@
{
"name": "audiobookshelf",
"version": "2.2.13",
"version": "2.2.15",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
"version": "2.2.13",
"version": "2.2.15",
"license": "GPL-3.0",
"dependencies": {
"axios": "^1.2.2",
"axios": "^0.27.2",
"express": "^4.17.1",
"graceful-fs": "^4.2.10",
"htmlparser2": "^8.0.1",
@@ -89,13 +89,12 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.2.2.tgz",
"integrity": "sha512-bz/J4gS2S3I7mpN/YZfGFTqhXTYzRho8Ay38w2otuuDR322KzFIWm/4W2K6gIwvWaws5n+mnb7D1lN9uD+QH6Q==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
"integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
"dependencies": {
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
"follow-redirects": "^1.14.9",
"form-data": "^4.0.0"
}
},
"node_modules/balanced-match": {
@@ -967,11 +966,6 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
@@ -1415,13 +1409,12 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"axios": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.2.2.tgz",
"integrity": "sha512-bz/J4gS2S3I7mpN/YZfGFTqhXTYzRho8Ay38w2otuuDR322KzFIWm/4W2K6gIwvWaws5n+mnb7D1lN9uD+QH6Q==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
"integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
"requires": {
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
"follow-redirects": "^1.14.9",
"form-data": "^4.0.0"
}
},
"balanced-match": {
@@ -2046,11 +2039,6 @@
"ipaddr.js": "1.9.1"
}
},
"proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.2.13",
"version": "2.2.15",
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
"scripts": {
@@ -30,7 +30,7 @@
"author": "advplyr",
"license": "GPL-3.0",
"dependencies": {
"axios": "^1.2.2",
"axios": "^0.27.2",
"express": "^4.17.1",
"graceful-fs": "^4.2.10",
"htmlparser2": "^8.0.1",

View File

@@ -684,13 +684,13 @@ class LibraryController {
res.sendStatus(200)
}
// GET: api/libraries/:id/scan
// POST: api/libraries/:id/scan
async scan(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryController] Non-root user attempted to scan library`, req.user)
return res.sendStatus(403)
}
var options = {
const options = {
forceRescan: req.query.force == 1
}
res.sendStatus(200)

View File

@@ -203,7 +203,7 @@ class LibraryItemController {
if (global.XAccel) {
Logger.debug(`Use X-Accel to serve static file ${libraryItem.media.coverPath}`)
return res.status(204).header({'X-Accel-Redirect': global.XAccel + libraryItem.media.coverPath}).send()
return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + libraryItem.media.coverPath }).send()
}
return res.sendFile(libraryItem.media.coverPath)
}
@@ -389,7 +389,7 @@ class LibraryItemController {
else res.sendStatus(500)
}
// GET: api/items/:id/scan (admin)
// POST: api/items/:id/scan (admin)
async scan(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryItemController] Non-admin user attempted to scan library item`, req.user)

View File

@@ -6,6 +6,10 @@ const { isObject, toNumber } = require('../utils/index')
class MeController {
constructor() { }
getCurrentUser(req, res) {
res.json(req.user.toJSONForBrowser())
}
// GET: api/me/listening-sessions
async getListeningSessions(req, res) {
var listeningSessions = await this.getUserListeningSessionsHelper(req.user.id)
@@ -184,6 +188,7 @@ class MeController {
})
}
// TODO: Deprecated. Removed from Android. Only used in iOS app now.
// POST: api/me/sync-local-progress
async syncLocalMediaProgress(req, res) {
if (!req.body.localMediaProgress) {

View File

@@ -19,6 +19,10 @@ class MiscController {
Logger.warn('User attempted to upload without permission', req.user)
return res.sendStatus(403)
}
if (!req.files) {
Logger.error('Invalid request, no files')
return res.sendStatus(400)
}
var files = Object.values(req.files)
var title = req.body.title
var author = req.body.author

View File

@@ -31,21 +31,24 @@ class PodcastController {
}
const podcastPath = filePathToPOSIX(payload.path)
if (await fs.pathExists(podcastPath)) {
Logger.error(`[PodcastController] Podcast folder already exists "${podcastPath}"`)
// Check if a library item with this podcast folder exists already
const existingLibraryItem = this.db.libraryItems.find(li => li.path === podcastPath && li.libraryId === library.id)
if (existingLibraryItem) {
Logger.error(`[PodcastController] Podcast already exists with name "${existingLibraryItem.media.metadata.title}" at path "${podcastPath}"`)
return res.status(400).send('Podcast already exists')
}
var success = await fs.ensureDir(podcastPath).then(() => true).catch((error) => {
const success = await fs.ensureDir(podcastPath).then(() => true).catch((error) => {
Logger.error(`[PodcastController] Failed to ensure podcast dir "${podcastPath}"`, error)
return false
})
if (!success) return res.status(400).send('Invalid podcast path')
await filePerms.setDefault(podcastPath)
var libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
var relPath = payload.path.replace(folder.fullPath, '')
let relPath = payload.path.replace(folder.fullPath, '')
if (relPath.startsWith('/')) relPath = relPath.slice(1)
const libraryItemPayload = {
@@ -60,14 +63,14 @@ class PodcastController {
media: payload.media
}
var libraryItem = new LibraryItem()
const libraryItem = new LibraryItem()
libraryItem.setData('podcast', libraryItemPayload)
// Download and save cover image
if (payload.media.metadata.imageUrl) {
// TODO: Scan cover image to library files
// Podcast cover will always go into library item folder
var coverResponse = await this.coverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl, true)
const coverResponse = await this.coverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl, true)
if (coverResponse) {
if (coverResponse.error) {
Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`)

View File

@@ -75,6 +75,11 @@ class SessionController {
this.playbackSessionManager.syncLocalSessionRequest(req.user, req.body, res)
}
// POST: api/session/local-all
syncLocalSessions(req, res) {
this.playbackSessionManager.syncLocalSessionsRequest(req, res)
}
openSessionMiddleware(req, res, next) {
var playbackSession = this.playbackSessionManager.getSession(req.params.id)
if (!playbackSession) return res.sendStatus(404)
@@ -89,8 +94,11 @@ class SessionController {
}
async middleware(req, res, next) {
var playbackSession = await this.db.getPlaybackSession(req.params.id)
if (!playbackSession) return res.sendStatus(404)
const playbackSession = await this.db.getPlaybackSession(req.params.id)
if (!playbackSession) {
Logger.error(`[SessionController] Unable to find playback session with id=${req.params.id}`)
return res.sendStatus(404)
}
if (req.method == 'DELETE' && !req.user.canDelete) {
Logger.warn(`[SessionController] User attempted to delete without permission`, req.user)

View File

@@ -3,6 +3,7 @@ const GoogleBooks = require('../providers/GoogleBooks')
const Audible = require('../providers/Audible')
const iTunes = require('../providers/iTunes')
const Audnexus = require('../providers/Audnexus')
const FantLab = require('../providers/FantLab')
const Logger = require('../Logger')
const { levenshteinDistance } = require('../utils/index')
@@ -13,6 +14,7 @@ class BookFinder {
this.audible = new Audible()
this.iTunesApi = new iTunes()
this.audnexus = new Audnexus()
this.fantLab = new FantLab()
this.verbose = false
}
@@ -146,6 +148,17 @@ class BookFinder {
return books
}
async getFantLabResults(title, author) {
var books = await this.fantLab.search(title, author)
if (this.verbose) Logger.debug(`FantLab Book Search Results: ${books.length || 0}`)
if (books.errorCode) {
Logger.error(`FantLab Search Error ${books.errorCode}`)
return []
}
return books
}
async getiTunesAudiobooksResults(title, author) {
return this.iTunesApi.searchAudiobooks(title)
}
@@ -172,7 +185,10 @@ class BookFinder {
books = await this.getiTunesAudiobooksResults(title, author)
} else if (provider === 'openlibrary') {
books = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
} else {
} else if (provider === 'fantlab') {
books = await this.getFantLabResults(title, author)
}
else {
books = await this.getGoogleBooksResults(title, author)
}
@@ -186,7 +202,7 @@ class BookFinder {
return this.search(provider, cleanedTitle, cleanedAuthor, isbn, asin, options)
}
if (["google", "audible", "itunes"].includes(provider)) return books
if (["google", "audible", "itunes", 'fantlab'].includes(provider)) return books
return books.sort((a, b) => {
return a.totalDistance - b.totalDistance

View File

@@ -295,14 +295,16 @@ class BackupManager {
// pipe archive data to the file
archive.pipe(output)
archive.directory(this.db.LibraryItemsPath, 'config/libraryItems/data')
archive.directory(this.db.UsersPath, 'config/users/data')
archive.directory(this.db.SessionsPath, 'config/sessions/data')
archive.directory(this.db.LibrariesPath, 'config/libraries/data')
archive.directory(this.db.SettingsPath, 'config/settings/data')
archive.directory(this.db.CollectionsPath, 'config/collections/data')
archive.directory(this.db.AuthorsPath, 'config/authors/data')
archive.directory(this.db.SeriesPath, 'config/series/data')
archive.directory(Path.join(this.db.LibraryItemsPath, 'data'), 'config/libraryItems/data')
archive.directory(Path.join(this.db.UsersPath, 'data'), 'config/users/data')
archive.directory(Path.join(this.db.SessionsPath, 'data'), 'config/sessions/data')
archive.directory(Path.join(this.db.LibrariesPath, 'data'), 'config/libraries/data')
archive.directory(Path.join(this.db.SettingsPath, 'data'), 'config/settings/data')
archive.directory(Path.join(this.db.CollectionsPath, 'data'), 'config/collections/data')
archive.directory(Path.join(this.db.AuthorsPath, 'data'), 'config/authors/data')
archive.directory(Path.join(this.db.SeriesPath, 'data'), 'config/series/data')
archive.directory(Path.join(this.db.PlaylistsPath, 'data'), 'config/playlists/data')
archive.directory(Path.join(this.db.FeedsPath, 'data'), 'config/feeds/data')
if (this.serverSettings.backupMetadataCovers) {
Logger.debug(`[BackupManager] Backing up Metadata Items "${this.ItemsMetadataPath}"`)

View File

@@ -54,7 +54,7 @@ class CoverManager {
for (let i = 0; i < filesInDir.length; i++) {
var file = filesInDir[i]
var _extname = Path.extname(file).toLowerCase()
var _filename = Path.basename(file, _extname)
var _filename = Path.basename(file, _extname).toLowerCase()
if (_filename === 'cover' && _extname !== newCoverExt && imageExtensions.includes(_extname)) {
var filepath = Path.join(dirpath, file)
Logger.debug(`[CoverManager] Removing old cover from metadata "${filepath}"`)
@@ -83,20 +83,20 @@ class CoverManager {
}
async uploadCover(libraryItem, coverFile) {
var extname = Path.extname(coverFile.name.toLowerCase())
const extname = Path.extname(coverFile.name.toLowerCase())
if (!extname || !globals.SupportedImageTypes.includes(extname.slice(1))) {
return {
error: `Invalid image type ${extname} (Supported: ${globals.SupportedImageTypes.join(',')})`
}
}
var coverDirPath = this.getCoverDirectory(libraryItem)
const coverDirPath = this.getCoverDirectory(libraryItem)
await fs.ensureDir(coverDirPath)
var coverFullPath = Path.posix.join(coverDirPath, `cover${extname}`)
const coverFullPath = Path.posix.join(coverDirPath, `cover${extname}`)
// Move cover from temp upload dir to destination
var success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => {
const success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => {
Logger.error('[CoverManager] Failed to move cover file', path, error)
return false
})

View File

@@ -21,7 +21,6 @@ class PlaybackSessionManager {
this.StreamsPath = Path.join(global.MetadataPath, 'streams')
this.sessions = []
this.localSessionLock = {}
}
getSession(sessionId) {
@@ -47,7 +46,7 @@ class PlaybackSessionManager {
async startSessionRequest(req, res, episodeId) {
const deviceInfo = this.getDeviceInfo(req)
Logger.debug(`[PlaybackSessionManager] startSessionRequest for device ${deviceInfo.deviceDescription}`)
const { user, libraryItem, body: options } = req
const session = await this.startSession(user, deviceInfo, libraryItem, episodeId, options)
res.json(session.toJSONForClient(libraryItem))
@@ -61,18 +60,84 @@ class PlaybackSessionManager {
}
}
async syncLocalSessionRequest(user, sessionJson, res) {
if (this.localSessionLock[sessionJson.id]) {
Logger.debug(`[PlaybackSessionManager] syncLocalSessionRequest: Local session is locked and already syncing`)
return res.status(500).send('Local session is locked and already syncing')
async syncLocalSessionsRequest(req, res) {
const user = req.user
const sessions = req.body.sessions || []
const syncResults = []
for (const sessionJson of sessions) {
Logger.info(`[PlaybackSessionManager] Syncing local session "${sessionJson.displayTitle}" (${sessionJson.id})`)
const result = await this.syncLocalSession(user, sessionJson)
syncResults.push(result)
}
res.json({
results: syncResults
})
}
async syncLocalSession(user, sessionJson) {
const libraryItem = this.db.getLibraryItem(sessionJson.libraryItemId)
if (!libraryItem) {
Logger.error(`[PlaybackSessionManager] syncLocalSessionRequest: Library item not found for session "${sessionJson.libraryItemId}"`)
return res.status(500).send('Library item not found')
const episode = (sessionJson.episodeId && libraryItem && libraryItem.isPodcast) ? libraryItem.media.getEpisode(sessionJson.episodeId) : null
if (!libraryItem || (libraryItem.isPodcast && !episode)) {
Logger.error(`[PlaybackSessionManager] syncLocalSession: Media item not found for session "${sessionJson.displayTitle}" (${sessionJson.id})`)
return {
id: sessionJson.id,
success: false,
error: 'Media item not found'
}
}
let session = await this.db.getPlaybackSession(sessionJson.id)
if (!session) {
// New session from local
session = new PlaybackSession(sessionJson)
Logger.debug(`[PlaybackSessionManager] Inserting new session for "${session.displayTitle}" (${session.id})`)
await this.db.insertEntity('session', session)
} else {
session.currentTime = sessionJson.currentTime
session.timeListening = sessionJson.timeListening
session.updatedAt = sessionJson.updatedAt
session.date = date.format(new Date(), 'YYYY-MM-DD')
session.dayOfWeek = date.format(new Date(), 'dddd')
Logger.debug(`[PlaybackSessionManager] Updated session for "${session.displayTitle}" (${session.id})`)
await this.db.updateEntity('session', session)
}
const result = {
id: session.id,
success: true,
progressSynced: false
}
const userProgressForItem = user.getMediaProgress(session.libraryItemId, session.episodeId)
if (userProgressForItem) {
if (userProgressForItem.lastUpdate > session.updatedAt) {
Logger.debug(`[PlaybackSessionManager] Not updating progress for "${session.displayTitle}" because it has been updated more recently`)
} else {
Logger.debug(`[PlaybackSessionManager] Updating progress for "${session.displayTitle}" with current time ${session.currentTime} (previously ${userProgressForItem.currentTime})`)
result.progressSynced = user.createUpdateMediaProgress(libraryItem, session.mediaProgressObject, session.episodeId)
}
} else {
Logger.debug(`[PlaybackSessionManager] Creating new media progress for media item "${session.displayTitle}"`)
result.progressSynced = user.createUpdateMediaProgress(libraryItem, session.mediaProgressObject, session.episodeId)
}
// Update user and emit socket event
if (result.progressSynced) {
await this.db.updateEntity('user', user)
const itemProgress = user.getMediaProgress(session.libraryItemId, session.episodeId)
SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', {
id: itemProgress.id,
data: itemProgress.toJSON()
})
}
return result
}
async syncLocalSessionRequest(user, sessionJson, res) {
// If server session is open for this same media item then close it
const userSessionForThisItem = this.sessions.find(playbackSession => {
if (playbackSession.userId !== user.id) return false
@@ -84,43 +149,13 @@ class PlaybackSessionManager {
await this.closeSession(user, userSessionForThisItem, null)
}
this.localSessionLock[sessionJson.id] = true // Lock local session
let session = await this.db.getPlaybackSession(sessionJson.id)
if (!session) {
// New session from local
session = new PlaybackSession(sessionJson)
await this.db.insertEntity('session', session)
// Sync
const result = await this.syncLocalSession(user, sessionJson)
if (result.error) {
res.status(500).send(result.error)
} else {
session.currentTime = sessionJson.currentTime
session.timeListening = sessionJson.timeListening
session.updatedAt = sessionJson.updatedAt
session.date = date.format(new Date(), 'YYYY-MM-DD')
session.dayOfWeek = date.format(new Date(), 'dddd')
await this.db.updateEntity('session', session)
res.sendStatus(200)
}
session.currentTime = sessionJson.currentTime
const itemProgressUpdate = {
duration: session.duration,
currentTime: session.currentTime,
progress: session.progress,
lastUpdate: session.updatedAt // Keep media progress update times the same as local
}
const wasUpdated = user.createUpdateMediaProgress(libraryItem, itemProgressUpdate, session.episodeId)
if (wasUpdated) {
await this.db.updateEntity('user', user)
const itemProgress = user.getMediaProgress(session.libraryItemId, session.episodeId)
SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', {
id: itemProgress.id,
data: itemProgress.toJSON()
})
}
delete this.localSessionLock[sessionJson.id] // Unlock local session
res.sendStatus(200)
}
async closeSessionRequest(user, session, syncData, res) {
@@ -132,7 +167,7 @@ class PlaybackSessionManager {
// Close any sessions already open for user
const userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id)
for (const session of userSessions) {
Logger.info(`[PlaybackSessionManager] startSession: Closing open session "${session.displayTitle}" for user "${user.username}"`)
Logger.info(`[PlaybackSessionManager] startSession: Closing open session "${session.displayTitle}" for user "${user.username}" (Device: ${session.deviceDescription})`)
await this.closeSession(user, session, null)
}
@@ -154,7 +189,7 @@ class PlaybackSessionManager {
if (libraryItem.mediaType === 'video') {
if (shouldDirectPlay) {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}"`)
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}" with id ${newPlaybackSession.id}`)
newPlaybackSession.videoTrack = libraryItem.media.getVideoTrack()
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
} else {
@@ -163,11 +198,11 @@ class PlaybackSessionManager {
} else {
let audioTracks = []
if (shouldDirectPlay) {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}"`)
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}" with id ${newPlaybackSession.id} (Device: ${newPlaybackSession.deviceDescription})`)
audioTracks = libraryItem.getDirectPlayTracklist(episodeId)
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
} else {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}"`)
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}" (Device: ${newPlaybackSession.deviceDescription})`)
const stream = new Stream(newPlaybackSession.id, this.StreamsPath, user, libraryItem, episodeId, userStartTime)
await stream.generatePlaylist()
stream.start() // Start transcode
@@ -177,7 +212,7 @@ class PlaybackSessionManager {
newPlaybackSession.playMethod = PlayMethod.TRANSCODE
stream.on('closed', () => {
Logger.debug(`[PlaybackSessionManager] Stream closed for session "${newPlaybackSession.id}"`)
Logger.debug(`[PlaybackSessionManager] Stream closed for session "${newPlaybackSession.id}" (Device: ${newPlaybackSession.deviceDescription})`)
newPlaybackSession.stream = null
})
}
@@ -202,7 +237,7 @@ class PlaybackSessionManager {
session.currentTime = syncData.currentTime
session.addListeningTime(syncData.timeListened)
Logger.debug(`[PlaybackSessionManager] syncSession "${session.id}" | Total Time Listened: ${session.timeListening}`)
Logger.debug(`[PlaybackSessionManager] syncSession "${session.id}" (Device: ${session.deviceDescription}) | Total Time Listened: ${session.timeListening}`)
const itemProgressUpdate = {
duration: syncData.duration,

View File

@@ -52,6 +52,14 @@ class DeviceInfo {
return obj
}
get deviceDescription() {
if (this.model) { // Set from mobile apps
if (this.sdkVersion) return `${this.model} SDK ${this.sdkVersion} / v${this.clientVersion}`
return `${this.model} / v${this.clientVersion}`
}
return `${this.osName} ${this.osVersion} / ${this.browserName}`
}
setData(ip, ua, clientDeviceInfo, serverVersion) {
this.ipAddress = ip || null
@@ -62,7 +70,7 @@ class DeviceInfo {
this.osVersion = uaObj.os.version || null
this.deviceType = uaObj.device.type || null
var cdi = clientDeviceInfo || {}
const cdi = clientDeviceInfo || {}
this.clientVersion = cdi.clientVersion || null
this.manufacturer = cdi.manufacturer || null
this.model = cdi.model || null

View File

@@ -141,11 +141,30 @@ class PlaybackSession {
this.updatedAt = session.updatedAt || null
}
get mediaItemId() {
if (this.episodeId) return `${this.libraryItemId}-${this.episodeId}`
return this.libraryItemId
}
get progress() { // Value between 0 and 1
if (!this.duration) return 0
return Math.max(0, Math.min(this.currentTime / this.duration, 1))
}
get deviceDescription() {
if (!this.deviceInfo) return 'No Device Info'
return this.deviceInfo.deviceDescription
}
get mediaProgressObject() {
return {
duration: this.duration,
currentTime: this.currentTime,
progress: this.progress,
lastUpdate: this.updatedAt
}
}
setData(libraryItem, user, mediaPlayer, deviceInfo, startTime, episodeId = null) {
this.id = getId('play')
this.userId = user.id

View File

@@ -252,12 +252,19 @@ class Book {
if (metadataAbs) {
Logger.debug(`[Book] Found metadata.abs file for "${this.metadata.title}"`)
const metadataText = await readTextFile(metadataAbs.metadata.path)
const abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this.metadata, 'book')
const abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this, 'book')
if (abmetadataUpdates && Object.keys(abmetadataUpdates).length) {
Logger.debug(`[Book] "${this.metadata.title}" changes found in metadata.abs file`, abmetadataUpdates)
metadataUpdatePayload = {
...metadataUpdatePayload,
...abmetadataUpdates
if (abmetadataUpdates.tags) { // Set media tags if updated
this.tags = abmetadataUpdates.tags
tagsUpdated = true
}
if (abmetadataUpdates.metadata) {
metadataUpdatePayload = {
...metadataUpdatePayload,
...abmetadataUpdates.metadata
}
}
}
}
@@ -489,4 +496,4 @@ class Book {
return this.metadata.authorName
}
}
module.exports = Book
module.exports = Book

View File

@@ -188,22 +188,33 @@ class Podcast {
}
async syncMetadataFiles(textMetadataFiles, opfMetadataOverrideDetails) {
var metadataUpdatePayload = {}
let metadataUpdatePayload = {}
let tagsUpdated = false
var metadataAbs = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.abs')
const metadataAbs = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.abs')
if (metadataAbs) {
var metadataText = await readTextFile(metadataAbs.metadata.path)
var abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this.metadata, 'podcast')
const metadataText = await readTextFile(metadataAbs.metadata.path)
const abmetadataUpdates = abmetadataGenerator.parseAndCheckForUpdates(metadataText, this, 'podcast')
if (abmetadataUpdates && Object.keys(abmetadataUpdates).length) {
Logger.debug(`[Podcast] "${this.metadata.title}" changes found in metadata.abs file`, abmetadataUpdates)
metadataUpdatePayload = abmetadataUpdates
if (abmetadataUpdates.tags) { // Set media tags if updated
this.tags = abmetadataUpdates.tags
tagsUpdated = true
}
if (abmetadataUpdates.metadata) {
metadataUpdatePayload = {
...metadataUpdatePayload,
...abmetadataUpdates.metadata
}
}
}
}
if (Object.keys(metadataUpdatePayload).length) {
return this.metadata.update(metadataUpdatePayload)
return this.metadata.update(metadataUpdatePayload) || tagsUpdated
}
return false
return tagsUpdated
}
searchEpisodes(query) {
@@ -305,4 +316,4 @@ class Podcast {
return this.episodes.find(ep => ep.id == episodeId)
}
}
module.exports = Podcast
module.exports = Podcast

152
server/providers/FantLab.js Normal file
View File

@@ -0,0 +1,152 @@
const axios = require('axios')
const Logger = require('../Logger')
class FantLab {
// 7 - other
// 11 - essay
// 12 - article
// 22 - disser
// 23 - monography
// 24 - study
// 25 - encyclopedy
// 26 - magazine
// 46 - sketch
// 47 - reportage
// 49 - excerpt
// 51 - interview
// 52 - review
// 55 - libretto
// 56 - anthology series
// 57 - newspaper
// types can get here https://api.fantlab.ru/config.json
_filterWorkType = [7, 11, 12, 22, 23, 24, 25, 26, 46, 47, 49, 51, 52, 55, 56, 57]
_baseUrl = 'https://api.fantlab.ru'
constructor() { }
async search(title, author) {
let searchString = encodeURIComponent(title)
if (author) {
searchString += encodeURIComponent(' ' + author)
}
const url = `${this._baseUrl}/search-works?q=${searchString}&page=1&onlymatches=1`
Logger.debug(`[FantLab] Search url: ${url}`)
const items = await axios.get(url).then((res) => {
return res.data || []
}).catch(error => {
Logger.error('[FantLab] search error', error)
return []
})
return Promise.all(items.map(async item => await this.getWork(item))).then(resArray => {
return resArray.filter(res => res)
})
}
async getWork(item) {
const { work_id, work_type_id } = item
if (this._filterWorkType.includes(work_type_id)) {
return null
}
const url = `${this._baseUrl}/work/${work_id}/extended`
const bookData = await axios.get(url).then((resp) => {
return resp.data || null
}).catch((error) => {
Logger.error(`[FantLab] work info request for url "${url}" error`, error)
return null
})
return this.cleanBookData(bookData)
}
async cleanBookData(bookData) {
let { authors, work_name_alts, work_id, work_name, work_year, work_description, image, classificatory, editions_blocks } = bookData
const subtitle = Array.isArray(work_name_alts) ? work_name_alts[0] : null
const authorNames = authors.map(au => (au.name || '').trim()).filter(au => au)
const imageAndIsbn = await this.tryGetCoverFromEditions(editions_blocks)
const imageToUse = imageAndIsbn?.imageUrl || image
return {
id: work_id,
title: work_name,
subtitle: subtitle || null,
author: authorNames.length ? authorNames.join(', ') : null,
publisher: null,
publishedYear: work_year,
description: work_description,
cover: imageToUse ? `https://fantlab.ru${imageToUse}` : null,
genres: this.tryGetGenres(classificatory),
isbn: imageAndIsbn?.isbn || null
}
}
tryGetGenres(classificatory) {
if (!classificatory || !classificatory.genre_group) return []
const genresGroup = classificatory.genre_group.find(group => group.genre_group_id == 1) // genres and subgenres
// genre_group_id=2 - General Characteristics
// genre_group_id=3 - Arena
// genre_group_id=4 - Duration of action
// genre_group_id=6 - Story moves
// genre_group_id=7 - Story linearity
// genre_group_id=5 - Recommended age of the reader
if (!genresGroup || !genresGroup.genre || !genresGroup.genre.length) return []
const rootGenre = genresGroup.genre[0]
const { label } = rootGenre
return [label].concat(this.tryGetSubGenres(rootGenre))
}
tryGetSubGenres(rootGenre) {
if (!rootGenre.genre || !rootGenre.genre.length) return []
return rootGenre.genre.map(g => g.label).filter(g => g)
}
async tryGetCoverFromEditions(editions) {
if (!editions) {
return null
}
// 30 = audio, 10 = paper
// Prefer audio if available
const bookEditions = editions['30'] || editions['10']
if (!bookEditions || !bookEditions.list || !bookEditions.list.length) {
return null
}
const lastEdition = bookEditions.list.pop()
const editionId = lastEdition['edition_id']
const isbn = lastEdition['isbn'] || null // get only from paper edition
return {
imageUrl: await this.getCoverFromEdition(editionId),
isbn
}
}
async getCoverFromEdition(editionId) {
if (!editionId) return null
const url = `${this._baseUrl}/edition/${editionId}`
const editionInfo = await axios.get(url).then((resp) => {
return resp.data || null
}).catch(error => {
Logger.error(`[FantLab] search cover from edition with url "${url}" error`, error)
return null
})
return editionInfo?.image || null
}
}
module.exports = FantLab

View File

@@ -60,6 +60,7 @@ class ApiRouter {
this.musicFinder = new MusicFinder()
this.router = express()
this.router.disable('x-powered-by')
this.init()
}
@@ -85,7 +86,7 @@ class ApiRouter {
this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this))
this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this))
this.router.get('/libraries/:id/matchall', LibraryController.middleware.bind(this), LibraryController.matchAll.bind(this))
this.router.get('/libraries/:id/scan', LibraryController.middleware.bind(this), LibraryController.scan.bind(this))
this.router.post('/libraries/:id/scan', LibraryController.middleware.bind(this), LibraryController.scan.bind(this))
this.router.get('/libraries/:id/recent-episodes', LibraryController.middleware.bind(this), LibraryController.getRecentEpisodes.bind(this))
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
@@ -107,7 +108,7 @@ class ApiRouter {
this.router.post('/items/:id/play', LibraryItemController.middleware.bind(this), LibraryItemController.startPlaybackSession.bind(this))
this.router.post('/items/:id/play/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.startEpisodePlaybackSession.bind(this))
this.router.patch('/items/:id/tracks', LibraryItemController.middleware.bind(this), LibraryItemController.updateTracks.bind(this))
this.router.get('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this))
this.router.post('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this))
this.router.get('/items/:id/tone-object', LibraryItemController.middleware.bind(this), LibraryItemController.getToneMetadataObject.bind(this))
this.router.post('/items/:id/chapters', LibraryItemController.middleware.bind(this), LibraryItemController.updateMediaChapters.bind(this))
this.router.post('/items/:id/tone-scan/:index?', LibraryItemController.middleware.bind(this), LibraryItemController.toneScan.bind(this))
@@ -161,6 +162,7 @@ class ApiRouter {
//
// Current User Routes (Me)
//
this.router.get('/me', MeController.getCurrentUser.bind(this))
this.router.get('/me/listening-sessions', MeController.getListeningSessions.bind(this))
this.router.get('/me/listening-stats', MeController.getListeningStats.bind(this))
this.router.get('/me/progress/:id/remove-from-continue-listening', MeController.removeItemFromContinueListening.bind(this))
@@ -173,8 +175,8 @@ class ApiRouter {
this.router.patch('/me/item/:id/bookmark', MeController.updateBookmark.bind(this))
this.router.delete('/me/item/:id/bookmark/:time', MeController.removeBookmark.bind(this))
this.router.patch('/me/password', MeController.updatePassword.bind(this))
this.router.patch('/me/settings', MeController.updateSettings.bind(this)) // TODO: Remove after mobile release v0.9.61-beta
this.router.post('/me/sync-local-progress', MeController.syncLocalMediaProgress.bind(this))
this.router.patch('/me/settings', MeController.updateSettings.bind(this)) // TODO: Deprecated. Remove after mobile release v0.9.61-beta
this.router.post('/me/sync-local-progress', MeController.syncLocalMediaProgress.bind(this)) // TODO: Deprecated. Removed from Android. Only used in iOS app now.
this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this))
this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this))
this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this))
@@ -214,11 +216,12 @@ class ApiRouter {
//
this.router.get('/sessions', SessionController.getAllWithUserData.bind(this))
this.router.delete('/sessions/:id', SessionController.middleware.bind(this), SessionController.delete.bind(this))
this.router.post('/session/local', SessionController.syncLocal.bind(this))
this.router.post('/session/local-all', SessionController.syncLocalSessions.bind(this))
// TODO: Update these endpoints because they are only for open playback sessions
this.router.get('/session/:id', SessionController.openSessionMiddleware.bind(this), SessionController.getOpenSession.bind(this))
this.router.post('/session/:id/sync', SessionController.openSessionMiddleware.bind(this), SessionController.sync.bind(this))
this.router.post('/session/:id/close', SessionController.openSessionMiddleware.bind(this), SessionController.close.bind(this))
this.router.post('/session/local', SessionController.syncLocal.bind(this))
//
// Podcast Routes

View File

@@ -14,6 +14,7 @@ class HlsRouter {
this.playbackSessionManager = playbackSessionManager
this.router = express()
this.router.disable('x-powered-by')
this.init()
}

View File

@@ -8,6 +8,7 @@ class StaticRouter {
this.db = db
this.router = express()
this.router.disable('x-powered-by')
this.init()
}
@@ -24,7 +25,7 @@ class StaticRouter {
// See: https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/
if (global.XAccel) {
Logger.debug(`Use X-Accel to serve static file ${fullPath}`)
return res.status(204).header({'X-Accel-Redirect': global.XAccel + fullPath}).send()
return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + fullPath }).send()
}
var opts = {}

View File

@@ -115,11 +115,16 @@ class MediaFileScanner {
const scanStart = Date.now()
const mediaMetadata = libraryItem.media.metadata || null
const proms = []
for (let i = 0; i < mediaLibraryFiles.length; i++) {
proms.push(this.scan(mediaType, mediaLibraryFiles[i], mediaMetadata))
const batchSize = 32
const results = []
for (let batch = 0; batch < mediaLibraryFiles.length; batch += batchSize) {
const proms = []
for (let i = batch; i < Math.min(batch + batchSize, mediaLibraryFiles.length); i++) {
proms.push(this.scan(mediaType, mediaLibraryFiles[i], mediaMetadata))
}
results.push(...await Promise.all(proms).then((scanResults) => scanResults.filter(sr => sr)))
}
const results = await Promise.all(proms).then((scanResults) => scanResults.filter(sr => sr))
return {
audioFiles: results.filter(r => r.audioFile).map(r => r.audioFile),
videoFiles: results.filter(r => r.videoFile).map(r => r.videoFile),

View File

@@ -581,7 +581,7 @@ class Scanner {
if (!existingLibraryItem) {
existingLibraryItem = this.db.libraryItems.find(li => li.ino === dirIno)
if (existingLibraryItem) {
Logger.debug(`[Scanner] scanFolderUpdates: Library item found by inode value "${existingLibraryItem.relPath} => ${itemDir}"`)
Logger.debug(`[Scanner] scanFolderUpdates: Library item found by inode value=${dirIno}. "${existingLibraryItem.relPath} => ${itemDir}"`)
// Update library item paths for scan and all library item paths will get updated in LibraryItem.checkScanData
existingLibraryItem.path = fullPath
existingLibraryItem.relPath = itemDir

View File

@@ -2,7 +2,7 @@ const fs = require('../libs/fsExtra')
const filePerms = require('./filePerms')
const package = require('../../package.json')
const Logger = require('../Logger')
const { getId } = require('./index')
const { getId, copyValue } = require('./index')
const CurrentAbMetadataVersion = 2
@@ -130,12 +130,13 @@ const metadataMappers = {
}
function generate(libraryItem, outputPath) {
var fileString = `;ABMETADATA${CurrentAbMetadataVersion}\n`
let fileString = `;ABMETADATA${CurrentAbMetadataVersion}\n`
fileString += `#audiobookshelf v${package.version}\n\n`
const mediaType = libraryItem.mediaType
fileString += `media=${mediaType}\n`
fileString += `tags=${JSON.stringify(libraryItem.media.tags)}\n`
const metadataMapper = metadataMappers[mediaType]
var mediaMetadata = libraryItem.media.metadata
@@ -159,7 +160,6 @@ function generate(libraryItem, outputPath) {
fileString += `title=${chapter.title}\n`
})
}
return fs.writeFile(outputPath, fileString).then(() => {
return filePerms.setDefault(outputPath, true).then(() => true)
}).catch((error) => {
@@ -223,17 +223,31 @@ function parseChapterLines(lines) {
return chapter
}
function parseTags(value) {
if (!value) return null
try {
const parsedTags = []
JSON.parse(value).forEach((loadedTag) => {
if (loadedTag.trim()) parsedTags.push(loadedTag) // Only push tags that are non-empty
})
return parsedTags
} catch (err) {
Logger.error(`[abmetadataGenerator] Error parsing TAGS "${value}":`, err.message)
return null
}
}
function parseAbMetadataText(text, mediaType) {
if (!text) return null
var lines = text.split(/\r?\n/)
let lines = text.split(/\r?\n/)
// Check first line and get abmetadata version number
var firstLine = lines.shift().toLowerCase()
const firstLine = lines.shift().toLowerCase()
if (!firstLine.startsWith(';abmetadata')) {
Logger.error(`Invalid abmetadata file first line is not ;abmetadata "${firstLine}"`)
return null
}
var abmetadataVersion = Number(firstLine.replace(';abmetadata', '').trim())
const abmetadataVersion = Number(firstLine.replace(';abmetadata', '').trim())
if (isNaN(abmetadataVersion) || abmetadataVersion != CurrentAbMetadataVersion) {
Logger.warn(`Invalid abmetadata version ${abmetadataVersion} - must use version ${CurrentAbMetadataVersion}`)
return null
@@ -244,9 +258,9 @@ function parseAbMetadataText(text, mediaType) {
lines = lines.filter(line => !!line.trim() && !ignoreFirstChars.includes(line[0]))
// Get lines that map to book details (all lines before the first chapter or description section)
var firstSectionLine = lines.findIndex(l => l.startsWith('['))
var detailLines = firstSectionLine > 0 ? lines.slice(0, firstSectionLine) : lines
var remainingLines = firstSectionLine > 0 ? lines.slice(firstSectionLine) : []
const firstSectionLine = lines.findIndex(l => l.startsWith('['))
const detailLines = firstSectionLine > 0 ? lines.slice(0, firstSectionLine) : lines
const remainingLines = firstSectionLine > 0 ? lines.slice(firstSectionLine) : []
if (!detailLines.length) {
Logger.error(`Invalid abmetadata file no detail lines`)
@@ -255,8 +269,8 @@ function parseAbMetadataText(text, mediaType) {
// Check the media type saved for this abmetadata file show warning if not matching expected
if (detailLines[0].toLowerCase().startsWith('media=')) {
var mediaLine = detailLines.shift() // Remove media line
var abMediaType = mediaLine.toLowerCase().split('=')[1].trim()
const mediaLine = detailLines.shift() // Remove media line
const abMediaType = mediaLine.toLowerCase().split('=')[1].trim()
if (abMediaType != mediaType) {
Logger.warn(`Invalid media type in abmetadata file ${abMediaType} expecting ${mediaType}`)
}
@@ -266,43 +280,46 @@ function parseAbMetadataText(text, mediaType) {
const metadataMapper = metadataMappers[mediaType]
// Put valid book detail values into map
const mediaMetadataDetails = {}
const mediaDetails = {
metadata: {},
chapters: [],
tags: null // When tags are null it will not be used
}
for (let i = 0; i < detailLines.length; i++) {
var line = detailLines[i]
var keyValue = line.split('=')
const line = detailLines[i]
const keyValue = line.split('=')
if (keyValue.length < 2) {
Logger.warn('abmetadata invalid line has no =', line)
} else if (!metadataMapper[keyValue[0].trim()]) {
} else if (keyValue[0].trim() === 'tags') { // Parse tags
const value = keyValue.slice(1).join('=').trim() // Everything after "tags="
mediaDetails.tags = parseTags(value)
} else if (!metadataMapper[keyValue[0].trim()]) { // Ensure valid media metadata key
Logger.warn(`abmetadata key "${keyValue[0].trim()}" is not a valid ${mediaType} metadata key`)
} else {
var key = keyValue.shift().trim()
var value = keyValue.join('=').trim()
mediaMetadataDetails[key] = metadataMapper[key].from(value)
const key = keyValue.shift().trim()
const value = keyValue.join('=').trim()
mediaDetails.metadata[key] = metadataMapper[key].from(value)
}
}
const chapters = []
// Parse sections for description and chapters
var sections = parseSections(remainingLines)
const sections = parseSections(remainingLines)
sections.forEach((section) => {
var sectionHeader = section.shift()
const sectionHeader = section.shift()
if (sectionHeader.toLowerCase().startsWith('[description]')) {
mediaMetadataDetails.description = section.join('\n')
mediaDetails.metadata.description = section.join('\n')
} else if (sectionHeader.toLowerCase().startsWith('[chapter]')) {
var chapter = parseChapterLines(section)
const chapter = parseChapterLines(section)
if (chapter) {
chapters.push(chapter)
mediaDetails.chapters.push(chapter)
}
}
})
chapters.sort((a, b) => a.start - b.start)
mediaDetails.chapters.sort((a, b) => a.start - b.start)
return {
metadata: mediaMetadataDetails,
chapters
}
return mediaDetails
}
module.exports.parse = parseAbMetadataText
@@ -376,42 +393,54 @@ function checkArraysChanged(abmetadataArray, mediaArray) {
return abmetadataArray.join(',') != mediaArray.join(',')
}
// Input text from abmetadata file and return object of metadata changes from media metadata
function parseAndCheckForUpdates(text, mediaMetadata, mediaType) {
if (!text || !mediaMetadata || !mediaType) {
// Input text from abmetadata file and return object of media changes
// only returns object of changes. empty object means no changes
function parseAndCheckForUpdates(text, media, mediaType) {
if (!text || !media || !media.metadata || !mediaType) {
Logger.error(`Invalid inputs to parseAndCheckForUpdates`)
return null
}
const mediaMetadata = media.metadata
const metadataUpdatePayload = {} // Only updated key/values
var updatePayload = {} // Only updated key/values
var abmetadataData = parseAbMetadataText(text, mediaType)
const abmetadataData = parseAbMetadataText(text, mediaType)
if (!abmetadataData || !abmetadataData.metadata) {
return null
}
var abMetadata = abmetadataData.metadata // Metadata from abmetadata file
const abMetadata = abmetadataData.metadata // Metadata from abmetadata file
for (const key in abMetadata) {
if (mediaMetadata[key] !== undefined) {
if (key === 'authors') {
var authorUpdatePayload = checkUpdatedBookAuthors(abMetadata[key], mediaMetadata[key])
if (authorUpdatePayload.hasUpdates) updatePayload.authors = authorUpdatePayload.authors
const authorUpdatePayload = checkUpdatedBookAuthors(abMetadata[key], mediaMetadata[key])
if (authorUpdatePayload.hasUpdates) metadataUpdatePayload.authors = authorUpdatePayload.authors
} else if (key === 'series') {
var seriesUpdatePayload = checkUpdatedBookSeries(abMetadata[key], mediaMetadata[key])
if (seriesUpdatePayload.hasUpdates) updatePayload.series = seriesUpdatePayload.series
const seriesUpdatePayload = checkUpdatedBookSeries(abMetadata[key], mediaMetadata[key])
if (seriesUpdatePayload.hasUpdates) metadataUpdatePayload.series = seriesUpdatePayload.series
} else if (key === 'genres' || key === 'narrators') { // Compare array differences
if (checkArraysChanged(abMetadata[key], mediaMetadata[key])) {
updatePayload[key] = abMetadata[key]
metadataUpdatePayload[key] = abMetadata[key]
}
} else if (abMetadata[key] !== mediaMetadata[key]) {
updatePayload[key] = abMetadata[key]
metadataUpdatePayload[key] = abMetadata[key]
}
} else {
Logger.warn('[abmetadataGenerator] Invalid key', key)
}
}
const updatePayload = {} // Only updated key/values
// Check update tags
if (abmetadataData.tags) {
if (checkArraysChanged(abmetadataData.tags, media.tags)) {
updatePayload.tags = abmetadataData.tags
}
}
if (Object.keys(metadataUpdatePayload).length) {
updatePayload.metadata = metadataUpdatePayload
}
return updatePayload
}
module.exports.parseAndCheckForUpdates = parseAndCheckForUpdates
module.exports.parseAndCheckForUpdates = parseAndCheckForUpdates

View File

@@ -22,7 +22,27 @@ function fetchCreators(creators, role) {
function fetchTagString(metadata, tag) {
if (!metadata[tag] || !metadata[tag].length) return null
const value = metadata[tag][0]
let value = metadata[tag][0]
/*
EXAMPLES:
"dc:title": [
{
"_": "The Quest for Character",
"$": {
"opf:file-as": "Quest for Character What the Story of Socrates and Alcibiades"
}
}
]
OR
"dc:title": [
"The Quest for Character"
]
*/
if (typeof value === 'object') value = value._
if (typeof value !== 'string') return null
return value
}

View File

@@ -191,7 +191,17 @@ module.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = fal
module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}"`)
return axios.get(feedUrl, { timeout: 6000 }).then(async (data) => {
return axios.get(feedUrl, { timeout: 6000, responseType: 'arraybuffer' }).then(async (data) => {
// Adding support for ios-8859-1 encoded RSS feeds.
// See: https://github.com/advplyr/audiobookshelf/issues/1489
const contentType = data.headers?.['content-type'] || '' // e.g. text/xml; charset=iso-8859-1
if (contentType.toLowerCase().includes('iso-8859-1')) {
data.data = data.data.toString('latin1')
} else {
data.data = data.data.toString()
}
if (!data || !data.data) {
Logger.error(`[podcastUtils] getPodcastFeed: Invalid podcast feed request response (${feedUrl})`)
return false