Compare commits

...

72 Commits

Author SHA1 Message Date
advplyr
14ee17de47 Version bump 2.2.18 2023-04-02 17:40:55 -05:00
advplyr
034b8956a2 Add:Batch embed metadata and queue system for metadata embedding #700 2023-04-02 16:13:18 -05:00
advplyr
1a3f0e332e Fix download podcast episode that is not mp3 2023-04-01 16:31:04 -05:00
advplyr
fc36e86db7 Update:Match tab show current cover and include resolutions #1605 2023-03-31 18:00:45 -05:00
advplyr
60b4bc1a7e Update:Show resolution under cover in book details modal #1547 2023-03-31 17:42:52 -05:00
advplyr
9fdc8df8bc Update:API endpoint for updating book media does not require an id for new series/authors #1540 2023-03-31 17:04:26 -05:00
advplyr
212b97fa20 Add:Parsing meta tags from podcast episode audio file #1488 2023-03-30 18:04:21 -05:00
advplyr
704fbaced8 Update:Download podcast episodes and embed meta tags #1488 2023-03-29 18:05:53 -05:00
advplyr
575a162f8b Update:API endpoint for get all users to use minimal payload 2023-03-29 14:56:50 -05:00
advplyr
d2e0844493 Epub reader updates for mobile 2023-03-26 14:44:59 -05:00
advplyr
f2baf3fafd Update epub media progress update 2023-03-26 13:50:44 -05:00
advplyr
916fd039ca Remove keydown event listener in epub reader 2023-03-26 13:40:47 -05:00
advplyr
e248b6d8d8 Fix:Parsing id3 tags case insensitive 2023-03-25 16:09:41 -05:00
advplyr
936de68622 Update epub reader only store up to 3MB of locations cache 2023-03-25 15:53:19 -05:00
advplyr
a99257e758 Fix getAllLibraryItemsInProgress route 2023-03-25 14:07:35 -05:00
advplyr
c89d77dd06 Merge pull request #1627 from vincentscode/epub-reader
Save Progress for EPUBs
2023-03-24 18:01:13 -05:00
advplyr
3138865d69 Update toc menu and media progress display 2023-03-24 17:57:41 -05:00
Vincent Schmandt
4d29ebd647 Save Locations locally, add separate progress tracker 2023-03-23 08:45:00 +01:00
advplyr
fd58df4729 Add:Abridged book detail, parse from audible, abridged book filter #1408 2023-03-22 18:05:43 -05:00
Vincent Schmandt
5078818295 Add MediaProgress fields
Add Table of Contents
2023-03-22 11:16:01 +01:00
advplyr
7181df0479 Fix:Patreon episodes with variable query strings #1622 2023-03-21 17:59:37 -05:00
Vincent Schmandt
6c618d7760 Adjust height to fit metadata 2023-03-21 13:36:06 +01:00
Vincent Schmandt
17b8cf19b7 Add Location Storage 2023-03-21 13:34:21 +01:00
Vincent Schmandt
e018f8341e EPUB progress persistence 2023-03-21 13:27:21 +01:00
advplyr
59b5f8cbbe Merge pull request #1624 from maltejur/master
Truncate long title in stream container
2023-03-20 16:01:23 -05:00
advplyr
d6108a0722 Merge pull request #1621 from burghy86/patch-9
Update it.json
2023-03-20 15:49:31 -05:00
advplyr
1af7e59d88 Merge pull request #1618 from fidoriel/transcode-continue-bug
Fix transcoded streams fail to continue listening
2023-03-20 15:49:11 -05:00
advplyr
7b425e9a9d Update client/players/LocalAudioPlayer.js 2023-03-20 15:48:42 -05:00
advplyr
596a03900b Update client/players/LocalAudioPlayer.js 2023-03-20 15:48:36 -05:00
advplyr
b283644d95 Update client/players/LocalAudioPlayer.js 2023-03-20 15:48:31 -05:00
Malte Jürgens
808690c137 truncate long title in stream container 2023-03-20 21:28:15 +01:00
burghy86
136c347586 Update it.json
fix new line
2023-03-20 12:20:02 +01:00
fidoriel
e81238038e m3u8url 2023-03-19 22:26:36 +00:00
fidoriel
fcf6964d7d hlsurl 2023-03-19 21:41:49 +00:00
advplyr
bd75ad4576 Version bump 2.2.17 2023-03-18 17:44:45 -05:00
advplyr
f970d8e539 Merge pull request #1517 from mfcar/addEpisodeFilter
Add episodes search
2023-03-18 17:25:53 -05:00
advplyr
c49010b4e1 Merge master 2023-03-18 17:26:11 -05:00
advplyr
146093d81e Add:Support for .awb AMR-WB audio file #1565 2023-03-17 16:52:07 -05:00
advplyr
11ccbf1913 Merge pull request #1609 from Linden-Ryuujin/feature/semicolonSeperators
Support for scanning semicolon seperated author and narator lists.
2023-03-16 17:06:22 -05:00
Linden Ryuujin
a4a334a18a Support for scanning semicolon seperated author and narator lists. 2023-03-16 21:44:03 +00:00
advplyr
387a37e4da Fix:Download podcast episodes that are not mp3 #1513 2023-03-15 18:04:31 -05:00
advplyr
ebad304aa9 Remove filePerms log 2023-03-14 15:38:53 -05:00
advplyr
8b557a0cb9 Fix:Private Patreon feed URLs getting encoded twice #1600 2023-03-14 15:38:19 -05:00
advplyr
40b808e73d Update:Use title ID3 tag on tracks when setting chapters and prefer audio metadata setting is enabled #679 2023-03-13 17:56:16 -05:00
advplyr
a8b57a1ce9 Cleanup rebuild tracks/set chapters 2023-03-13 17:45:44 -05:00
advplyr
35315843f2 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-03-12 15:52:55 -05:00
advplyr
27b9d3b94f Update:Add support for MKA audio files #1597 2023-03-12 15:52:49 -05:00
advplyr
0010ac5a40 Merge pull request #1599 from Nab0y/master
Update Russian localization
2023-03-11 13:58:42 -06:00
Dmitry Naboychenko
884808f34e Update russian localization 2023-03-11 22:40:24 +03:00
advplyr
f75ed07497 Update readme screenshot 2023-03-10 16:23:07 -06:00
advplyr
b707d6f3c9 Update Dockerfile run command to use node index.js 2023-03-09 11:47:48 -06:00
advplyr
a2d4a4a906 Update:Home page episodes dont show a number if no episode number 2023-03-08 08:30:54 -06:00
advplyr
434d743d99 Clean translation files alphabetized 2023-03-07 10:28:10 -06:00
advplyr
30f16b05fe Merge pull request #1586 from Machou/master
fr_FR update
2023-03-07 10:13:07 -06:00
Machou
92a88f4416 Update fr.json 2023-03-07 17:05:51 +01:00
advplyr
5c9c122af2 Update:Config side nav line height and padding for cleaner wrapping 2023-03-07 09:42:33 -06:00
advplyr
620d5ce578 Add:Spanish language option 2023-03-07 09:37:55 -06:00
advplyr
363e1cee4b Merge pull request #1587 from apineiro97/master
Spanish Translation
2023-03-07 09:30:16 -06:00
advplyr
93f576772a Merge pull request #1585 from springsunx/patch-1
Update zh-cn.json
2023-03-07 09:27:52 -06:00
Machou
d4612bae92 Update fr.json 2023-03-07 06:48:24 +01:00
Arturo Pineiro
e01af27008 LabelSettingsOverdriveMediaMarkersHelp 2023-03-06 21:41:02 -08:00
Arturo Pineiro
657fe0a650 Spanish translation 2023-03-06 21:36:38 -08:00
Arturo Pineiro
9a6ec5548e fix missing translations 2023-03-06 21:33:51 -08:00
Arturo Pineiro
0807509ea7 added "ButtonDownloadQueue": "Queue", 2023-03-06 21:28:09 -08:00
Arturo Pineiro
d9d1c4e360 Added Spanish translation 2023-03-06 21:18:59 -08:00
blok
2135e5b066 fr_FR update 2023-03-07 05:54:20 +01:00
blok
b69eb10ae0 fr_FR update 2023-03-07 05:36:53 +01:00
SunX
e1512b6f54 Update zh-cn.json 2023-03-07 11:07:01 +08:00
Machou
1b8e8215d6 Update fr.json 2023-03-07 03:35:21 +01:00
mfcar
eeb7c80518 Add translation strings and change the input type to search 2023-02-21 19:30:42 +00:00
mfcar
f650ae7f18 Add episode filter on the episodes list 2023-02-19 20:48:39 +00:00
mfcar
6d138ae905 Add episode filter 2023-02-19 20:07:32 +00:00
74 changed files with 2347 additions and 1576 deletions

View File

@@ -29,4 +29,4 @@ HEALTHCHECK \
--timeout=3s \
--start-period=10s \
CMD curl -f http://127.0.0.1/healthcheck || exit 1
CMD ["npm", "start"]
CMD ["node", "index.js"]

View File

