mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-01-01 12:09:43 -05:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e8547f0c8 | ||
|
|
96930d7ecc | ||
|
|
23f2c8a251 | ||
|
|
c5372d1405 | ||
|
|
dcfbed5f30 | ||
|
|
895ab8d18a | ||
|
|
8b5d05739f | ||
|
|
a8f6202302 | ||
|
|
5d40fdf277 | ||
|
|
f35c96e118 | ||
|
|
8f8d6f81ab | ||
|
|
e195eec1c5 | ||
|
|
33846e46fa | ||
|
|
2ad03bcb9a | ||
|
|
371cd3b2e5 | ||
|
|
b9307143bd | ||
|
|
36e44e902a | ||
|
|
4529fc0124 | ||
|
|
ba07761de3 | ||
|
|
3b7ce69327 | ||
|
|
cf927f61a0 | ||
|
|
61c32d99e7 | ||
|
|
19e39f6321 | ||
|
|
f9e6655359 | ||
|
|
debf0f495d | ||
|
|
3383ec2046 | ||
|
|
b957e1a36b | ||
|
|
c93f17051a | ||
|
|
5983f0262f | ||
|
|
96a8e74d38 | ||
|
|
6f3d488c3d | ||
|
|
74dcc4f9e4 | ||
|
|
8c4d3b93c8 | ||
|
|
c411cf04cc | ||
|
|
d1b25da408 | ||
|
|
08f765fa51 | ||
|
|
337cf90c4b | ||
|
|
17b930e13d | ||
|
|
639b600570 | ||
|
|
7a751b8f91 | ||
|
|
68621e0c07 | ||
|
|
5abb02e93a | ||
|
|
573079c5a1 | ||
|
|
5bde320ac7 | ||
|
|
5f63d97e59 | ||
|
|
bf2fe3faea |
@@ -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';
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 : ''
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) }} %</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 }}. <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 }}. <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">
|
||||
|
||||
@@ -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 }}. </p>
|
||||
<p class="text-sm text-white text-opacity-70 w-8">{{ index + 1 }}. </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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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) {
|
||||
|
||||
@@ -52,6 +52,10 @@ export const state = () => ({
|
||||
{
|
||||
text: 'Audible.es',
|
||||
value: 'audible.es'
|
||||
},
|
||||
{
|
||||
text: 'FantLab.ru',
|
||||
value: 'fantlab'
|
||||
}
|
||||
],
|
||||
podcastProviders: [
|
||||
|
||||
@@ -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
618
client/strings/ru.json
Normal 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": "Пользователь удален"
|
||||
}
|
||||
@@ -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
38
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"`)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
152
server/providers/FantLab.js
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -14,6 +14,7 @@ class HlsRouter {
|
||||
this.playbackSessionManager = playbackSessionManager
|
||||
|
||||
this.router = express()
|
||||
this.router.disable('x-powered-by')
|
||||
this.init()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user