@@ -58,9 +58,6 @@
<span class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
{{ $strings.ButtonPlay }}
</ui-btn>
<ui-tooltip v-if="userIsAdminOrUp && isBookLibrary" :text="$strings.ButtonQuickMatch" direction="bottom">
<ui-icon-btn :disabled="processingBatch" icon="auto_awesome" @click="batchAutoMatchClick" class="mx-1.5" />
</ui-tooltip>
<ui-tooltip v-if="isBookLibrary" :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
</ui-tooltip>
@@ -75,8 +72,11 @@
<ui-tooltip v-if="userCanDelete" :text="$strings.ButtonRemove" direction="bottom">
<ui-icon-btn :disabled="processingBatch" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
</ui-tooltip>
<ui-tooltip :text="$strings.LabelDeselectAll" direction="bottom">
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatch ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
<ui-context-menu-dropdown v-if="contextMenuItems.length && !processingBatch" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
<ui-tooltip :text="$strings.LabelDeselectAll" direction="bottom" class="flex items-center">
<span class="material-icons text-3xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatch ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
</ui-tooltip>
</div>
</div>
@@ -160,9 +160,59 @@ export default {
},
isHttps() {
return location.protocol === 'https:' || process.env.NODE_ENV === 'development'
},
contextMenuItems() {
if (!this.userIsAdminOrUp) return []
const options = [
{
text: this.$strings.ButtonQuickMatch,
action: 'quick-match'
}
]
if (!this.isPodcastLibrary && this.selectedMediaItemsArePlayable) {
options.push({
text: 'Quick Embed Metadata',
action: 'quick-embed'
})
}
return options
}
},
methods: {
requestBatchQuickEmbed() {
const payload = {
message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?',
callback: (confirmed) => {
if (confirmed) {
this.$axios
.$post(`/api/tools/batch/embed-metadata`, {
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
})
.then(() => {
console.log('Audio metadata embed started')
this.cancelSelectionMode()
})
.catch((error) => {
console.error('Audio metadata embed failed', error)
const errorMsg = error.response.data || 'Failed to embed metadata'
this.$toast.error(errorMsg)
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
contextMenuAction(action) {
if (action === 'quick-embed') {
this.requestBatchQuickEmbed()
} else if (action === 'quick-match') {
this.batchAutoMatchClick()
}
},
async playSelectedItems() {
this.$store.commit('setProcessingBatch', true)

View File

@@ -5,8 +5,8 @@
<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>
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-3 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 class="leading-4">{{ 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>

View File

@@ -1,12 +1,12 @@
<template>
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-50 bg-primary px-2 md:px-4 pb-1 md:pb-4 pt-2">
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 md:h-40 z-50 bg-primary px-2 md:px-4 pb-1 md:pb-4 pt-2">
<div id="videoDock" />
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-2 top-2 md:left-4 cursor-pointer">
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
</nuxt-link>
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
<div>
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg">
<div class="min-w-0">
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
{{ title }}
</nuxt-link>
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">

View File

@@ -105,8 +105,10 @@
</div>
<!-- Podcast Episode # -->
<div v-if="recentEpisodeNumber && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">Episode #{{ recentEpisodeNumber }}</p>
<div v-if="recentEpisodeNumber !== null && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
</p>
</div>
<!-- Podcast Num Episodes -->
@@ -242,7 +244,7 @@ export default {
if (this.recentEpisode.episode) {
return this.recentEpisode.episode.replace(/^#/, '')
}
return this.recentEpisode.index
return ''
},
collapsedSeries() {
// Only added to item object when collapseSeries is enabled
@@ -323,8 +325,13 @@ export default {
if (this.episodeProgress) return this.episodeProgress
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
useEBookProgress() {
if (!this.userProgress || this.userProgress.progress) return false
return this.userProgress.ebookProgress > 0
},
userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0
if (this.useEBookProgress) return Math.max(Math.min(1, this.userProgress.ebookProgress), 0)
return this.userProgress ? Math.max(Math.min(1, this.userProgress.progress), 0) || 0 : 0
},
itemIsFinished() {
return this.userProgress ? !!this.userProgress.isFinished : false

View File

@@ -185,6 +185,11 @@ export default {
value: 'tracks',
sublist: true
},
{
text: this.$strings.LabelAbridged,
value: 'abridged',
sublist: false
},
{
text: this.$strings.ButtonIssues,
value: 'issues',

View File

@@ -2,7 +2,8 @@
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
<div class="flex flex-wrap">
<div class="relative">
<covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<!-- book cover overlay -->
<div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
@@ -27,14 +28,14 @@
</form>
</div>
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-primary">
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-white border-opacity-10">
<div class="flex items-center justify-center py-2">
<p>{{ localCovers.length }} local image{{ localCovers.length !== 1 ? 's' : '' }}</p>
<div class="flex-grow" />
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? $strings.ButtonHide : $strings.ButtonShow }}</ui-btn>
</div>
<div v-if="showLocalCovers" class="flex items-center justify-center">
<div v-if="showLocalCovers" class="flex items-center justify-center pb-2">
<template v-for="cover in localCovers">
<div :key="cover.path" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(cover)">
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">

View File

@@ -34,13 +34,25 @@
</div>
<ui-checkbox v-model="selectAll" checkbox-bg="bg" @input="selectAllToggled" />
<form @submit.prevent="submitMatchUpdate">
<div v-if="selectedMatchOrig.cover" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly :label="$strings.LabelCover" class="flex-grow mx-4" />
<div class="min-w-12 max-w-12 md:min-w-16 md:max-w-16">
<a :href="selectedMatch.cover" target="_blank" class="w-full bg-primary">
<img :src="selectedMatch.cover" class="h-full w-full object-contain" />
</a>
<div v-if="selectedMatchOrig.cover" class="flex flex-wrap md:flex-nowrap items-center justify-center">
<div class="flex flex-grow items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly :label="$strings.LabelCover" class="flex-grow mx-4" />
</div>
<div class="flex py-2">
<div>
<p class="text-center text-gray-200">New</p>
<a :href="selectedMatch.cover" target="_blank" class="bg-primary">
<covers-preview-cover :src="selectedMatch.cover" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</a>
</div>
<div v-if="media.coverPath">
<p class="text-center text-gray-200">Current</p>
<a :href="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" target="_blank" class="bg-primary">
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</a>
</div>
</div>
</div>
<div v-if="selectedMatchOrig.title" class="flex items-center py-2">
@@ -164,13 +176,20 @@
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.releaseDate || '' }}</p>
</div>
</div>
<div v-if="selectedMatchOrig.explicit != null" class="flex items-center py-2">
<div v-if="selectedMatchOrig.explicit != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.explicit == null }">
<ui-checkbox v-model="selectedMatchUsage.explicit" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4">
<ui-checkbox v-model="selectedMatch.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
<div class="flex-grow ml-4" :class="{ 'pt-4': mediaMetadata.explicit != null }">
<ui-checkbox v-model="selectedMatch.explicit" :label="$strings.LabelExplicit" :disabled="!selectedMatchUsage.explicit" :checkbox-bg="!selectedMatchUsage.explicit ? 'bg' : 'primary'" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
<p v-if="mediaMetadata.explicit != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.explicit ? 'Explicit (checked)' : 'Not Explicit (unchecked)' }}</p>
</div>
</div>
<div v-if="selectedMatchOrig.abridged != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.abridged == null }">
<ui-checkbox v-model="selectedMatchUsage.abridged" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4" :class="{ 'pt-4': mediaMetadata.abridged != null }">
<ui-checkbox v-model="selectedMatch.abridged" :label="$strings.LabelAbridged" :disabled="!selectedMatchUsage.abridged" :checkbox-bg="!selectedMatchUsage.abridged ? 'bg' : 'primary'" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
<p v-if="mediaMetadata.abridged != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.abridged ? 'Abridged (checked)' : 'Unabridged (unchecked)' }}</p>
</div>
</div>
<div class="flex items-center justify-end py-2">
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
@@ -216,6 +235,7 @@ export default {
explicit: true,
asin: true,
isbn: true,
abridged: true,
// Podcast specific
itunesPageUrl: true,
itunesId: true,
@@ -360,6 +380,7 @@ export default {
explicit: true,
asin: true,
isbn: true,
abridged: true,
// Podcast specific
itunesPageUrl: true,
itunesId: true,
@@ -476,7 +497,6 @@ export default {
} else if (key === 'narrator') {
updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim())
} else if (key === 'genres') {
// updatePayload.metadata.genres = this.selectedMatch[key].split(',').map((v) => v.trim())
updatePayload.metadata.genres = [...this.selectedMatch[key]]
} else if (key === 'tags') {
updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim())

View File

@@ -46,8 +46,20 @@
>{{ $strings.ButtonOpenManager }}
<span class="material-icons text-lg ml-2">launch</span>
</ui-btn>
<ui-btn v-if="!isMetadataEmbedQueued && !isEmbedTaskRunning" class="w-full mt-4" small @click.stop="quickEmbed">Quick Embed</ui-btn>
</div>
</div>
<!-- queued alert -->
<widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mt-4">
<p class="text-lg">Queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)</p>
</widgets-alert>
<!-- processing alert -->
<widgets-alert v-if="isEmbedTaskRunning" type="warning" class="mt-4">
<p class="text-lg">Currently embedding metadata</p>
</widgets-alert>
</div>
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">{{ $strings.MessageNoAudioTracks }}</p>
@@ -71,10 +83,10 @@ export default {
return this.$store.state.showExperimentalFeatures
},
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
return this.libraryItem?.id || null
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
return this.libraryItem?.media || {}
},
mediaTracks() {
return this.media.tracks || []
@@ -92,9 +104,49 @@ export default {
showMp3Split() {
if (!this.mediaTracks.length) return false
return this.isSingleM4b && this.chapters.length
},
queuedEmbedLIds() {
return this.$store.state.tasks.queuedEmbedLIds || []
},
isMetadataEmbedQueued() {
return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId)
},
tasks() {
return this.$store.getters['tasks/getTasksByLibraryItemId'](this.libraryItemId)
},
embedTask() {
return this.tasks.find((t) => t.action === 'embed-metadata')
},
encodeTask() {
return this.tasks.find((t) => t.action === 'encode-m4b')
},
isEmbedTaskRunning() {
return this.embedTask && !this.embedTask?.isFinished
},
isEncodeTaskRunning() {
return this.encodeTask && !this.encodeTask?.isFinished
}
},
methods: {},
mounted() {}
methods: {
quickEmbed() {
const payload = {
message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?',
callback: (confirmed) => {
if (confirmed) {
this.$axios
.$post(`/api/tools/item/${this.libraryItemId}/embed-metadata`)
.then(() => {
console.log('Audio metadata encode started')
})
.catch((error) => {
console.error('Audio metadata encode failed', error)
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
}
}
}
</script>

View File

@@ -6,16 +6,21 @@
</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">
<div v-if="episodes.length" class="w-full py-3 mx-auto flex">
<form @submit.prevent="submit" class="flex flex-grow">
<ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" />
</form>
</div>
<div ref="episodeContainer" id="episodes-scroll" class="w-full overflow-x-hidden overflow-y-auto">
<div
v-for="(episode, index) in episodes"
v-for="(episode, index) in episodesList"
:key="index"
class="relative"
:class="itemEpisodeMap[episode.enclosure.url] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
:class="itemEpisodeMap[episode.enclosure.url?.split('?')[0]] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
@click="toggleSelectEpisode(index, episode)"
>
<div class="absolute top-0 left-0 h-full flex items-center p-2">
<span v-if="itemEpisodeMap[episode.enclosure.url]" class="material-icons text-success text-xl">download_done</span>
<span v-if="itemEpisodeMap[episode.enclosure.url?.split('?')[0]]" class="material-icons text-success text-xl">download_done</span>
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
</div>
<div class="px-8 py-2">
@@ -59,7 +64,10 @@ export default {
return {
processing: false,
selectedEpisodes: {},
selectAll: false
selectAll: false,
search: null,
searchTimeout: null,
searchText: null
}
},
watch: {
@@ -84,7 +92,7 @@ export default {
return this.libraryItem.media.metadata.title || 'Unknown'
},
allDownloaded() {
return !this.episodes.some((episode) => !this.itemEpisodeMap[episode.enclosure.url])
return !this.episodes.some((episode) => !this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]])
},
episodesSelected() {
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
@@ -100,23 +108,39 @@ export default {
itemEpisodeMap() {
var map = {}
this.itemEpisodes.forEach((item) => {
if (item.enclosure) map[item.enclosure.url] = true
if (item.enclosure) map[item.enclosure.url.split('?')[0]] = true
})
return map
},
episodesList() {
return this.episodes.filter((episode) => {
if (!this.searchText) return true
return (episode.title && episode.title.toLowerCase().includes(this.searchText)) || (episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
})
}
},
methods: {
inputUpdate() {
clearTimeout(this.searchTimeout)
this.searchTimeout = setTimeout(() => {
if (!this.search || !this.search.trim()) {
this.searchText = ''
return
}
this.searchText = this.search.toLowerCase().trim()
}, 500)
},
toggleSelectAll(val) {
for (let i = 0; i < this.episodes.length; i++) {
const episode = this.episodes[i]
if (this.itemEpisodeMap[episode.enclosure.url]) this.selectedEpisodes[String(i)] = false
if (this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]]) this.selectedEpisodes[String(i)] = false
else this.$set(this.selectedEpisodes, String(i), val)
}
},
checkSetIsSelectedAll() {
for (let i = 0; i < this.episodes.length; i++) {
const episode = this.episodes[i]
if (!this.itemEpisodeMap[episode.enclosure.url] && !this.selectedEpisodes[String(i)]) {
if (!this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]] && !this.selectedEpisodes[String(i)]) {
this.selectAll = false
return
}
@@ -124,7 +148,7 @@ export default {
this.selectAll = true
},
toggleSelectEpisode(index, episode) {
if (this.itemEpisodeMap[episode.enclosure.url]) return
if (this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]]) return
this.$set(this.selectedEpisodes, String(index), !this.selectedEpisodes[String(index)])
this.checkSetIsSelectedAll()
},

View File

@@ -17,16 +17,22 @@
<div v-if="currentFeed.meta" class="mt-5">
<div class="flex py-0.5">
<div class="w-48"><span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRssFeedPreventIndexing }}</span></div>
<div> {{ currentFeed.meta.preventIndexing ? 'Yes' : 'No' }} </div>
<div class="w-48">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedPreventIndexing }}</span>
</div>
<div>{{ currentFeed.meta.preventIndexing ? 'Yes' : 'No' }}</div>
</div>
<div v-if="currentFeed.meta.ownerName" class="flex py-0.5">
<div class="w-48"><span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRssFeedCustomOwnerName }}</span></div>
<div> {{ currentFeed.meta.ownerName }} </div>
<div class="w-48">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerName }}</span>
</div>
<div>{{ currentFeed.meta.ownerName }}</div>
</div>
<div v-if="currentFeed.meta.ownerEmail" class="flex py-0.5">
<div class="w-48"><span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRssFeedCustomOwnerEmail }}</span></div>
<div> {{ currentFeed.meta.ownerEmail }} </div>
<div class="w-48">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerEmail }}</span>
</div>
<div>{{ currentFeed.meta.ownerEmail }}</div>
</div>
</div>
</div>
@@ -62,7 +68,7 @@ export default {
preventIndexing: true,
ownerName: '',
ownerEmail: ''
},
}
}
},
watch: {

View File

@@ -1,18 +1,14 @@
<template>
<div class="h-full w-full">
<div class="h-full flex items-center">
<div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden justify-center">
<span v-show="hasPrev" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
<div class="h-full flex items-center justify-center">
<div style="width: 100px; max-width: 100px" class="h-full hidden sm:flex items-center overflow-x-hidden justify-center">
<span v-if="hasPrev" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
</div>
<div id="frame" class="w-full" style="height: 650px">
<div id="viewer" class="border border-gray-100 bg-white shadow-md"></div>
<div class="py-4 flex justify-center" style="height: 50px">
<p>{{ progress }}%</p>
</div>
<div id="frame" class="w-full" style="height: 80%">
<div id="viewer"></div>
</div>
<div style="width: 100px; max-width: 100px" class="h-full flex items-center justify-center overflow-x-hidden">
<span v-show="hasNext" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="next">chevron_right</span>
<div style="width: 100px; max-width: 100px" class="h-full hidden sm:flex items-center justify-center overflow-x-hidden">
<span v-if="hasNext" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="next">chevron_right</span>
</div>
</div>
</div>
@@ -21,108 +17,252 @@
<script>
import ePub from 'epubjs'
/**
* @typedef {object} EpubReader
* @property {ePub.Book} book
* @property {ePub.Rendition} rendition
*/
export default {
props: {
url: String
url: String,
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
windowWidth: 0,
/** @type {ePub.Book} */
book: null,
rendition: null,
chapters: [],
title: '',
author: '',
progress: 0,
hasNext: true,
hasPrev: false
/** @type {ePub.Rendition} */
rendition: null
}
},
computed: {},
methods: {
changedChapter() {
if (this.rendition) {
this.rendition.display(this.selectedChapter)
}
computed: {
/** @returns {string} */
libraryItemId() {
return this.libraryItem?.id
},
hasPrev() {
return !this.rendition?.location?.atStart
},
hasNext() {
return !this.rendition?.location?.atEnd
},
/** @returns {Array<ePub.NavItem>} */
chapters() {
return this.book ? this.book.navigation.toc : []
},
userMediaProgress() {
if (!this.libraryItemId) return
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
localStorageLocationsKey() {
return `ebookLocations-${this.libraryItemId}`
},
readerWidth() {
if (this.windowWidth < 640) return this.windowWidth
return this.windowWidth - 200
}
},
methods: {
prev() {
if (this.rendition) {
this.rendition.prev()
}
return this.rendition?.prev()
},
next() {
if (this.rendition) {
this.rendition.next()
return this.rendition?.next()
},
goToChapter(href) {
return this.rendition?.display(href)
},
keyUp(e) {
const rtl = this.book.package.metadata.direction === 'rtl'
if ((e.keyCode || e.which) == 37) {
return rtl ? this.next() : this.prev()
} else if ((e.keyCode || e.which) == 39) {
return rtl ? this.prev() : this.next()
}
},
keyUp() {
if ((e.keyCode || e.which) == 37) {
this.prev()
} else if ((e.keyCode || e.which) == 39) {
this.next()
/**
* @param {object} payload
* @param {string} payload.ebookLocation - CFI of the current location
* @param {string} payload.ebookProgress - eBook Progress Percentage
*/
updateProgress(payload) {
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
console.error('EpubReader.updateProgress failed:', error)
})
},
getAllEbookLocationData() {
const locations = []
let totalSize = 0 // Total in bytes
for (const key in localStorage) {
if (!localStorage.hasOwnProperty(key) || !key.startsWith('ebookLocations-')) {
continue
}
try {
const ebookLocations = JSON.parse(localStorage[key])
if (!ebookLocations.locations) throw new Error('Invalid locations object')
ebookLocations.key = key
ebookLocations.size = (localStorage[key].length + key.length) * 2
locations.push(ebookLocations)
totalSize += ebookLocations.size
} catch (error) {
console.error('Failed to parse ebook locations', key, error)
localStorage.removeItem(key)
}
}
// Sort by oldest lastAccessed first
locations.sort((a, b) => a.lastAccessed - b.lastAccessed)
return {
locations,
totalSize
}
},
/** @param {string} locationString */
checkSaveLocations(locationString) {
const maxSizeInBytes = 3000000 // Allow epub locations to take up to 3MB of space
const newLocationsSize = JSON.stringify({ lastAccessed: Date.now(), locations: locationString }).length * 2
// Too large overall
if (newLocationsSize > maxSizeInBytes) {
console.error('Epub locations are too large to store. Size =', newLocationsSize)
return
}
const ebookLocationsData = this.getAllEbookLocationData()
let availableSpace = maxSizeInBytes - ebookLocationsData.totalSize
// Remove epub locations until there is room for locations
while (availableSpace < newLocationsSize && ebookLocationsData.locations.length) {
const oldestLocation = ebookLocationsData.locations.shift()
console.log(`Removing cached locations for epub "${oldestLocation.key}" taking up ${oldestLocation.size} bytes`)
availableSpace += oldestLocation.size
localStorage.removeItem(oldestLocation.key)
}
console.log(`Cacheing epub locations with key "${this.localStorageLocationsKey}" taking up ${newLocationsSize} bytes`)
this.saveLocations(locationString)
},
/** @param {string} locationString */
saveLocations(locationString) {
localStorage.setItem(
this.localStorageLocationsKey,
JSON.stringify({
lastAccessed: Date.now(),
locations: locationString
})
)
},
loadLocations() {
const locationsObjString = localStorage.getItem(this.localStorageLocationsKey)
if (!locationsObjString) return null
const locationsObject = JSON.parse(locationsObjString)
// Remove invalid location objects
if (!locationsObject.locations) {
console.error('Invalid epub locations stored', this.localStorageLocationsKey)
localStorage.removeItem(this.localStorageLocationsKey)
return null
}
// Update lastAccessed
this.saveLocations(locationsObject.locations)
return locationsObject.locations
},
/** @param {string} location - CFI of the new location */
relocated(location) {
if (this.userMediaProgress?.ebookLocation === location.start.cfi) {
return
}
if (location.end.percentage) {
this.updateProgress({
ebookLocation: location.start.cfi,
ebookProgress: location.end.percentage
})
} else {
this.updateProgress({
ebookLocation: location.start.cfi
})
}
},
initEpub() {
// var book = ePub(this.url, {
// requestHeaders: {
// Authorization: `Bearer ${this.userToken}`
// }
// })
var book = ePub(this.url)
this.book = book
/** @type {EpubReader} */
const reader = this
this.rendition = book.renderTo('viewer', {
width: window.innerWidth - 200,
height: 600,
ignoreClass: 'annotator-hl',
manager: 'continuous',
spread: 'always'
/** @type {ePub.Book} */
reader.book = new ePub(reader.url, {
width: this.readerWidth,
height: window.innerHeight - 50
})
var displayed = this.rendition.display()
book.ready
.then(() => {
console.log('Book ready')
return book.locations.generate(1600)
/** @type {ePub.Rendition} */
reader.rendition = reader.book.renderTo('viewer', {
width: this.readerWidth,
height: window.innerHeight * 0.8
})
// load saved progress
reader.rendition.display(this.userMediaProgress?.ebookLocation || reader.book.locations.start)
// load style
reader.rendition.themes.default({ '*': { color: '#fff!important' } })
reader.book.ready.then(() => {
// set up event listeners
reader.rendition.on('relocated', reader.relocated)
reader.rendition.on('keydown', reader.keyUp)
let touchStart = 0
let touchEnd = 0
reader.rendition.on('touchstart', (event) => {
touchStart = event.changedTouches[0].screenX
})
.then((locations) => {
// console.log('Loaded locations', locations)
// Wait for book to be rendered to get current page
displayed.then(() => {
// Get the current CFI
var currentLocation = this.rendition.currentLocation()
if (!currentLocation.start) {
console.error('No Start', currentLocation)
} else {
var currentPage = book.locations.percentageFromCfi(currentLocation.start.cfi)
// console.log('current page', currentPage)
}
reader.rendition.on('touchend', (event) => {
touchEnd = event.changedTouches[0].screenX
const touchDistanceX = Math.abs(touchEnd - touchStart)
if (touchStart < touchEnd && touchDistanceX > 120) {
this.next()
}
if (touchStart > touchEnd && touchDistanceX > 120) {
this.prev()
}
})
// load ebook cfi locations
const savedLocations = this.loadLocations()
if (savedLocations) {
reader.book.locations.load(savedLocations)
} else {
reader.book.locations.generate().then(() => {
this.checkSaveLocations(reader.book.locations.save())
})
})
book.loaded.navigation.then((toc) => {
var _chapters = []
toc.forEach((chapter) => {
_chapters.push(chapter)
})
this.chapters = _chapters
})
book.loaded.metadata.then((metadata) => {
this.author = metadata.creator
this.title = metadata.title
})
this.rendition.on('keyup', this.keyUp)
this.rendition.on('relocated', (location) => {
var percent = book.locations.percentageFromCfi(location.start.cfi)
this.progress = Math.floor(percent * 100)
this.hasNext = !location.atEnd
this.hasPrev = !location.atStart
}
})
},
resize() {
this.windowWidth = window.innerWidth
this.rendition?.resize(this.readerWidth, window.innerHeight * 0.8)
}
},
beforeDestroy() {
window.removeEventListener('resize', this.resize)
this.book?.destroy()
},
mounted() {
this.windowWidth = window.innerWidth
window.addEventListener('resize', this.resize)
this.initEpub()
}
}

View File

@@ -1,88 +0,0 @@
<template>
<div class="h-full w-full">
<div id="viewer" class="border border-gray-100 bg-white text-black shadow-md h-screen overflow-y-auto p-4" v-html="pageHtml"></div>
</div>
</template>
<script>
export default {
props: {
url: String,
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
bookInfo: {},
page: 0,
numPages: 0,
pageHtml: '',
progress: 0
}
},
computed: {
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
},
hasPrev() {
return this.page > 0
},
hasNext() {
return this.page < this.numPages - 1
}
},
methods: {
prev() {
if (!this.hasPrev) return
this.page--
this.loadPage()
},
next() {
if (!this.hasNext) return
this.page++
this.loadPage()
},
keyUp() {
if ((e.keyCode || e.which) == 37) {
this.prev()
} else if ((e.keyCode || e.which) == 39) {
this.next()
}
},
loadPage() {
this.$axios
.$get(`/api/ebooks/${this.libraryItemId}/page/${this.page}?dev=${this.$isDev ? 1 : 0}`)
.then((html) => {
this.pageHtml = html
})
.catch((error) => {
console.error('Failed to load page', error)
this.$toast.error('Failed to load page')
})
},
loadInfo() {
this.$axios
.$get(`/api/ebooks/${this.libraryItemId}/info?dev=${this.$isDev ? 1 : 0}`)
.then((bookInfo) => {
this.bookInfo = bookInfo
this.numPages = bookInfo.pages
this.page = 0
this.loadPage()
})
.catch((error) => {
console.error('Failed to load page', error)
this.$toast.error('Failed to load info')
})
},
initEpub() {
if (!this.libraryItemId) return
this.loadInfo()
}
},
mounted() {
this.initEpub()
}
}
</script>

View File

@@ -1,24 +1,48 @@
<template>
<div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-60 bg-primary text-white">
<div class="absolute top-4 right-4 z-20">
<span class="material-icons cursor-pointer text-4xl" @click="close">close</span>
<div class="absolute top-4 left-4 z-20">
<span v-if="hasToC && !tocOpen" ref="tocButton" class="material-icons cursor-pointer text-2xl" @click="toggleToC">menu</span>
</div>
<div class="absolute top-4 left-4">
<h1 class="text-2xl mb-1">{{ abTitle }}</h1>
<p v-if="abAuthor">by {{ abAuthor }}</p>
<div class="absolute top-4 left-1/2 transform -translate-x-1/2">
<h1 class="text-lg sm:text-xl md:text-2xl mb-1" style="line-height: 1.15; font-weight: 100">
<span style="font-weight: 600">{{ abTitle }}</span>
<span v-if="abAuthor" style="display: inline"> </span>
<span v-if="abAuthor">{{ abAuthor }}</span>
</h1>
</div>
<div class="absolute top-4 right-4 z-20">
<span v-if="hasSettings" class="material-icons cursor-pointer text-2xl" @click="openSettings">settings</span>
<span class="material-icons cursor-pointer text-2xl" @click="close">close</span>
</div>
<component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" :library-item="selectedLibraryItem" />
<div class="absolute bottom-2 left-2">{{ ebookType }}</div>
<!-- TOC side nav -->
<div v-if="tocOpen" class="w-full h-full fixed inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div>
<div v-if="hasToC" class="w-72 h-full max-h-full absolute top-0 left-0 bg-bg shadow-xl transition-transform z-30" :class="tocOpen ? 'translate-x-0' : '-translate-x-72'" @click.stop.prevent="toggleToC">
<div class="p-4 h-full overflow-hidden">
<p class="text-lg font-semibold mb-2">Table of Contents</p>
<div class="tocContent">
<ul>
<li v-for="chapter in chapters" :key="chapter.id" class="py-1">
<a :href="chapter.href" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.label }}</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {}
return {
chapters: [],
tocOpen: false
}
},
watch: {
show(newVal) {
@@ -37,13 +61,18 @@ export default {
}
},
componentName() {
if (this.ebookType === 'epub' && this.$isDev) return 'readers-epub-reader2'
else if (this.ebookType === 'epub') return 'readers-epub-reader'
if (this.ebookType === 'epub') return 'readers-epub-reader'
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
else if (this.ebookType === 'comic') return 'readers-comic-reader'
return null
},
hasToC() {
return this.isEpub
},
hasSettings() {
return false
},
abTitle() {
return this.mediaMetadata.title
},
@@ -111,18 +140,29 @@ export default {
}
},
methods: {
toggleToC() {
this.tocOpen = !this.tocOpen
this.chapters = this.$refs.readerComponent.chapters
},
openSettings() {},
hotkey(action) {
console.log('Reader hotkey', action)
if (!this.$refs.readerComponent) return
if (action === this.$hotkeys.EReader.NEXT_PAGE) {
if (this.$refs.readerComponent.next) this.$refs.readerComponent.next()
this.next()
} else if (action === this.$hotkeys.EReader.PREV_PAGE) {
if (this.$refs.readerComponent.prev) this.$refs.readerComponent.prev()
this.prev()
} else if (action === this.$hotkeys.EReader.CLOSE) {
this.close()
}
},
next() {
if (this.$refs.readerComponent?.next) this.$refs.readerComponent.next()
},
prev() {
if (this.$refs.readerComponent?.prev) this.$refs.readerComponent.prev()
},
registerListeners() {
this.$eventBus.$on('reader-hotkey', this.hotkey)
},
@@ -151,4 +191,8 @@ export default {
.ebook-viewer {
height: calc(100% - 96px);
}
.tocContent {
height: calc(100% - 36px);
overflow-y: auto;
}
</style>

View File

@@ -19,7 +19,12 @@
</template>
</div>
<p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p>
<template v-for="episode in episodesSorted">
<div v-if="episodes.length" class="w-full py-3 mx-auto flex">
<form @submit.prevent="submit" class="flex flex-grow">
<ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" />
</form>
</div>
<template v-for="episode in episodesList">
<tables-podcast-episode-table-row ref="episodeRow" :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" :selection-mode="isSelectionMode" class="item" @play="playEpisode" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" @selected="episodeSelected" @addToQueue="addEpisodeToQueue" @addToPlaylist="addToPlaylist" />
</template>
@@ -46,7 +51,10 @@ export default {
selectedEpisodes: [],
episodesToRemove: [],
processing: false,
quickMatchingEpisodes: false
quickMatchingEpisodes: false,
search: null,
searchTimeout: null,
searchText: null,
}
},
watch: {
@@ -137,6 +145,15 @@ export default {
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
})
},
episodesList() {
return this.episodesSorted.filter((episode) => {
if (!this.searchText) return true
return (
(episode.title && episode.title.toLowerCase().includes(this.searchText)) ||
(episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
)
})
},
selectedIsFinished() {
// Find an item that is not finished, if none then all items finished
return !this.selectedEpisodes.find((episode) => {
@@ -152,6 +169,16 @@ export default {
}
},
methods: {
inputUpdate() {
clearTimeout(this.searchTimeout)
this.searchTimeout = setTimeout(() => {
if (!this.search || !this.search.trim()) {
this.searchText = ''
return
}
this.searchText = this.search.toLowerCase().trim()
}, 500)
},
contextMenuAction(action) {
if (action === 'quick-match-episodes') {
if (this.quickMatchingEpisodes) return

View File

@@ -4,7 +4,7 @@
<input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" />
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
</div>
<div v-if="label" class="select-none text-gray-100" :class="labelClassname">{{ label }}</div>
<div v-if="label" class="select-none" :class="[labelClassname, disabled ? 'text-gray-400' : 'text-gray-100']">{{ label }}</div>
</label>
</template>

View File

@@ -51,8 +51,8 @@ export default {
tooltip.style.zIndex = 100
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
tooltip.innerHTML = this.text
tooltip.addEventListener('mouseover', this.cancelHide);
tooltip.addEventListener('mouseleave', this.hideTooltip);
tooltip.addEventListener('mouseover', this.cancelHide)
tooltip.addEventListener('mouseleave', this.hideTooltip)
this.setTooltipPosition(tooltip)
@@ -107,7 +107,7 @@ export default {
this.isShowing = false
},
cancelHide() {
if (this.hideTimeout) clearTimeout(this.hideTimeout);
if (this.hideTimeout) clearTimeout(this.hideTimeout)
},
mouseover() {
if (!this.isShowing) this.showTooltip()

View File

@@ -50,7 +50,7 @@
</div>
<div class="flex flex-wrap mt-2 -mx-1">
<div class="w-full md:w-1/2 px-1">
<div class="w-full md:w-1/4 px-1">
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" :label="$strings.LabelPublisher" />
</div>
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
@@ -61,6 +61,11 @@
<ui-checkbox v-model="details.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div>
</div>
<div class="flex-grow px-1 pt-6 mt-2 md:mt-0">
<div class="flex justify-center">
<ui-checkbox v-model="details.abridged" :label="$strings.LabelAbridged" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div>
</div>
</div>
</form>
</div>
@@ -89,7 +94,8 @@ export default {
isbn: null,
asin: null,
genres: [],
explicit: false
explicit: false,
abridged: false
},
newTags: []
}
@@ -271,6 +277,7 @@ export default {
this.details.isbn = this.mediaMetadata.isbn || null
this.details.asin = this.mediaMetadata.asin || null
this.details.explicit = !!this.mediaMetadata.explicit
this.details.abridged = !!this.mediaMetadata.abridged
this.newTags = [...(this.media.tags || [])]
},
submitForm() {

View File

@@ -73,6 +73,8 @@ export default {
return `/library/${task.data.libraryId}/podcast/download-queue`
case 'encode-m4b':
return `/audiobook/${task.data.libraryItemId}/manage?tool=m4b`
case 'embed-metadata':
return `/audiobook/${task.data.libraryItemId}/manage?tool=embed`
default:
return ''
}

View File

@@ -19,10 +19,10 @@
<ui-checkbox v-model="preventIndexing" :label="$strings.LabelPreventIndexing" checkbox-bg="primary" border-color="gray-600" label-class="pl-2" />
</div>
<div class="w-full relative mb-1">
<ui-text-input-with-label v-model="ownerName" :label="$strings.LabelRssFeedCustomOwnerName" />
<ui-text-input-with-label v-model="ownerName" :label="$strings.LabelRSSFeedCustomOwnerName" />
</div>
<div class="w-full relative mb-1">
<ui-text-input-with-label v-model="ownerEmail" :label="$strings.LabelRssFeedCustomOwnerEmail" />
<ui-text-input-with-label v-model="ownerEmail" :label="$strings.LabelRSSFeedCustomOwnerEmail" />
</div>
</template>
</div>
@@ -84,9 +84,7 @@ export default {
}
}
},
methods: {
},
mounted() {
}
methods: {},
mounted() {}
}
</script>

View File

@@ -278,6 +278,13 @@ export default {
console.log('Task finished', task)
this.$store.commit('tasks/addUpdateTask', task)
},
metadataEmbedQueueUpdate(data) {
if (data.queued) {
this.$store.commit('tasks/addQueuedEmbedLId', data.libraryItemId)
} else {
this.$store.commit('tasks/removeQueuedEmbedLId', data.libraryItemId)
}
},
userUpdated(user) {
if (this.$store.state.user.user.id === user.id) {
this.$store.commit('user/setUser', user)
@@ -418,6 +425,7 @@ export default {
// Task Listeners
this.socket.on('task_started', this.taskStarted)
this.socket.on('task_finished', this.taskFinished)
this.socket.on('metadata_embed_queue_update', this.metadataEmbedQueueUpdate)
this.socket.on('backup_applied', this.backupApplied)
@@ -531,12 +539,18 @@ export default {
},
loadTasks() {
this.$axios
.$get('/api/tasks')
.$get('/api/tasks?include=queue')
.then((payload) => {
console.log('Fetched tasks', payload)
if (payload.tasks) {
this.$store.commit('tasks/setTasks', payload.tasks)
}
if (payload.queuedTaskData?.embedMetadata?.length) {
this.$store.commit(
'tasks/setQueuedEmbedLIds',
payload.queuedTaskData.embedMetadata.map((td) => td.libraryItemId)
)
}
})
.catch((error) => {
console.error('Failed to load tasks', error)

View File

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

View File

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

View File

@@ -62,14 +62,20 @@
<div class="w-full h-px bg-white bg-opacity-10 my-8" />
<div class="w-full max-w-4xl mx-auto">
<div v-if="isEmbedTool" class="w-full flex justify-end items-center mb-4">
<ui-checkbox v-if="!isFinished" v-model="shouldBackupAudioFiles" label="Backup audio files" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" />
<!-- queued alert -->
<widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mb-4">
<p class="text-lg">Audiobook is queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)</p>
</widgets-alert>
<!-- metadata embed action buttons -->
<div v-else-if="isEmbedTool" class="w-full flex justify-end items-center mb-4">
<ui-checkbox v-if="!isTaskFinished" v-model="shouldBackupAudioFiles" :disabled="processing" label="Backup audio files" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" />
<div class="flex-grow" />
<ui-btn v-if="!isFinished" color="primary" :loading="processing" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn>
<ui-btn v-if="!isTaskFinished" color="primary" :loading="processing" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn>
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageEmbedFinished }}</p>
</div>
<!-- m4b embed action buttons -->
<div v-else class="w-full flex items-center mb-4">
<button :disabled="processing" class="text-sm uppercase text-gray-200 flex items-center pt-px pl-1 pr-2 hover:bg-white/5 rounded-md" @click="showEncodeOptions = !showEncodeOptions">
<span class="material-icons text-xl">{{ showEncodeOptions ? 'check_box' : 'check_box_outline_blank' }}</span> <span class="pl-1">Use Advanced Options</span>
@@ -83,6 +89,7 @@
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageM4BFinished }}</p>
</div>
<!-- advanced encoding options -->
<div v-if="isM4BTool" class="overflow-hidden">
<transition name="slide">
<div v-if="showEncodeOptions" class="mb-4 pb-4 border-b border-white/10">
@@ -191,6 +198,7 @@ export default {
cnosole.error('No audio files')
return redirect('/?error=no audio files')
}
return {
libraryItem
}
@@ -200,7 +208,6 @@ export default {
processing: false,
audiofilesEncoding: {},
audiofilesFinished: {},
isFinished: false,
toneObject: null,
selectedTool: 'embed',
isCancelingEncode: false,
@@ -272,11 +279,28 @@ export default {
isTaskFinished() {
return this.task && this.task.isFinished
},
tasks() {
return this.$store.getters['tasks/getTasksByLibraryItemId'](this.libraryItemId)
},
embedTask() {
return this.tasks.find((t) => t.action === 'embed-metadata')
},
encodeTask() {
return this.tasks.find((t) => t.action === 'encode-m4b')
},
task() {
return this.$store.getters['tasks/getTaskByLibraryItemId'](this.libraryItemId)
if (this.isEmbedTool) return this.embedTask
else if (this.isM4BTool) return this.encodeTask
return null
},
taskRunning() {
return this.task && !this.task.isFinished
},
queuedEmbedLIds() {
return this.$store.state.tasks.queuedEmbedLIds || []
},
isMetadataEmbedQueued() {
return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId)
}
},
methods: {
@@ -322,7 +346,7 @@ export default {
.catch((error) => {
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.$toast.error(errorMsg)
this.processing = true
this.processing = false
})
},
embedClick() {
@@ -349,24 +373,6 @@ export default {
this.processing = false
})
},
audioMetadataStarted(data) {
console.log('audio metadata started', data)
if (data.libraryItemId !== this.libraryItemId) return
this.audiofilesFinished = {}
},
audioMetadataFinished(data) {
console.log('audio metadata finished', data)
if (data.libraryItemId !== this.libraryItemId) return
this.processing = false
this.audiofilesEncoding = {}
if (data.failed) {
this.$toast.error(data.error)
} else {
this.isFinished = true
this.$toast.success('Audio file metadata updated')
}
},
audiofileMetadataStarted(data) {
if (data.libraryItemId !== this.libraryItemId) return
this.$set(this.audiofilesEncoding, data.ino, true)
@@ -412,14 +418,10 @@ export default {
},
mounted() {
this.init()
this.$root.socket.on('audio_metadata_started', this.audioMetadataStarted)
this.$root.socket.on('audio_metadata_finished', this.audioMetadataFinished)
this.$root.socket.on('audiofile_metadata_started', this.audiofileMetadataStarted)
this.$root.socket.on('audiofile_metadata_finished', this.audiofileMetadataFinished)
},
beforeDestroy() {
this.$root.socket.off('audio_metadata_started', this.audioMetadataStarted)
this.$root.socket.off('audio_metadata_finished', this.audioMetadataFinished)
this.$root.socket.off('audiofile_metadata_started', this.audiofileMetadataStarted)
this.$root.socket.off('audiofile_metadata_finished', this.audiofileMetadataFinished)
}

View File

@@ -124,6 +124,14 @@
{{ sizePretty }}
</div>
</div>
<div v-if="isBook" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelAbridged }}</span>
</div>
<div>
{{ isAbridged ? 'Yes' : 'No' }}
</div>
</div>
</div>
<div class="hidden md:block flex-grow" />
</div>
@@ -156,7 +164,7 @@
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''">
<p v-if="progressPercent < 1" class="leading-6">{{ $strings.LabelYourProgress }}: {{ Math.round(progressPercent * 100) }}%</p>
<p v-else class="text-xs">{{ $strings.LabelFinished }} {{ $formatDate(userProgressFinishedAt, dateFormat) }}</p>
<p v-if="progressPercent < 1" class="text-gray-200 text-xs">{{ $getString('LabelTimeRemaining', [$elapsedPretty(userTimeRemaining)]) }}</p>
<p v-if="progressPercent < 1 && !useEBookProgress" class="text-gray-200 text-xs">{{ $getString('LabelTimeRemaining', [$elapsedPretty(userTimeRemaining)]) }}</p>
<p class="text-gray-400 text-xs pt-1">{{ $strings.LabelStarted }} {{ $formatDate(userProgressStartedAt, dateFormat) }}</p>
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
@@ -319,7 +327,10 @@ export default {
return this.libraryItem.isInvalid
},
isExplicit() {
return this.mediaMetadata.explicit || false
return !!this.mediaMetadata.explicit
},
isAbridged() {
return !!this.mediaMetadata.abridged
},
invalidAudioFiles() {
if (!this.isBook) return []
@@ -471,7 +482,12 @@ export default {
const duration = this.userMediaProgress.duration || this.duration
return duration - this.userMediaProgress.currentTime
},
useEBookProgress() {
if (!this.userMediaProgress || this.userMediaProgress.progress) return false
return this.userMediaProgress.ebookProgress > 0
},
progressPercent() {
if (this.useEBookProgress) return Math.max(Math.min(1, this.userMediaProgress.ebookProgress), 0)
return this.userMediaProgress ? Math.max(Math.min(1, this.userMediaProgress.progress), 0) : 0
},
userProgressStartedAt() {

View File

@@ -127,6 +127,7 @@ export default class LocalAudioPlayer extends EventEmitter {
setHlsStream() {
this.trackStartTime = 0
this.currentTrackIndex = 0
// iOS does not support Media Elements but allows for HLS in the native audio player
if (!Hls.isSupported()) {

View File

@@ -1,6 +1,6 @@
const SupportedFileTypes = {
image: ['png', 'jpg', 'jpeg', 'webp'],
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma'],
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb'],
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
info: ['nfo'],
text: ['txt'],

View File

@@ -7,7 +7,7 @@ const defaultCode = 'en-us'
const languageCodeMap = {
'de': { label: 'Deutsch', dateFnsLocale: 'de' },
'en-us': { label: 'English', dateFnsLocale: 'enUS' },
// 'es': { label: 'Español', dateFnsLocale: 'es' },
'es': { label: 'Español', dateFnsLocale: 'es' },
'fr': { label: 'Français', dateFnsLocale: 'fr' },
'hr': { label: 'Hrvatski', dateFnsLocale: 'hr' },
'it': { label: 'Italiano', dateFnsLocale: 'it' },

View File

@@ -99,14 +99,14 @@ export const getters = {
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}`
},
getLibraryItemCoverSrcById: (state, getters, rootState, rootGetters) => (libraryItemId, placeholder = null) => {
getLibraryItemCoverSrcById: (state, getters, rootState, rootGetters) => (libraryItemId, placeholder = null, raw = false) => {
if (!placeholder) placeholder = `${rootState.routerBasePath}/book_placeholder.jpg`
if (!libraryItemId) return placeholder
var userToken = rootGetters['user/getToken']
if (process.env.NODE_ENV !== 'production') { // Testing
return `http://localhost:3333${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}`
return `http://localhost:3333${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}${raw ? '&raw=1' : ''}`
}
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}`
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}${raw ? '&raw=1' : ''}`
},
getIsBatchSelectingMediaItems: (state) => {
return state.selectedMediaItems.length

View File

@@ -1,11 +1,12 @@
export const state = () => ({
tasks: []
tasks: [],
queuedEmbedLIds: []
})
export const getters = {
getTaskByLibraryItemId: (state) => (libraryItemId) => {
return state.tasks.find(t => t.data && t.data.libraryItemId === libraryItemId)
getTasksByLibraryItemId: (state) => (libraryItemId) => {
return state.tasks.filter(t => t.data && t.data.libraryItemId === libraryItemId)
}
}
@@ -18,14 +19,31 @@ export const mutations = {
state.tasks = tasks
},
addUpdateTask(state, task) {
var index = state.tasks.findIndex(d => d.id === task.id)
const index = state.tasks.findIndex(d => d.id === task.id)
if (index >= 0) {
state.tasks.splice(index, 1, task)
} else {
// Remove duplicate (only have one library item per action)
state.tasks = state.tasks.filter(_task => {
if (!_task.data?.libraryItemId || _task.action !== task.action) return true
return _task.data.libraryItemId !== task.data.libraryItemId
})
state.tasks.push(task)
}
},
removeTask(state, task) {
state.tasks = state.tasks.filter(d => d.id !== task.id)
},
setQueuedEmbedLIds(state, libraryItemIds) {
state.queuedEmbedLIds = libraryItemIds
},
addQueuedEmbedLId(state, libraryItemId) {
if (!state.queuedEmbedLIds.some(lid => lid === libraryItemId)) {
state.queuedEmbedLIds.push(libraryItemId)
}
},
removeQueuedEmbedLId(state, libraryItemId) {
state.queuedEmbedLIds = state.queuedEmbedLIds.filter(lid => lid !== libraryItemId)
}
}

View File

@@ -129,8 +129,8 @@
"HeaderPreviewCover": "Vorschau Titelbild",
"HeaderRemoveEpisode": "Episode löschen",
"HeaderRemoveEpisodes": "Lösche {0} Episoden",
"HeaderRSSFeedIsOpen": "RSS-Feed ist geöffnet",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS-Feed ist geöffnet",
"HeaderSavedMediaProgress": "Gespeicherte Hörfortschritte",
"HeaderSchedule": "Zeitplan",
"HeaderScheduleLibraryScans": "Automatische Bibliotheksscans",
@@ -155,6 +155,7 @@
"HeaderUpdateLibrary": "Bibliothek aktualisieren",
"HeaderUsers": "Benutzer",
"HeaderYourStats": "Eigene Statistiken",
"LabelAbridged": "Abridged",
"LabelAccountType": "Kontoart",
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Gast",
@@ -324,12 +325,12 @@
"LabelRegion": "Region",
"LabelReleaseDate": "Veröffentlichungsdatum",
"LabelRemoveCover": "Lösche Titelbild",
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
"LabelRSSFeedOpen": "RSS Feed Offen",
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
"LabelRSSFeedSlug": "RSS Feed Schlagwort",
"LabelRSSFeedURL": "RSS Feed URL",
"LabelRssFeedCustomOwnerName": "Custom owner Name",
"LabelRssFeedCustomOwnerEmail": "Custom owner Email",
"LabelRssFeedPreventIndexing": "Prevent Indexing",
"LabelSearchTerm": "Begriff suchen",
"LabelSearchTitle": "Titel",
"LabelSearchTitleOrASIN": "Titel oder ASIN",
@@ -420,6 +421,7 @@
"LabelTracksMultiTrack": "Mehrfachdatei",
"LabelTracksSingleTrack": "Einzeldatei",
"LabelType": "Typ",
"LabelUnabridged": "Unabridged",
"LabelUnknown": "Unbekannt",
"LabelUpdateCover": "Titelbild aktualisieren",
"LabelUpdateCoverHelp": "Erlaube das Überschreiben bestehender Titelbilder für die ausgewählten Hörbücher wenn eine Übereinstimmung gefunden wird",
@@ -502,8 +504,8 @@
"MessageNoCollections": "Keine Sammlungen",
"MessageNoCoversFound": "Keine Titelbilder gefunden",
"MessageNoDescription": "Keine Beschreibung",
"MessageNoDownloadsQueued": "No downloads queued",
"MessageNoDownloadsInProgress": "No downloads currently in progress",
"MessageNoDownloadsQueued": "No downloads queued",
"MessageNoEpisodeMatchesFound": "Keine Episodenübereinstimmungen gefunden",
"MessageNoEpisodes": "Keine Episoden",
"MessageNoFoldersAvailable": "Keine Ordner verfügbar",
@@ -566,6 +568,7 @@
"PlaceholderNewFolderPath": "Neuer Ordnerpfad",
"PlaceholderNewPlaylist": "Neuer Wiedergabelistenname",
"PlaceholderSearch": "Suche...",
"PlaceholderSearchEpisode": "Search episode...",
"ToastAccountUpdateFailed": "Aktualisierung des Kontos fehlgeschlagen",
"ToastAccountUpdateSuccess": "Konto aktualisiert",
"ToastAuthorImageRemoveFailed": "Bild konnte nicht entfernt werden",
@@ -635,4 +638,4 @@
"ToastSocketFailedToConnect": "Verbindung zum WebSocket fehlgeschlagen",
"ToastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden",
"ToastUserDeleteSuccess": "Benutzer gelöscht"
}
}

View File

@@ -129,8 +129,8 @@
"HeaderPreviewCover": "Preview Cover",
"HeaderRemoveEpisode": "Remove Episode",
"HeaderRemoveEpisodes": "Remove {0} Episodes",
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
"HeaderSavedMediaProgress": "Saved Media Progress",
"HeaderSchedule": "Schedule",
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
@@ -155,6 +155,7 @@
"HeaderUpdateLibrary": "Update Library",
"HeaderUsers": "Users",
"HeaderYourStats": "Your Stats",
"LabelAbridged": "Abridged",
"LabelAccountType": "Account Type",
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Guest",
@@ -324,12 +325,12 @@
"LabelRegion": "Region",
"LabelReleaseDate": "Release Date",
"LabelRemoveCover": "Remove cover",
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
"LabelRSSFeedOpen": "RSS Feed Open",
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
"LabelRSSFeedSlug": "RSS Feed Slug",
"LabelRSSFeedURL": "RSS Feed URL",
"LabelRssFeedCustomOwnerName": "Custom owner Name",
"LabelRssFeedCustomOwnerEmail": "Custom owner Email",
"LabelRssFeedPreventIndexing": "Prevent Indexing",
"LabelSearchTerm": "Search Term",
"LabelSearchTitle": "Search Title",
"LabelSearchTitleOrASIN": "Search Title or ASIN",
@@ -420,6 +421,7 @@
"LabelTracksMultiTrack": "Multi-track",
"LabelTracksSingleTrack": "Single-track",
"LabelType": "Type",
"LabelUnabridged": "Unabridged",
"LabelUnknown": "Unknown",
"LabelUpdateCover": "Update Cover",
"LabelUpdateCoverHelp": "Allow overwriting of existing covers for the selected books when a match is located",
@@ -502,8 +504,8 @@
"MessageNoCollections": "No Collections",
"MessageNoCoversFound": "No Covers Found",
"MessageNoDescription": "No description",
"MessageNoDownloadsQueued": "No downloads queued",
"MessageNoDownloadsInProgress": "No downloads currently in progress",
"MessageNoDownloadsQueued": "No downloads queued",
"MessageNoEpisodeMatchesFound": "No episode matches found",
"MessageNoEpisodes": "No Episodes",
"MessageNoFoldersAvailable": "No Folders Available",
@@ -566,6 +568,7 @@
"PlaceholderNewFolderPath": "New folder path",
"PlaceholderNewPlaylist": "New playlist name",
"PlaceholderSearch": "Search..",
"PlaceholderSearchEpisode": "Search episode..",
"ToastAccountUpdateFailed": "Failed to update account",
"ToastAccountUpdateSuccess": "Account updated",
"ToastAuthorImageRemoveFailed": "Failed to remove image",
@@ -635,4 +638,4 @@
"ToastSocketFailedToConnect": "Socket failed to connect",
"ToastUserDeleteFailed": "Failed to delete user",
"ToastUserDeleteSuccess": "User deleted"
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -8,15 +8,15 @@
"ButtonAuthors": "Auteurs",
"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 épisodes",
"ButtonCancelEncode": "Annuler lencodage",
"ButtonChangeRootPassword": "Modifier le mot de passe Administrateur",
"ButtonCheckAndDownloadNewEpisodes": "Vérifier et 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 l'analyse",
"ButtonConfigureScanner": "Configurer lanalyse",
"ButtonCreate": "Créer",
"ButtonCreateBackup": "Créer une sauvegarde",
"ButtonDelete": "Effacer",
@@ -31,16 +31,16 @@
"ButtonIssues": "Parutions",
"ButtonLatest": "Dernière version",
"ButtonLibrary": "Bibliothèque",
"ButtonLogout": "Se Déconnecter",
"ButtonLookup": "Rechercher",
"ButtonLogout": "Me déconnecter",
"ButtonLookup": "Chercher",
"ButtonManageTracks": "Gérer les pistes",
"ButtonMapChapterTitles": "Correspondance des titres de chapitres",
"ButtonMatchAllAuthors": "Rechercher tous les auteurs",
"ButtonMatchBooks": "Rechercher les Livres",
"ButtonNevermind": "Oubliez cela",
"ButtonMatchAllAuthors": "Chercher tous les auteurs",
"ButtonMatchBooks": "Chercher les livres",
"ButtonNevermind": "Non merci",
"ButtonOk": "Ok",
"ButtonOpenFeed": "Ouvrir le Flux",
"ButtonOpenManager": "Ouvrir le Gestionnaire",
"ButtonOpenFeed": "Ouvrir le flux",
"ButtonOpenManager": "Ouvrir le gestionnaire",
"ButtonPlay": "Écouter",
"ButtonPlaying": "En lecture",
"ButtonPlaylists": "Listes de lecture",
@@ -60,25 +60,25 @@
"ButtonReset": "Réinitialiser",
"ButtonRestore": "Rétablir",
"ButtonSave": "Sauvegarder",
"ButtonSaveAndClose": "Sauvegarder & Fermer",
"ButtonSaveAndClose": "Sauvegarder et Fermer",
"ButtonSaveTracklist": "Sauvegarder la liste de lecture",
"ButtonScan": "Analyser",
"ButtonScanLibrary": "Analyser la bibliothèque",
"ButtonSearch": "Rechercher",
"ButtonSearch": "Chercher",
"ButtonSelectFolderPath": "Sélectionner le chemin du dossier",
"ButtonSeries": "Séries",
"ButtonSetChaptersFromTracks": "Positionner les chapitres par rapports aux pistes",
"ButtonShiftTimes": "Décaler le temps du livre",
"ButtonShiftTimes": "Décaler lhorodatage du livre",
"ButtonShow": "Afficher",
"ButtonStartM4BEncode": "Démarrer l'encodage M4B",
"ButtonStartM4BEncode": "Démarrer lencodage M4B",
"ButtonStartMetadataEmbed": "Démarrer les Métadonnées intégrées",
"ButtonSubmit": "Soumettre",
"ButtonUpload": "Téléverser",
"ButtonUploadBackup": "Téléverser une sauvegarde",
"ButtonUploadCover": "Téléverser une couverture",
"ButtonUploadOPMLFile": "Téléverser un fichier OPML",
"ButtonUserDelete": "Effacer l'utilisateur {0}",
"ButtonUserEdit": "Modifier l'utilisateur {0}",
"ButtonUserDelete": "Effacer lutilisateur {0}",
"ButtonUserEdit": "Modifier lutilisateur {0}",
"ButtonViewAll": "Afficher tout",
"ButtonYes": "Oui",
"HeaderAccount": "Compte",
@@ -87,7 +87,7 @@
"HeaderAudiobookTools": "Outils de Gestion de Fichier Audiobook",
"HeaderAudioTracks": "Pistes zudio",
"HeaderBackups": "Sauvegardes",
"HeaderChangePassword": "Chager le mot de passe",
"HeaderChangePassword": "Modifier le mot de passe",
"HeaderChapters": "Chapitres",
"HeaderChooseAFolder": "Choisir un dossier",
"HeaderCollection": "Collection",
@@ -102,19 +102,19 @@
"HeaderIgnoredFiles": "Fichiers Ignorés",
"HeaderItemFiles": "Fichiers des Articles",
"HeaderItemMetadataUtils": "Outils de gestion des métadonnées",
"HeaderLastListeningSession": "Dernière Session d'écoute",
"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'écoute",
"HeaderListeningStats": "Statistiques d'écoute",
"HeaderListeningSessions": "Sessions découte",
"HeaderListeningStats": "Statistiques découte",
"HeaderLogin": "Connexion",
"HeaderLogs": "Journaux",
"HeaderManageGenres": "Gérer les genres",
"HeaderManageTags": "Gérer les étiquettes",
"HeaderMapDetails": "Édition en masse",
"HeaderMatch": "Rechercher",
"HeaderMatch": "Chercher",
"HeaderMetadataToEmbed": "Métadonnée à intégrer",
"HeaderNewAccount": "Nouveau compte",
"HeaderNewLibrary": "Nouvelle bibliothèque",
@@ -122,15 +122,15 @@
"HeaderOpenRSSFeed": "Ouvrir Flux RSS",
"HeaderOtherFiles": "Autres fichiers",
"HeaderPermissions": "Permissions",
"HeaderPlayerQueue": "Liste d'écoute",
"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",
"HeaderRemoveEpisode": "Supprimer lépisode",
"HeaderRemoveEpisodes": "Suppression de {0} épisodes",
"HeaderRSSFeedIsOpen": "Le Flux RSS est actif",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "Le Flux RSS est actif",
"HeaderSavedMediaProgress": "Progression de la sauvegarde des médias",
"HeaderSchedule": "Programmation",
"HeaderScheduleLibraryScans": "Analyse automatique de la bibliothèque",
@@ -144,23 +144,24 @@
"HeaderSleepTimer": "Minuterie",
"HeaderStatsLargestItems": "Articles les plus lourd",
"HeaderStatsLongestItems": "Articles les plus long (heures)",
"HeaderStatsMinutesListeningChart": "Minutes d'écoute (7 derniers jours)",
"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",
"HeaderUpdateAuthor": "Mettre à jour lauteur",
"HeaderUpdateDetails": "Mettre à jour les détails",
"HeaderUpdateLibrary": "Mettre à jour la bibliothèque",
"HeaderUsers": "Utilisateurs",
"HeaderYourStats": "Vos statistiques",
"LabelAbridged": "Abridged",
"LabelAccountType": "Type de compte",
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Invité",
"LabelAccountTypeUser": "Utilisateur",
"LabelActivity": "Activité",
"LabelAddedAt": "Date d'ajout",
"LabelAddedAt": "Date dajout",
"LabelAddToCollection": "Ajouter à la collection",
"LabelAddToCollectionBatch": "Ajout de {0} livres à la lollection",
"LabelAddToPlaylist": "Ajouter à la liste de lecture",
@@ -173,8 +174,8 @@
"LabelAuthorFirstLast": "Auteur (Prénom Nom)",
"LabelAuthorLastFirst": "Auteur (Nom, Prénom)",
"LabelAuthors": "Auteurs",
"LabelAutoDownloadEpisodes": "Téléchargement automatique d'épisode",
"LabelBackToUser": "Revenir à l'Utilisateur",
"LabelAutoDownloadEpisodes": "Téléchargement automatique dépisode",
"LabelBackToUser": "Revenir à lUtilisateur",
"LabelBackupsEnableAutomaticBackups": "Activer les sauvegardes automatiques",
"LabelBackupsEnableAutomaticBackupsHelp": "Sauvegardes Enregistrées dans /metadata/backups",
"LabelBackupsMaxBackupSize": "Taille maximale de la sauvegarde (en Go)",
@@ -182,7 +183,7 @@
"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",
"LabelChangePassword": "Modifier le mot de passe",
"LabelChaptersFound": "Chapitres trouvés",
"LabelChapterTitle": "Titres du chapitre",
"LabelClosePlayer": "Fermer le lecteur",
@@ -193,7 +194,7 @@
"LabelContinueListening": "Continuer la lecture",
"LabelContinueSeries": "Continuer la série",
"LabelCover": "Couverture",
"LabelCoverImageURL": "URL vers l'image de couverture",
"LabelCoverImageURL": "URL vers limage de couverture",
"LabelCreatedAt": "Créé le",
"LabelCronExpression": "Expression Cron",
"LabelCurrent": "Courrant",
@@ -203,7 +204,7 @@
"LabelDescription": "Description",
"LabelDeselectAll": "Tout déselectionner",
"LabelDevice": "Appareil",
"LabelDeviceInfo": "Détail de l'appareil",
"LabelDeviceInfo": "Détail de lappareil",
"LabelDirectory": "Répertoire",
"LabelDiscFromFilename": "Disque depuis le fichier",
"LabelDiscFromMetadata": "Disque depuis les métadonnées",
@@ -214,8 +215,8 @@
"LabelEnable": "Activer",
"LabelEnd": "Fin",
"LabelEpisode": "Épisode",
"LabelEpisodeTitle": "Titre de l'épisode",
"LabelEpisodeType": "Type de l'épisode",
"LabelEpisodeTitle": "Titre de lépisode",
"LabelEpisodeType": "Type de lépisode",
"LabelExample": "Example",
"LabelExplicit": "Restriction",
"LabelFeedURL": "URL deu flux",
@@ -223,7 +224,7 @@
"LabelFileBirthtime": "Creation du fichier",
"LabelFileModified": "Modification du fichier",
"LabelFilename": "Nom de fichier",
"LabelFilterByUser": "Filtrer par l'utilisateur",
"LabelFilterByUser": "Filtrer par lutilisateur",
"LabelFindEpisodes": "Trouver des épisodes",
"LabelFinished": "Fini(e)",
"LabelFolder": "Dossier",
@@ -253,7 +254,7 @@
"LabelLastTime": "Progression",
"LabelLastUpdate": "Dernière mise à jour",
"LabelLess": "Moins",
"LabelLibrariesAccessibleToUser": "Bibliothèque accessible à l'utilisateur",
"LabelLibrariesAccessibleToUser": "Bibliothèque accessible à lutilisateur",
"LabelLibrary": "Bibliothèque",
"LabelLibraryItem": "Article de bibliothèque",
"LabelLibraryName": "Nom de la bibliothèque",
@@ -262,7 +263,7 @@
"LabelLogLevelDebug": "Debug",
"LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Rechercher de nouveaux épisode après cette date",
"LabelLookForNewEpisodesAfterDate": "Chercher de nouveaux épisode après cette date",
"LabelMediaPlayer": "Lecteur multimédia",
"LabelMediaType": "Type de média",
"LabelMetadataProvider": "Fournisseur de métadonnées",
@@ -282,32 +283,32 @@
"LabelNextScheduledRun": "Next scheduled run",
"LabelNotes": "Notes",
"LabelNotFinished": "Non terminé(e)",
"LabelNotificationAppriseURL": "URL(s) d'apprise",
"LabelNotificationAppriseURL": "URL(s) dapprise",
"LabelNotificationAvailableVariables": "Variables disponibles",
"LabelNotificationBodyTemplate": "Modèle de Message",
"LabelNotificationEvent": "Evènement de Notification",
"LabelNotificationsMaxFailedAttempts": "Nombres de tentatives d'envoi",
"LabelNotificationsMaxFailedAttempts": "Nombres de tentatives denvoi",
"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.",
"LabelNotificationsMaxQueueSizeHelp": "La limite de notification est de un évènement par seconde. Le notification seront ignorées si la file dattente est à son maximum. Cela empêche un flot trop important.",
"LabelNotificationTitleTemplate": "Modèle de Titre",
"LabelNotStarted": "Non Démarré(e)",
"LabelNumberOfBooks": "Nombre de Livres",
"LabelNumberOfEpisodes": "Nombre d'Episodes",
"LabelNumberOfEpisodes": "Nombre dEpisodes",
"LabelOpenRSSFeed": "Ouvrir le flux RSS",
"LabelOverwrite": "Ecraser",
"LabelPassword": "Mot de Passe",
"LabelOverwrite": "Écraser",
"LabelPassword": "Mot de passe",
"LabelPath": "Chemin",
"LabelPermissionsAccessAllLibraries": "Peut accéder à toutes les bibliothèque",
"LabelPermissionsAccessAllTags": "Peut accéder à toutes les étiquettes",
"LabelPermissionsAccessExplicitContent": "Peut acceter au contenu restreint",
"LabelPermissionsAccessExplicitContent": "Peut accéder au contenu restreint",
"LabelPermissionsDelete": "Peut supprimer",
"LabelPermissionsDownload": "Peut télécharger",
"LabelPermissionsUpdate": "Peut mettre à Jour",
"LabelPermissionsUpdate": "Peut mettre à jour",
"LabelPermissionsUpload": "Peut téléverser",
"LabelPhotoPathURL": "Chemin / URL des photos",
"LabelPlaylists": "Listes de lecture",
"LabelPlayMethod": "Méthode d'écoute",
"LabelPlayMethod": "Méthode découte",
"LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcast Type",
@@ -317,19 +318,19 @@
"LabelProvider": "Fournisseur",
"LabelPubDate": "Date de publication",
"LabelPublisher": "Éditeur",
"LabelPublishYear": "Année d'édition",
"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",
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
"LabelRSSFeedOpen": "Flux RSS ouvert",
"LabelRSSFeedSlug": "Identificateur d'adresse du Flux RSS ",
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
"LabelRSSFeedSlug": "Identificateur dadresse du Flux RSS ",
"LabelRSSFeedURL": "Adresse du flux RSS",
"LabelRssFeedCustomOwnerName": "Custom owner Name",
"LabelRssFeedCustomOwnerEmail": "Custom owner Email",
"LabelRssFeedPreventIndexing": "Prevent Indexing",
"LabelSearchTerm": "Terme de recherche",
"LabelSearchTitle": "Titre de recherche",
"LabelSearchTitleOrASIN": "Recherche du titre ou ASIN",
@@ -338,40 +339,40 @@
"LabelSeries": "Séries",
"LabelSeriesName": "Nom de la série",
"LabelSeriesProgress": "Progression de séries",
"LabelSettingsBookshelfViewHelp": "Design Skeuomorphic avec une étagère en bois",
"LabelSettingsBookshelfViewHelp": "Interface Skeuomorphic avec une étagère en bois",
"LabelSettingsChromecastSupport": "Support du Chromecast",
"LabelSettingsDateFormat": "Format de date",
"LabelSettingsDisableWatcher": "Désactiver la surveillance",
"LabelSettingsDisableWatcherForLibrary": "Désactiver la surveillance des dossiers pour la bibliothèque",
"LabelSettingsDisableWatcherHelp": "Désactive la mise à jour automatique lorsque les fichiers changent. *Nécessite un redémarrage*",
"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)",
"LabelSettingsEnableEReaderHelp": "E-reader est toujours en cours de développement, mais ce paramètre lactive pour tous les utilisateurs (ou utiliser linterrupteur « Fonctionnalités expérimentales » pour lactiver 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": "Chercher des couvertures de livre",
"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",
"LabelSettingsFindCoversHelp": "Si votre livre audio ne possède pas de couverture intégrée ou une image de couverture dans le dossier, lanalyser tentera de récupérer une couverture.<br>Attention, cela peut augmenter le temps danalyse.",
"LabelSettingsHomePageBookshelfView": "La page daccueil 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.",
"LabelSettingsOverdriveMediaMarkersHelp": "Les fichiers MP3 dOverdrive 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",
"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\"",
"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",
"LabelSettingsPreferMatchedMetadata": "Préférer les Métadonnées par correspondance",
"LabelSettingsPreferMatchedMetadataHelp": "Les métadonnées par correspondance écrase les détails de l'article lors d'une Recherche par Correspondance Rapide. Par défaut, la recherche par correspondance rapide ne comblera que les éléments manquant.",
"LabelSettingsPreferMatchedMetadataHelp": "Les métadonnées par correspondance écrase les détails de larticle lors dune recherche par correspondance rapide. Par défaut, la recherche par correspondance rapide ne comblera que les éléments manquant.",
"LabelSettingsPreferOPFMetadata": "Préférer les Métadonnées OPF",
"LabelSettingsPreferOPFMetadataHelp": "Les fichiers de métadonnées OPF seront utilisés à la place des noms de dossier pour les détails du Livre Audio",
"LabelSettingsSkipMatchingBooksWithASIN": "Ignorer la recherche par correspondance sur les livres ayant déjà un ASIN",
"LabelSettingsSkipMatchingBooksWithISBN": "Ignorer la recherche par correspondance sur les livres ayant déjà un ISBN",
"LabelSettingsSortingIgnorePrefixes": "Ignorer les préfixes lors du tri",
"LabelSettingsSortingIgnorePrefixesHelp": "i.e. pour le préfixe \"le\", le livre avec pour titre \"Le Titre du Livre\" sera trié en tant que \"Titre du Livre, Le\"",
"LabelSettingsSortingIgnorePrefixesHelp": "i.e. pour le préfixe « le », le livre avec pour titre « Le Titre du Livre » sera trié en tant que « Titre du Livre, Le »",
"LabelSettingsSquareBookCovers": "Utiliser des couvertures carrées",
"LabelSettingsSquareBookCoversHelp": "Préférer les couvertures carrées par rapport aux couvertures standardes de 1.6:1.",
"LabelSettingsStoreCoversWithItem": "Enregistrer la couverture avec les articles",
"LabelSettingsStoreCoversWithItemHelp": "Par défaut, les couvertures sont enregistrées dans /metadata/items. Activer ce paramètre enregistrera les couvertures dans le dossier avec les fichiersde l'article. Seul un fichier nommé \"cover\" sera gardé.",
"LabelSettingsStoreCoversWithItemHelp": "Par défaut, les couvertures sont enregistrées dans /metadata/items. Activer ce paramètre enregistrera les couvertures dans le dossier avec les fichiers de larticle. Seul un fichier nommé « cover » sera conservé.",
"LabelSettingsStoreMetadataWithItem": "Enregistrer les Métadonnées avec les articles",
"LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les métadonnées sont enregistrées dans /metadata/items. Activer ce paramètre enregistrera les métadonnées dans le dossier de l'article avec une extension \".abs\".",
"LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les métadonnées sont enregistrées dans /metadata/items. Activer ce paramètre enregistrera les métadonnées dans le dossier de larticle avec une extension « .abs ».",
"LabelSettingsTimeFormat": "Time Format",
"LabelShowAll": "Afficher Tout",
"LabelSize": "Taille",
@@ -385,13 +386,13 @@
"LabelStatsBestDay": "Meilleur Jour",
"LabelStatsDailyAverage": "Moyenne Journalière",
"LabelStatsDays": "Jours",
"LabelStatsDaysListened": "Jours d'écoute",
"LabelStatsDaysListened": "Jours découte",
"LabelStatsHours": "Heures",
"LabelStatsInARow": "d'affilé(s)",
"LabelStatsInARow": "daffilé(s)",
"LabelStatsItemsFinished": "Articles terminés",
"LabelStatsItemsInLibrary": "Articles dans la Bibliothèque",
"LabelStatsMinutes": "minutes",
"LabelStatsMinutesListening": "Minutes d'écoute",
"LabelStatsMinutesListening": "Minutes découte",
"LabelStatsOverallDays": "Jours au total",
"LabelStatsOverallHours": "Heures au total",
"LabelStatsWeekListening": "Écoute de la semaine",
@@ -399,10 +400,10 @@
"LabelSupportedFileTypes": "Types de fichiers supportés",
"LabelTag": "Étiquette",
"LabelTags": "Étiquettes",
"LabelTagsAccessibleToUser": "Étiquettes accessibles à l'utilisateur",
"LabelTagsAccessibleToUser": "Étiquettes accessibles à lutilisateur",
"LabelTasks": "Tasks Running",
"LabelTimeListened": "Temps d'écoute",
"LabelTimeListenedToday": "Nombres d'écoutes Aujourd'hui",
"LabelTimeListened": "Temps découte",
"LabelTimeListenedToday": "Nombres découtes Aujourdhui",
"LabelTimeRemaining": "{0} restantes",
"LabelTimeToShift": "Temps de décalage en secondes",
"LabelTitle": "Titre",
@@ -411,169 +412,171 @@
"LabelToolsMakeM4b": "Créer un fichier Livre Audio M4B",
"LabelToolsMakeM4bDescription": "Génère un fichier Livre Audio .M4B avec intégration des métadonnées, image de couverture et les chapitres.",
"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.",
"LabelToolsSplitM4bDescription": "Créer plusieurs fichier MP3 à partir du découpage par chapitre, en incluant les métadonnées, limage de couverture et les chapitres.",
"LabelTotalDuration": "Durée Totale",
"LabelTotalTimeListened": "Temps d'écoute total",
"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",
"LabelTracksMultiTrack": "Piste multiple",
"LabelTracksSingleTrack": "Piste simple",
"LabelType": "Type",
"LabelUnabridged": "Unabridged",
"LabelUnknown": "Inconnu",
"LabelUpdateCover": "Mettre à jour la Couverture",
"LabelUpdateCoverHelp": "Autoriser la mise à jour de la couverture existante lorsqu'une correspondance est trouvée",
"LabelUpdateCover": "Mettre à jour la couverture",
"LabelUpdateCoverHelp": "Autoriser la mise à jour de la couverture existante lorsquune correspondance est trouvée",
"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",
"LabelUpdateDetails": "Mettre à jours les détails",
"LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsquune correspondance est trouvée",
"LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers",
"LabelUploaderDropFiles": "Déposer des fichiers",
"LabelUseChapterTrack": "Utiliser la Piste du Chapitre",
"LabelUseFullTrack": "Utiliser la Piste Complète",
"LabelUseChapterTrack": "Utiliser la piste du chapitre",
"LabelUseFullTrack": "Utiliser la piste Complète",
"LabelUser": "Utilisateur",
"LabelUsername": "Nom d'Utilisateur",
"LabelUsername": "Nom dutilisateur",
"LabelValue": "Valeur",
"LabelVersion": "Version",
"LabelViewBookmarks": "Afficher les Signets",
"LabelViewChapters": "Afficher les Chapitres",
"LabelViewBookmarks": "Afficher les signets",
"LabelViewChapters": "Afficher les chapitres",
"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",
"LabelYourAudiobookDuration": "Durée de vos livres audios",
"LabelYourBookmarks": "Vos signets",
"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.",
"MessageBookshelfNoCollections": "Vous n'avez pas encore de collections",
"MessageBookshelfNoResultsForFilter": "Aucun résultat pour le filtre \"{0}: {1}\"",
"MessageBookshelfNoRSSFeeds": "Aucun flux RSS n'est ouvert",
"MessageBookshelfNoSeries": "Vous n'avez aucune séries",
"MessageAddToPlayerQueue": "Ajouter en file dattente",
"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 />lURL de lAPI 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 nincluent pas les fichiers de votre bibliothèque.",
"MessageBatchQuickMatchDescription": "La recherche par correspondance rapide tentera dajouter les couvertures et les métadonnées manquantes pour les articles sélectionnés. Activer loption suivante pour autoriser la recherche par correspondance à écraser les données existantes.",
"MessageBookshelfNoCollections": "Vous navez pas encore de collections",
"MessageBookshelfNoResultsForFilter": "Aucun résultat pour le filtre « {0}: {1} »",
"MessageBookshelfNoRSSFeeds": "Aucun flux RSS nest ouvert",
"MessageBookshelfNoSeries": "Vous navez aucune séries",
"MessageChapterEndIsAfter": "Le Chapitre Fin est situé à la fin de votre Livre Audio",
"MessageChapterErrorFirstNotZero": "Le premier capitre doit débuter à 0",
"MessageChapterErrorStartGteDuration": "Horodatage invalide car il doit débuter avant la fin du livre",
"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...",
"MessageCheckingCron": "Vérification du cron",
"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}\" ?",
"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}\" ?",
"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 ?",
"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": "Êtes-vous sûr 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}\".",
"MessageDownloadingEpisode": "Téléchargement de l'épisode",
"MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans l'ordre correct",
"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 lordre correct",
"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.",
"MessageFeedURLWillBe": "lURL 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 sils étaient nouveaux.",
"MessageImportantNotice": "Information Importante !",
"MessageInsertChapterBelow": "Insérer le chapitre ci-dessous",
"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...",
"MessageListeningSessionsInTheLastYear": "{0} sessions découte lan 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.",
"MessageMapChapterTitles": "Faire correspondre les titres des chapitres aux chapitres existants de votre livre audio sans ajuster lhorodatage.",
"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",
"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": "Aucune piste audio",
"MessageNoAuthors": "Aucun auteur",
"MessageNoBackups": "Aucune sauvegarde",
"MessageNoBookmarks": "Aucun signet",
"MessageNoChapters": "Aucun chapitre",
"MessageNoCollections": "Aucune collection",
"MessageNoCoversFound": "Aucune couverture trouvée",
"MessageNoDescription": "Pas de description",
"MessageNoDownloadsQueued": "No downloads queued",
"MessageNoDownloadsInProgress": "No downloads currently in progress",
"MessageNoEpisodeMatchesFound": "Pas de correspondance d'épisode trouvée",
"MessageNoDescription": "Aucune description",
"MessageNoDownloadsInProgress": "Aucun téléchargement en cours",
"MessageNoDownloadsQueued": "Aucun téléchargement en file dattente",
"MessageNoEpisodeMatchesFound": "Aucune correspondance dépisode trouvée",
"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'é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",
"MessageNoSearchResultsFor": "Pas de résultats de recherche pour \"{0}\"",
"MessageNoSeries": "Pas de séries",
"MessageNoTags": "Pas d'étiquettes",
"MessageNoGenres": "Aucun genre",
"MessageNoIssues": "Aucune parution",
"MessageNoItems": "Aucun article",
"MessageNoItemsFound": "Aucun article trouvé",
"MessageNoListeningSessions": "Aucune session découte en cours",
"MessageNoLogs": "Aucun journaux",
"MessageNoMediaProgress": "Aucun média en cours",
"MessageNoNotifications": "Aucune notification",
"MessageNoPodcastsFound": "Aucun podcast trouvé",
"MessageNoResults": "Aucun résultat",
"MessageNoSearchResultsFor": "Aucun résultat pour la recherche « {0} »",
"MessageNoSeries": "Aucune série",
"MessageNoTags": "Aucune détiquettes",
"MessageNoTasksRunning": "No Tasks Running",
"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",
"MessageNoUpdateNecessary": "Aucune mise à jour nécessaire",
"MessageNoUpdatesWereNecessary": "Aucune mise à jour nétait nécessaire",
"MessageNoUserPlaylists": "Vous navez aucune liste de lecture",
"MessageOr": "ou",
"MessagePauseChapter": "Suspendre la lecture 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. Souhaitez-vous continuer ?",
"MessagePodcastHasNoRSSFeedForMatching": "Le Podcast na pas dURL 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 na 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": "Êtes-vous certain de vouloir supprimer définitivement l'utilisateur \"{0}\" ?",
"MessageRemoveFromPlayerQueue": "Supprimer de la liste découte",
"MessageRemoveUserWarning": "Êtes-vous certain de vouloir supprimer définitivement lutilisateur « {0} » ?",
"MessageReportBugsAndContribute": "Remonter des anomalies, demander des fonctionnalités et contribuer sur",
"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.",
"MessageRestoreBackupWarning": "Restaurer la sauvegarde écrasera la base de donnée située dans le dossier /config ainsi que les images sur /metadata/items et /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} ?",
"MessageThinking": "On réfléchit...",
"MessageStartPlaybackAtTime": "Démarrer la lecture pour « {0} » à {1} ?",
"MessageThinking": "Je cherche…",
"MessageUploaderItemFailed": "Échec du téléversement",
"MessageUploaderItemSuccess": "Téléversement effectué !",
"MessageUploading": "Téléversement...",
"MessageUploading": "Téléversement",
"MessageValidCronExpression": "Expression cron valide",
"MessageWatcherIsDisabledGlobally": "La Surveillance est désactivée par un paramètre global du serveur",
"MessageWatcherIsDisabledGlobally": "La surveillance est désactivée par un paramètre global du serveur",
"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.",
"NoteChangeRootPassword": "seul lutilisateur « root » peut utiliser un mot de passe vide",
"NoteChapterEditorTimes": "Information : lhorodatage 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 nest 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 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.",
"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",
"PlaceholderSearch": "Recherche...",
"PlaceholderSearchEpisode": "Search episode...",
"ToastAccountUpdateFailed": "Échec de la mise à jour du compte",
"ToastAccountUpdateSuccess": "Compte mis à jour",
"ToastAuthorImageRemoveFailed": "Échec de la suppression de l'image",
"ToastAuthorImageRemoveSuccess": "Image de l'auteur supprimée",
"ToastAuthorUpdateFailed": "Échec de la mise à jour de l'auteur",
"ToastAuthorImageRemoveFailed": "Échec de la suppression de limage",
"ToastAuthorImageRemoveSuccess": "Image de lauteur supprimée",
"ToastAuthorUpdateFailed": "Échec de la mise à jour de lauteur",
"ToastAuthorUpdateMerged": "Auteur fusionné",
"ToastAuthorUpdateSuccess": "Auteur mis à jour",
"ToastAuthorUpdateSuccessNoImageFound": "Auteur mis à jour (pas d'image trouvée)",
"ToastAuthorUpdateSuccessNoImageFound": "Auteur mis à jour (aucune image trouvée)",
"ToastBackupCreateFailed": "Échec de la création de sauvegarde",
"ToastBackupCreateSuccess": "Sauvegarde créée",
"ToastBackupDeleteFailed": "Échec de la suppression de sauvegarde",
@@ -597,23 +600,23 @@
"ToastCollectionRemoveSuccess": "Collection supprimée",
"ToastCollectionUpdateFailed": "Échec de la mise à jour de la collection",
"ToastCollectionUpdateSuccess": "Collection mise à jour",
"ToastItemCoverUpdateFailed": "Échec de la mise à jour de la couverture de l'article",
"ToastItemCoverUpdateSuccess": "Couverture de l'article mise à jour",
"ToastItemDetailsUpdateFailed": "Échec de la mise à jour des détails de l'article",
"ToastItemDetailsUpdateSuccess": "Détails de l'article mis à jour",
"ToastItemDetailsUpdateUnneeded": "Pas de mise à jour nécessaire pour les détails de l'article",
"ToastItemMarkedAsFinishedFailed": "Échec de l'annotation terminée",
"ToastItemCoverUpdateFailed": "Échec de la mise à jour de la couverture de larticle",
"ToastItemCoverUpdateSuccess": "Couverture de larticle mise à jour",
"ToastItemDetailsUpdateFailed": "Échec de la mise à jour des détails de larticle",
"ToastItemDetailsUpdateSuccess": "Détails de larticle mis à jour",
"ToastItemDetailsUpdateUnneeded": "Pas de mise à jour nécessaire sur les détails de larticle",
"ToastItemMarkedAsFinishedFailed": "Échec de lannotation terminée",
"ToastItemMarkedAsFinishedSuccess": "Article marqué comme terminé",
"ToastItemMarkedAsNotFinishedFailed": "Échec de l'annotation non-terminée",
"ToastItemMarkedAsNotFinishedFailed": "Échec de lannotation non-terminée",
"ToastItemMarkedAsNotFinishedSuccess": "Article marqué comme non-terminé",
"ToastLibraryCreateFailed": "Échec de la création de bibliothèque",
"ToastLibraryCreateSuccess": "Bibliothèque \"{0}\" créée",
"ToastLibraryCreateSuccess": "Bibliothèque « {0} » créée",
"ToastLibraryDeleteFailed": "Échec de la suppression de la bibliothèque",
"ToastLibraryDeleteSuccess": "Bibliothèque supprimée",
"ToastLibraryScanFailedToStart": "Échec du démarrage de l'analyse",
"ToastLibraryScanFailedToStart": "Échec du démarrage de lanalyse",
"ToastLibraryScanStarted": "Analyse de la bibliothèque démarrée",
"ToastLibraryUpdateFailed": "Échec de la mise à jour de la bibliothèque",
"ToastLibraryUpdateSuccess": "Bibliothèque \"{0}\" mise à jour",
"ToastLibraryUpdateSuccess": "Bibliothèque « {0} » mise à jour",
"ToastPlaylistCreateFailed": "Échec de la création de la liste de lecture",
"ToastPlaylistCreateSuccess": "Liste de lecture créée",
"ToastPlaylistRemoveFailed": "Échec de la suppression de la liste de lecture",
@@ -622,17 +625,17 @@
"ToastPlaylistUpdateSuccess": "Liste de lecture mise à jour",
"ToastPodcastCreateFailed": "Échec de la création du Podcast",
"ToastPodcastCreateSuccess": "Podcast créé",
"ToastRemoveItemFromCollectionFailed": "Échec de la suppression de l'article de la collection",
"ToastRemoveItemFromCollectionFailed": "Échec de la suppression de larticle de la collection",
"ToastRemoveItemFromCollectionSuccess": "Article supprimé de la collection",
"ToastRSSFeedCloseFailed": "Échec de la fermeture du flux RSS",
"ToastRSSFeedCloseSuccess": "Flux RSS fermé",
"ToastSeriesUpdateFailed": "Echec de la mise à jour de la série",
"ToastSeriesUpdateFailed": "Échec de la mise à jour de la série",
"ToastSeriesUpdateSuccess": "Mise à jour de la série réussie",
"ToastSessionDeleteFailed": "Échec de la suppression de session",
"ToastSessionDeleteSuccess": "Session supprimée",
"ToastSocketConnected": "WebSocket connecté",
"ToastSocketDisconnected": "WebSocket déconnecté",
"ToastSocketFailedToConnect": "Échec de la connexion WebSocket",
"ToastUserDeleteFailed": "Échec de la suppression de l'utilisateur",
"ToastUserDeleteFailed": "Échec de la suppression de lutilisateur",
"ToastUserDeleteSuccess": "Utilisateur supprimé"
}
}

View File

@@ -129,8 +129,8 @@
"HeaderPreviewCover": "Pregledaj Cover",
"HeaderRemoveEpisode": "Ukloni epizodu",
"HeaderRemoveEpisodes": "Ukloni {0} epizoda/-e",
"HeaderRSSFeedIsOpen": "RSS Feed je otvoren",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed je otvoren",
"HeaderSavedMediaProgress": "Spremljen Media Progress",
"HeaderSchedule": "Schedule",
"HeaderScheduleLibraryScans": "Zakaži automatsko skeniranje biblioteke",
@@ -155,6 +155,7 @@
"HeaderUpdateLibrary": "Aktualiziraj biblioteku",
"HeaderUsers": "Korinici",
"HeaderYourStats": "Tvoja statistika",
"LabelAbridged": "Abridged",
"LabelAccountType": "Vrsta korisničkog računa",
"LabelAccountTypeAdmin": "Administrator",
"LabelAccountTypeGuest": "Gost",
@@ -324,12 +325,12 @@
"LabelRegion": "Regija",
"LabelReleaseDate": "Datum izlaska",
"LabelRemoveCover": "Remove cover",
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
"LabelRSSFeedOpen": "RSS Feed Open",
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
"LabelRSSFeedSlug": "RSS Feed Slug",
"LabelRSSFeedURL": "RSS Feed URL",
"LabelRssFeedCustomOwnerName": "Custom owner Name",
"LabelRssFeedCustomOwnerEmail": "Custom owner Email",
"LabelRssFeedPreventIndexing": "Prevent Indexing",
"LabelSearchTerm": "Traži pojam",
"LabelSearchTitle": "Traži naslov",
"LabelSearchTitleOrASIN": "Traži naslov ili ASIN",
@@ -420,6 +421,7 @@
"LabelTracksMultiTrack": "Multi-track",
"LabelTracksSingleTrack": "Single-track",
"LabelType": "Tip",
"LabelUnabridged": "Unabridged",
"LabelUnknown": "Nepoznato",
"LabelUpdateCover": "Aktualiziraj Cover",
"LabelUpdateCoverHelp": "Dozvoli postavljanje novog covera za odabrane knjige nakon što je match pronađen.",
@@ -502,8 +504,8 @@
"MessageNoCollections": "Nema kolekcija",
"MessageNoCoversFound": "Covers nisu pronađeni",
"MessageNoDescription": "Nema opisa",
"MessageNoDownloadsQueued": "No downloads queued",
"MessageNoDownloadsInProgress": "No downloads currently in progress",
"MessageNoDownloadsQueued": "No downloads queued",
"MessageNoEpisodeMatchesFound": "Nijedna epizoda pronađena",
"MessageNoEpisodes": "Nema epizoda",
"MessageNoFoldersAvailable": "Nema dostupnih foldera",
@@ -566,6 +568,7 @@
"PlaceholderNewFolderPath": "Nova folder putanja",
"PlaceholderNewPlaylist": "New playlist name",
"PlaceholderSearch": "Traži...",
"PlaceholderSearchEpisode": "Search episode...",
"ToastAccountUpdateFailed": "Neuspješno aktualiziranje korisničkog računa",
"ToastAccountUpdateSuccess": "Korisnički račun aktualiziran",
"ToastAuthorImageRemoveFailed": "Neuspješno uklanjanje slike",
@@ -635,4 +638,4 @@
"ToastSocketFailedToConnect": "Socket failed to connect",
"ToastUserDeleteFailed": "Neuspješno brisanje korisnika",
"ToastUserDeleteSuccess": "Korisnik obrisan"
}
}

View File

@@ -20,8 +20,8 @@
"ButtonCreate": "Crea",
"ButtonCreateBackup": "Crea un Backup",
"ButtonDelete": "Elimina",
"ButtonDownloadQueue": "Queue",
"ButtonEdit": "Edit",
"ButtonDownloadQueue": "Coda",
"ButtonEdit": "Modifica",
"ButtonEditChapters": "Modifica Capitoli",
"ButtonEditPodcast": "Modifica Podcast",
"ButtonForceReScan": "Forza Re-Scan",
@@ -129,8 +129,8 @@
"HeaderPreviewCover": "Anteprima Cover",
"HeaderRemoveEpisode": "Rimuovi Episodi",
"HeaderRemoveEpisodes": "Rimuovi {0} Episodi",
"HeaderRSSFeedIsOpen": "RSS Feed è aperto",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed è aperto",
"HeaderSavedMediaProgress": "Progressi salvati",
"HeaderSchedule": "Schedula",
"HeaderScheduleLibraryScans": "Schedula la scansione della libreria",
@@ -155,6 +155,7 @@
"HeaderUpdateLibrary": "Aggiorna Libreria",
"HeaderUsers": "Utenti",
"HeaderYourStats": "Statistiche Personali",
"LabelAbridged": "Abridged",
"LabelAccountType": "Tipo di Account",
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Ospite",
@@ -167,7 +168,7 @@
"LabelAddToPlaylistBatch": "Aggiungi {0} file alla Playlist",
"LabelAll": "Tutti",
"LabelAllUsers": "Tutti gli Utenti",
"LabelAlreadyInYourLibrary": "Already in your library",
"LabelAlreadyInYourLibrary": "Già esistente nella libreria",
"LabelAppend": "Appese",
"LabelAuthor": "Autore",
"LabelAuthorFirstLast": "Autore (Per Nome)",
@@ -238,7 +239,7 @@
"LabelInProgress": "In Corso",
"LabelInterval": "Intervallo",
"LabelIntervalCustomDailyWeekly": "Personalizza giorni/settimane",
"LabelIntervalEvery12Hours": "EOgni 12 Ore",
"LabelIntervalEvery12Hours": "Ogni 12 Ore",
"LabelIntervalEvery15Minutes": "Ogni 15 Minuti",
"LabelIntervalEvery2Hours": "Ogni 2 Ore",
"LabelIntervalEvery30Minutes": "Ogni 30 Minuti",
@@ -278,8 +279,8 @@
"LabelNewestAuthors": "Autori Recenti",
"LabelNewestEpisodes": "Episodi Recenti",
"LabelNewPassword": "Nuova Password",
"LabelNextBackupDate": "Next backup date",
"LabelNextScheduledRun": "Next scheduled run",
"LabelNextBackupDate": "Data Prossimo Backup",
"LabelNextScheduledRun": "Data prossima esecuzione schedulata",
"LabelNotes": "Note",
"LabelNotFinished": "Da Completare",
"LabelNotificationAppriseURL": "Apprendi URL(s)",
@@ -295,7 +296,7 @@
"LabelNumberOfBooks": "Numero di libri",
"LabelNumberOfEpisodes": "# degli episodi",
"LabelOpenRSSFeed": "Apri RSS Feed",
"LabelOverwrite": "Overwrite",
"LabelOverwrite": "Sovrascrivi",
"LabelPassword": "Password",
"LabelPath": "Percorso",
"LabelPermissionsAccessAllLibraries": "Può accedere a tutte le librerie",
@@ -310,9 +311,9 @@
"LabelPlayMethod": "Metodo di riproduzione",
"LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcast Type",
"LabelPodcastType": "Timo di Podcast",
"LabelPrefixesToIgnore": "Suffissi da ignorare (specificando maiuscole e minuscole)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelPreventIndexing": "Impedisci che il tuo feed venga indicizzato da iTunes e dalle directory dei podcast di Google",
"LabelProgress": "Cominciati",
"LabelProvider": "Provider",
"LabelPubDate": "Data Pubblicazione",
@@ -320,16 +321,16 @@
"LabelPublishYear": "Anno Pubblicazione",
"LabelRecentlyAdded": "Aggiunti Recentemente",
"LabelRecentSeries": "Serie Recenti",
"LabelRecommended": "Recommended",
"LabelRecommended": "Raccomandati",
"LabelRegion": "Regione",
"LabelReleaseDate": "Data Release",
"LabelRemoveCover": "Remove cover",
"LabelRemoveCover": "Rimuovi cover",
"LabelRSSFeedCustomOwnerEmail": "Email del proprietario personalizzato",
"LabelRSSFeedCustomOwnerName": "Nome del proprietario personalizzato",
"LabelRSSFeedOpen": "RSS Feed Aperto",
"LabelRSSFeedPreventIndexing": "Impedisci l'indicizzazione",
"LabelRSSFeedSlug": "RSS Feed Slug",
"LabelRSSFeedURL": "RSS Feed URL",
"LabelRssFeedCustomOwnerName": "Custom owner Name",
"LabelRssFeedCustomOwnerEmail": "Custom owner Email",
"LabelRssFeedPreventIndexing": "Prevent Indexing",
"LabelSearchTerm": "Ricerca",
"LabelSearchTitle": "Cerca Titolo",
"LabelSearchTitleOrASIN": "Cerca titolo o ASIN",
@@ -372,7 +373,7 @@
"LabelSettingsStoreCoversWithItemHelp": "Di default, le immagini di copertina sono salvate dentro /metadata/items, abilitando questa opzione le copertine saranno archiviate nella cartella della libreria corrispondente. Verrà conservato solo un file denominato \"cover\"",
"LabelSettingsStoreMetadataWithItem": "Archivia i metadata con il file",
"LabelSettingsStoreMetadataWithItemHelp": "Di default, i metadati sono salvati dentro /metadata/items, abilitando questa opzione si memorizzeranno i metadata nella cartella della libreria. I file avranno estensione .abs",
"LabelSettingsTimeFormat": "Time Format",
"LabelSettingsTimeFormat": "Formato Ora",
"LabelShowAll": "Mostra Tutto",
"LabelSize": "Dimensione",
"LabelSleepTimer": "Sleep timer",
@@ -400,7 +401,7 @@
"LabelTag": "Tag",
"LabelTags": "Tags",
"LabelTagsAccessibleToUser": "Tags permessi agli Utenti",
"LabelTasks": "Tasks Running",
"LabelTasks": "Processi in esecuzione",
"LabelTimeListened": "Tempo di Ascolto",
"LabelTimeListenedToday": "Tempo di Ascolto Oggi",
"LabelTimeRemaining": "{0} rimanente",
@@ -420,6 +421,7 @@
"LabelTracksMultiTrack": "Multi-traccia",
"LabelTracksSingleTrack": "Traccia-singola",
"LabelType": "Tipo",
"LabelUnabridged": "Unabridged",
"LabelUnknown": "Sconosciuto",
"LabelUpdateCover": "Aggiornamento Cover",
"LabelUpdateCoverHelp": "Consenti la sovrascrittura delle copertine esistenti per i libri selezionati quando viene trovata una corrispondenza",
@@ -502,8 +504,8 @@
"MessageNoCollections": "Nessuna Raccolta",
"MessageNoCoversFound": "Nessuna Cover Trovata",
"MessageNoDescription": "Nessuna descrizione",
"MessageNoDownloadsQueued": "No downloads queued",
"MessageNoDownloadsInProgress": "No downloads currently in progress",
"MessageNoDownloadsInProgress": "Nessun download attualmente in corso",
"MessageNoDownloadsQueued": "Nessuna coda di download",
"MessageNoEpisodeMatchesFound": "Nessun episodio corrispondente trovato",
"MessageNoEpisodes": "Nessun Episodio",
"MessageNoFoldersAvailable": "Nessuna Cartella disponibile",
@@ -520,7 +522,7 @@
"MessageNoSearchResultsFor": "Nessun risultato per \"{0}\"",
"MessageNoSeries": "Nessuna Serie",
"MessageNoTags": "No Tags",
"MessageNoTasksRunning": "No Tasks Running",
"MessageNoTasksRunning": "Nessun processo in esecuzione",
"MessageNotYetImplemented": "Non Ancora Implementato",
"MessageNoUpdateNecessary": "Nessun aggiornamento necessario",
"MessageNoUpdatesWereNecessary": "Nessun aggiornamento necessario",
@@ -566,6 +568,7 @@
"PlaceholderNewFolderPath": "Nuovo percorso Cartella",
"PlaceholderNewPlaylist": "Nome nuova playlist",
"PlaceholderSearch": "Cerca..",
"PlaceholderSearchEpisode": "Cerca Episodio..",
"ToastAccountUpdateFailed": "Aggiornamento Account Fallito",
"ToastAccountUpdateSuccess": "Account Aggiornato",
"ToastAuthorImageRemoveFailed": "Rimozione immagine autore Fallita",
@@ -626,7 +629,7 @@
"ToastRemoveItemFromCollectionSuccess": "Oggetto rimosso dalla Raccolta",
"ToastRSSFeedCloseFailed": "Errore chiusura RSS feed",
"ToastRSSFeedCloseSuccess": "RSS feed chiuso",
"ToastSeriesUpdateFailed": "Aggiornaemnto Serie Fallito",
"ToastSeriesUpdateFailed": "Aggiornaento Serie Fallito",
"ToastSeriesUpdateSuccess": "Serie Aggornate",
"ToastSessionDeleteFailed": "Errore eliminazione sessione",
"ToastSessionDeleteSuccess": "Sessione cancellata",
@@ -635,4 +638,4 @@
"ToastSocketFailedToConnect": "Socket non riesce a connettersi",
"ToastUserDeleteFailed": "Errore eliminazione utente",
"ToastUserDeleteSuccess": "Utente eliminato"
}
}

View File

@@ -129,8 +129,8 @@
"HeaderPreviewCover": "Podgląd okładki",
"HeaderRemoveEpisode": "Usuń odcinek",
"HeaderRemoveEpisodes": "Usuń {0} odcinków",
"HeaderRSSFeedIsOpen": "Kanał RSS jest otwarty",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "Kanał RSS jest otwarty",
"HeaderSavedMediaProgress": "Zapisany postęp",
"HeaderSchedule": "Harmonogram",
"HeaderScheduleLibraryScans": "Zaplanuj automatyczne skanowanie biblioteki",
@@ -155,6 +155,7 @@
"HeaderUpdateLibrary": "Zaktualizuj bibliotekę",
"HeaderUsers": "Użytkownicy",
"HeaderYourStats": "Twoje statystyki",
"LabelAbridged": "Abridged",
"LabelAccountType": "Typ konta",
"LabelAccountTypeAdmin": "Administrator",
"LabelAccountTypeGuest": "Gość",
@@ -324,12 +325,12 @@
"LabelRegion": "Region",
"LabelReleaseDate": "Data wydania",
"LabelRemoveCover": "Remove cover",
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
"LabelRSSFeedOpen": "RSS Feed otwarty",
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
"LabelRSSFeedSlug": "RSS Feed Slug",
"LabelRSSFeedURL": "URL kanały RSS",
"LabelRssFeedCustomOwnerName": "Custom owner Name",
"LabelRssFeedCustomOwnerEmail": "Custom owner Email",
"LabelRssFeedPreventIndexing": "Prevent Indexing",
"LabelSearchTerm": "Wyszukiwanie frazy",
"LabelSearchTitle": "Wyszukaj tytuł",
"LabelSearchTitleOrASIN": "Szukaj tytuł lub ASIN",
@@ -420,6 +421,7 @@
"LabelTracksMultiTrack": "Multi-track",
"LabelTracksSingleTrack": "Single-track",
"LabelType": "Typ",
"LabelUnabridged": "Unabridged",
"LabelUnknown": "Nieznany",
"LabelUpdateCover": "Zaktalizuj odkładkę",
"LabelUpdateCoverHelp": "Umożliwienie nadpisania istniejących okładek dla wybranych książek w przypadku znalezienia dopasowania",
@@ -502,8 +504,8 @@
"MessageNoCollections": "Brak kolekcji",
"MessageNoCoversFound": "Okładki nieznalezione",
"MessageNoDescription": "Brak opisu",
"MessageNoDownloadsQueued": "No downloads queued",
"MessageNoDownloadsInProgress": "No downloads currently in progress",
"MessageNoDownloadsQueued": "No downloads queued",
"MessageNoEpisodeMatchesFound": "Nie znaleziono pasujących odcinków",
"MessageNoEpisodes": "Brak odcinków",
"MessageNoFoldersAvailable": "Brak dostępnych folderów",
@@ -566,6 +568,7 @@
"PlaceholderNewFolderPath": "Nowa ścieżka folderu",
"PlaceholderNewPlaylist": "New playlist name",
"PlaceholderSearch": "Szukanie..",
"PlaceholderSearchEpisode": "Search episode..",
"ToastAccountUpdateFailed": "Nie udało się zaktualizować konta",
"ToastAccountUpdateSuccess": "Zaktualizowano konto",
"ToastAuthorImageRemoveFailed": "Nie udało się usunąć obrazu",
@@ -635,4 +638,4 @@
"ToastSocketFailedToConnect": "Poączenie z serwerem nie powiodło się",
"ToastUserDeleteFailed": "Nie udało się usunąć użytkownika",
"ToastUserDeleteSuccess": "Użytkownik usunięty"
}
}

View File

@@ -1,31 +1,31 @@
{
"ButtonAdd": "Добавить",
"ButtonAddChapters": "Добавить Главы",
"ButtonAddPodcasts": "Добавить Подкасты",
"ButtonAddChapters": "Добавить главы",
"ButtonAddPodcasts": "Добавить подкасты",
"ButtonAddYourFirstLibrary": "Добавьте Вашу первую библиотеку",
"ButtonApply": "Применить",
"ButtonApplyChapters": "Применить Главы",
"ButtonApplyChapters": "Применить главы",
"ButtonAuthors": "Авторы",
"ButtonBrowseForFolder": "Выбрать Папку",
"ButtonBrowseForFolder": "Выбрать папку",
"ButtonCancel": "Отмена",
"ButtonCancelEncode": "Отменить Кодирование",
"ButtonChangeRootPassword": "Поменять Мастер Пароль",
"ButtonCheckAndDownloadNewEpisodes": "Проверка и Загрузка Новых Эпизодов",
"ButtonCancelEncode": "Отменить кодирование",
"ButtonChangeRootPassword": "Поменять мастер пароль",
"ButtonCheckAndDownloadNewEpisodes": "Проверка и Загрузка новых эпизодов",
"ButtonChooseAFolder": "Выбор папки",
"ButtonChooseFiles": "Выбор файлов",
"ButtonClearFilter": "Очистить Фильтр",
"ButtonCloseFeed": "Закрыть Канал",
"ButtonClearFilter": "Очистить фильтр",
"ButtonCloseFeed": "Закрыть канал",
"ButtonCollections": "Коллекции",
"ButtonConfigureScanner": "Конфигурация Сканера",
"ButtonConfigureScanner": "Конфигурация сканера",
"ButtonCreate": "Создать",
"ButtonCreateBackup": "Создать бэкап",
"ButtonDelete": "Удалить",
"ButtonDownloadQueue": "Queue",
"ButtonDownloadQueue": "Очередь",
"ButtonEdit": "Редактировать",
"ButtonEditChapters": "Редактировать Главы",
"ButtonEditPodcast": "Редактировать Подкаст",
"ButtonForceReScan": "Принудительно Пере сканировать",
"ButtonFullPath": "Полный Путь",
"ButtonEditChapters": "Редактировать главы",
"ButtonEditPodcast": "Редактировать подкаст",
"ButtonForceReScan": "Принудительно пересканировать",
"ButtonFullPath": "Полный путь",
"ButtonHide": "Скрыть",
"ButtonHome": "Домой",
"ButtonIssues": "Проблемы",
@@ -33,148 +33,149 @@
"ButtonLibrary": "Библиотека",
"ButtonLogout": "Выход",
"ButtonLookup": "Найти",
"ButtonManageTracks": "Управление Треками",
"ButtonMapChapterTitles": "Найти Названия Глав",
"ButtonMatchAllAuthors": "Найти Всех Авторов",
"ButtonMatchBooks": "Найти Книги",
"ButtonManageTracks": "Управление треками",
"ButtonMapChapterTitles": "Найти названия глав",
"ButtonMatchAllAuthors": "Найти всех авторов",
"ButtonMatchBooks": "Найти книги",
"ButtonNevermind": "Не важно",
"ButtonOk": "Ok",
"ButtonOpenFeed": "Открыть Канал",
"ButtonOpenManager": "Открыть Менеджер",
"ButtonOpenFeed": "Открыть канал",
"ButtonOpenManager": "Открыть менеджер",
"ButtonPlay": "Слушать",
"ButtonPlaying": "Проигрывается",
"ButtonPlaylists": "Плейлисты",
"ButtonPurgeAllCache": "Очистить Весь Кэш",
"ButtonPurgeItemsCache": "Очистить Кэш Элементов",
"ButtonPurgeMediaProgress": "Очистить Прогресс Медиа",
"ButtonPurgeAllCache": "Очистить весь кэш",
"ButtonPurgeItemsCache": "Очистить кэш элементов",
"ButtonPurgeMediaProgress": "Очистить прогресс медиа",
"ButtonQueueAddItem": "Добавить в очередь",
"ButtonQueueRemoveItem": "Удалить из очереди",
"ButtonQuickMatch": "Быстрый Поиск",
"ButtonQuickMatch": "Быстрый поиск",
"ButtonRead": "Читать",
"ButtonRemove": "Удалить",
"ButtonRemoveAll": "Удалить Всё",
"ButtonRemoveAllLibraryItems": "Удалить Все Элементы Библиотеки",
"ButtonRemoveFromContinueListening": "Удалить из Продолжить Слушать",
"ButtonRemoveSeriesFromContinueSeries": "Удалить Серию из Продолжить Серию",
"ButtonReScan": "Пере сканировать",
"ButtonRemoveAll": "Удалить всё",
"ButtonRemoveAllLibraryItems": "Удалить все элементы библиотеки",
"ButtonRemoveFromContinueListening": "Удалить из Продолжить слушать",
"ButtonRemoveSeriesFromContinueSeries": "Удалить серию из Продолжить серию",
"ButtonReScan": "Пересканировать",
"ButtonReset": "Сбросить",
"ButtonRestore": "Восстановить",
"ButtonSave": "Сохранить",
"ButtonSaveAndClose": "Сохранить и Закрыть",
"ButtonSaveTracklist": "Сохранить Список треков",
"ButtonSaveAndClose": "Сохранить и закрыть",
"ButtonSaveTracklist": "Сохранить список треков",
"ButtonScan": "Сканировать",
"ButtonScanLibrary": "Сканировать Библиотеку",
"ButtonScanLibrary": "Сканировать библиотеку",
"ButtonSearch": "Поиск",
"ButtonSelectFolderPath": "Выберите Путь Папки",
"ButtonSelectFolderPath": "Выберите путь папки",
"ButtonSeries": "Серии",
"ButtonSetChaptersFromTracks": "Установить главы из треков",
"ButtonShiftTimes": "Смещение",
"ButtonShow": "Показать",
"ButtonStartM4BEncode": "Начать Кодирование M4B",
"ButtonStartMetadataEmbed": "Начать Встраивание Метаданных",
"ButtonStartM4BEncode": "Начать кодирование M4B",
"ButtonStartMetadataEmbed": "Начать встраивание метаданных",
"ButtonSubmit": "Применить",
"ButtonUpload": "Загрузить",
"ButtonUploadBackup": "Загрузить Бэкап",
"ButtonUploadCover": "Загрузить Обложку",
"ButtonUploadBackup": "Загрузить бэкап",
"ButtonUploadCover": "Загрузить обложку",
"ButtonUploadOPMLFile": "Загрузить Файл OPML",
"ButtonUserDelete": "Удалить пользователя {0}",
"ButtonUserEdit": "Редактировать пользователя {0}",
"ButtonViewAll": "Посмотреть Все",
"ButtonViewAll": "Посмотреть все",
"ButtonYes": "Да",
"HeaderAccount": "Учетная запись",
"HeaderAdvanced": "Дополнительно",
"HeaderAppriseNotificationSettings": "Настройки Оповещений",
"HeaderAudiobookTools": "Инструменты Файлов Аудиокниг",
"HeaderAudioTracks": "Аудио Треки",
"HeaderAppriseNotificationSettings": "Настройки оповещений",
"HeaderAudiobookTools": "Инструменты файлов аудиокниг",
"HeaderAudioTracks": "Аудио треки",
"HeaderBackups": "Бэкапы",
"HeaderChangePassword": "Изменить Пароль",
"HeaderChangePassword": "Изменить пароль",
"HeaderChapters": "Главы",
"HeaderChooseAFolder": "Выберите Папку",
"HeaderChooseAFolder": "Выберите папку",
"HeaderCollection": "Коллекция",
"HeaderCollectionItems": "Элементы Коллекции",
"HeaderCollectionItems": "Элементы коллекции",
"HeaderCover": "Обложка",
"HeaderCurrentDownloads": "Current Downloads",
"HeaderCurrentDownloads": "Текущие закачки",
"HeaderDetails": "Подробности",
"HeaderDownloadQueue": "Download Queue",
"HeaderDownloadQueue": "Очередь скачивания",
"HeaderEpisodes": "Эпизоды",
"HeaderFiles": "Файлы",
"HeaderFindChapters": "Найти Главы",
"HeaderFindChapters": "Найти главы",
"HeaderIgnoredFiles": "Игнорируемые Файлы",
"HeaderItemFiles": "Файлы Элемента",
"HeaderItemFiles": "Файлы элемента",
"HeaderItemMetadataUtils": "Утилиты",
"HeaderLastListeningSession": "Последний Сеанс Прослушивания",
"HeaderLastListeningSession": "Последний сеанс прослушивания",
"HeaderLatestEpisodes": "Последние эпизоды",
"HeaderLibraries": "Библиотеки",
"HeaderLibraryFiles": "Файлы Библиотеки",
"HeaderLibraryStats": "Статистика Библиотеки",
"HeaderLibraryFiles": "Файлы библиотеки",
"HeaderLibraryStats": "Статистика библиотеки",
"HeaderListeningSessions": "Сеансы",
"HeaderListeningStats": "Статистика Прослушивания",
"HeaderListeningStats": "Статистика прослушивания",
"HeaderLogin": "Логин",
"HeaderLogs": "Логи",
"HeaderManageGenres": "Редактировать Жанры",
"HeaderManageTags": "Редактировать Теги",
"HeaderManageGenres": "Редактировать жанры",
"HeaderManageTags": "Редактировать теги",
"HeaderMapDetails": "Найти подробности",
"HeaderMatch": "Поиск",
"HeaderMetadataToEmbed": "Метаинформация для встраивания",
"HeaderNewAccount": "Новая Учетная запись",
"HeaderNewLibrary": "Новая Библиотека",
"HeaderNewAccount": "Новая учетная запись",
"HeaderNewLibrary": "Новая библиотека",
"HeaderNotifications": "Уведомления",
"HeaderOpenRSSFeed": "Открыть RSS-канал",
"HeaderOtherFiles": "Другие Файлы",
"HeaderOtherFiles": "Другие файлы",
"HeaderPermissions": "Разрешения",
"HeaderPlayerQueue": "Очередь Воспроизведения",
"HeaderPlayerQueue": "Очередь воспроизведения",
"HeaderPlaylist": "Плейлист",
"HeaderPlaylistItems": "Элементы Списка Воспроизведения",
"HeaderPodcastsToAdd": "Подкасты для Добавления",
"HeaderPreviewCover": "Предпросмотр Обложки",
"HeaderRemoveEpisode": "Удалить Эпизод",
"HeaderRemoveEpisodes": "Удалить {0} Эпизодов",
"HeaderRSSFeedIsOpen": "RSS-канал Открыт",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderSavedMediaProgress": "Прогресс Медиа Сохранен",
"HeaderPlaylistItems": "Элементы списка воспроизведения",
"HeaderPodcastsToAdd": "Подкасты для добавления",
"HeaderPreviewCover": "Предпросмотр обложки",
"HeaderRemoveEpisode": "Удалить эпизод",
"HeaderRemoveEpisodes": "Удалить {0} эпизодов",
"HeaderRSSFeedGeneral": "Сведения о RSS",
"HeaderRSSFeedIsOpen": "RSS-канал открыт",
"HeaderSavedMediaProgress": "Прогресс медиа сохранен",
"HeaderSchedule": "Планировщик",
"HeaderScheduleLibraryScans": "Планировщик Автоматического Сканирования Библиотеки",
"HeaderScheduleLibraryScans": "Планировщик автоматического сканирования библиотеки",
"HeaderSession": "Сеансы",
"HeaderSetBackupSchedule": "Установить Планировщик Бэкапов",
"HeaderSetBackupSchedule": "Установить планировщик бэкапов",
"HeaderSettings": "Настройки",
"HeaderSettingsDisplay": "Дисплей",
"HeaderSettingsExperimental": "Экспериментальные Функции",
"HeaderSettingsExperimental": "Экспериментальные функции",
"HeaderSettingsGeneral": "Основные",
"HeaderSettingsScanner": "Сканер",
"HeaderSleepTimer": "Таймер Сна",
"HeaderStatsLargestItems": "Largest Items",
"HeaderStatsLongestItems": "Самые Длинные Книги (часов)",
"HeaderSleepTimer": "Таймер сна",
"HeaderStatsLargestItems": "Самые большые элементы",
"HeaderStatsLongestItems": "Самые длинные элементы (часов)",
"HeaderStatsMinutesListeningChart": "Минут прослушивания (последние 7 дней)",
"HeaderStatsRecentSessions": "Последние Сеансы",
"HeaderStatsTop10Authors": "Топ 10 Авторов",
"HeaderStatsTop5Genres": "Топ 5 Жанров",
"HeaderStatsRecentSessions": "Последние сеансы",
"HeaderStatsTop10Authors": "Топ 10 авторов",
"HeaderStatsTop5Genres": "Топ 5 жанров",
"HeaderTools": "Инструменты",
"HeaderUpdateAccount": "Обновить Учетную запись",
"HeaderUpdateAuthor": "Обновить Автора",
"HeaderUpdateDetails": "Обновить Детали",
"HeaderUpdateLibrary": "Обновить Библиотеку",
"HeaderUpdateAccount": "Обновить учетную запись",
"HeaderUpdateAuthor": "Обновить автора",
"HeaderUpdateDetails": "Обновить детали",
"HeaderUpdateLibrary": "Обновить библиотеку",
"HeaderUsers": "Пользователи",
"HeaderYourStats": "Ваша Статистика",
"LabelAccountType": "Тип Учетной записи",
"HeaderYourStats": "Ваша статистика",
"LabelAbridged": "Abridged",
"LabelAccountType": "Тип учетной записи",
"LabelAccountTypeAdmin": "Администратор",
"LabelAccountTypeGuest": "Гость",
"LabelAccountTypeUser": "Пользователь",
"LabelActivity": "Активность",
"LabelAddedAt": обавить В",
"LabelAddToCollection": "Добавить в Коллекцию",
"LabelAddToCollectionBatch": "Добавить {0} Книг в Коллекцию",
"LabelAddToPlaylist": "Добавить в Плейлист",
"LabelAddToPlaylistBatch": "Добавить {0} Элементов в Плейлист",
"LabelAddedAt": ата добавления",
"LabelAddToCollection": "Добавить в коллекцию",
"LabelAddToCollectionBatch": "Добавить {0} книг в коллекцию",
"LabelAddToPlaylist": "Добавить в плейлист",
"LabelAddToPlaylistBatch": "Добавить {0} элементов в плейлист",
"LabelAll": "Все",
"LabelAllUsers": "Все пользователи",
"LabelAlreadyInYourLibrary": "Already in your library",
"LabelAlreadyInYourLibrary": "Уже в Вашей библиотеке",
"LabelAppend": "Добавить",
"LabelAuthor": "Автор",
"LabelAuthorFirstLast": "Автор (Имя Фамилия)",
"LabelAuthorLastFirst": "Автор (Фамилия, Имя)",
"LabelAuthors": "Авторы",
"LabelAutoDownloadEpisodes": "Скачивать Эпизоды Автоматически",
"LabelBackToUser": "Назад к Пользователю",
"LabelAutoDownloadEpisodes": "Скачивать эпизоды автоматически",
"LabelBackToUser": "Назад к пользователю",
"LabelBackupsEnableAutomaticBackups": "Включить автоматическое бэкапирование",
"LabelBackupsEnableAutomaticBackupsHelp": "Бэкапы сохраняются в /metadata/backups",
"LabelBackupsMaxBackupSize": "Максимальный размер бэкапа (в GB)",
@@ -182,28 +183,28 @@
"LabelBackupsNumberToKeep": "Сохранять бэкапов",
"LabelBackupsNumberToKeepHelp": "За один раз только 1 бэкап будет удален, так что если у вас будет больше бэкапов, то их нужно удалить вручную.",
"LabelBooks": "Книги",
"LabelChangePassword": "Изменить Пароль",
"LabelChangePassword": "Изменить пароль",
"LabelChaptersFound": "глав найдено",
"LabelChapterTitle": "Название Главы",
"LabelChapterTitle": "Название главы",
"LabelClosePlayer": "Закрыть проигрыватель",
"LabelCollapseSeries": "Свернуть Серии",
"LabelCollapseSeries": "Свернуть серии",
"LabelCollections": "Коллекции",
"LabelComplete": "Завершить",
"LabelConfirmPassword": "Подтвердить Пароль",
"LabelContinueListening": "Продолжить Слушать",
"LabelContinueSeries": "Продолжить Серию",
"LabelConfirmPassword": "Подтвердить пароль",
"LabelContinueListening": "Продолжить слушать",
"LabelContinueSeries": "Продолжить серию",
"LabelCover": "Обложка",
"LabelCoverImageURL": "URL Изображения Обложки",
"LabelCoverImageURL": "URL изображения обложки",
"LabelCreatedAt": "Создано",
"LabelCronExpression": "Выражение Cron",
"LabelCurrent": "Текущий",
"LabelCurrently": "Текущее:",
"LabelCustomCronExpression": "Custom Cron Expression:",
"LabelCustomCronExpression": "Пользовательское выражение Cron:",
"LabelDatetime": "Дата и время",
"LabelDescription": "Описание",
"LabelDeselectAll": "Снять Выделение",
"LabelDeselectAll": "Снять выделение",
"LabelDevice": "Устройство",
"LabelDeviceInfo": "Информация об Устройстве",
"LabelDeviceInfo": "Информация об устройстве",
"LabelDirectory": "Каталог",
"LabelDiscFromFilename": "Диск из Имени файла",
"LabelDiscFromMetadata": "Диск из Метаданных",
@@ -214,17 +215,17 @@
"LabelEnable": "Включить",
"LabelEnd": "Конец",
"LabelEpisode": "Эпизод",
"LabelEpisodeTitle": "Имя Эпизода",
"LabelEpisodeType": "Тип Эпизода",
"LabelExample": "Example",
"LabelEpisodeTitle": "Имя эпизода",
"LabelEpisodeType": "Тип эпизода",
"LabelExample": "Пример",
"LabelExplicit": "Явный",
"LabelFeedURL": "URL Канала",
"LabelFeedURL": "URL канала",
"LabelFile": "Файл",
"LabelFileBirthtime": "Дата Создания",
"LabelFileModified": "Дата Модификации",
"LabelFileBirthtime": "Дата создания",
"LabelFileModified": "Дата модификации",
"LabelFilename": "Имя файла",
"LabelFilterByUser": "Фильтр по Пользователю",
"LabelFindEpisodes": "Найти Эпизоды",
"LabelFilterByUser": "Фильтр по пользователю",
"LabelFindEpisodes": "Найти эпизоды",
"LabelFinished": "Закончен",
"LabelFolder": "Папка",
"LabelFolders": "Папки",
@@ -233,7 +234,7 @@
"LabelHardDeleteFile": "Жесткое удаление файла",
"LabelHour": "Часы",
"LabelIcon": "Иконка",
"LabelIncludeInTracklist": "Включать в Список воспроизведения",
"LabelIncludeInTracklist": "Включать в список воспроизведения",
"LabelIncomplete": "Не завершен",
"LabelInProgress": "В процессе",
"LabelInterval": "Интервал",
@@ -245,103 +246,103 @@
"LabelIntervalEvery6Hours": "Каждые 6 часов",
"LabelIntervalEveryDay": "Каждый день",
"LabelIntervalEveryHour": "Каждый час",
"LabelInvalidParts": "Неверные Части",
"LabelInvalidParts": "Неверные части",
"LabelItem": "Элемент",
"LabelLanguage": "Язык",
"LabelLanguageDefaultServer": "Язык Сервера по Умолчанию",
"LabelLastSeen": "Последнее Сканирование",
"LabelLastTime": "Последний по Времени",
"LabelLastUpdate": "Последний Обновленный",
"LabelLanguageDefaultServer": "Язык сервера по умолчанию",
"LabelLastSeen": "Последнее сканирование",
"LabelLastTime": "Последний по времени",
"LabelLastUpdate": "Последний обновленный",
"LabelLess": "Менее",
"LabelLibrariesAccessibleToUser": "Библиотеки Доступные для Пользователя",
"LabelLibrariesAccessibleToUser": "Библиотеки доступные для пользователя",
"LabelLibrary": "Библиотека",
"LabelLibraryItem": "Элемент Библиотеки",
"LabelLibraryName": "Имя Библиотеки",
"LabelLibraryItem": "Элемент библиотеки",
"LabelLibraryName": "Имя библиотеки",
"LabelLimit": "Лимит",
"LabelListenAgain": "Послушать Снова",
"LabelListenAgain": "Послушать снова",
"LabelLogLevelDebug": "Debug",
"LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Искать новые эпизоды после этой даты",
"LabelMediaPlayer": "Медиа Проигрыватель",
"LabelMediaType": "Тип Медиа",
"LabelMediaPlayer": "Медиа проигрыватель",
"LabelMediaType": "Тип медиа",
"LabelMetadataProvider": "Провайдер",
"LabelMetaTag": "Мета Тег",
"LabelMetaTag": "Мета тег",
"LabelMinute": "Минуты",
"LabelMissing": "Потеряно",
"LabelMissingParts": "Потерянные Части",
"LabelMissingParts": "Потерянные части",
"LabelMore": "Еще",
"LabelName": "Имя",
"LabelNarrator": "Читает",
"LabelNarrators": "Чтецы",
"LabelNew": "Новый",
"LabelNewestAuthors": "Новые Авторы",
"LabelNewestEpisodes": "Новые Эпизоды",
"LabelNewPassword": "Новый Пароль",
"LabelNextBackupDate": "Next backup date",
"LabelNextScheduledRun": "Next scheduled run",
"LabelNewestAuthors": "Новые авторы",
"LabelNewestEpisodes": "Новые эпизоды",
"LabelNewPassword": "Новый пароль",
"LabelNextBackupDate": "Следующая дата бэкапирования",
"LabelNextScheduledRun": "Следущий запланированный запуск",
"LabelNotes": "Заметки",
"LabelNotFinished": "Не Завершено",
"LabelNotFinished": "Не завершено",
"LabelNotificationAppriseURL": "URL(ы) для извещений",
"LabelNotificationAvailableVariables": "Доступные переменные",
"LabelNotificationBodyTemplate": "Шаблон Тела",
"LabelNotificationEvent": "Событие Оповещения",
"LabelNotificationBodyTemplate": "Шаблон тела",
"LabelNotificationEvent": "Событие оповещения",
"LabelNotificationsMaxFailedAttempts": "Макс. попыток",
"LabelNotificationsMaxFailedAttemptsHelp": "Уведомления будут выключены если произойдет ошибка отправки данное количество раз",
"LabelNotificationsMaxQueueSize": "Макс. размер очереди для событий уведомлений",
"LabelNotificationsMaxQueueSizeHelp": "События ограничены 1 в секунду. События будут игнорированы если в очереди максимальное количество. Это предотвращает спам сообщениями.",
"LabelNotificationTitleTemplate": "Шаблон Заголовка",
"LabelNotStarted": "Не Запущено",
"LabelNumberOfBooks": "Количество Книг",
"LabelNotificationTitleTemplate": "Шаблон заголовка",
"LabelNotStarted": "Не запущено",
"LabelNumberOfBooks": "Количество книг",
"LabelNumberOfEpisodes": "# Эпизодов",
"LabelOpenRSSFeed": "Открыть RSS-канал",
"LabelOverwrite": "Перезаписать",
"LabelPassword": "Пароль",
"LabelPath": "Путь",
"LabelPermissionsAccessAllLibraries": "Есть Доступ ко всем Библиотекам",
"LabelPermissionsAccessAllTags": "Есть Доступ ко всем Тегам",
"LabelPermissionsAccessExplicitContent": "Есть Доступ к Явному Содержимому",
"LabelPermissionsDelete": "Может Удалять",
"LabelPermissionsDownload": "Может Скачивать",
"LabelPermissionsUpdate": "Может Обновлять",
"LabelPermissionsUpload": "Может Закачивать",
"LabelPhotoPathURL": "Путь к Фото/URL",
"LabelPermissionsAccessAllLibraries": "Есть доступ ко всем библиотекам",
"LabelPermissionsAccessAllTags": "Есть доступ ко всем тегам",
"LabelPermissionsAccessExplicitContent": "Есть доступ к явному содержимому",
"LabelPermissionsDelete": "Может удалять",
"LabelPermissionsDownload": "Может скачивать",
"LabelPermissionsUpdate": "Может обновлять",
"LabelPermissionsUpload": "Может закачивать",
"LabelPhotoPathURL": "Путь к фото/URL",
"LabelPlaylists": "Плейлисты",
"LabelPlayMethod": "Метод Воспроизведения",
"LabelPlayMethod": "Метод воспроизведения",
"LabelPodcast": "Подкаст",
"LabelPodcasts": "Подкасты",
"LabelPodcastType": "Podcast Type",
"LabelPrefixesToIgnore": "Игнорируемые Префиксы (без учета регистра)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelPodcastType": "Тип подкаста",
"LabelPrefixesToIgnore": "Игнорируемые префиксы (без учета регистра)",
"LabelPreventIndexing": "Запретить индексацию фида каталогами подкастов iTunes и Google",
"LabelProgress": "Прогресс",
"LabelProvider": "Провайдер",
"LabelPubDate": "Дата Публикации",
"LabelPubDate": "Дата публикации",
"LabelPublisher": "Издатель",
"LabelPublishYear": "Год Публикации",
"LabelRecentlyAdded": "Недавно Добавленные",
"LabelRecentSeries": "Последние Серии",
"LabelPublishYear": "Год публикации",
"LabelRecentlyAdded": "Недавно добавленные",
"LabelRecentSeries": "Последние серии",
"LabelRecommended": "Рекомендованное",
"LabelRegion": "Регион",
"LabelReleaseDate": "Дата Выхода",
"LabelReleaseDate": "Дата выхода",
"LabelRemoveCover": "Удалить обложку",
"LabelRSSFeedCustomOwnerEmail": "Пользовательский Email владельца",
"LabelRSSFeedCustomOwnerName": "Пользовательское Имя владельца",
"LabelRSSFeedOpen": "Открыть RSS-канал",
"LabelRSSFeedPreventIndexing": "Запретить индексирование",
"LabelRSSFeedSlug": "Встроить RSS-канал",
"LabelRSSFeedURL": "URL RSS-канала",
"LabelRssFeedCustomOwnerName": "Custom owner Name",
"LabelRssFeedCustomOwnerEmail": "Custom owner Email",
"LabelRssFeedPreventIndexing": "Prevent Indexing",
"LabelSearchTerm": "Поисковый Запрос",
"LabelSearchTitle": "Поиск по Названию",
"LabelSearchTitleOrASIN": "Поиск по Названию или ASIN",
"LabelSearchTerm": "Поисковый запрос",
"LabelSearchTitle": "Поиск по названию",
"LabelSearchTitleOrASIN": "Поиск по названию или ASIN",
"LabelSeason": "Сезон",
"LabelSequence": "Последовательность",
"LabelSeries": "Серия",
"LabelSeriesName": "Имя Серии",
"LabelSeriesProgress": "Прогресс Серии",
"LabelSeriesName": "Имя серии",
"LabelSeriesProgress": "Прогресс серии",
"LabelSettingsBookshelfViewHelp": "Конструкция с деревянными полками",
"LabelSettingsChromecastSupport": "Поддержка Chromecast",
"LabelSettingsDateFormat": "Формат Даты",
"LabelSettingsDisableWatcher": "Отключить Отслеживание",
"LabelSettingsDateFormat": "Формат даты",
"LabelSettingsDisableWatcher": "Отключить отслеживание",
"LabelSettingsDisableWatcherForLibrary": "Отключить отслеживание для библиотеки",
"LabelSettingsDisableWatcherHelp": "Отключает автоматическое добавление/обновление элементов, когда обнаружено изменение файлов. *Требуется перезапуск сервера",
"LabelSettingsEnableEReader": "Включить e-reader для всех пользователей",
@@ -350,7 +351,7 @@
"LabelSettingsExperimentalFeaturesHelp": "Функционал в разработке на который Вы могли бы дать отзыв или помочь в тестировании. Нажмите для открытия обсуждения на github.",
"LabelSettingsFindCovers": "Найти обложки",
"LabelSettingsFindCoversHelp": "Если у Ваших аудиокниг нет встроенной обложки или файла обложки в папке книги, то сканер попробует найти обложку.<br>Примечание: Это увеличит время сканирования",
"LabelSettingsHomePageBookshelfView": "Вид книжной полки на Домашней Странице",
"LabelSettingsHomePageBookshelfView": "Вид книжной полки на Домашней странице",
"LabelSettingsLibraryBookshelfView": "Вид книжной полки в Библиотеке",
"LabelSettingsOverdriveMediaMarkers": "Overdrive Media Markers для глав",
"LabelSettingsOverdriveMediaMarkersHelp": "MP3 файлы из Overdrive поставляется с таймингами глав, встроенными в виде пользовательских метаданных. При включении этого параметра эти теги будут автоматически использоваться для таймингов глав",
@@ -372,59 +373,60 @@
"LabelSettingsStoreCoversWithItemHelp": "По умолчанию обложки сохраняются в папке /metadata/items, при включении этой настройки обложка будет храниться в папке элемента. Будет сохраняться только один файл с именем \"cover\"",
"LabelSettingsStoreMetadataWithItem": "Хранить метаинформацию с элементом",
"LabelSettingsStoreMetadataWithItemHelp": "По умолчанию метаинформация сохраняется в папке /metadata/items, при включении этой настройки метаинформация будет храниться в папке элемента. Используется расширение файла .abs",
"LabelSettingsTimeFormat": "Time Format",
"LabelShowAll": "Показать Все",
"LabelSettingsTimeFormat": "Формат времени",
"LabelShowAll": "Показать все",
"LabelSize": "Размер",
"LabelSleepTimer": "Таймер сна",
"LabelStart": "Начало",
"LabelStarted": "Начат",
"LabelStartedAt": "Начато В",
"LabelStartTime": "Время Начала",
"LabelStatsAudioTracks": "Аудио Треки",
"LabelStartTime": "Время начала",
"LabelStatsAudioTracks": "Аудио треки",
"LabelStatsAuthors": "Авторы",
"LabelStatsBestDay": "Лучший День",
"LabelStatsDailyAverage": "В среднем в День",
"LabelStatsDailyAverage": "В среднем в день",
"LabelStatsDays": "Дней",
"LabelStatsDaysListened": "Дней Прослушано",
"LabelStatsDaysListened": "Дней прослушано",
"LabelStatsHours": "Часов",
"LabelStatsInARow": "в строке",
"LabelStatsItemsFinished": "Элементов Завершено",
"LabelStatsItemsInLibrary": "Элементов в Библиотеке",
"LabelStatsInARow": "беспрерывно",
"LabelStatsItemsFinished": "Элементов завершено",
"LabelStatsItemsInLibrary": "Элементов в библиотеке",
"LabelStatsMinutes": "минут",
"LabelStatsMinutesListening": "Минут Прослушано",
"LabelStatsOverallDays": "Всего Дней",
"LabelStatsOverallHours": "Всего Часов",
"LabelStatsWeekListening": "Недель Прослушано",
"LabelStatsMinutesListening": "Минут прослушано",
"LabelStatsOverallDays": "Всего дней",
"LabelStatsOverallHours": "Всего сасов",
"LabelStatsWeekListening": "Недель прослушано",
"LabelSubtitle": "Подзаголовок",
"LabelSupportedFileTypes": "Поддерживаемые типы файлов",
"LabelTag": "Тег",
"LabelTags": "Теги",
"LabelTagsAccessibleToUser": "Теги Доступные для Пользователя",
"LabelTasks": "Tasks Running",
"LabelTimeListened": "Время Прослушивания",
"LabelTimeListenedToday": "Время Прослушивания Сегодня",
"LabelTagsAccessibleToUser": "Теги доступные для пользователя",
"LabelTasks": "Запущенные задачи",
"LabelTimeListened": "Время прослушивания",
"LabelTimeListenedToday": "Время прослушивания сегодня",
"LabelTimeRemaining": "{0} осталось",
"LabelTimeToShift": "Время смещения в сек.",
"LabelTitle": "Название",
"LabelToolsEmbedMetadata": "Встроить Метаданные",
"LabelToolsEmbedMetadata": "Встроить метаданные",
"LabelToolsEmbedMetadataDescription": "Встроить метаданные в аудио файлы, включая обложку и главы.",
"LabelToolsMakeM4b": "Создать M4B Файл Аудиокниги",
"LabelToolsMakeM4b": "Создать M4B файл аудиокниги",
"LabelToolsMakeM4bDescription": "Создает .M4B файл аудиокниги с встроенными метаданными, обложкой и главами.",
"LabelToolsSplitM4b": "Разделить M4B на MP3 файлы",
"LabelToolsSplitM4bDescription": "Создает MP3 файла из M4B, разделяет на главы с встроенными метаданными, обложкой и главами.",
"LabelTotalDuration": "Общая Длина",
"LabelTotalTimeListened": "Всего Прослушано",
"LabelTotalDuration": "Общая длина",
"LabelTotalTimeListened": "Всего прослушано",
"LabelTrackFromFilename": "Трек из Имени файла",
"LabelTrackFromMetadata": "Трек из Метаданных",
"LabelTracks": "Треков",
"LabelTracksMultiTrack": "Мультитрек",
"LabelTracksSingleTrack": "Один трек",
"LabelType": "Тип",
"LabelUnabridged": "Unabridged",
"LabelUnknown": "Неизвестно",
"LabelUpdateCover": "Обновить Обложку",
"LabelUpdateCover": "Обновить обложку",
"LabelUpdateCoverHelp": "Позволяет перезаписывать существующие обложки для выбранных книг если будут найдены",
"LabelUpdatedAt": "Обновлено в",
"LabelUpdateDetails": "Обновить Подробности",
"LabelUpdateDetails": "Обновить подробности",
"LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены",
"LabelUploaderDragAndDrop": "Перетащите файлы или каталоги",
"LabelUploaderDropFiles": "Перетащите файлы",
@@ -439,10 +441,10 @@
"LabelViewQueue": "Очередь воспроизведения",
"LabelVolume": "Громкость",
"LabelWeekdaysToRun": "Дни недели для запуска",
"LabelYourAudiobookDuration": "Продолжительность Вашей Книги",
"LabelYourBookmarks": "Ваши Закладки",
"LabelYourPlaylists": "Ваши Плейлисты",
"LabelYourProgress": "Ваш Прогресс",
"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> сохраняют файлы из папок библиотек.",
@@ -482,8 +484,8 @@
"MessageForceReScanDescription": "будет сканировать все файлы снова, как свежее сканирование. Теги ID3 аудиофайлов, OPF-файлы и текстовые файлы будут сканироваться как новые.",
"MessageImportantNotice": "Важное замечание!",
"MessageInsertChapterBelow": "Вставить главу ниже",
"MessageItemsSelected": "{0} Элементов Выделено",
"MessageItemsUpdated": "{0} Элементов Обновлено",
"MessageItemsSelected": "{0} Элементов выделено",
"MessageItemsUpdated": "{0} Элементов обновлено",
"MessageJoinUsOn": "Присоединяйтесь к нам в",
"MessageListeningSessionsInTheLastYear": "{0} сеансов прослушивания в прошлом году",
"MessageLoading": "Загрузка...",
@@ -491,36 +493,36 @@
"MessageM4BFailed": "M4B Ошибка!",
"MessageM4BFinished": "M4B Завершено!",
"MessageMapChapterTitles": "Сопоставление названий глав с существующими главами аудиокниги без корректировки временных меток",
"MessageMarkAsFinished": "Отметить, как Завершенную",
"MessageMarkAsNotFinished": "Отметить, как Не Завершенную",
"MessageMarkAsFinished": "Отметить, как завершенную",
"MessageMarkAsNotFinished": "Отметить, как не завершенную",
"MessageMatchBooksDescription": "попытается сопоставить книги в библиотеке с книгой из выбранного поставщика поиска и заполнить пустые детали и обложку. Не перезаписывает сведения.",
"MessageNoAudioTracks": "Нет аудио треков",
"MessageNoAuthors": "Нет Авторов",
"MessageNoBackups": "Нет Бэкапов",
"MessageNoBookmarks": "Нет Закладок",
"MessageNoChapters": "Нет Глав",
"MessageNoCollections": "Нет Коллекций",
"MessageNoAuthors": "Нет авторов",
"MessageNoBackups": "Нет бэкапов",
"MessageNoBookmarks": "Нет закладок",
"MessageNoChapters": "Нет глав",
"MessageNoCollections": "Нет коллекций",
"MessageNoCoversFound": "Обложек не найдено",
"MessageNoDescription": "Нет описания",
"MessageNoDownloadsQueued": "No downloads queued",
"MessageNoDownloadsInProgress": "No downloads currently in progress",
"MessageNoDownloadsInProgress": "В настоящее время загрузка не выполняется",
"MessageNoDownloadsQueued": "Нет загрузок в очереди",
"MessageNoEpisodeMatchesFound": "Совпадения эпизодов не найдены",
"MessageNoEpisodes": "Нет Эпизодов",
"MessageNoEpisodes": "Нет эпизодов",
"MessageNoFoldersAvailable": "Нет доступных папок",
"MessageNoGenres": "Нет Жанров",
"MessageNoIssues": "Нет Проблем",
"MessageNoItems": "Нет Элементов",
"MessageNoGenres": "Нет жанров",
"MessageNoIssues": "Нет проблем",
"MessageNoItems": "Нет элементов",
"MessageNoItemsFound": "Элементы не найдены",
"MessageNoListeningSessions": "Нет Сеансов Прослушивания",
"MessageNoLogs": "Нет Логов",
"MessageNoMediaProgress": "Нет Прогресса Медиа",
"MessageNoNotifications": "Нет Уведомлений",
"MessageNoListeningSessions": "Нет сеансов прослушивания",
"MessageNoLogs": "Нет логов",
"MessageNoMediaProgress": "Нет прогресса медиа",
"MessageNoNotifications": "Нет уведомлений",
"MessageNoPodcastsFound": "Подкасты не найдены",
"MessageNoResults": "Нет Результатов",
"MessageNoResults": "Нет результатов",
"MessageNoSearchResultsFor": "Нет результатов поиска для \"{0}\"",
"MessageNoSeries": "Нет Серий",
"MessageNoTags": "Нет Тегов",
"MessageNoTasksRunning": "No Tasks Running",
"MessageNoSeries": "Нет серий",
"MessageNoTags": "Нет тегов",
"MessageNoTasksRunning": "Нет выполняемых задач",
"MessageNotYetImplemented": "Пока не реализовано",
"MessageNoUpdateNecessary": "Обновление не требуется",
"MessageNoUpdatesWereNecessary": "Обновления не требовались",
@@ -546,7 +548,7 @@
"MessageStartPlaybackAtTime": "Начать воспроизведение для \"{0}\" с {1}?",
"MessageThinking": "Думаю...",
"MessageUploaderItemFailed": "Не удалось загрузить",
"MessageUploaderItemSuccess": "Успешно Загружено!",
"MessageUploaderItemSuccess": "Успешно загружено!",
"MessageUploading": "Загрузка...",
"MessageValidCronExpression": "Верное cron выражение",
"MessageWatcherIsDisabledGlobally": "Наблюдатель отключен глобально в настройках сервера",
@@ -566,6 +568,7 @@
"PlaceholderNewFolderPath": "Путь к новой папке",
"PlaceholderNewPlaylist": "Новое название плейлиста",
"PlaceholderSearch": "Поиск...",
"PlaceholderSearchEpisode": "Search episode...",
"ToastAccountUpdateFailed": "Не удалось обновить учетную запись",
"ToastAccountUpdateSuccess": "Учетная запись обновлена",
"ToastAuthorImageRemoveFailed": "Не удалось удалить изображение",
@@ -635,4 +638,4 @@
"ToastSocketFailedToConnect": "Не удалось подключить сокет",
"ToastUserDeleteFailed": "Не удалось удалить пользователя",
"ToastUserDeleteSuccess": "Пользователь удален"
}
}

View File

@@ -20,7 +20,7 @@
"ButtonCreate": "创建",
"ButtonCreateBackup": "创建备份",
"ButtonDelete": "删除",
"ButtonDownloadQueue": "Queue",
"ButtonDownloadQueue": "下载队列",
"ButtonEdit": "编辑",
"ButtonEditChapters": "编辑章节",
"ButtonEditPodcast": "编辑播客",
@@ -93,15 +93,15 @@
"HeaderCollection": "收藏",
"HeaderCollectionItems": "收藏项目",
"HeaderCover": "封面",
"HeaderCurrentDownloads": "Current Downloads",
"HeaderCurrentDownloads": "当前下载",
"HeaderDetails": "详情",
"HeaderDownloadQueue": "Download Queue",
"HeaderDownloadQueue": "下载队列",
"HeaderEpisodes": "剧集",
"HeaderFiles": "文件",
"HeaderFindChapters": "查找章节",
"HeaderIgnoredFiles": "忽略的文件",
"HeaderItemFiles": "项目文件",
"HeaderItemMetadataUtils": "项目元数据管理程序",
"HeaderItemMetadataUtils": "项目元数据管理",
"HeaderLastListeningSession": "最后一次收听会话",
"HeaderLatestEpisodes": "最新剧集",
"HeaderLibraries": "媒体库",
@@ -129,8 +129,8 @@
"HeaderPreviewCover": "预览封面",
"HeaderRemoveEpisode": "移除剧集",
"HeaderRemoveEpisodes": "移除 {0} 剧集",
"HeaderRSSFeedGeneral": "RSS 详细信息",
"HeaderRSSFeedIsOpen": "RSS 源已打开",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderSavedMediaProgress": "保存媒体进度",
"HeaderSchedule": "计划任务",
"HeaderScheduleLibraryScans": "自动扫描媒体库",
@@ -142,7 +142,7 @@
"HeaderSettingsGeneral": "通用",
"HeaderSettingsScanner": "扫描",
"HeaderSleepTimer": "睡眠计时",
"HeaderStatsLargestItems": "Largest Items",
"HeaderStatsLargestItems": "最大的项目",
"HeaderStatsLongestItems": "项目时长(小时)",
"HeaderStatsMinutesListeningChart": "收听分钟数(最近7天)",
"HeaderStatsRecentSessions": "历史会话",
@@ -155,6 +155,7 @@
"HeaderUpdateLibrary": "更新媒体库",
"HeaderUsers": "用户",
"HeaderYourStats": "你的统计数据",
"LabelAbridged": "Abridged",
"LabelAccountType": "帐户类型",
"LabelAccountTypeAdmin": "管理员",
"LabelAccountTypeGuest": "来宾",
@@ -167,7 +168,7 @@
"LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表",
"LabelAll": "全部",
"LabelAllUsers": "所有用户",
"LabelAlreadyInYourLibrary": "Already in your library",
"LabelAlreadyInYourLibrary": "已存在你的库中",
"LabelAppend": "附加",
"LabelAuthor": "作者",
"LabelAuthorFirstLast": "作者 (姓 名)",
@@ -198,7 +199,7 @@
"LabelCronExpression": "计划任务表达式",
"LabelCurrent": "当前",
"LabelCurrently": "当前:",
"LabelCustomCronExpression": "Custom Cron Expression:",
"LabelCustomCronExpression": "自定义计划任务表达式:",
"LabelDatetime": "日期时间",
"LabelDescription": "描述",
"LabelDeselectAll": "全部取消选择",
@@ -216,7 +217,7 @@
"LabelEpisode": "剧集",
"LabelEpisodeTitle": "剧集标题",
"LabelEpisodeType": "剧集类型",
"LabelExample": "Example",
"LabelExample": "示例",
"LabelExplicit": "信息准确",
"LabelFeedURL": "源 URL",
"LabelFile": "文件",
@@ -278,8 +279,8 @@
"LabelNewestAuthors": "最新作者",
"LabelNewestEpisodes": "最新剧集",
"LabelNewPassword": "新密码",
"LabelNextBackupDate": "Next backup date",
"LabelNextScheduledRun": "Next scheduled run",
"LabelNextBackupDate": "下次备份日期",
"LabelNextScheduledRun": "下次任务运行",
"LabelNotes": "注释",
"LabelNotFinished": "未听完",
"LabelNotificationAppriseURL": "通知 URL(s)",
@@ -310,9 +311,9 @@
"LabelPlayMethod": "播放方法",
"LabelPodcast": "播客",
"LabelPodcasts": "播客",
"LabelPodcastType": "Podcast Type",
"LabelPodcastType": "播客类型",
"LabelPrefixesToIgnore": "忽略的前缀 (不区分大小写)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelPreventIndexing": "防止 iTunes Google 播客目录对你的源进行索引",
"LabelProgress": "进度",
"LabelProvider": "供应商",
"LabelPubDate": "出版日期",
@@ -324,12 +325,12 @@
"LabelRegion": "区域",
"LabelReleaseDate": "发布日期",
"LabelRemoveCover": "移除封面",
"LabelRSSFeedCustomOwnerEmail": "自定义所有者电子邮件",
"LabelRSSFeedCustomOwnerName": "自定义所有者名称",
"LabelRSSFeedOpen": "打开 RSS 源",
"LabelRSSFeedPreventIndexing": "防止索引",
"LabelRSSFeedSlug": "RSS 源段",
"LabelRSSFeedURL": "RSS 源 URL",
"LabelRssFeedCustomOwnerName": "Custom owner Name",
"LabelRssFeedCustomOwnerEmail": "Custom owner Email",
"LabelRssFeedPreventIndexing": "Prevent Indexing",
"LabelSearchTerm": "搜索项",
"LabelSearchTitle": "搜索标题",
"LabelSearchTitleOrASIN": "搜索标题或 ASIN",
@@ -372,7 +373,7 @@
"LabelSettingsStoreCoversWithItemHelp": "默认情况下封面存储在/metadata/items文件夹中, 启用此设置将存储封面在你媒体项目文件夹中. 只保留一个名为 \"cover\" 的文件",
"LabelSettingsStoreMetadataWithItem": "存储项目元数据",
"LabelSettingsStoreMetadataWithItemHelp": "默认情况下元数据文件存储在/metadata/items文件夹中, 启用此设置将存储元数据在你媒体项目文件夹中. 使 .abs 文件护展名",
"LabelSettingsTimeFormat": "Time Format",
"LabelSettingsTimeFormat": "时间格式",
"LabelShowAll": "全部显示",
"LabelSize": "文件大小",
"LabelSleepTimer": "睡眠定时",
@@ -400,7 +401,7 @@
"LabelTag": "标签",
"LabelTags": "标签",
"LabelTagsAccessibleToUser": "用户可访问的标签",
"LabelTasks": "Tasks Running",
"LabelTasks": "正在运行的任务",
"LabelTimeListened": "收听时间",
"LabelTimeListenedToday": "今日收听的时间",
"LabelTimeRemaining": "剩余 {0}",
@@ -420,6 +421,7 @@
"LabelTracksMultiTrack": "多轨",
"LabelTracksSingleTrack": "单轨",
"LabelType": "类型",
"LabelUnabridged": "Unabridged",
"LabelUnknown": "未知",
"LabelUpdateCover": "更新封面",
"LabelUpdateCoverHelp": "找到匹配项时允许覆盖所选书籍存在的封面",
@@ -502,8 +504,8 @@
"MessageNoCollections": "没有收藏",
"MessageNoCoversFound": "没有找到封面",
"MessageNoDescription": "没有描述",
"MessageNoDownloadsQueued": "No downloads queued",
"MessageNoDownloadsInProgress": "No downloads currently in progress",
"MessageNoDownloadsInProgress": "当前没有正在进行的下载",
"MessageNoDownloadsQueued": "下载队列无任务",
"MessageNoEpisodeMatchesFound": "没有找到任何剧集匹配项",
"MessageNoEpisodes": "没有剧集",
"MessageNoFoldersAvailable": "没有可用文件夹",
@@ -520,7 +522,7 @@
"MessageNoSearchResultsFor": "没有搜索到结果 \"{0}\"",
"MessageNoSeries": "无系列",
"MessageNoTags": "无标签",
"MessageNoTasksRunning": "No Tasks Running",
"MessageNoTasksRunning": "没有正在运行的任务",
"MessageNotYetImplemented": "尚未实施",
"MessageNoUpdateNecessary": "无需更新",
"MessageNoUpdatesWereNecessary": "无需更新",
@@ -566,6 +568,7 @@
"PlaceholderNewFolderPath": "输入文件夹路径",
"PlaceholderNewPlaylist": "输入播放列表名称",
"PlaceholderSearch": "查找..",
"PlaceholderSearchEpisode": "Search episode..",
"ToastAccountUpdateFailed": "账户更新失败",
"ToastAccountUpdateSuccess": "帐户已更新",
"ToastAuthorImageRemoveFailed": "作者图像删除失败",
@@ -635,4 +638,4 @@
"ToastSocketFailedToConnect": "网络连接失败",
"ToastUserDeleteFailed": "删除用户失败",
"ToastUserDeleteSuccess": "用户已删除"
}
}

BIN
images/DemoLibrary.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
"version": "2.2.16",
"version": "2.2.18",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
"version": "2.2.16",
"version": "2.2.18",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.2.16",
"version": "2.2.18",
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
"scripts": {

View File

@@ -49,7 +49,7 @@ Check out the [API documentation](https://api.audiobookshelf.org/)
<br />
<img alt="Library Screenshot" src="https://github.com/advplyr/audiobookshelf/raw/master/images/LibraryStreamSquare.png" />
<img alt="Library Screenshot" src="https://github.com/advplyr/audiobookshelf/raw/master/images/DemoLibrary.png" />
<br />

View File

@@ -256,13 +256,13 @@ class MeController {
}
// GET: api/me/items-in-progress
async getAllLibraryItemsInProgress(req, res) {
getAllLibraryItemsInProgress(req, res) {
const limit = !isNaN(req.query.limit) ? Number(req.query.limit) || 25 : 25
var itemsInProgress = []
let itemsInProgress = []
for (const mediaProgress of req.user.mediaProgress) {
if (!mediaProgress.isFinished && mediaProgress.progress > 0) {
const libraryItem = await this.db.getLibraryItem(mediaProgress.libraryItemId)
if (!mediaProgress.isFinished && (mediaProgress.progress > 0 || mediaProgress.ebookProgress > 0)) {
const libraryItem = this.db.getLibraryItem(mediaProgress.libraryItemId)
if (libraryItem) {
if (mediaProgress.episodeId && libraryItem.mediaType === 'podcast') {
const episode = libraryItem.media.episodes.find(ep => ep.id === mediaProgress.episodeId)

View File

@@ -90,9 +90,19 @@ class MiscController {
// GET: api/tasks
getTasks(req, res) {
res.json({
const includeArray = (req.query.include || '').split(',')
const data = {
tasks: this.taskManager.tasks.map(t => t.toJSON())
})
}
if (includeArray.includes('queue')) {
data.queuedTaskData = {
embedMetadata: this.audioMetadataManager.getQueuedTaskData()
}
}
res.json(data)
}
// PATCH: api/settings (admin)

View File

@@ -3,14 +3,8 @@ const Logger = require('../Logger')
class ToolsController {
constructor() { }
// POST: api/tools/item/:id/encode-m4b
async encodeM4b(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error('[MiscController] encodeM4b: Non-admin user attempting to make m4b', req.user)
return res.sendStatus(403)
}
if (req.libraryItem.isMissing || req.libraryItem.isInvalid) {
Logger.error(`[MiscController] encodeM4b: library item not found or invalid ${req.params.id}`)
return res.status(404).send('Audiobook not found')
@@ -34,11 +28,6 @@ class ToolsController {
// DELETE: api/tools/item/:id/encode-m4b
async cancelM4bEncode(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error('[MiscController] cancelM4bEncode: Non-admin user attempting to cancel m4b encode', req.user)
return res.sendStatus(403)
}
const workerTask = this.abMergeManager.getPendingTaskByLibraryItemId(req.params.id)
if (!workerTask) return res.sendStatus(404)
@@ -49,14 +38,14 @@ class ToolsController {
// POST: api/tools/item/:id/embed-metadata
async embedAudioFileMetadata(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryItemController] Non-root user attempted to update audio metadata`, req.user)
return res.sendStatus(403)
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
Logger.error(`[ToolsController] Invalid library item`)
return res.sendStatus(500)
}
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
Logger.error(`[LibraryItemController] Invalid library item`)
return res.sendStatus(500)
if (this.audioMetadataManager.getIsLibraryItemQueuedOrProcessing(req.libraryItem.id)) {
Logger.error(`[ToolsController] Library item (${req.libraryItem.id}) is already in queue or processing`)
return res.status(500).send('Library item is already in queue or processing')
}
const options = {
@@ -67,16 +56,66 @@ class ToolsController {
res.sendStatus(200)
}
itemMiddleware(req, res, next) {
var item = this.db.libraryItems.find(li => li.id === req.params.id)
if (!item || !item.media) return res.sendStatus(404)
// POST: api/tools/batch/embed-metadata
async batchEmbedMetadata(req, res) {
const libraryItemIds = req.body.libraryItemIds || []
if (!libraryItemIds.length) {
return res.status(400).send('Invalid request payload')
}
// Check user can access this library item
if (!req.user.checkCanAccessLibraryItem(item)) {
const libraryItems = []
for (const libraryItemId of libraryItemIds) {
const libraryItem = this.db.getLibraryItem(libraryItemId)
if (!libraryItem) {
Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not found`)
return res.sendStatus(404)
}
// Check user can access this library item
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not accessible to user`, req.user)
return res.sendStatus(403)
}
if (libraryItem.isMissing || !libraryItem.hasAudioFiles || !libraryItem.isBook) {
Logger.error(`[ToolsController] Batch embed invalid library item (${libraryItemId})`)
return res.sendStatus(500)
}
if (this.audioMetadataManager.getIsLibraryItemQueuedOrProcessing(libraryItemId)) {
Logger.error(`[ToolsController] Batch embed library item (${libraryItemId}) is already in queue or processing`)
return res.status(500).send('Library item is already in queue or processing')
}
libraryItems.push(libraryItem)
}
const options = {
forceEmbedChapters: req.query.forceEmbedChapters === '1',
backup: req.query.backup === '1'
}
this.audioMetadataManager.handleBatchEmbed(req.user, libraryItems, options)
res.sendStatus(200)
}
middleware(req, res, next) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryItemController] Non-root user attempted to access tools route`, req.user)
return res.sendStatus(403)
}
req.libraryItem = item
if (req.params.id) {
const item = this.db.libraryItems.find(li => li.id === req.params.id)
if (!item || !item.media) return res.sendStatus(404)
// Check user can access this library item
if (!req.user.checkCanAccessLibraryItem(item)) {
return res.sendStatus(403)
}
req.libraryItem = item
}
next()
}
}

View File

@@ -11,9 +11,9 @@ class UserController {
findAll(req, res) {
if (!req.user.isAdminOrUp) return res.sendStatus(403)
const hideRootToken = !req.user.isRoot
const users = this.db.users.map(u => this.userJsonWithItemProgressDetails(u, hideRootToken))
res.json({
users: users
// Minimal toJSONForBrowser does not include mediaProgress and bookmarks
users: this.db.users.map(u => u.toJSONForBrowser(hideRootToken, true))
})
}

View File

@@ -5,18 +5,42 @@ const Logger = require('../Logger')
const fs = require('../libs/fsExtra')
const { secondsToTimestamp } = require('../utils/index')
const toneHelpers = require('../utils/toneHelpers')
const filePerms = require('../utils/filePerms')
const Task = require('../objects/Task')
class AudioMetadataMangaer {
constructor(db, taskManager) {
this.db = db
this.taskManager = taskManager
this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items')
this.MAX_CONCURRENT_TASKS = 1
this.tasksRunning = []
this.tasksQueued = []
}
/**
* Get queued task data
* @return {Array}
*/
getQueuedTaskData() {
return this.tasksQueued.map(t => t.data)
}
getIsLibraryItemQueuedOrProcessing(libraryItemId) {
return this.tasksQueued.some(t => t.data.libraryItemId === libraryItemId) || this.tasksRunning.some(t => t.data.libraryItemId === libraryItemId)
}
getToneMetadataObjectForApi(libraryItem) {
return toneHelpers.getToneMetadataObject(libraryItem)
return toneHelpers.getToneMetadataObject(libraryItem, libraryItem.media.chapters, libraryItem.media.tracks.length)
}
handleBatchEmbed(user, libraryItems, options = {}) {
libraryItems.forEach((li) => {
this.updateMetadataForItem(user, li, options)
})
}
async updateMetadataForItem(user, libraryItem, options = {}) {
@@ -25,99 +49,144 @@ class AudioMetadataMangaer {
const audioFiles = libraryItem.media.includedAudioFiles
const itemAudioMetadataPayload = {
userId: user.id,
const task = new Task()
const itemCachePath = Path.join(this.itemsCacheDir, libraryItem.id)
// Only writing chapters for single file audiobooks
const chapters = (audioFiles.length == 1 || forceEmbedChapters) ? libraryItem.media.chapters.map(c => ({ ...c })) : null
// Create task
const taskData = {
libraryItemId: libraryItem.id,
startedAt: Date.now(),
audioFiles: audioFiles.map(af => ({ index: af.index, ino: af.ino, filename: af.metadata.filename }))
libraryItemPath: libraryItem.path,
userId: user.id,
audioFiles: audioFiles.map(af => (
{
index: af.index,
ino: af.ino,
filename: af.metadata.filename,
path: af.metadata.path,
cachePath: Path.join(itemCachePath, af.metadata.filename)
}
)),
coverPath: libraryItem.media.coverPath,
metadataObject: toneHelpers.getToneMetadataObject(libraryItem, chapters, audioFiles.length),
itemCachePath,
chapters,
options: {
forceEmbedChapters,
backupFiles
}
}
const taskDescription = `Embedding metadata in audiobook "${libraryItem.media.metadata.title}".`
task.setData('embed-metadata', 'Embedding Metadata', taskDescription, taskData)
SocketAuthority.emitter('audio_metadata_started', itemAudioMetadataPayload)
if (this.tasksRunning.length >= this.MAX_CONCURRENT_TASKS) {
Logger.info(`[AudioMetadataManager] Queueing embed metadata for audiobook "${libraryItem.media.metadata.title}"`)
SocketAuthority.adminEmitter('metadata_embed_queue_update', {
libraryItemId: libraryItem.id,
queued: true
})
this.tasksQueued.push(task)
} else {
this.runMetadataEmbed(task)
}
}
// Ensure folder for backup files
const itemCacheDir = Path.join(global.MetadataPath, `cache/items/${libraryItem.id}`)
async runMetadataEmbed(task) {
this.tasksRunning.push(task)
this.taskManager.addTask(task)
Logger.info(`[AudioMetadataManager] Starting metadata embed task`, task.description)
// Ensure item cache dir exists
let cacheDirCreated = false
if (!await fs.pathExists(itemCacheDir)) {
await fs.mkdir(itemCacheDir)
await filePerms.setDefault(itemCacheDir, true)
if (!await fs.pathExists(task.data.itemCachePath)) {
await fs.mkdir(task.data.itemCachePath)
cacheDirCreated = true
}
// Write chapters file
const toneJsonPath = Path.join(itemCacheDir, 'metadata.json')
// Create metadata json file
const toneJsonPath = Path.join(task.data.itemCachePath, 'metadata.json')
try {
const chapters = (audioFiles.length == 1 || forceEmbedChapters) ? libraryItem.media.chapters : null
await toneHelpers.writeToneMetadataJsonFile(libraryItem, chapters, toneJsonPath, audioFiles.length)
await fs.writeFile(toneJsonPath, JSON.stringify({ meta: task.data.metadataObject }, null, 2))
} catch (error) {
Logger.error(`[AudioMetadataManager] Write metadata.json failed`, error)
itemAudioMetadataPayload.failed = true
itemAudioMetadataPayload.error = 'Failed to write metadata.json'
SocketAuthority.emitter('audio_metadata_finished', itemAudioMetadataPayload)
task.setFailed('Failed to write metadata.json')
this.handleTaskFinished(task)
return
}
const results = []
for (const af of audioFiles) {
const result = await this.updateAudioFileMetadataWithTone(libraryItem, af, toneJsonPath, itemCacheDir, backupFiles)
results.push(result)
// Tag audio files
for (const af of task.data.audioFiles) {
SocketAuthority.adminEmitter('audiofile_metadata_started', {
libraryItemId: task.data.libraryItemId,
ino: af.ino
})
// Backup audio file
if (task.data.options.backupFiles) {
try {
const backupFilePath = Path.join(task.data.itemCachePath, af.filename)
await fs.copy(af.path, backupFilePath)
Logger.debug(`[AudioMetadataManager] Backed up audio file at "${backupFilePath}"`)
} catch (err) {
Logger.error(`[AudioMetadataManager] Failed to backup audio file "${af.path}"`, err)
}
}
const _toneMetadataObject = {
'ToneJsonFile': toneJsonPath,
'TrackNumber': af.index,
}
if (task.data.coverPath) {
_toneMetadataObject['CoverFile'] = task.data.coverPath
}
const success = await toneHelpers.tagAudioFile(af.path, _toneMetadataObject)
if (success) {
Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${af.path}"`)
}
SocketAuthority.adminEmitter('audiofile_metadata_finished', {
libraryItemId: task.data.libraryItemId,
ino: af.ino
})
}
// Remove temp cache file/folder if not backing up
if (!backupFiles) {
if (!task.data.options.backupFiles) {
// If cache dir was created from this then remove it
if (cacheDirCreated) {
await fs.remove(itemCacheDir)
await fs.remove(task.data.itemCachePath)
} else {
await fs.remove(toneJsonPath)
}
}
const elapsed = Date.now() - itemAudioMetadataPayload.startedAt
Logger.debug(`[AudioMetadataManager] Elapsed ${secondsToTimestamp(elapsed / 1000, true)}`)
itemAudioMetadataPayload.results = results
itemAudioMetadataPayload.elapsed = elapsed
itemAudioMetadataPayload.finishedAt = Date.now()
SocketAuthority.emitter('audio_metadata_finished', itemAudioMetadataPayload)
task.setFinished()
this.handleTaskFinished(task)
}
async updateAudioFileMetadataWithTone(libraryItem, audioFile, toneJsonPath, itemCacheDir, backupFiles) {
const resultPayload = {
libraryItemId: libraryItem.id,
index: audioFile.index,
ino: audioFile.ino,
filename: audioFile.metadata.filename
}
SocketAuthority.emitter('audiofile_metadata_started', resultPayload)
handleTaskFinished(task) {
this.taskManager.taskFinished(task)
this.tasksRunning = this.tasksRunning.filter(t => t.id !== task.id)
// Backup audio file
if (backupFiles) {
try {
const backupFilePath = Path.join(itemCacheDir, audioFile.metadata.filename)
await fs.copy(audioFile.metadata.path, backupFilePath)
Logger.debug(`[AudioMetadataManager] Backed up audio file at "${backupFilePath}"`)
} catch (err) {
Logger.error(`[AudioMetadataManager] Failed to backup audio file "${audioFile.metadata.path}"`, err)
}
if (this.tasksRunning.length < this.MAX_CONCURRENT_TASKS && this.tasksQueued.length) {
Logger.info(`[AudioMetadataManager] Task finished and dequeueing next task. ${this.tasksQueued} tasks queued.`)
const nextTask = this.tasksQueued.shift()
SocketAuthority.emitter('metadata_embed_queue_update', {
libraryItemId: nextTask.data.libraryItemId,
queued: false
})
this.runMetadataEmbed(nextTask)
} else if (this.tasksRunning.length > 0) {
Logger.debug(`[AudioMetadataManager] Task finished but not dequeueing. Currently running ${this.tasksRunning.length} tasks. ${this.tasksQueued.length} tasks queued.`)
} else {
Logger.debug(`[AudioMetadataManager] Task finished and no tasks remain in queue`)
}
const _toneMetadataObject = {
'ToneJsonFile': toneJsonPath,
'TrackNumber': audioFile.index,
}
if (libraryItem.media.coverPath) {
_toneMetadataObject['CoverFile'] = libraryItem.media.coverPath
}
resultPayload.success = await toneHelpers.tagAudioFile(audioFile.metadata.path, _toneMetadataObject)
if (resultPayload.success) {
Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${audioFile.metadata.path}"`)
}
SocketAuthority.emitter('audiofile_metadata_finished', resultPayload)
return resultPayload
}
}
module.exports = AudioMetadataMangaer

View File

@@ -4,11 +4,12 @@ const SocketAuthority = require('../SocketAuthority')
const fs = require('../libs/fsExtra')
const { getPodcastFeed } = require('../utils/podcastUtils')
const { downloadFile, removeFile } = require('../utils/fileUtils')
const { removeFile, downloadFile } = require('../utils/fileUtils')
const filePerms = require('../utils/filePerms')
const { levenshteinDistance } = require('../utils/index')
const opmlParser = require('../utils/parsers/parseOPML')
const prober = require('../utils/prober')
const ffmpegHelpers = require('../utils/ffmpegHelpers')
const LibraryFile = require('../objects/files/LibraryFile')
const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload')
@@ -93,10 +94,22 @@ class PodcastManager {
await filePerms.setDefault(this.currentDownload.libraryItem.path)
}
let success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath).then(() => true).catch((error) => {
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
return false
})
let success = false
if (this.currentDownload.urlFileExtension === 'mp3') {
// Download episode and tag it
success = await ffmpegHelpers.downloadPodcastEpisode(this.currentDownload).catch((error) => {
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
return false
})
} else {
// Download episode only
success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath).then(() => true).catch((error) => {
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
return false
})
}
if (success) {
success = await this.scanAddPodcastEpisodeAudioFile()
if (!success) {
@@ -126,22 +139,22 @@ class PodcastManager {
}
async scanAddPodcastEpisodeAudioFile() {
var libraryFile = await this.getLibraryFile(this.currentDownload.targetPath, this.currentDownload.targetRelPath)
const libraryFile = await this.getLibraryFile(this.currentDownload.targetPath, this.currentDownload.targetRelPath)
// TODO: Set meta tags on new audio file
var audioFile = await this.probeAudioFile(libraryFile)
const audioFile = await this.probeAudioFile(libraryFile)
if (!audioFile) {
return false
}
var libraryItem = this.db.libraryItems.find(li => li.id === this.currentDownload.libraryItem.id)
const libraryItem = this.db.libraryItems.find(li => li.id === this.currentDownload.libraryItem.id)
if (!libraryItem) {
Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`)
return false
}
var podcastEpisode = this.currentDownload.podcastEpisode
const podcastEpisode = this.currentDownload.podcastEpisode
podcastEpisode.audioFile = audioFile
libraryItem.media.addPodcastEpisode(podcastEpisode)
if (libraryItem.isInvalid) {

View File

@@ -1,6 +1,7 @@
const Path = require('path')
const { getId } = require('../utils/index')
const { sanitizeFilename } = require('../utils/fileUtils')
const globals = require('../utils/globals')
class PodcastEpisodeDownload {
constructor() {
@@ -40,8 +41,18 @@ class PodcastEpisodeDownload {
}
}
get urlFileExtension() {
const cleanUrl = this.url.split('?')[0] // Remove query string
return Path.extname(cleanUrl).substring(1).toLowerCase()
}
get fileExtension() {
const extname = this.urlFileExtension
if (globals.SupportedAudioTypes.includes(extname)) return extname
return 'mp3'
}
get targetFilename() {
return sanitizeFilename(`${this.podcastEpisode.title}.mp3`)
return sanitizeFilename(`${this.podcastEpisode.title}.${this.fileExtension}`)
}
get targetPath() {
return Path.join(this.libraryItem.path, this.targetFilename)
@@ -56,7 +67,14 @@ class PodcastEpisodeDownload {
setData(podcastEpisode, libraryItem, isAutoDownload, libraryId) {
this.id = getId('epdl')
this.podcastEpisode = podcastEpisode
this.url = encodeURI(podcastEpisode.enclosure.url)
const url = podcastEpisode.enclosure.url
if (decodeURIComponent(url) !== url) { // Already encoded
this.url = url
} else {
this.url = encodeURI(url)
}
this.libraryItem = libraryItem
this.isAutoDownload = isAutoDownload
this.createdAt = Date.now()

View File

@@ -82,7 +82,8 @@ class Stream extends EventEmitter {
AudioMimeType.WMA,
AudioMimeType.AIFF,
AudioMimeType.WEBM,
AudioMimeType.WEBMA
AudioMimeType.WEBMA,
AudioMimeType.AWB
]
}
get codecsToForceAAC() {

View File

@@ -1,4 +1,5 @@
const Path = require('path')
const Logger = require('../../Logger')
const { getId, cleanStringForSearch } = require('../../utils/index')
const AudioFile = require('../files/AudioFile')
const AudioTrack = require('../files/AudioTrack')
@@ -106,6 +107,10 @@ class PodcastEpisode {
get enclosureUrl() {
return this.enclosure ? this.enclosure.url : null
}
get pubYear() {
if (!this.publishedAt) return null
return new Date(this.publishedAt).getFullYear()
}
setData(data, index = 1) {
this.id = getId('ep')
@@ -128,6 +133,9 @@ class PodcastEpisode {
this.audioFile = audioFile
this.title = Path.basename(audioFile.metadata.filename, Path.extname(audioFile.metadata.filename))
this.index = index
this.setDataFromAudioMetaTags(audioFile.metaTags, true)
this.addedAt = Date.now()
this.updatedAt = Date.now()
}
@@ -164,5 +172,76 @@ class PodcastEpisode {
searchQuery(query) {
return cleanStringForSearch(this.title).includes(query)
}
setDataFromAudioMetaTags(audioFileMetaTags, overrideExistingDetails = false) {
if (!audioFileMetaTags) return false
const MetadataMapArray = [
{
tag: 'tagComment',
altTag: 'tagSubtitle',
key: 'description'
},
{
tag: 'tagSubtitle',
key: 'subtitle'
},
{
tag: 'tagDate',
key: 'pubDate'
},
{
tag: 'tagDisc',
key: 'season',
},
{
tag: 'tagTrack',
altTag: 'tagSeriesPart',
key: 'episode'
},
{
tag: 'tagTitle',
key: 'title'
},
{
tag: 'tagEpisodeType',
key: 'episodeType'
}
]
MetadataMapArray.forEach((mapping) => {
let value = audioFileMetaTags[mapping.tag]
let tagToUse = mapping.tag
if (!value && mapping.altTag) {
tagToUse = mapping.altTag
value = audioFileMetaTags[mapping.altTag]
}
if (value && typeof value === 'string') {
value = value.trim() // Trim whitespace
if (mapping.key === 'pubDate' && (!this.pubDate || overrideExistingDetails)) {
const pubJsDate = new Date(value)
if (pubJsDate && !isNaN(pubJsDate)) {
this.publishedAt = pubJsDate.valueOf()
this.pubDate = value
Logger.debug(`[PodcastEpisode] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${this[mapping.key]}`)
} else {
Logger.warn(`[PodcastEpisode] Mapping pubDate with tag ${tagToUse} has invalid date "${value}"`)
}
} else if (mapping.key === 'episodeType' && (!this.episodeType || overrideExistingDetails)) {
if (['full', 'trailer', 'bonus'].includes(value)) {
this.episodeType = value
Logger.debug(`[PodcastEpisode] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${this[mapping.key]}`)
} else {
Logger.warn(`[PodcastEpisode] Mapping episodeType with invalid value "${value}". Must be one of [full, trailer, bonus].`)
}
} else if (!this[mapping.key] || overrideExistingDetails) {
this[mapping.key] = value
Logger.debug(`[PodcastEpisode] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${this[mapping.key]}`)
}
}
})
}
}
module.exports = PodcastEpisode

View File

@@ -356,9 +356,9 @@ class Book {
}
updateAudioTracks(orderedFileData) {
var index = 1
let index = 1
this.audioFiles = orderedFileData.map((fileData) => {
var audioFile = this.audioFiles.find(af => af.ino === fileData.ino)
const audioFile = this.audioFiles.find(af => af.ino === fileData.ino)
audioFile.manuallyVerified = true
audioFile.invalid = false
audioFile.error = null
@@ -376,11 +376,11 @@ class Book {
this.rebuildTracks()
}
rebuildTracks(preferOverdriveMediaMarker) {
rebuildTracks() {
Logger.debug(`[Book] Tracks being rebuilt...!`)
this.audioFiles.sort((a, b) => a.index - b.index)
this.missingParts = []
this.setChapters(preferOverdriveMediaMarker)
this.setChapters()
this.checkUpdateMissingTracks()
}
@@ -412,14 +412,16 @@ class Book {
return wasUpdated
}
setChapters(preferOverdriveMediaMarker = false) {
setChapters() {
const preferOverdriveMediaMarker = !!global.ServerSettings.scannerPreferOverdriveMediaMarker
// If 1 audio file without chapters, then no chapters will be set
var includedAudioFiles = this.audioFiles.filter(af => !af.exclude)
const includedAudioFiles = this.audioFiles.filter(af => !af.exclude)
if (!includedAudioFiles.length) return
// If overdrive media markers are present and preferred, use those instead
if (preferOverdriveMediaMarker) {
var overdriveChapters = parseOverdriveMediaMarkersAsChapters(includedAudioFiles)
const overdriveChapters = parseOverdriveMediaMarkersAsChapters(includedAudioFiles)
if (overdriveChapters) {
Logger.info('[Book] Overdrive Media Markers and preference found! Using these for chapter definitions')
this.chapters = overdriveChapters
@@ -460,17 +462,26 @@ class Book {
})
}
} else if (includedAudioFiles.length > 1) {
const preferAudioMetadata = !!global.ServerSettings.scannerPreferAudioMetadata
// Build chapters from audio files
this.chapters = []
var currChapterId = 0
var currStartTime = 0
let currChapterId = 0
let currStartTime = 0
includedAudioFiles.forEach((file) => {
if (file.duration) {
let title = file.metadata.filename ? Path.basename(file.metadata.filename, Path.extname(file.metadata.filename)) : `Chapter ${currChapterId}`
// When prefer audio metadata server setting is set then use ID3 title tag as long as it is not the same as the book title
if (preferAudioMetadata && file.metaTags?.tagTitle && file.metaTags?.tagTitle !== this.metadata.title) {
title = file.metaTags.tagTitle
}
this.chapters.push({
id: currChapterId++,
start: currStartTime,
end: currStartTime + file.duration,
title: file.metadata.filename ? Path.basename(file.metadata.filename, Path.extname(file.metadata.filename)) : `Chapter ${currChapterId}`
title
})
currStartTime += file.duration
}

View File

@@ -175,6 +175,10 @@ class Podcast {
return null
}
findEpisodeWithInode(inode) {
return this.episodes.find(ep => ep.audioFile.ino === inode)
}
setData(mediaData) {
this.metadata = new PodcastMetadata()
if (mediaData.metadata) {
@@ -315,5 +319,13 @@ class Podcast {
getEpisode(episodeId) {
return this.episodes.find(ep => ep.id == episodeId)
}
// Audio file metadata tags map to podcast details
setMetadataFromAudioFile(overrideExistingDetails = false) {
if (!this.episodes.length) return false
const audioFile = this.episodes[0].audioFile
if (!audioFile?.metaTags) return false
return this.metadata.setDataFromAudioMetaTags(audioFile.metaTags, overrideExistingDetails)
}
}
module.exports = Podcast

View File

@@ -1,9 +1,12 @@
class AudioMetaTags {
constructor(metadata) {
this.tagAlbum = null
this.tagAlbumSort = null
this.tagArtist = null
this.tagArtistSort = null
this.tagGenre = null
this.tagTitle = null
this.tagTitleSort = null
this.tagSeries = null
this.tagSeriesPart = null
this.tagTrack = null
@@ -20,6 +23,9 @@ class AudioMetaTags {
this.tagIsbn = null
this.tagLanguage = null
this.tagASIN = null
this.tagItunesId = null
this.tagPodcastType = null
this.tagEpisodeType = null
this.tagOverdriveMediaMarker = null
this.tagOriginalYear = null
this.tagReleaseCountry = null
@@ -94,9 +100,12 @@ class AudioMetaTags {
construct(metadata) {
this.tagAlbum = metadata.tagAlbum || null
this.tagAlbumSort = metadata.tagAlbumSort || null
this.tagArtist = metadata.tagArtist || null
this.tagArtistSort = metadata.tagArtistSort || null
this.tagGenre = metadata.tagGenre || null
this.tagTitle = metadata.tagTitle || null
this.tagTitleSort = metadata.tagTitleSort || null
this.tagSeries = metadata.tagSeries || null
this.tagSeriesPart = metadata.tagSeriesPart || null
this.tagTrack = metadata.tagTrack || null
@@ -113,6 +122,9 @@ class AudioMetaTags {
this.tagIsbn = metadata.tagIsbn || null
this.tagLanguage = metadata.tagLanguage || null
this.tagASIN = metadata.tagASIN || null
this.tagItunesId = metadata.tagItunesId || null
this.tagPodcastType = metadata.tagPodcastType || null
this.tagEpisodeType = metadata.tagEpisodeType || null
this.tagOverdriveMediaMarker = metadata.tagOverdriveMediaMarker || null
this.tagOriginalYear = metadata.tagOriginalYear || null
this.tagReleaseCountry = metadata.tagReleaseCountry || null
@@ -128,9 +140,12 @@ class AudioMetaTags {
// Data parsed in prober.js
setData(payload) {
this.tagAlbum = payload.file_tag_album || null
this.tagAlbumSort = payload.file_tag_albumsort || null
this.tagArtist = payload.file_tag_artist || null
this.tagArtistSort = payload.file_tag_artistsort || null
this.tagGenre = payload.file_tag_genre || null
this.tagTitle = payload.file_tag_title || null
this.tagTitleSort = payload.file_tag_titlesort || null
this.tagSeries = payload.file_tag_series || null
this.tagSeriesPart = payload.file_tag_seriespart || null
this.tagTrack = payload.file_tag_track || null
@@ -147,6 +162,9 @@ class AudioMetaTags {
this.tagIsbn = payload.file_tag_isbn || null
this.tagLanguage = payload.file_tag_language || null
this.tagASIN = payload.file_tag_asin || null
this.tagItunesId = payload.file_tag_itunesid || null
this.tagPodcastType = payload.file_tag_podcasttype || null
this.tagEpisodeType = payload.file_tag_episodetype || null
this.tagOverdriveMediaMarker = payload.file_tag_overdrive_media_marker || null
this.tagOriginalYear = payload.file_tag_originalyear || null
this.tagReleaseCountry = payload.file_tag_releasecountry || null
@@ -166,9 +184,12 @@ class AudioMetaTags {
updateData(payload) {
const dataMap = {
tagAlbum: payload.file_tag_album || null,
tagAlbumSort: payload.file_tag_albumsort || null,
tagArtist: payload.file_tag_artist || null,
tagArtistSort: payload.file_tag_artistsort || null,
tagGenre: payload.file_tag_genre || null,
tagTitle: payload.file_tag_title || null,
tagTitleSort: payload.file_tag_titlesort || null,
tagSeries: payload.file_tag_series || null,
tagSeriesPart: payload.file_tag_seriespart || null,
tagTrack: payload.file_tag_track || null,
@@ -185,6 +206,9 @@ class AudioMetaTags {
tagIsbn: payload.file_tag_isbn || null,
tagLanguage: payload.file_tag_language || null,
tagASIN: payload.file_tag_asin || null,
tagItunesId: payload.file_tag_itunesid || null,
tagPodcastType: payload.file_tag_podcasttype || null,
tagEpisodeType: payload.file_tag_episodetype || null,
tagOverdriveMediaMarker: payload.file_tag_overdrive_media_marker || null,
tagOriginalYear: payload.file_tag_originalyear || null,
tagReleaseCountry: payload.file_tag_releasecountry || null,

View File

@@ -17,6 +17,7 @@ class BookMetadata {
this.asin = null
this.language = null
this.explicit = false
this.abridged = false
if (metadata) {
this.construct(metadata)
@@ -38,6 +39,7 @@ class BookMetadata {
this.asin = metadata.asin
this.language = metadata.language
this.explicit = !!metadata.explicit
this.abridged = !!metadata.abridged
}
toJSON() {
@@ -55,7 +57,8 @@ class BookMetadata {
isbn: this.isbn,
asin: this.asin,
language: this.language,
explicit: this.explicit
explicit: this.explicit,
abridged: this.abridged
}
}
@@ -76,7 +79,8 @@ class BookMetadata {
isbn: this.isbn,
asin: this.asin,
language: this.language,
explicit: this.explicit
explicit: this.explicit,
abridged: this.abridged
}
}
@@ -100,7 +104,8 @@ class BookMetadata {
authorName: this.authorName,
authorNameLF: this.authorNameLF,
narratorName: this.narratorName,
seriesName: this.seriesName
seriesName: this.seriesName,
abridged: this.abridged
}
}

View File

@@ -136,5 +136,74 @@ class PodcastMetadata {
}
return hasUpdates
}
setDataFromAudioMetaTags(audioFileMetaTags, overrideExistingDetails = false) {
const MetadataMapArray = [
{
tag: 'tagAlbum',
altTag: 'tagSeries',
key: 'title'
},
{
tag: 'tagArtist',
key: 'author'
},
{
tag: 'tagGenre',
key: 'genres'
},
{
tag: 'tagLanguage',
key: 'language'
},
{
tag: 'tagItunesId',
key: 'itunesId'
},
{
tag: 'tagPodcastType',
key: 'type',
}
]
const updatePayload = {}
MetadataMapArray.forEach((mapping) => {
let value = audioFileMetaTags[mapping.tag]
let tagToUse = mapping.tag
if (!value && mapping.altTag) {
value = audioFileMetaTags[mapping.altTag]
tagToUse = mapping.altTag
}
if (value && typeof value === 'string') {
value = value.trim() // Trim whitespace
if (mapping.key === 'genres' && (!this.genres.length || overrideExistingDetails)) {
updatePayload.genres = this.parseGenresTag(value)
Logger.debug(`[Podcast] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${updatePayload.genres.join(', ')}`)
} else if (!this[mapping.key] || overrideExistingDetails) {
updatePayload[mapping.key] = value
Logger.debug(`[Podcast] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${updatePayload[mapping.key]}`)
}
}
})
if (Object.keys(updatePayload).length) {
return this.update(updatePayload)
}
return false
}
parseGenresTag(genreTag) {
if (!genreTag || !genreTag.length) return []
const separators = ['/', '//', ';']
for (let i = 0; i < separators.length; i++) {
if (genreTag.includes(separators[i])) {
return genreTag.split(separators[i]).map(genre => genre.trim()).filter(g => !!g)
}
}
return [genreTag]
}
}
module.exports = PodcastMetadata

View File

@@ -10,6 +10,9 @@ class MediaProgress {
this.isFinished = false
this.hideFromContinueListening = false
this.ebookLocation = null // current cfi tag
this.ebookProgress = null // 0 to 1
this.lastUpdate = null
this.startedAt = null
this.finishedAt = null
@@ -29,6 +32,8 @@ class MediaProgress {
currentTime: this.currentTime,
isFinished: this.isFinished,
hideFromContinueListening: this.hideFromContinueListening,
ebookLocation: this.ebookLocation,
ebookProgress: this.ebookProgress,
lastUpdate: this.lastUpdate,
startedAt: this.startedAt,
finishedAt: this.finishedAt
@@ -44,13 +49,15 @@ class MediaProgress {
this.currentTime = progress.currentTime
this.isFinished = !!progress.isFinished
this.hideFromContinueListening = !!progress.hideFromContinueListening
this.ebookLocation = progress.ebookLocation || null
this.ebookProgress = progress.ebookProgress
this.lastUpdate = progress.lastUpdate
this.startedAt = progress.startedAt
this.finishedAt = progress.finishedAt || null
}
get inProgress() {
return !this.isFinished && this.progress > 0
return !this.isFinished && (this.progress > 0 || this.ebookLocation != null)
}
setData(libraryItemId, progress, episodeId = null) {
@@ -62,6 +69,8 @@ class MediaProgress {
this.currentTime = progress.currentTime || 0
this.isFinished = !!progress.isFinished || this.progress == 1
this.hideFromContinueListening = !!progress.hideFromContinueListening
this.ebookLocation = progress.ebookLocation
this.ebookProgress = Math.min(1, (progress.ebookProgress || 0))
this.lastUpdate = Date.now()
this.finishedAt = null
if (this.isFinished) {

View File

@@ -101,12 +101,12 @@ class User {
}
}
toJSONForBrowser() {
return {
toJSONForBrowser(hideRootToken = false, minimal = false) {
const json = {
id: this.id,
username: this.username,
type: this.type,
token: this.token,
token: (this.type === 'root' && hideRootToken) ? '' : this.token,
mediaProgress: this.mediaProgress ? this.mediaProgress.map(li => li.toJSON()) : [],
seriesHideFromContinueListening: [...this.seriesHideFromContinueListening],
bookmarks: this.bookmarks ? this.bookmarks.map(b => b.toJSON()) : [],
@@ -119,6 +119,11 @@ class User {
librariesAccessible: [...this.librariesAccessible],
itemTagsAccessible: [...this.itemTagsAccessible]
}
if (minimal) {
delete json.mediaProgress
delete json.bookmarks
}
return json
}
// Data broadcasted

View File

@@ -19,7 +19,7 @@ class Audible {
}
cleanResult(item) {
const { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language, runtimeLengthMin } = item
const { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language, runtimeLengthMin, formatType } = item
const series = []
if (seriesPrimary) {
@@ -54,7 +54,8 @@ class Audible {
language: language ? language.charAt(0).toUpperCase() + language.slice(1) : null,
duration: runtimeLengthMin && !isNaN(runtimeLengthMin) ? Number(runtimeLengthMin) : 0,
region: item.region || null,
rating: item.rating || null
rating: item.rating || null,
abridged: formatType === 'abridged'
}
}

View File

@@ -271,9 +271,10 @@ class ApiRouter {
//
// Tools Routes (Admin and up)
//
this.router.post('/tools/item/:id/encode-m4b', ToolsController.itemMiddleware.bind(this), ToolsController.encodeM4b.bind(this))
this.router.delete('/tools/item/:id/encode-m4b', ToolsController.itemMiddleware.bind(this), ToolsController.cancelM4bEncode.bind(this))
this.router.post('/tools/item/:id/embed-metadata', ToolsController.itemMiddleware.bind(this), ToolsController.embedAudioFileMetadata.bind(this))
this.router.post('/tools/item/:id/encode-m4b', ToolsController.middleware.bind(this), ToolsController.encodeM4b.bind(this))
this.router.delete('/tools/item/:id/encode-m4b', ToolsController.middleware.bind(this), ToolsController.cancelM4bEncode.bind(this))
this.router.post('/tools/item/:id/embed-metadata', ToolsController.middleware.bind(this), ToolsController.embedAudioFileMetadata.bind(this))
this.router.post('/tools/batch/embed-metadata', ToolsController.middleware.bind(this), ToolsController.batchEmbedMetadata.bind(this))
//
// RSS Feed Routes (Admin and up)
@@ -339,10 +340,7 @@ class ApiRouter {
// Helper Methods
//
userJsonWithItemProgressDetails(user, hideRootToken = false) {
const json = user.toJSONForBrowser()
if (json.type === 'root' && hideRootToken) {
json.token = ''
}
const json = user.toJSONForBrowser(hideRootToken)
json.mediaProgress = json.mediaProgress.map(lip => {
const libraryItem = this.db.libraryItems.find(li => li.id === lip.libraryItemId)
@@ -507,11 +505,16 @@ class ApiRouter {
// Create new authors if in payload
if (mediaMetadata.authors && mediaMetadata.authors.length) {
// TODO: validate authors
const newAuthors = []
for (let i = 0; i < mediaMetadata.authors.length; i++) {
if (mediaMetadata.authors[i].id.startsWith('new')) {
let author = this.db.authors.find(au => au.checkNameEquals(mediaMetadata.authors[i].name))
const authorName = (mediaMetadata.authors[i].name || '').trim()
if (!authorName) {
Logger.error(`[ApiRouter] Invalid author object, no name`, mediaMetadata.authors[i])
continue
}
if (!mediaMetadata.authors[i].id || mediaMetadata.authors[i].id.startsWith('new')) {
let author = this.db.authors.find(au => au.checkNameEquals(authorName))
if (!author) {
author = new Author()
author.setData(mediaMetadata.authors[i])
@@ -531,11 +534,16 @@ class ApiRouter {
// Create new series if in payload
if (mediaMetadata.series && mediaMetadata.series.length) {
// TODO: validate series
const newSeries = []
for (let i = 0; i < mediaMetadata.series.length; i++) {
if (mediaMetadata.series[i].id.startsWith('new')) {
let seriesItem = this.db.series.find(se => se.checkNameEquals(mediaMetadata.series[i].name))
const seriesName = (mediaMetadata.series[i].name || '').trim()
if (!seriesName) {
Logger.error(`[ApiRouter] Invalid series object, no name`, mediaMetadata.series[i])
continue
}
if (!mediaMetadata.series[i].id || mediaMetadata.series[i].id.startsWith('new')) {
let seriesItem = this.db.series.find(se => se.checkNameEquals(seriesName))
if (!seriesItem) {
seriesItem = new Series()
seriesItem.setData(mediaMetadata.series[i])

View File

@@ -221,7 +221,7 @@ class MediaFileScanner {
*/
async scanMediaFiles(mediaLibraryFiles, libraryItem, libraryScan = null) {
const preferAudioMetadata = libraryScan ? !!libraryScan.preferAudioMetadata : !!global.ServerSettings.scannerPreferAudioMetadata
const preferOverdriveMediaMarker = libraryScan ? !!libraryScan.preferOverdriveMediaMarker : !!global.ServerSettings.scannerPreferOverdriveMediaMarker
const preferOverdriveMediaMarker = !!global.ServerSettings.scannerPreferOverdriveMediaMarker
let hasUpdated = false
@@ -280,7 +280,7 @@ class MediaFileScanner {
}
if (hasUpdated) {
libraryItem.media.rebuildTracks(preferOverdriveMediaMarker)
libraryItem.media.rebuildTracks()
}
} else if (libraryItem.mediaType === 'podcast') { // Podcast Media Type
const existingAudioFiles = mediaScanResult.audioFiles.filter(af => libraryItem.media.findFileWithInode(af.ino))
@@ -296,11 +296,17 @@ class MediaFileScanner {
// Update audio file metadata for audio files already there
existingAudioFiles.forEach((af) => {
const peAudioFile = libraryItem.media.findFileWithInode(af.ino)
if (peAudioFile.updateFromScan && peAudioFile.updateFromScan(af)) {
const podcastEpisode = libraryItem.media.findEpisodeWithInode(af.ino)
if (podcastEpisode?.audioFile.updateFromScan(af)) {
hasUpdated = true
podcastEpisode.setDataFromAudioMetaTags(podcastEpisode.audioFile.metaTags, false)
}
})
if (libraryItem.media.setMetadataFromAudioFile(preferAudioMetadata)) {
hasUpdated = true
}
} else if (libraryItem.mediaType === 'music') { // Music
// Only one audio file in library item
if (newAudioFiles.length) { // New audio file

View File

@@ -793,7 +793,7 @@ class Scanner {
async quickMatchBookBuildUpdatePayload(libraryItem, matchData, options) {
// Update media metadata if not set OR overrideDetails flag
const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'genres', 'tags', 'language', 'explicit', 'asin', 'isbn']
const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'genres', 'tags', 'language', 'explicit', 'abridged', 'asin', 'isbn']
const updatePayload = {}
updatePayload.metadata = {}

View File

@@ -121,6 +121,10 @@ const bookMetadataMapper = {
explicit: {
to: (m) => m.explicit ? 'Y' : 'N',
from: (v) => v && v.toLowerCase() == 'y'
},
abridged: {
to: (m) => m.abridged ? 'Y' : 'N',
from: (v) => v && v.toLowerCase() == 'y'
}
}

View File

@@ -46,7 +46,9 @@ module.exports.AudioMimeType = {
WMA: 'audio/x-ms-wma',
AIFF: 'audio/x-aiff',
WEBM: 'audio/webm',
WEBMA: 'audio/webm'
WEBMA: 'audio/webm',
MKA: 'audio/x-matroska',
AWB: 'audio/amr-wb'
}
module.exports.VideoMimeType = {

View File

@@ -1,3 +1,4 @@
const axios = require('axios')
const Ffmpeg = require('../libs/fluentFfmpeg')
const fs = require('../libs/fsExtra')
const Path = require('path')
@@ -86,3 +87,68 @@ async function resizeImage(filePath, outputPath, width, height) {
})
}
module.exports.resizeImage = resizeImage
module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
return new Promise(async (resolve) => {
const response = await axios({
url: podcastEpisodeDownload.url,
method: 'GET',
responseType: 'stream',
timeout: 30000
})
const ffmpeg = Ffmpeg(response.data)
ffmpeg.outputOptions(
'-c', 'copy',
'-metadata', 'podcast=1'
)
const podcastMetadata = podcastEpisodeDownload.libraryItem.media.metadata
const podcastEpisode = podcastEpisodeDownload.podcastEpisode
const taggings = {
'album': podcastMetadata.title,
'album-sort': podcastMetadata.title,
'artist': podcastMetadata.author,
'artist-sort': podcastMetadata.author,
'comment': podcastEpisode.description,
'subtitle': podcastEpisode.subtitle,
'disc': podcastEpisode.season,
'genre': podcastMetadata.genres.length ? podcastMetadata.genres.join(';') : null,
'language': podcastMetadata.language,
'MVNM': podcastMetadata.title,
'MVIN': podcastEpisode.episode,
'track': podcastEpisode.episode,
'series-part': podcastEpisode.episode,
'title': podcastEpisode.title,
'title-sort': podcastEpisode.title,
'year': podcastEpisode.pubYear,
'date': podcastEpisode.pubDate,
'releasedate': podcastEpisode.pubDate,
'itunes-id': podcastMetadata.itunesId,
'podcast-type': podcastMetadata.type,
'episode-type': podcastMetadata.episodeType
}
for (const tag in taggings) {
if (taggings[tag]) {
ffmpeg.addOption('-metadata', `${tag}=${taggings[tag]}`)
}
}
ffmpeg.addOutput(podcastEpisodeDownload.targetPath)
ffmpeg.on('start', (cmd) => {
Logger.debug(`[FfmpegHelpers] downloadPodcastEpisode: Cmd: ${cmd}`)
})
ffmpeg.on('error', (err, stdout, stderr) => {
Logger.error(`[FfmpegHelpers] downloadPodcastEpisode: Error ${err} ${stdout} ${stderr}`)
resolve(false)
})
ffmpeg.on('end', () => {
Logger.debug(`[FfmpegHelpers] downloadPodcastEpisode: Complete`)
resolve(podcastEpisodeDownload.targetPath)
})
ffmpeg.run()
})
}

View File

@@ -107,7 +107,6 @@ module.exports.setDefaultDirSync = (path, silent = false) => {
const uid = global.Uid
const gid = global.Gid
if (isNaN(uid) || isNaN(gid)) {
if (!silent) Logger.debug('Not modifying permissions since no uid/gid is specified')
return true
}
if (!silent) Logger.debug(`[FilePerms] Setting dir permission "${mode}" for uid ${uid} and gid ${gid} | "${path}"`)

View File

@@ -1,6 +1,6 @@
const globals = {
SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'],
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma'],
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb'],
SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
SupportedVideoTypes: ['mp4'],
TextFileTypes: ['txt', 'nfo'],

View File

@@ -108,7 +108,7 @@ module.exports.reqSupportsWebp = (req) => {
module.exports.areEquivalent = areEquivalent
module.exports.copyValue = (val) => {
if (!val) return null
if (!val) return val === false ? false : null
if (!this.isObject(val)) return val
if (Array.isArray(val)) {

View File

@@ -67,6 +67,8 @@ module.exports = {
filtered = filtered.filter(li => li.hasIssues)
} else if (filterBy === 'feed-open') {
filtered = filtered.filter(li => feedsArray.some(feed => feed.entityId === li.id))
} else if (filterBy === 'abridged') {
filtered = filtered.filter(li => !!li.media.metadata?.abridged)
}
return filtered

View File

@@ -43,6 +43,8 @@ module.exports.parse = (nameString) => {
// Example &LF: Friedman, Milton & Friedman, Rose
if (nameString.includes('&')) {
nameString.split('&').forEach((asa) => splitNames = splitNames.concat(asa.split(',')))
} else if (nameString.includes(';')) {
nameString.split(';').forEach((asa) => splitNames = splitNames.concat(asa.split(',')))
} else {
splitNames = nameString.split(',')
}

View File

@@ -73,7 +73,8 @@ function tryGrabChannelLayout(stream) {
function tryGrabTags(stream, ...tags) {
if (!stream.tags) return null
for (let i = 0; i < tags.length; i++) {
const value = stream.tags[tags[i]] || stream.tags[tags[i].toUpperCase()]
const tagKey = Object.keys(stream.tags).find(t => t.toLowerCase() === tags[i].toLowerCase())
const value = stream.tags[tagKey]
if (value && value.trim()) return value.trim()
}
return null
@@ -161,15 +162,19 @@ function parseTags(format, verbose) {
if (verbose) {
Logger.debug('Tags', format.tags)
}
const tags = {
file_tag_encoder: tryGrabTags(format, 'encoder', 'tsse', 'tss'),
file_tag_encodedby: tryGrabTags(format, 'encoded_by', 'tenc', 'ten'),
file_tag_title: tryGrabTags(format, 'title', 'tit2', 'tt2'),
file_tag_titlesort: tryGrabTags(format, 'title-sort', 'tsot'),
file_tag_subtitle: tryGrabTags(format, 'subtitle', 'tit3', 'tt3'),
file_tag_track: tryGrabTags(format, 'track', 'trck', 'trk'),
file_tag_disc: tryGrabTags(format, 'discnumber', 'disc', 'disk', 'tpos'),
file_tag_album: tryGrabTags(format, 'album', 'talb', 'tal'),
file_tag_albumsort: tryGrabTags(format, 'album-sort', 'tsoa'),
file_tag_artist: tryGrabTags(format, 'artist', 'tpe1', 'tp1'),
file_tag_artistsort: tryGrabTags(format, 'artist-sort', 'tsop'),
file_tag_albumartist: tryGrabTags(format, 'albumartist', 'album_artist', 'tpe2'),
file_tag_date: tryGrabTags(format, 'date', 'tyer', 'tye'),
file_tag_composer: tryGrabTags(format, 'composer', 'tcom', 'tcm'),
@@ -179,9 +184,12 @@ function parseTags(format, verbose) {
file_tag_genre: tryGrabTags(format, 'genre', 'tcon', 'tco'),
file_tag_series: tryGrabTags(format, 'series', 'show', 'mvnm'),
file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id', 'mvin'),
file_tag_isbn: tryGrabTags(format, 'isbn'),
file_tag_isbn: tryGrabTags(format, 'isbn'), // custom
file_tag_language: tryGrabTags(format, 'language', 'lang'),
file_tag_asin: tryGrabTags(format, 'asin'),
file_tag_asin: tryGrabTags(format, 'asin'), // custom
file_tag_itunesid: tryGrabTags(format, 'itunes-id'), // custom
file_tag_podcasttype: tryGrabTags(format, 'podcast-type'), // custom
file_tag_episodetype: tryGrabTags(format, 'episode-type'), // custom
file_tag_originalyear: tryGrabTags(format, 'originalyear'),
file_tag_releasecountry: tryGrabTags(format, 'MusicBrainz Album Release Country', 'releasecountry'),
file_tag_releasestatus: tryGrabTags(format, 'MusicBrainz Album Status', 'releasestatus', 'musicbrainz_albumstatus'),

View File

@@ -1,78 +1,8 @@
const tone = require('node-tone')
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
const { secondsToTimestamp } = require('./index')
module.exports.writeToneChaptersFile = (chapters, filePath) => {
var chaptersTxt = ''
for (const chapter of chapters) {
chaptersTxt += `${secondsToTimestamp(chapter.start, true, true)} ${chapter.title}\n`
}
return fs.writeFile(filePath, chaptersTxt)
}
module.exports.getToneMetadataObject = (libraryItem, chaptersFile) => {
const coverPath = libraryItem.media.coverPath
const bookMetadata = libraryItem.media.metadata
const metadataObject = {
'Title': bookMetadata.title || '',
'Album': bookMetadata.title || '',
'TrackTotal': libraryItem.media.tracks.length
}
const additionalFields = []
if (bookMetadata.subtitle) {
metadataObject['Subtitle'] = bookMetadata.subtitle
}
if (bookMetadata.authorName) {
metadataObject['Artist'] = bookMetadata.authorName
metadataObject['AlbumArtist'] = bookMetadata.authorName
}
if (bookMetadata.description) {
metadataObject['Comment'] = bookMetadata.description
metadataObject['Description'] = bookMetadata.description
}
if (bookMetadata.narratorName) {
metadataObject['Narrator'] = bookMetadata.narratorName
metadataObject['Composer'] = bookMetadata.narratorName
}
if (bookMetadata.firstSeriesName) {
metadataObject['MovementName'] = bookMetadata.firstSeriesName
}
if (bookMetadata.firstSeriesSequence) {
metadataObject['Movement'] = bookMetadata.firstSeriesSequence
}
if (bookMetadata.genres.length) {
metadataObject['Genre'] = bookMetadata.genres.join('/')
}
if (bookMetadata.publisher) {
metadataObject['Publisher'] = bookMetadata.publisher
}
if (bookMetadata.asin) {
additionalFields.push(`ASIN=${bookMetadata.asin}`)
}
if (bookMetadata.isbn) {
additionalFields.push(`ISBN=${bookMetadata.isbn}`)
}
if (coverPath) {
metadataObject['CoverFile'] = coverPath
}
if (parsePublishedYear(bookMetadata.publishedYear)) {
metadataObject['PublishingDate'] = parsePublishedYear(bookMetadata.publishedYear)
}
if (chaptersFile) {
metadataObject['ChaptersFile'] = chaptersFile
}
if (additionalFields.length) {
metadataObject['AdditionalFields'] = additionalFields
}
return metadataObject
}
module.exports.writeToneMetadataJsonFile = (libraryItem, chapters, filePath, trackTotal) => {
function getToneMetadataObject(libraryItem, chapters, trackTotal) {
const bookMetadata = libraryItem.media.metadata
const coverPath = libraryItem.media.coverPath
@@ -133,6 +63,12 @@ module.exports.writeToneMetadataJsonFile = (libraryItem, chapters, filePath, tra
metadataObject['chapters'] = metadataChapters
}
return metadataObject
}
module.exports.getToneMetadataObject = getToneMetadataObject
module.exports.writeToneMetadataJsonFile = (libraryItem, chapters, filePath, trackTotal) => {
const metadataObject = getToneMetadataObject(libraryItem, chapters, trackTotal)
return fs.writeFile(filePath, JSON.stringify({ meta: metadataObject }, null, 2))
}