mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-01-01 04:00:45 -05:00
Compare commits
124 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
128c554543 | ||
|
|
1b5ab6c378 | ||
|
|
e4961feffb | ||
|
|
eb5f257b8c | ||
|
|
e271e89835 | ||
|
|
f5009f76f4 | ||
|
|
a3e63e03d2 | ||
|
|
2ae3ea346f | ||
|
|
8542d433a2 | ||
|
|
03984f96d4 | ||
|
|
eab019c577 | ||
|
|
179f11f55d | ||
|
|
5a21e63d0b | ||
|
|
24ef105732 | ||
|
|
589c4f73d2 | ||
|
|
55fdc48d5d | ||
|
|
4d45a902bb | ||
|
|
69bac2ec1e | ||
|
|
122ec140e8 | ||
|
|
6a0adf7433 | ||
|
|
c1b2aaec9f | ||
|
|
a49acdb2e4 | ||
|
|
9b67fbe8d9 | ||
|
|
2d13215f1f | ||
|
|
a77c3aae93 | ||
|
|
164937b454 | ||
|
|
b0a8f3d207 | ||
|
|
77cc0934be | ||
|
|
718890cfad | ||
|
|
418adcf891 | ||
|
|
b96f878d69 | ||
|
|
22b8622c67 | ||
|
|
3dc9416da6 | ||
|
|
5e5b674c17 | ||
|
|
3656eab8bf | ||
|
|
25ca950dd0 | ||
|
|
8fca84e4bd | ||
|
|
56579f440b | ||
|
|
a59311f795 | ||
|
|
042c89039c | ||
|
|
d94482827a | ||
|
|
a8dab5653b | ||
|
|
1d1200a3f2 | ||
|
|
4d110ebe7e | ||
|
|
b300f0d10c | ||
|
|
6dc4dc8f49 | ||
|
|
dfae6cf89f | ||
|
|
d7f18bdd8b | ||
|
|
05b102722b | ||
|
|
ef954ee68f | ||
|
|
dbaea9f87d | ||
|
|
64768ec2f9 | ||
|
|
14ee17de47 | ||
|
|
034b8956a2 | ||
|
|
1a3f0e332e | ||
|
|
fc36e86db7 | ||
|
|
60b4bc1a7e | ||
|
|
9fdc8df8bc | ||
|
|
212b97fa20 | ||
|
|
704fbaced8 | ||
|
|
575a162f8b | ||
|
|
d2e0844493 | ||
|
|
f2baf3fafd | ||
|
|
916fd039ca | ||
|
|
e248b6d8d8 | ||
|
|
936de68622 | ||
|
|
a99257e758 | ||
|
|
c89d77dd06 | ||
|
|
3138865d69 | ||
|
|
4d29ebd647 | ||
|
|
fd58df4729 | ||
|
|
5078818295 | ||
|
|
7181df0479 | ||
|
|
6c618d7760 | ||
|
|
17b8cf19b7 | ||
|
|
e018f8341e | ||
|
|
59b5f8cbbe | ||
|
|
d6108a0722 | ||
|
|
1af7e59d88 | ||
|
|
7b425e9a9d | ||
|
|
596a03900b | ||
|
|
b283644d95 | ||
|
|
808690c137 | ||
|
|
136c347586 | ||
|
|
e81238038e | ||
|
|
fcf6964d7d | ||
|
|
bd75ad4576 | ||
|
|
f970d8e539 | ||
|
|
c49010b4e1 | ||
|
|
146093d81e | ||
|
|
11ccbf1913 | ||
|
|
a4a334a18a | ||
|
|
387a37e4da | ||
|
|
ebad304aa9 | ||
|
|
8b557a0cb9 | ||
|
|
40b808e73d | ||
|
|
a8b57a1ce9 | ||
|
|
35315843f2 | ||
|
|
27b9d3b94f | ||
|
|
0010ac5a40 | ||
|
|
884808f34e | ||
|
|
f75ed07497 | ||
|
|
b707d6f3c9 | ||
|
|
a2d4a4a906 | ||
|
|
434d743d99 | ||
|
|
30f16b05fe | ||
|
|
92a88f4416 | ||
|
|
5c9c122af2 | ||
|
|
620d5ce578 | ||
|
|
363e1cee4b | ||
|
|
93f576772a | ||
|
|
d4612bae92 | ||
|
|
e01af27008 | ||
|
|
657fe0a650 | ||
|
|
9a6ec5548e | ||
|
|
0807509ea7 | ||
|
|
d9d1c4e360 | ||
|
|
2135e5b066 | ||
|
|
b69eb10ae0 | ||
|
|
e1512b6f54 | ||
|
|
1b8e8215d6 | ||
|
|
eeb7c80518 | ||
|
|
f650ae7f18 | ||
|
|
6d138ae905 |
@@ -1,4 +1,15 @@
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:16
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
&& apt-get install ffmpeg gnupg2 -y
|
||||
# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster
|
||||
ARG VARIANT=16
|
||||
FROM mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT} as base
|
||||
|
||||
# Setup the node environment
|
||||
ENV NODE_ENV=development
|
||||
|
||||
# Install additional OS packages.
|
||||
RUN apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends \
|
||||
curl tzdata ffmpeg && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Move tone executable to appropriate directory
|
||||
COPY --from=sandreas/tone:v0.1.2 /usr/local/bin/tone /usr/local/bin/
|
||||
|
||||
9
.devcontainer/dev.js
Normal file
9
.devcontainer/dev.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// Using port 3333 is important when running the client web app separately
|
||||
const Path = require('path')
|
||||
module.exports.config = {
|
||||
Port: 3333,
|
||||
ConfigPath: Path.resolve('config'),
|
||||
MetadataPath: Path.resolve('metadata'),
|
||||
FFmpegPath: '/usr/bin/ffmpeg',
|
||||
FFProbePath: '/usr/bin/ffprobe'
|
||||
}
|
||||
@@ -1,12 +1,40 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
|
||||
{
|
||||
"build": { "dockerfile": "Dockerfile" },
|
||||
"mounts": [
|
||||
"source=abs-node-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume"
|
||||
],
|
||||
"features": {
|
||||
"fish": "latest"
|
||||
"name": "Audiobookshelf",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
// Update 'VARIANT' to pick a Node version: 18, 16, 14.
|
||||
// Append -bullseye or -buster to pin to an OS version.
|
||||
// Use -bullseye variants on local arm64/Apple Silicon.
|
||||
"args": {
|
||||
"VARIANT": "16"
|
||||
}
|
||||
},
|
||||
"extensions": [
|
||||
"eamodio.gitlens"
|
||||
]
|
||||
"mounts": [
|
||||
"source=abs-server-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume",
|
||||
"source=abs-client-node_modules,target=${containerWorkspaceFolder}/client/node_modules,type=volume"
|
||||
],
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"forwardPorts": [
|
||||
3000,
|
||||
3333
|
||||
],
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "sh .devcontainer/post-create.sh",
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
// Configure properties specific to VS Code.
|
||||
"vscode": {
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"octref.vetur"
|
||||
]
|
||||
}
|
||||
}
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
29
.devcontainer/post-create.sh
Normal file
29
.devcontainer/post-create.sh
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Mark the working directory as safe for use with git
|
||||
git config --global --add safe.directory $PWD
|
||||
|
||||
# If there is no dev.js file, create it
|
||||
if [ ! -f dev.js ]; then
|
||||
cp .devcontainer/dev.js .
|
||||
fi
|
||||
|
||||
# Update permissions for node_modules folders
|
||||
# https://code.visualstudio.com/remote/advancedcontainers/improve-performance#_use-a-targeted-named-volume
|
||||
if [ -d node_modules ]; then
|
||||
sudo chown $(id -u):$(id -g) node_modules
|
||||
fi
|
||||
|
||||
if [ -d client/node_modules ]; then
|
||||
sudo chown $(id -u):$(id -g) client/node_modules
|
||||
fi
|
||||
|
||||
# Install packages for the server
|
||||
if [ -f package.json ]; then
|
||||
npm ci
|
||||
fi
|
||||
|
||||
# Install packages and build the client
|
||||
if [ -f client/package.json ]; then
|
||||
(cd client; npm ci; npm run generate)
|
||||
fi
|
||||
5
.gitattributes
vendored
Normal file
5
.gitattributes
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Set the default behavior, in case people don't have core.autocrlf set.
|
||||
* text=auto
|
||||
|
||||
# Declare files that will always have CRLF line endings on checkout.
|
||||
.devcontainer/post-create.sh text eol=lf
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,6 +1,6 @@
|
||||
.env
|
||||
dev.js
|
||||
node_modules/
|
||||
/dev.js
|
||||
**/node_modules/
|
||||
/config/
|
||||
/audiobooks/
|
||||
/audiobooks2/
|
||||
|
||||
44
.vscode/launch.json
vendored
Normal file
44
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Debug server",
|
||||
"runtimeExecutable": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"dev"
|
||||
],
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Debug client (nuxt)",
|
||||
"runtimeExecutable": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"dev"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/client",
|
||||
"skipFiles": [
|
||||
"${workspaceFolder}/<node_internals>/**"
|
||||
]
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Debug server and client (nuxt)",
|
||||
"configurations": [
|
||||
"Debug server",
|
||||
"Debug client (nuxt)"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
40
.vscode/tasks.json
vendored
Normal file
40
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"path": "client",
|
||||
"type": "npm",
|
||||
"script": "generate",
|
||||
"detail": "nuxt generate",
|
||||
"label": "Build client",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"dependsOn": [
|
||||
"Build client"
|
||||
],
|
||||
"type": "npm",
|
||||
"script": "dev",
|
||||
"detail": "nodemon --watch server index.js",
|
||||
"label": "Run server",
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "client",
|
||||
"type": "npm",
|
||||
"script": "dev",
|
||||
"detail": "nuxt",
|
||||
"label": "Run Live-reload client",
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -237,26 +287,37 @@ export default {
|
||||
})
|
||||
},
|
||||
batchDeleteClick() {
|
||||
const audiobookText = this.numMediaItemsSelected > 1 ? `these ${this.numMediaItemsSelected} items` : 'this item'
|
||||
const confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the items from Audiobookshelf`
|
||||
if (confirm(confirmMsg)) {
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
this.$axios
|
||||
.$post(`/api/items/batch/delete`, {
|
||||
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
||||
})
|
||||
.then(() => {
|
||||
this.$toast.success('Batch delete success!')
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error('Batch delete failed')
|
||||
console.error('Failed to batch delete', error)
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
})
|
||||
const payload = {
|
||||
message: `This will delete ${this.numMediaItemsSelected} library items from the database and your file system. Are you sure?`,
|
||||
checkboxLabel: 'Delete from file system. Uncheck to only remove from database.',
|
||||
yesButtonText: this.$strings.ButtonDelete,
|
||||
yesButtonColor: 'error',
|
||||
checkboxDefaultValue: true,
|
||||
callback: (confirmed, hardDelete) => {
|
||||
if (confirmed) {
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
|
||||
this.$axios
|
||||
.$post(`/api/items/batch/delete?hard=${hardDelete ? 1 : 0}`, {
|
||||
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
||||
})
|
||||
.then(() => {
|
||||
this.$toast.success('Batch delete success')
|
||||
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Batch delete failed', error)
|
||||
this.$toast.error('Batch delete failed')
|
||||
})
|
||||
.finally(() => {
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
batchEditClick() {
|
||||
this.$router.push('/batch')
|
||||
|
||||
@@ -163,6 +163,14 @@ export default {
|
||||
text: this.$strings.LabelAddedAt,
|
||||
value: 'addedAt'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLastBookAdded,
|
||||
value: 'lastBookAdded'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLastBookUpdated,
|
||||
value: 'lastBookUpdated'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelTotalDuration,
|
||||
value: 'totalDuration'
|
||||
@@ -181,6 +189,9 @@ export default {
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||
},
|
||||
currentLibraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
@@ -315,7 +326,11 @@ export default {
|
||||
const payload = {}
|
||||
if (author.asin) payload.asin = author.asin
|
||||
else payload.q = author.name
|
||||
console.log('Payload', payload, 'author', author)
|
||||
|
||||
payload.region = 'us'
|
||||
if (this.libraryProvider.startsWith('audible.')) {
|
||||
payload.region = this.libraryProvider.split('.').pop() || 'us'
|
||||
}
|
||||
|
||||
this.$eventBus.$emit(`searching-author-${author.id}`, true)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
@@ -81,7 +81,7 @@ export default {
|
||||
sleepTimerRemaining: 0,
|
||||
sleepTimer: null,
|
||||
displayTitle: null,
|
||||
initialPlaybackRate: 1,
|
||||
currentPlaybackRate: 1,
|
||||
syncFailedToast: null
|
||||
}
|
||||
},
|
||||
@@ -120,17 +120,22 @@ export default {
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
streamEpisode() {
|
||||
if (!this.$store.state.streamEpisodeId) return null
|
||||
const episodes = this.streamLibraryItem.media.episodes || []
|
||||
return episodes.find((ep) => ep.id === this.$store.state.streamEpisodeId)
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.streamLibraryItem ? this.streamLibraryItem.id : null
|
||||
return this.streamLibraryItem?.id || null
|
||||
},
|
||||
media() {
|
||||
return this.streamLibraryItem ? this.streamLibraryItem.media || {} : {}
|
||||
return this.streamLibraryItem?.media || {}
|
||||
},
|
||||
isPodcast() {
|
||||
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'podcast' : false
|
||||
return this.streamLibraryItem?.mediaType === 'podcast'
|
||||
},
|
||||
isMusic() {
|
||||
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'music' : false
|
||||
return this.streamLibraryItem?.mediaType === 'music'
|
||||
},
|
||||
isExplicit() {
|
||||
return this.mediaMetadata.explicit || false
|
||||
@@ -139,6 +144,7 @@ export default {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
chapters() {
|
||||
if (this.streamEpisode) return this.streamEpisode.chapters || []
|
||||
return this.media.chapters || []
|
||||
},
|
||||
title() {
|
||||
@@ -152,7 +158,8 @@ export default {
|
||||
return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null
|
||||
},
|
||||
totalDurationPretty() {
|
||||
return this.$secondsToTimestamp(this.totalDuration)
|
||||
// Adjusted by playback rate
|
||||
return this.$secondsToTimestamp(this.totalDuration / this.currentPlaybackRate)
|
||||
},
|
||||
podcastAuthor() {
|
||||
if (!this.isPodcast) return null
|
||||
@@ -255,7 +262,7 @@ export default {
|
||||
this.playerHandler.setVolume(volume)
|
||||
},
|
||||
setPlaybackRate(playbackRate) {
|
||||
this.initialPlaybackRate = playbackRate
|
||||
this.currentPlaybackRate = playbackRate
|
||||
this.playerHandler.setPlaybackRate(playbackRate)
|
||||
},
|
||||
seek(time) {
|
||||
@@ -384,7 +391,7 @@ export default {
|
||||
libraryItem: session.libraryItem,
|
||||
episodeId: session.episodeId
|
||||
})
|
||||
this.playerHandler.prepareOpenSession(session, this.initialPlaybackRate)
|
||||
this.playerHandler.prepareOpenSession(session, this.currentPlaybackRate)
|
||||
},
|
||||
streamOpen(session) {
|
||||
console.log(`[StreamContainer] Stream session open`, session)
|
||||
@@ -451,7 +458,7 @@ export default {
|
||||
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
|
||||
})
|
||||
|
||||
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate, payload.startTime)
|
||||
this.playerHandler.load(libraryItem, episodeId, true, this.currentPlaybackRate, payload.startTime)
|
||||
},
|
||||
pauseItem() {
|
||||
this.playerHandler.pause()
|
||||
@@ -459,6 +466,13 @@ export default {
|
||||
showFailedProgressSyncs() {
|
||||
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
|
||||
this.syncFailedToast = this.$toast('Progress is not being synced. Restart playback', { timeout: false, type: 'error' })
|
||||
},
|
||||
sessionClosedEvent(sessionId) {
|
||||
if (this.playerHandler.currentSessionId === sessionId) {
|
||||
console.log('sessionClosedEvent closing current session', sessionId)
|
||||
this.playerHandler.resetPlayer() // Closes player without reporting to server
|
||||
this.$store.commit('setMediaPlaying', null)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -77,6 +77,12 @@ export default {
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -92,6 +98,11 @@ export default {
|
||||
if (this.asin) payload.asin = this.asin
|
||||
else payload.q = this.name
|
||||
|
||||
payload.region = 'us'
|
||||
if (this.libraryProvider.startsWith('audible.')) {
|
||||
payload.region = this.libraryProvider.split('.').pop() || 'us'
|
||||
}
|
||||
|
||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return null
|
||||
|
||||
@@ -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
|
||||
@@ -519,6 +526,14 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.userCanDelete) {
|
||||
items.push({
|
||||
func: 'deleteLibraryItem',
|
||||
text: this.$strings.ButtonDelete
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
},
|
||||
_socket() {
|
||||
@@ -770,6 +785,35 @@ export default {
|
||||
this.store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.recentEpisode }])
|
||||
this.store.commit('globals/setShowPlaylistsModal', true)
|
||||
},
|
||||
deleteLibraryItem() {
|
||||
const payload = {
|
||||
message: 'This will delete the library item from the database and your file system. Are you sure?',
|
||||
checkboxLabel: 'Delete from file system. Uncheck to only remove from database.',
|
||||
yesButtonText: this.$strings.ButtonDelete,
|
||||
yesButtonColor: 'error',
|
||||
checkboxDefaultValue: true,
|
||||
callback: (confirmed, hardDelete) => {
|
||||
if (confirmed) {
|
||||
this.processing = true
|
||||
const axios = this.$axios || this.$nuxt.$axios
|
||||
axios
|
||||
.$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)
|
||||
.then(() => {
|
||||
this.$toast.success('Item deleted')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete item', error)
|
||||
this.$toast.error('Failed to delete item')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
createMoreMenu() {
|
||||
if (!this.$refs.moreIcon) return
|
||||
|
||||
|
||||
@@ -81,13 +81,20 @@ export default {
|
||||
return this.title
|
||||
},
|
||||
displaySortLine() {
|
||||
if (this.orderBy === 'addedAt') {
|
||||
// return this.addedAt
|
||||
return 'Added ' + this.$formatDate(this.addedAt, this.dateFormat)
|
||||
} else if (this.orderBy === 'totalDuration') {
|
||||
return 'Duration: ' + this.$elapsedPrettyExtended(this.totalDuration, false)
|
||||
switch (this.orderBy) {
|
||||
case 'addedAt':
|
||||
return `${this.$strings.LabelAdded} ${this.$formatDate(this.addedAt, this.dateFormat)}`
|
||||
case 'totalDuration':
|
||||
return `${this.$strings.LabelDuration} ${this.$elapsedPrettyExtended(this.totalDuration, false)}`
|
||||
case 'lastBookUpdated':
|
||||
const lastUpdated = Math.max(...(this.books).map(x => x.updatedAt), 0)
|
||||
return `${this.$strings.LabelLastBookUpdated} ${this.$formatDate(lastUpdated, this.dateFormat)}`
|
||||
case 'lastBookAdded':
|
||||
const lastBookAdded = Math.max(...(this.books).map(x => x.addedAt), 0)
|
||||
return `${this.$strings.LabelLastBookAdded} ${this.$formatDate(lastBookAdded, this.dateFormat)}`
|
||||
default:
|
||||
return null
|
||||
}
|
||||
return null
|
||||
},
|
||||
books() {
|
||||
return this.series ? this.series.books || [] : []
|
||||
|
||||
@@ -185,6 +185,11 @@ export default {
|
||||
value: 'tracks',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAbridged,
|
||||
value: 'abridged',
|
||||
sublist: false
|
||||
},
|
||||
{
|
||||
text: this.$strings.ButtonIssues,
|
||||
value: 'issues',
|
||||
|
||||
118
client/components/modals/AudioFileDataModal.vue
Normal file
118
client/components/modals/AudioFileDataModal.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="audiofile-data-modal" :width="700" :height="'unset'">
|
||||
<div v-if="audioFile" ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
||||
<p class="text-base text-gray-200">{{ metadata.filename }}</p>
|
||||
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
|
||||
<ui-text-input-with-label :value="metadata.path" readonly :label="$strings.LabelPath" class="mb-4 text-sm" />
|
||||
|
||||
<div class="flex flex-col sm:flex-row text-sm">
|
||||
<div class="w-full sm:w-1/2">
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelSize }}
|
||||
</p>
|
||||
<p>{{ $bytesPretty(metadata.size) }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelDuration }}
|
||||
</p>
|
||||
<p>{{ $secondsToTimestamp(audioFile.duration) }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">{{ $strings.LabelFormat }}</p>
|
||||
<p>{{ audioFile.format }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelChapters }}
|
||||
</p>
|
||||
<p>{{ audioFile.chapters?.length || 0 }}</p>
|
||||
</div>
|
||||
<div v-if="audioFile.embeddedCoverArt" class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelEmbeddedCover }}
|
||||
</p>
|
||||
<p>{{ audioFile.embeddedCoverArt || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-1/2">
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelCodec }}
|
||||
</p>
|
||||
<p>{{ audioFile.codec }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelChannels }}
|
||||
</p>
|
||||
<p>{{ audioFile.channels }} ({{ audioFile.channelLayout }})</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelBitrate }}
|
||||
</p>
|
||||
<p>{{ $bytesPretty(audioFile.bitRate || 0, 0) }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">{{ $strings.LabelTimeBase }}</p>
|
||||
<p>{{ audioFile.timeBase }}</p>
|
||||
</div>
|
||||
<div v-if="audioFile.language" class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelLanguage }}
|
||||
</p>
|
||||
<p>{{ audioFile.language || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
|
||||
<p class="font-bold mb-2">{{ $strings.LabelMetaTags }}</p>
|
||||
|
||||
<div v-for="(value, key) in metaTags" :key="key" class="flex mb-1 text-sm">
|
||||
<p class="w-32 min-w-32 text-black-50 mb-1">
|
||||
{{ key.replace('tag', '') }}
|
||||
</p>
|
||||
<p>{{ value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
audioFile: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
metadata() {
|
||||
return this.audioFile?.metadata || {}
|
||||
},
|
||||
metaTags() {
|
||||
return this.audioFile?.metaTags || {}
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -2,13 +2,13 @@
|
||||
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
|
||||
<div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<template v-for="chap in chapters">
|
||||
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
|
||||
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end / _playbackRate <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
|
||||
<p class="chapter-title truncate text-sm md:text-base">
|
||||
{{ chap.title }}
|
||||
</p>
|
||||
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended(chap.end - chap.start) }}</span>
|
||||
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended((chap.end - chap.start) / _playbackRate) }}</span>
|
||||
<span class="flex-grow" />
|
||||
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
|
||||
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start / _playbackRate) }}</span>
|
||||
|
||||
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
||||
</div>
|
||||
@@ -28,7 +28,8 @@ export default {
|
||||
currentChapter: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
playbackRate: Number
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
@@ -47,11 +48,15 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
_playbackRate() {
|
||||
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
|
||||
return this.playbackRate
|
||||
},
|
||||
currentChapterId() {
|
||||
return this.currentChapter ? this.currentChapter.id : null
|
||||
},
|
||||
currentChapterStart() {
|
||||
return this.currentChapter ? this.currentChapter.start : 0
|
||||
return (this.currentChapter?.start || 0) / this._playbackRate
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -61,13 +66,11 @@ export default {
|
||||
scrollToChapter() {
|
||||
if (!this.currentChapterId) return
|
||||
|
||||
var container = this.$refs.container
|
||||
if (container) {
|
||||
var currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
||||
if (this.$refs.container) {
|
||||
const currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
||||
if (currChapterEl) {
|
||||
var offsetTop = currChapterEl.offsetTop
|
||||
var containerHeight = container.clientHeight
|
||||
container.scrollTo({ top: offsetTop - containerHeight / 2 })
|
||||
const containerHeight = this.$refs.container.clientHeight
|
||||
this.$refs.container.scrollTo({ top: currChapterEl.offsetTop - containerHeight / 2 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,8 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<ui-btn small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
|
||||
<ui-btn v-if="!isOpenSession" small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
|
||||
<ui-btn v-else small color="error" @click.stop="closeSessionClick">Close Open Session</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
@@ -157,6 +158,9 @@ export default {
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
},
|
||||
isOpenSession() {
|
||||
return !!this._session.open
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -188,6 +192,24 @@ export default {
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.$toast.error(errMsg || this.$strings.ToastSessionDeleteFailed)
|
||||
})
|
||||
},
|
||||
closeSessionClick() {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post(`/api/session/${this._session.id}/close`)
|
||||
.then(() => {
|
||||
this.$toast.success('Session closed')
|
||||
this.show = false
|
||||
this.$emit('closedSession')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to close session', error)
|
||||
const errMsg = error.response?.data || ''
|
||||
this.$toast.error(errMsg || 'Failed to close open session')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -9,10 +9,14 @@
|
||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<div v-if="!timerSet" class="w-full">
|
||||
<template v-for="time in sleepTimes">
|
||||
<div :key="time.text" class="flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-bg relative" @click="setTime(time)">
|
||||
<div :key="time.text" class="flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-bg relative" @click="setTime(time.seconds)">
|
||||
<p class="text-xl text-center">{{ time.text }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<form class="flex items-center justify-center px-6 py-3" @submit.prevent="submitCustomTime">
|
||||
<ui-text-input v-model="customTime" type="number" step="any" min="0.1" placeholder="Time in minutes" class="w-48" />
|
||||
<ui-btn color="success" type="submit" padding-x="0" class="h-9 w-12 flex items-center justify-center ml-1">Set</ui-btn>
|
||||
</form>
|
||||
</div>
|
||||
<div v-else class="w-full p-4">
|
||||
<div class="mb-4 flex items-center justify-center">
|
||||
@@ -48,19 +52,28 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
customTime: null,
|
||||
sleepTimes: [
|
||||
{
|
||||
seconds: 10,
|
||||
text: '10 seconds'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 5,
|
||||
text: '5 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 15,
|
||||
text: '15 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 20,
|
||||
text: '20 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 30,
|
||||
text: '30 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 45,
|
||||
text: '45 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 60,
|
||||
text: '60 minutes'
|
||||
@@ -72,10 +85,6 @@ export default {
|
||||
{
|
||||
seconds: 60 * 120,
|
||||
text: '2 hours'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 180,
|
||||
text: '3 hours'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -97,8 +106,17 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setTime(time) {
|
||||
this.$emit('set', time.seconds)
|
||||
submitCustomTime() {
|
||||
if (!this.customTime || isNaN(this.customTime) || Number(this.customTime) <= 0) {
|
||||
this.customTime = null
|
||||
return
|
||||
}
|
||||
|
||||
const timeInSeconds = Math.round(Number(this.customTime) * 60)
|
||||
this.setTime(timeInSeconds)
|
||||
},
|
||||
setTime(seconds) {
|
||||
this.$emit('set', seconds)
|
||||
},
|
||||
increment(amount) {
|
||||
this.$emit('increment', amount)
|
||||
|
||||
@@ -85,6 +85,12 @@ export default {
|
||||
},
|
||||
title() {
|
||||
return this.$strings.HeaderUpdateAuthor
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -151,6 +157,11 @@ export default {
|
||||
if (this.authorCopy.asin) payload.asin = this.authorCopy.asin
|
||||
else payload.q = this.authorCopy.name
|
||||
|
||||
payload.region = 'us'
|
||||
if (this.libraryProvider.startsWith('audible.')) {
|
||||
payload.region = this.libraryProvider.split('.').pop() || 'us'
|
||||
}
|
||||
|
||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return null
|
||||
|
||||
@@ -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' }">
|
||||
|
||||
@@ -7,11 +7,6 @@
|
||||
|
||||
<div class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-white border-opacity-5'">
|
||||
<div class="flex items-center px-4">
|
||||
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8 hidden md:block" :padding-x="3" small @click.stop.prevent="removeItem">{{ $strings.ButtonRemove }}</ui-btn>
|
||||
<ui-icon-btn bg-color="error" icon="delete" class="md:hidden" :size="7" icon-font-size="1rem" @click.stop.prevent="removeItem" />
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-tooltip :disabled="!!quickMatching" :text="$getString('MessageQuickMatchDescription', [libraryProvider])" direction="bottom" class="mr-2 md:mr-4">
|
||||
<ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">{{ $strings.ButtonQuickMatch }}</ui-btn>
|
||||
</ui-tooltip>
|
||||
@@ -20,6 +15,8 @@
|
||||
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">{{ $strings.ButtonReScan }}</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<!-- desktop -->
|
||||
<ui-btn @click="save" class="mx-2 hidden md:block">{{ $strings.ButtonSave }}</ui-btn>
|
||||
<ui-btn @click="saveAndClose" class="mx-2 hidden md:block">{{ $strings.ButtonSaveAndClose }}</ui-btn>
|
||||
@@ -77,9 +74,6 @@ export default {
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
libraryId() {
|
||||
return this.libraryItem ? this.libraryItem.libraryId : null
|
||||
},
|
||||
@@ -184,23 +178,6 @@ export default {
|
||||
}
|
||||
return false
|
||||
},
|
||||
removeItem() {
|
||||
if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) {
|
||||
this.isProcessing = true
|
||||
this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}`)
|
||||
.then(() => {
|
||||
console.log('Item removed')
|
||||
this.$toast.success('Item Removed')
|
||||
this.$emit('close')
|
||||
this.isProcessing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Remove item failed', error)
|
||||
this.isProcessing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
checkIsScrollable() {
|
||||
this.$nextTick(() => {
|
||||
var formWrapper = document.getElementById('formWrapper')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<tables-library-files-table expanded :files="libraryFiles" :library-item-id="libraryItem.id" :is-missing="isMissing" />
|
||||
<tables-library-files-table expanded :library-item="libraryItem" :is-missing="isMissing" in-modal />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -30,9 +30,6 @@ export default {
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
libraryFiles() {
|
||||
return this.libraryItem.libraryFiles || []
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -38,7 +38,8 @@ export default {
|
||||
currentChapter: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
playbackRate: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -63,6 +64,10 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
_playbackRate() {
|
||||
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
|
||||
return this.playbackRate
|
||||
},
|
||||
currentChapterDuration() {
|
||||
if (!this.currentChapter) return 0
|
||||
return this.currentChapter.end - this.currentChapter.start
|
||||
@@ -81,8 +86,8 @@ export default {
|
||||
clickTrack(e) {
|
||||
if (this.loading) return
|
||||
|
||||
var offsetX = e.offsetX
|
||||
var perc = offsetX / this.trackWidth
|
||||
const offsetX = e.offsetX
|
||||
const perc = offsetX / this.trackWidth
|
||||
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
|
||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||
const time = baseTime + perc * duration
|
||||
@@ -111,7 +116,7 @@ export default {
|
||||
this.updateReadyTrack()
|
||||
},
|
||||
updateReadyTrack() {
|
||||
var widthReady = Math.round(this.trackWidth * this.percentReady)
|
||||
const widthReady = Math.round(this.trackWidth * this.percentReady)
|
||||
if (this.readyTrackWidth === widthReady) return
|
||||
this.readyTrackWidth = widthReady
|
||||
if (this.$refs.readyTrack) this.$refs.readyTrack.style.width = widthReady + 'px'
|
||||
@@ -124,7 +129,7 @@ export default {
|
||||
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
|
||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||
|
||||
var ptWidth = Math.round((time / duration) * this.trackWidth)
|
||||
const ptWidth = Math.round((time / duration) * this.trackWidth)
|
||||
if (this.playedTrackWidth === ptWidth) {
|
||||
return
|
||||
}
|
||||
@@ -133,7 +138,7 @@ export default {
|
||||
},
|
||||
setChapterTicks() {
|
||||
this.chapterTicks = this.chapters.map((chap) => {
|
||||
var perc = chap.start / this.duration
|
||||
const perc = chap.start / this.duration
|
||||
return {
|
||||
title: chap.title,
|
||||
left: perc * this.trackWidth
|
||||
@@ -141,7 +146,7 @@ export default {
|
||||
})
|
||||
},
|
||||
mousemoveTrack(e) {
|
||||
var offsetX = e.offsetX
|
||||
const offsetX = e.offsetX
|
||||
|
||||
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
|
||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||
@@ -167,7 +172,7 @@ export default {
|
||||
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
||||
}
|
||||
if (this.$refs.hoverTimestampText) {
|
||||
var hoverText = this.$secondsToTimestamp(progressTime)
|
||||
var hoverText = this.$secondsToTimestamp(progressTime / this._playbackRate)
|
||||
|
||||
var chapter = this.chapters.find((chapter) => chapter.start <= totalTime && totalTime < chapter.end)
|
||||
if (chapter && chapter.title) {
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate.sync="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
|
||||
</div>
|
||||
|
||||
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" @seek="seek" />
|
||||
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" :playback-rate="playbackRate" @seek="seek" />
|
||||
|
||||
<div class="flex">
|
||||
<p ref="currentTimestamp" class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">00:00:00</p>
|
||||
@@ -59,7 +59,7 @@
|
||||
<p class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
||||
</div>
|
||||
|
||||
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :playback-rate="playbackRate" :chapters="chapters" @select="selectChapter" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -92,6 +92,11 @@ export default {
|
||||
useChapterTrack: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
playbackRate() {
|
||||
this.updateTimestamp()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sleepTimerRemainingString() {
|
||||
var rounded = Math.round(this.sleepTimerRemaining)
|
||||
@@ -213,18 +218,14 @@ export default {
|
||||
}
|
||||
},
|
||||
increasePlaybackRate() {
|
||||
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
||||
if (currentRateIndex >= rates.length - 1) return
|
||||
this.playbackRate = rates[currentRateIndex + 1] || 1
|
||||
this.playbackRateChanged(this.playbackRate)
|
||||
if (this.playbackRate >= 10) return
|
||||
this.playbackRate = Number((this.playbackRate + 0.1).toFixed(1))
|
||||
this.setPlaybackRate(this.playbackRate)
|
||||
},
|
||||
decreasePlaybackRate() {
|
||||
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
||||
if (currentRateIndex <= 0) return
|
||||
this.playbackRate = rates[currentRateIndex - 1] || 1
|
||||
this.playbackRateChanged(this.playbackRate)
|
||||
if (this.playbackRate <= 0.5) return
|
||||
this.playbackRate = Number((this.playbackRate - 0.1).toFixed(1))
|
||||
this.setPlaybackRate(this.playbackRate)
|
||||
},
|
||||
setPlaybackRate(playbackRate) {
|
||||
this.$emit('setPlaybackRate', playbackRate)
|
||||
@@ -289,14 +290,13 @@ export default {
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(percentageReady)
|
||||
},
|
||||
updateTimestamp() {
|
||||
var ts = this.$refs.currentTimestamp
|
||||
const ts = this.$refs.currentTimestamp
|
||||
if (!ts) {
|
||||
console.error('No timestamp el')
|
||||
return
|
||||
}
|
||||
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
|
||||
var currTimeClean = this.$secondsToTimestamp(time)
|
||||
ts.innerText = currTimeClean
|
||||
ts.innerText = this.$secondsToTimestamp(time / this.playbackRate)
|
||||
},
|
||||
setBufferTime(bufferTime) {
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
|
||||
@@ -312,7 +312,7 @@ export default {
|
||||
this.useChapterTrack = this.chapters.length ? _useChapterTrack : false
|
||||
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
|
||||
this.$emit('setPlaybackRate', this.playbackRate)
|
||||
this.setPlaybackRate(this.playbackRate)
|
||||
},
|
||||
settingsUpdated(settings) {
|
||||
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||
<div ref="content" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickedOutside">
|
||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||
<p class="text-lg mb-8 mt-2 px-1" v-html="message" />
|
||||
<p class="text-lg mb-6 mt-2 px-1" v-html="message" />
|
||||
|
||||
<ui-checkbox v-if="checkboxLabel" v-model="checkboxValue" checkbox-bg="bg" :label="checkboxLabel" label-class="pl-2 text-base" class="mb-6 px-1" />
|
||||
|
||||
<div class="flex px-1 items-center">
|
||||
<ui-btn v-if="isYesNo" color="primary" @click="nevermind">{{ $strings.ButtonCancel }}</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="isYesNo" color="success" @click="confirm">{{ $strings.ButtonYes }}</ui-btn>
|
||||
<ui-btn v-if="isYesNo" :color="yesButtonColor" @click="confirm">{{ yesButtonText }}</ui-btn>
|
||||
<ui-btn v-else color="primary" @click="confirm">{{ $strings.ButtonOk }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
@@ -21,7 +24,8 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
el: null,
|
||||
content: null
|
||||
content: null,
|
||||
checkboxValue: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -57,6 +61,18 @@ export default {
|
||||
persistent() {
|
||||
return !!this.confirmPromptOptions.persistent
|
||||
},
|
||||
checkboxLabel() {
|
||||
return this.confirmPromptOptions.checkboxLabel
|
||||
},
|
||||
yesButtonText() {
|
||||
return this.confirmPromptOptions.yesButtonText || this.$strings.ButtonYes
|
||||
},
|
||||
yesButtonColor() {
|
||||
return this.confirmPromptOptions.yesButtonColor || 'success'
|
||||
},
|
||||
checkboxDefaultValue() {
|
||||
return !!this.confirmPromptOptions.checkboxDefaultValue
|
||||
},
|
||||
isYesNo() {
|
||||
return this.type === 'yesNo'
|
||||
},
|
||||
@@ -84,10 +100,11 @@ export default {
|
||||
this.show = false
|
||||
},
|
||||
confirm() {
|
||||
if (this.callback) this.callback(true)
|
||||
if (this.callback) this.callback(true, this.checkboxValue)
|
||||
this.show = false
|
||||
},
|
||||
setShow() {
|
||||
this.checkboxValue = this.checkboxDefaultValue
|
||||
this.$eventBus.$emit('showing-prompt', true)
|
||||
document.body.appendChild(this.el)
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
123
client/components/tables/AudioTracksTableRow.vue
Normal file
123
client/components/tables/AudioTracksTableRow.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<tr>
|
||||
<td class="text-center">
|
||||
<p>{{ track.index }}</p>
|
||||
</td>
|
||||
<td class="font-sans">{{ showFullPath ? track.metadata.path : track.metadata.filename }}</td>
|
||||
<td v-if="!showFullPath" class="hidden lg:table-cell">
|
||||
{{ track.audioFile.codec || '' }}
|
||||
</td>
|
||||
<td v-if="!showFullPath" class="hidden xl:table-cell">
|
||||
{{ $bytesPretty(track.audioFile.bitRate || 0, 0) }}
|
||||
</td>
|
||||
<td class="hidden md:table-cell">
|
||||
{{ $bytesPretty(track.metadata.size) }}
|
||||
</td>
|
||||
<td class="hidden sm:table-cell">
|
||||
{{ $secondsToTimestamp(track.duration) }}
|
||||
</td>
|
||||
<td v-if="contextMenuItems.length" class="text-center">
|
||||
<ui-context-menu-dropdown :items="contextMenuItems" menu-width="110px" @action="contextMenuAction" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItemId: String,
|
||||
showFullPath: Boolean,
|
||||
track: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userIsAdmin() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
contextMenuItems() {
|
||||
const items = []
|
||||
if (this.userCanDownload) {
|
||||
items.push({
|
||||
text: this.$strings.LabelDownload,
|
||||
action: 'download'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.userCanDelete) {
|
||||
items.push({
|
||||
text: this.$strings.ButtonDelete,
|
||||
action: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.userIsAdmin) {
|
||||
items.push({
|
||||
text: this.$strings.LabelMoreInfo,
|
||||
action: 'more'
|
||||
})
|
||||
}
|
||||
return items
|
||||
},
|
||||
downloadUrl() {
|
||||
return `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(this.track.metadata.relPath).replace(/^\//, '')}?token=${this.userToken}`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
contextMenuAction(action) {
|
||||
if (action === 'delete') {
|
||||
this.deleteLibraryFile()
|
||||
} else if (action === 'download') {
|
||||
this.downloadLibraryFile()
|
||||
} else if (action === 'more') {
|
||||
this.$emit('showMore', this.track.audioFile)
|
||||
}
|
||||
},
|
||||
deleteLibraryFile() {
|
||||
const payload = {
|
||||
message: 'This will delete the file from your file system. Are you sure?',
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}/file/${this.track.audioFile.ino}`)
|
||||
.then(() => {
|
||||
this.$toast.success('File deleted')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete file', error)
|
||||
this.$toast.error('Failed to delete file')
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
downloadLibraryFile() {
|
||||
const a = document.createElement('a')
|
||||
a.style.display = 'none'
|
||||
a.href = this.downloadUrl
|
||||
a.download = this.track.metadata.filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => {
|
||||
a.remove()
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -6,7 +6,7 @@
|
||||
<span class="text-sm font-mono">{{ files.length }}</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
|
||||
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
|
||||
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
|
||||
<span class="material-icons text-4xl">expand_more</span>
|
||||
</div>
|
||||
@@ -18,60 +18,76 @@
|
||||
<th class="text-left px-4">{{ $strings.LabelPath }}</th>
|
||||
<th class="text-left w-24 min-w-24">{{ $strings.LabelSize }}</th>
|
||||
<th class="text-left px-4 w-24">{{ $strings.LabelType }}</th>
|
||||
<th v-if="userCanDownload && !isMissing" class="text-center w-20">{{ $strings.LabelDownload }}</th>
|
||||
<th v-if="userCanDelete || userCanDownload || (userIsAdmin && audioFiles.length && !inModal)" class="text-center w-16"></th>
|
||||
</tr>
|
||||
<template v-for="file in files">
|
||||
<tr :key="file.path">
|
||||
<td class="px-4">
|
||||
{{ showFullPath ? file.metadata.path : file.metadata.relPath }}
|
||||
</td>
|
||||
<td class="font-mono">
|
||||
{{ $bytesPretty(file.metadata.size) }}
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
<div class="flex items-center">
|
||||
<p>{{ file.fileType }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="userCanDownload && !isMissing" class="text-center">
|
||||
<a :href="`${$config.routerBasePath}/s/item/${libraryItemId}/${$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-for="file in filesWithAudioFile">
|
||||
<tables-library-files-table-row :key="file.path" :libraryItemId="libraryItemId" :showFullPath="showFullPath" :file="file" :inModal="inModal" @showMore="showMore" />
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :audio-file="selectedAudioFile" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
files: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
libraryItemId: String,
|
||||
isMissing: Boolean,
|
||||
expanded: Boolean // start expanded
|
||||
expanded: Boolean, // start expanded
|
||||
inModal: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showFiles: false,
|
||||
showFullPath: false
|
||||
showFullPath: false,
|
||||
showAudioFileDataModal: false,
|
||||
selectedAudioFile: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userIsAdmin() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
files() {
|
||||
return this.libraryItem.libraryFiles || []
|
||||
},
|
||||
audioFiles() {
|
||||
return this.libraryItem.media?.audioFiles || []
|
||||
},
|
||||
filesWithAudioFile() {
|
||||
return this.files.map((file) => {
|
||||
if (file.fileType === 'audio') {
|
||||
file.audioFile = this.audioFiles.find((af) => af.ino === file.ino)
|
||||
}
|
||||
return file
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickBar() {
|
||||
this.showFiles = !this.showFiles
|
||||
},
|
||||
showMore(audioFile) {
|
||||
this.selectedAudioFile = audioFile
|
||||
this.showAudioFileDataModal = true
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
118
client/components/tables/LibraryFilesTableRow.vue
Normal file
118
client/components/tables/LibraryFilesTableRow.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<tr>
|
||||
<td class="px-4">
|
||||
{{ showFullPath ? file.metadata.path : file.metadata.relPath }}
|
||||
</td>
|
||||
<td>
|
||||
{{ $bytesPretty(file.metadata.size) }}
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
<div class="flex items-center">
|
||||
<p>{{ file.fileType }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="contextMenuItems.length" class="text-center">
|
||||
<ui-context-menu-dropdown :items="contextMenuItems" menu-width="110px" @action="contextMenuAction" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItemId: String,
|
||||
showFullPath: Boolean,
|
||||
file: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
inModal: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userIsAdmin() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
downloadUrl() {
|
||||
return `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(this.file.metadata.relPath).replace(/^\//, '')}?token=${this.userToken}`
|
||||
},
|
||||
contextMenuItems() {
|
||||
const items = []
|
||||
if (this.userCanDownload) {
|
||||
items.push({
|
||||
text: this.$strings.LabelDownload,
|
||||
action: 'download'
|
||||
})
|
||||
}
|
||||
if (this.userCanDelete) {
|
||||
items.push({
|
||||
text: this.$strings.ButtonDelete,
|
||||
action: 'delete'
|
||||
})
|
||||
}
|
||||
// Currently not showing this option in the Files tab modal
|
||||
if (this.userIsAdmin && this.file.audioFile && !this.inModal) {
|
||||
items.push({
|
||||
text: this.$strings.LabelMoreInfo,
|
||||
action: 'more'
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
contextMenuAction(action) {
|
||||
if (action === 'delete') {
|
||||
this.deleteLibraryFile()
|
||||
} else if (action === 'download') {
|
||||
this.downloadLibraryFile()
|
||||
} else if (action === 'more') {
|
||||
this.$emit('showMore', this.file.audioFile)
|
||||
}
|
||||
},
|
||||
deleteLibraryFile() {
|
||||
const payload = {
|
||||
message: 'This will delete the file from your file system. Are you sure?',
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}/file/${this.file.ino}`)
|
||||
.then(() => {
|
||||
this.$toast.success('File deleted')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete file', error)
|
||||
this.$toast.error('Failed to delete file')
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
downloadLibraryFile() {
|
||||
const a = document.createElement('a')
|
||||
a.style.display = 'none'
|
||||
a.href = this.downloadUrl
|
||||
a.download = this.file.metadata.filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => {
|
||||
a.remove()
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -5,9 +5,8 @@
|
||||
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
||||
</div>
|
||||
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
|
||||
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
|
||||
<nuxt-link v-if="userCanUpdate && !isFile" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4" @mousedown.prevent>
|
||||
<ui-btn small color="primary">{{ $strings.ButtonManageTracks }}</ui-btn>
|
||||
</nuxt-link>
|
||||
@@ -21,41 +20,20 @@
|
||||
<tr>
|
||||
<th class="w-10">#</th>
|
||||
<th class="text-left">{{ $strings.LabelFilename }}</th>
|
||||
<th class="text-left w-20">{{ $strings.LabelSize }}</th>
|
||||
<th class="text-left w-20">{{ $strings.LabelDuration }}</th>
|
||||
<th v-if="userCanDownload" class="text-center w-20">{{ $strings.LabelDownload }}</th>
|
||||
<th v-if="showExperimentalFeatures" class="text-center w-20">
|
||||
<div class="flex items-center">
|
||||
<p>Tone</p>
|
||||
<ui-tooltip text="Experimental feature for testing Tone library metadata scan results. Results logged in browser console." class="ml-2 w-2" direction="left">
|
||||
<span class="material-icons-outlined text-sm">information</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!showFullPath" class="text-left w-20 hidden lg:table-cell">{{ $strings.LabelCodec }}</th>
|
||||
<th v-if="!showFullPath" class="text-left w-20 hidden xl:table-cell">{{ $strings.LabelBitrate }}</th>
|
||||
<th class="text-left w-20 hidden md:table-cell">{{ $strings.LabelSize }}</th>
|
||||
<th class="text-left w-20 hidden sm:table-cell">{{ $strings.LabelDuration }}</th>
|
||||
<th class="text-center w-16"></th>
|
||||
</tr>
|
||||
<template v-for="track in tracks">
|
||||
<tr :key="track.index">
|
||||
<td class="text-center">
|
||||
<p>{{ track.index }}</p>
|
||||
</td>
|
||||
<td class="font-sans">{{ showFullPath ? track.metadata.path : track.metadata.filename }}</td>
|
||||
<td class="font-mono">
|
||||
{{ $bytesPretty(track.metadata.size) }}
|
||||
</td>
|
||||
<td class="font-mono">
|
||||
{{ $secondsToTimestamp(track.duration) }}
|
||||
</td>
|
||||
<td v-if="userCanDownload" class="text-center">
|
||||
<a :href="`${$config.routerBasePath}/s/item/${libraryItemId}/${$encodeUriPath(track.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text pt-1">download</span></a>
|
||||
</td>
|
||||
<td v-if="showExperimentalFeatures" class="text-center">
|
||||
<ui-icon-btn borderless :loading="toneProbing" icon="search" @click="toneProbe(track.index)" />
|
||||
</td>
|
||||
</tr>
|
||||
<tables-audio-tracks-table-row :key="track.index" :track="track" :library-item-id="libraryItemId" :showFullPath="showFullPath" @showMore="showMore" />
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :audio-file="selectedAudioFile" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -77,47 +55,31 @@ export default {
|
||||
return {
|
||||
showTracks: false,
|
||||
showFullPath: false,
|
||||
toneProbing: false
|
||||
selectedAudioFile: null,
|
||||
showAudioFileDataModal: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userIsAdmin() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickBar() {
|
||||
this.showTracks = !this.showTracks
|
||||
},
|
||||
toneProbe(index) {
|
||||
this.toneProbing = true
|
||||
|
||||
this.$axios
|
||||
.$post(`/api/items/${this.libraryItemId}/tone-scan/${index}`)
|
||||
.then((data) => {
|
||||
console.log('Tone probe data', data)
|
||||
if (data.error) {
|
||||
this.$toast.error('Tone probe error: ' + data.error)
|
||||
} else {
|
||||
this.$toast.success('Tone probe successful! Check browser console')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to tone probe', error)
|
||||
this.$toast.error('Tone probe failed')
|
||||
})
|
||||
.finally(() => {
|
||||
this.toneProbing = false
|
||||
})
|
||||
showMore(audioFile) {
|
||||
this.selectedAudioFile = audioFile
|
||||
this.showAudioFileDataModal = true
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<div class="flex justify-between pt-2 max-w-xl">
|
||||
<p v-if="episode.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p>
|
||||
<p v-if="episode.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p>
|
||||
<p v-if="episode.chapters?.length" class="text-sm text-gray-300">{{ episode.chapters.length }} Chapters</p>
|
||||
<p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, dateFormat) }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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: {
|
||||
@@ -131,12 +139,27 @@ export default {
|
||||
return episodeProgress && !episodeProgress.isFinished
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (this.sortDesc) {
|
||||
return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
||||
let aValue = a[this.sortKey]
|
||||
let bValue = b[this.sortKey]
|
||||
|
||||
// Sort episodes with no pub date as the oldest
|
||||
if (this.sortKey === 'publishedAt') {
|
||||
if (!aValue) aValue = Number.MAX_VALUE
|
||||
if (!bValue) bValue = Number.MAX_VALUE
|
||||
}
|
||||
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
||||
|
||||
if (this.sortDesc) {
|
||||
return String(bValue).localeCompare(String(aValue), undefined, { numeric: true, sensitivity: 'base' })
|
||||
}
|
||||
return String(aValue).localeCompare(String(bValue), 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 +175,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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<template>
|
||||
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
|
||||
<button type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<span class="material-icons" :class="iconClass">more_vert</span>
|
||||
</button>
|
||||
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu">
|
||||
<button type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<span class="material-icons" :class="iconClass">more_vert</span>
|
||||
</button>
|
||||
</slot>
|
||||
|
||||
<transition name="menu">
|
||||
<div v-show="showMenu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg max-h-56 w-48 rounded-md py-1 overflow-auto focus:outline-none sm:text-sm">
|
||||
<div v-show="showMenu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 overflow-auto focus:outline-none sm:text-sm" :style="{ width: menuWidth }">
|
||||
<template v-for="(item, index) in items">
|
||||
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)">
|
||||
<p>{{ item.text }}</p>
|
||||
@@ -27,6 +29,10 @@ export default {
|
||||
iconClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
menuWidth: {
|
||||
type: String,
|
||||
default: '192px'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative">
|
||||
<input :id="inputId" ref="input" v-model="inputValue" :type="actualType" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||
<input :id="inputId" ref="input" v-model="inputValue" :type="actualType" :step="step" :min="min" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
|
||||
<span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
|
||||
</div>
|
||||
@@ -32,7 +32,9 @@ export default {
|
||||
noSpinner: Boolean,
|
||||
textCenter: Boolean,
|
||||
clearable: Boolean,
|
||||
inputId: String
|
||||
inputId: String,
|
||||
step: [String, Number],
|
||||
min: [String, Number]
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -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()
|
||||
|
||||
70
client/components/widgets/AbridgedIndicator.vue
Normal file
70
client/components/widgets/AbridgedIndicator.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<ui-tooltip :text="$strings.LabelAbridged" direction="top">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12px" height="12px" viewBox="0 0 512 512" class="ml-1">
|
||||
<path
|
||||
fill="white"
|
||||
d="M 89.00,40.12
|
||||
C 89.00,40.12 127.00,40.12 127.00,40.12
|
||||
127.00,40.12 198.00,40.12 198.00,40.12
|
||||
198.00,40.12 416.00,40.12 416.00,40.12
|
||||
446.58,40.05 472.95,66.42 473.00,97.00
|
||||
473.00,97.00 473.00,303.00 473.00,303.00
|
||||
473.00,303.00 473.00,418.00 473.00,418.00
|
||||
472.65,447.55 445.06,472.95 416.00,473.00
|
||||
416.00,473.00 210.00,473.00 210.00,473.00
|
||||
210.00,473.00 95.00,473.00 95.00,473.00
|
||||
65.45,472.65 40.05,445.06 40.00,416.00
|
||||
40.00,416.00 40.00,136.00 40.00,136.00
|
||||
40.00,136.00 40.00,109.00 40.00,109.00
|
||||
40.00,109.00 40.00,96.00 40.00,96.00
|
||||
40.07,81.58 46.89,67.14 57.01,57.01
|
||||
61.17,52.86 64.86,50.13 70.00,47.31
|
||||
77.25,43.33 81.02,42.18 89.00,40.12 Z
|
||||
M 372.00,392.00
|
||||
C 372.00,392.00 364.02,364.00 364.02,364.00
|
||||
364.02,364.00 350.72,319.00 350.72,319.00
|
||||
350.72,319.00 310.42,183.00 310.42,183.00
|
||||
310.42,183.00 296.86,137.00 296.86,137.00
|
||||
296.86,137.00 291.30,121.99 291.30,121.99
|
||||
291.30,121.99 284.00,121.00 284.00,121.00
|
||||
284.00,121.00 230.00,121.00 230.00,121.00
|
||||
230.00,121.00 222.51,122.02 222.51,122.02
|
||||
222.51,122.02 216.86,137.00 216.86,137.00
|
||||
216.86,137.00 203.28,183.00 203.28,183.00
|
||||
203.28,183.00 163.28,318.00 163.28,318.00
|
||||
163.28,318.00 148.71,367.00 148.71,367.00
|
||||
148.71,367.00 142.00,392.00 142.00,392.00
|
||||
142.00,392.00 183.00,392.00 183.00,392.00
|
||||
183.00,392.00 190.86,390.43 190.86,390.43
|
||||
190.86,390.43 195.86,375.00 195.86,375.00
|
||||
195.86,375.00 206.00,338.00 206.00,338.00
|
||||
206.00,338.00 293.00,338.00 293.00,338.00
|
||||
295.64,338.01 299.26,337.65 301.30,339.60
|
||||
303.23,341.43 304.80,348.22 305.58,351.00
|
||||
305.58,351.00 313.00,378.00 313.00,378.00
|
||||
316.91,391.63 315.20,391.98 325.00,392.00
|
||||
325.00,392.00 372.00,392.00 372.00,392.00 Z
|
||||
M 254.00,170.00
|
||||
C 254.00,170.00 256.00,170.00 256.00,170.00
|
||||
256.00,170.00 263.12,197.00 263.12,197.00
|
||||
263.12,197.00 282.88,268.00 282.88,268.00
|
||||
282.88,268.00 290.00,296.00 290.00,296.00
|
||||
290.00,296.00 219.00,296.00 219.00,296.00
|
||||
219.00,296.00 230.58,253.00 230.58,253.00
|
||||
230.58,253.00 254.00,170.00 254.00,170.00 Z"
|
||||
/>
|
||||
</svg>
|
||||
</ui-tooltip>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -16,7 +16,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<tables-tracks-table :title="$strings.LabelStatsAudioTracks" :tracks="media.tracks" :is-file="isFile" :library-item-id="libraryItemId" class="mt-6" />
|
||||
<tables-tracks-table :title="$strings.LabelStatsAudioTracks" :tracks="tracksWithAudioFile" :is-file="isFile" :library-item-id="libraryItemId" class="mt-6" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -34,6 +34,12 @@ export default {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
tracksWithAudioFile() {
|
||||
return this.media.tracks.map((track) => {
|
||||
track.audioFile = this.media.audioFiles.find((af) => af.metadata.path === track.metadata.path)
|
||||
return track
|
||||
})
|
||||
},
|
||||
missingPartChunks() {
|
||||
if (this.missingParts === 1) return this.missingParts[0]
|
||||
var chunks = []
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1,6 +1,40 @@
|
||||
<template>
|
||||
<ui-tooltip v-if="explicit" :text="$strings.LabelExplicit" direction="top">
|
||||
<span class="material-icons ml-1" style="font-size: 0.8rem">explicit</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12px" height="12px" viewBox="0 0 512 512" class="ml-1">
|
||||
<path
|
||||
fill="white"
|
||||
d="M 89.00,40.12
|
||||
C 89.00,40.12 127.00,40.12 127.00,40.12
|
||||
127.00,40.12 198.00,40.12 198.00,40.12
|
||||
198.00,40.12 416.00,40.12 416.00,40.12
|
||||
446.58,40.05 472.95,66.42 473.00,97.00
|
||||
473.00,97.00 473.00,303.00 473.00,303.00
|
||||
473.00,303.00 473.00,418.00 473.00,418.00
|
||||
472.65,447.55 445.06,472.95 416.00,473.00
|
||||
416.00,473.00 210.00,473.00 210.00,473.00
|
||||
210.00,473.00 95.00,473.00 95.00,473.00
|
||||
65.45,472.65 40.05,445.06 40.00,416.00
|
||||
40.00,416.00 40.00,136.00 40.00,136.00
|
||||
40.00,136.00 40.00,109.00 40.00,109.00
|
||||
40.00,109.00 40.00,96.00 40.00,96.00
|
||||
40.07,81.58 46.89,67.14 57.01,57.01
|
||||
61.17,52.86 64.86,50.13 70.00,47.31
|
||||
77.25,43.33 81.02,42.18 89.00,40.12 Z
|
||||
M 337.00,121.00
|
||||
C 337.00,121.00 175.00,121.00 175.00,121.00
|
||||
175.00,121.00 175.00,392.00 175.00,392.00
|
||||
175.00,392.00 337.00,392.00 337.00,392.00
|
||||
337.00,392.00 337.00,349.00 337.00,349.00
|
||||
337.00,349.00 226.00,349.00 226.00,349.00
|
||||
226.00,349.00 226.00,274.00 226.00,274.00
|
||||
226.00,274.00 332.00,274.00 332.00,274.00
|
||||
332.00,274.00 332.00,232.00 332.00,232.00
|
||||
332.00,232.00 226.00,232.00 226.00,232.00
|
||||
226.00,232.00 226.00,164.00 226.00,164.00
|
||||
226.00,164.00 337.00,164.00 337.00,164.00
|
||||
337.00,164.00 337.00,121.00 337.00,121.00 Z"
|
||||
/>
|
||||
</svg>
|
||||
</ui-tooltip>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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 ''
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
@@ -292,8 +299,17 @@ export default {
|
||||
userStreamUpdate(user) {
|
||||
this.$store.commit('users/updateUserOnline', user)
|
||||
},
|
||||
userSessionClosed(sessionId) {
|
||||
if (this.$refs.streamContainer) this.$refs.streamContainer.sessionClosedEvent(sessionId)
|
||||
},
|
||||
userMediaProgressUpdate(payload) {
|
||||
this.$store.commit('user/updateMediaProgress', payload)
|
||||
|
||||
if (payload.data) {
|
||||
if (this.$store.getters['getIsMediaStreaming'](payload.data.libraryItemId, payload.data.episodeId)) {
|
||||
// TODO: Update currently open session if being played from another device
|
||||
}
|
||||
}
|
||||
},
|
||||
collectionAdded(collection) {
|
||||
if (this.currentLibraryId !== collection.libraryId) return
|
||||
@@ -398,6 +414,7 @@ export default {
|
||||
this.socket.on('user_online', this.userOnline)
|
||||
this.socket.on('user_offline', this.userOffline)
|
||||
this.socket.on('user_stream_update', this.userStreamUpdate)
|
||||
this.socket.on('user_session_closed', this.userSessionClosed)
|
||||
this.socket.on('user_item_progress_updated', this.userMediaProgressUpdate)
|
||||
|
||||
// Collection Listeners
|
||||
@@ -418,6 +435,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 +549,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)
|
||||
|
||||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.2.16",
|
||||
"version": "2.2.19",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.2.16",
|
||||
"version": "2.2.19",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.2.16",
|
||||
"version": "2.2.19",
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -21,13 +21,14 @@
|
||||
<ui-checkbox v-model="showSecondInputs" checkbox-bg="primary" small label-class="text-sm text-gray-200 pl-1" label="Show seconds" class="mx-2" />
|
||||
<div class="w-32 hidden lg:block" />
|
||||
</div>
|
||||
<div class="flex items-center mb-3 py-1">
|
||||
<div class="flex items-center mb-3 py-1 -mx-1">
|
||||
<div class="w-12 hidden lg:block" />
|
||||
<ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg' : 'primary'" small @click="showShiftTimes = !showShiftTimes">{{ $strings.ButtonShiftTimes }}</ui-btn>
|
||||
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</ui-btn>
|
||||
<ui-btn v-if="chapters.length" color="primary" small class="mx-1" @click.stop="removeAllChaptersClick">{{ $strings.ButtonRemoveAll }}</ui-btn>
|
||||
<ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg' : 'primary'" class="mx-1" small @click="showShiftTimes = !showShiftTimes">{{ $strings.ButtonShiftTimes }}</ui-btn>
|
||||
<ui-btn color="primary" small :class="{ 'mx-1': newChapters.length > 1 }" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="hasChanges" small class="mx-2" @click.stop="resetChapters">{{ $strings.ButtonReset }}</ui-btn>
|
||||
<ui-btn v-if="hasChanges" color="success" :disabled="!hasChanges" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn>
|
||||
<ui-btn v-if="hasChanges" small class="mx-1" @click.stop="resetChapters">{{ $strings.ButtonReset }}</ui-btn>
|
||||
<ui-btn v-if="hasChanges" color="success" class="mx-1" :disabled="!hasChanges" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn>
|
||||
<div class="w-32 hidden lg:block" />
|
||||
</div>
|
||||
|
||||
@@ -41,7 +42,7 @@
|
||||
<ui-text-input v-model="shiftAmount" type="number" class="max-w-20" style="height: 30px" />
|
||||
<ui-btn color="primary" class="mx-1" small @click="shiftChapterTimes">{{ $strings.ButtonAdd }}</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<span class="material-icons text-gray-200 hover:text-white cursor-pointer" @click="showShiftTimes = false">close</span>
|
||||
<span class="material-icons text-gray-200 hover:text-white cursor-pointer" @click="showShiftTimes = false">expand_less</span>
|
||||
</div>
|
||||
<p class="text-xs py-1.5 text-gray-300 max-w-md">{{ $strings.NoteChapterEditorTimes }}</p>
|
||||
</div>
|
||||
@@ -329,6 +330,7 @@ export default {
|
||||
chap.start = Math.max(0, chap.start + amount)
|
||||
}
|
||||
}
|
||||
this.checkChapters()
|
||||
},
|
||||
editItem() {
|
||||
this.$store.commit('showEditModal', this.libraryItem)
|
||||
@@ -587,6 +589,45 @@ export default {
|
||||
]
|
||||
}
|
||||
this.checkChapters()
|
||||
},
|
||||
removeAllChaptersClick() {
|
||||
const payload = {
|
||||
message: this.$strings.MessageConfirmRemoveAllChapters,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.removeAllChapters()
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
removeAllChapters() {
|
||||
this.saving = true
|
||||
const payload = {
|
||||
chapters: []
|
||||
}
|
||||
this.$axios
|
||||
.$post(`/api/items/${this.libraryItem.id}/chapters`, payload)
|
||||
.then((data) => {
|
||||
if (data.updated) {
|
||||
this.$toast.success('Chapters removed')
|
||||
if (this.previousRoute) {
|
||||
this.$router.push(this.previousRoute)
|
||||
} else {
|
||||
this.$router.push(`/item/${this.libraryItem.id}`)
|
||||
}
|
||||
} else {
|
||||
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove chapters', error)
|
||||
this.$toast.error('Failed to remove chapters')
|
||||
})
|
||||
.finally(() => {
|
||||
this.saving = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -52,9 +52,53 @@
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-white text-opacity-50">{{ $strings.MessageNoListeningSessions }}</p>
|
||||
|
||||
<!-- open listening sessions table -->
|
||||
<p v-if="openListeningSessions.length" class="text-lg mb-4 mt-8">Open Listening Sessions</p>
|
||||
<div v-if="openListeningSessions.length" class="block max-w-full">
|
||||
<table class="userSessionsTable">
|
||||
<tr class="bg-primary bg-opacity-40">
|
||||
<th class="w-48 min-w-48 text-left">{{ $strings.LabelItem }}</th>
|
||||
<th class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th>
|
||||
<th class="w-32 min-w-32 text-left hidden md:table-cell">{{ $strings.LabelPlayMethod }}</th>
|
||||
<th class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
|
||||
<th class="w-32 min-w-32">{{ $strings.LabelTimeListened }}</th>
|
||||
<th class="w-16 min-w-16">{{ $strings.LabelLastTime }}</th>
|
||||
<th class="flex-grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
|
||||
</tr>
|
||||
|
||||
<tr v-for="session in openListeningSessions" :key="`open-${session.id}`" class="cursor-pointer" @click="showSession(session)">
|
||||
<td class="py-1 max-w-48">
|
||||
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell">
|
||||
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
|
||||
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell">
|
||||
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||
</td>
|
||||
<td class="hidden sm:table-cell">
|
||||
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
||||
</td>
|
||||
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
|
||||
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</app-settings-content>
|
||||
|
||||
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" @removedSession="removedSession" />
|
||||
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" @removedSession="removedSession" @closedSession="closedSession" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -81,6 +125,7 @@ export default {
|
||||
showSessionModal: false,
|
||||
selectedSession: null,
|
||||
listeningSessions: [],
|
||||
openListeningSessions: [],
|
||||
numPages: 0,
|
||||
total: 0,
|
||||
currentPage: 0,
|
||||
@@ -114,6 +159,9 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closedSession() {
|
||||
this.loadOpenSessions()
|
||||
},
|
||||
removedSession() {
|
||||
// If on last page and this was the last session then load prev page
|
||||
if (this.currentPage == this.numPages - 1) {
|
||||
@@ -222,7 +270,7 @@ export default {
|
||||
async loadSessions(page) {
|
||||
var userFilterQuery = this.selectedUser ? `&user=${this.selectedUser}` : ''
|
||||
const data = await this.$axios.$get(`/api/sessions?page=${page}&itemsPerPage=${this.itemsPerPage}${userFilterQuery}`).catch((err) => {
|
||||
console.error('Failed to load listening sesions', err)
|
||||
console.error('Failed to load listening sessions', err)
|
||||
return null
|
||||
})
|
||||
if (!data) {
|
||||
@@ -236,8 +284,24 @@ export default {
|
||||
this.listeningSessions = data.sessions
|
||||
this.userFilter = data.userFilter
|
||||
},
|
||||
async loadOpenSessions() {
|
||||
const data = await this.$axios.$get('/api/sessions/open').catch((err) => {
|
||||
console.error('Failed to load open sessions', err)
|
||||
return null
|
||||
})
|
||||
if (!data) {
|
||||
this.$toast.error('Failed to load open sessions')
|
||||
return
|
||||
}
|
||||
|
||||
this.openListeningSessions = (data.sessions || []).map((s) => {
|
||||
s.open = true
|
||||
return s
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.loadSessions(0)
|
||||
this.loadOpenSessions()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="bg-bg page overflow-hidden" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div class="w-full h-full overflow-y-auto px-2 py-6 md:p-8">
|
||||
<div class="flex flex-col md:flex-row max-w-6xl mx-auto">
|
||||
<div class="w-full flex justify-center md:block md:w-52" style="min-width: 208px">
|
||||
<div class="w-full h-full overflow-y-auto px-2 py-6 lg:p-8">
|
||||
<div class="flex flex-col lg:flex-row max-w-6xl mx-auto">
|
||||
<div class="w-full flex justify-center lg:block lg:w-52" style="min-width: 208px">
|
||||
<div class="relative" style="height: fit-content">
|
||||
<covers-book-cover :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
|
||||
@@ -21,13 +21,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow px-2 py-6 md:py-0 md:px-10">
|
||||
<div class="flex-grow px-2 py-6 lg:py-0 md:px-10">
|
||||
<div class="flex justify-center">
|
||||
<div class="mb-4">
|
||||
<h1 class="text-2xl md:text-3xl font-semibold">
|
||||
<div class="flex items-center">
|
||||
{{ title }}
|
||||
<widgets-explicit-indicator :explicit="isExplicit" />
|
||||
<widgets-abridged-indicator v-if="isAbridged" />
|
||||
</div>
|
||||
</h1>
|
||||
|
||||
@@ -156,7 +157,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">
|
||||
@@ -193,27 +194,18 @@
|
||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="mx-0.5" @click="toggleFinished" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip v-if="showCollectionsButton" :text="$strings.LabelCollections" direction="top">
|
||||
<ui-icon-btn icon="collections_bookmark" class="mx-0.5" outlined @click="collectionsClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip v-if="!isPodcast && tracks.length" :text="$strings.LabelYourPlaylists" direction="top">
|
||||
<ui-icon-btn icon="playlist_add" class="mx-0.5" outlined @click="playlistsClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
<!-- Only admin or root user can download new episodes -->
|
||||
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" :text="$strings.LabelFindEpisodes" direction="top">
|
||||
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip v-if="bookmarks.length" :text="$strings.LabelYourBookmarks" direction="top">
|
||||
<ui-icon-btn :icon="bookmarks.length ? 'bookmarks' : 'bookmark_border'" class="mx-0.5" @click="clickBookmarksBtn" />
|
||||
</ui-tooltip>
|
||||
|
||||
<!-- RSS feed -->
|
||||
<ui-tooltip v-if="showRssFeedBtn" :text="$strings.LabelOpenRSSFeed" direction="top">
|
||||
<ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeed ? 'success' : 'primary'" outlined @click="clickRSSFeed" />
|
||||
</ui-tooltip>
|
||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" menu-width="148px" @action="contextMenuAction">
|
||||
<template #default="{ showMenu, clickShowMenu, disabled }">
|
||||
<button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<span class="material-icons">more_horiz</span>
|
||||
</button>
|
||||
</template>
|
||||
</ui-context-menu-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="my-4 max-w-2xl">
|
||||
@@ -232,7 +224,7 @@
|
||||
|
||||
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" class="mt-6" />
|
||||
|
||||
<tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item-id="libraryItemId" :files="libraryFiles" class="mt-6" />
|
||||
<tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item="libraryItem" class="mt-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -276,6 +268,12 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
downloadUrl() {
|
||||
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/download?token=${this.userToken}`
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
@@ -288,9 +286,6 @@ export default {
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
isFile() {
|
||||
return this.libraryItem.isFile
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
@@ -300,6 +295,9 @@ export default {
|
||||
isDeveloperMode() {
|
||||
return this.$store.state.developerMode
|
||||
},
|
||||
isFile() {
|
||||
return this.libraryItem.isFile
|
||||
},
|
||||
isBook() {
|
||||
return this.libraryItem.mediaType === 'book'
|
||||
},
|
||||
@@ -319,7 +317,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 +472,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() {
|
||||
@@ -510,12 +516,56 @@ export default {
|
||||
},
|
||||
showCollectionsButton() {
|
||||
return this.isBook && this.userCanUpdate
|
||||
},
|
||||
contextMenuItems() {
|
||||
const items = []
|
||||
|
||||
if (this.showCollectionsButton) {
|
||||
items.push({
|
||||
text: this.$strings.LabelCollections,
|
||||
action: 'collections'
|
||||
})
|
||||
}
|
||||
|
||||
if (!this.isPodcast && this.tracks.length) {
|
||||
items.push({
|
||||
text: this.$strings.LabelYourPlaylists,
|
||||
action: 'playlists'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.bookmarks.length) {
|
||||
items.push({
|
||||
text: this.$strings.LabelYourBookmarks,
|
||||
action: 'bookmarks'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.showRssFeedBtn) {
|
||||
items.push({
|
||||
text: this.$strings.LabelOpenRSSFeed,
|
||||
action: 'rss-feeds'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.userCanDownload) {
|
||||
items.push({
|
||||
text: this.$strings.LabelDownload,
|
||||
action: 'download'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.userCanDelete) {
|
||||
items.push({
|
||||
text: this.$strings.ButtonDelete,
|
||||
action: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickBookmarksBtn() {
|
||||
this.showBookmarksModal = true
|
||||
},
|
||||
selectBookmark(bookmark) {
|
||||
if (!bookmark) return
|
||||
if (this.isStreaming) {
|
||||
@@ -691,14 +741,6 @@ export default {
|
||||
})
|
||||
}
|
||||
},
|
||||
collectionsClick() {
|
||||
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||
this.$store.commit('globals/setShowCollectionsModal', true)
|
||||
},
|
||||
playlistsClick() {
|
||||
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem }])
|
||||
this.$store.commit('globals/setShowPlaylistsModal', true)
|
||||
},
|
||||
clickRSSFeed() {
|
||||
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
|
||||
id: this.libraryItemId,
|
||||
@@ -756,6 +798,58 @@ export default {
|
||||
}
|
||||
this.$store.commit('addItemToQueue', queueItem)
|
||||
}
|
||||
},
|
||||
downloadLibraryItem() {
|
||||
const a = document.createElement('a')
|
||||
a.style.display = 'none'
|
||||
a.href = this.downloadUrl
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => {
|
||||
a.remove()
|
||||
})
|
||||
},
|
||||
deleteLibraryItem() {
|
||||
const payload = {
|
||||
message: 'This will delete the library item from the database and your file system. Are you sure?',
|
||||
checkboxLabel: 'Delete from file system. Uncheck to only remove from database.',
|
||||
yesButtonText: this.$strings.ButtonDelete,
|
||||
yesButtonColor: 'error',
|
||||
checkboxDefaultValue: true,
|
||||
callback: (confirmed, hardDelete) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)
|
||||
.then(() => {
|
||||
this.$toast.success('Item deleted')
|
||||
this.$router.replace(`/library/${this.libraryId}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete item', error)
|
||||
this.$toast.error('Failed to delete item')
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
contextMenuAction(action) {
|
||||
if (action === 'collections') {
|
||||
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||
this.$store.commit('globals/setShowCollectionsModal', true)
|
||||
} else if (action === 'playlists') {
|
||||
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem }])
|
||||
this.$store.commit('globals/setShowPlaylistsModal', true)
|
||||
} else if (action === 'bookmarks') {
|
||||
this.showBookmarksModal = true
|
||||
} else if (action === 'rss-feeds') {
|
||||
this.clickRSSFeed()
|
||||
} else if (action === 'download') {
|
||||
this.downloadLibraryItem()
|
||||
} else if (action === 'delete') {
|
||||
this.deleteLibraryItem()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -123,7 +123,7 @@ export default class PlayerHandler {
|
||||
|
||||
playerError() {
|
||||
// Switch to HLS stream on error
|
||||
if (!this.isCasting && !this.currentStreamId && (this.player instanceof LocalAudioPlayer)) {
|
||||
if (!this.isCasting && (this.player instanceof LocalAudioPlayer)) {
|
||||
console.log(`[PlayerHandler] Audio player error switching to HLS stream`)
|
||||
this.prepare(true)
|
||||
}
|
||||
@@ -173,16 +173,30 @@ export default class PlayerHandler {
|
||||
this.ctx.setBufferTime(buffertime)
|
||||
}
|
||||
|
||||
getDeviceId() {
|
||||
let deviceId = localStorage.getItem('absDeviceId')
|
||||
if (!deviceId) {
|
||||
deviceId = this.ctx.$randomId()
|
||||
localStorage.setItem('absDeviceId', deviceId)
|
||||
}
|
||||
return deviceId
|
||||
}
|
||||
|
||||
async prepare(forceTranscode = false) {
|
||||
var payload = {
|
||||
this.currentSessionId = null // Reset session
|
||||
|
||||
const payload = {
|
||||
deviceInfo: {
|
||||
deviceId: this.getDeviceId()
|
||||
},
|
||||
supportedMimeTypes: this.player.playableMimeTypes,
|
||||
mediaPlayer: this.isCasting ? 'chromecast' : 'html5',
|
||||
forceTranscode,
|
||||
forceDirectPlay: this.isCasting || this.isVideo // TODO: add transcode support for chromecast
|
||||
}
|
||||
|
||||
var path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play`
|
||||
var session = await this.ctx.$axios.$post(path, payload).catch((error) => {
|
||||
const path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play`
|
||||
const session = await this.ctx.$axios.$post(path, payload).catch((error) => {
|
||||
console.error('Failed to start stream', error)
|
||||
})
|
||||
this.prepareSession(session)
|
||||
@@ -238,12 +252,17 @@ export default class PlayerHandler {
|
||||
closePlayer() {
|
||||
console.log('[PlayerHandler] Close Player')
|
||||
this.sendCloseSession()
|
||||
this.resetPlayer()
|
||||
}
|
||||
|
||||
resetPlayer() {
|
||||
if (this.player) {
|
||||
this.player.destroy()
|
||||
}
|
||||
this.player = null
|
||||
this.playerState = 'IDLE'
|
||||
this.libraryItem = null
|
||||
this.currentSessionId = null
|
||||
this.startTime = 0
|
||||
this.stopPlayInterval()
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import Vue from 'vue'
|
||||
import cronParser from 'cron-parser'
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
Vue.prototype.$randomId = () => nanoid()
|
||||
|
||||
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
|
||||
if (isNaN(bytes) || bytes == 0) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,11 +155,13 @@
|
||||
"HeaderUpdateLibrary": "Bibliothek aktualisieren",
|
||||
"HeaderUsers": "Benutzer",
|
||||
"HeaderYourStats": "Eigene Statistiken",
|
||||
"LabelAbridged": "Gekürzt",
|
||||
"LabelAccountType": "Kontoart",
|
||||
"LabelAccountTypeAdmin": "Admin",
|
||||
"LabelAccountTypeGuest": "Gast",
|
||||
"LabelAccountTypeUser": "Benutzer",
|
||||
"LabelActivity": "Aktivitäten",
|
||||
"LabelAdded": "Added",
|
||||
"LabelAddedAt": "Hinzugefügt am",
|
||||
"LabelAddToCollection": "Zur Sammlung hinzufügen",
|
||||
"LabelAddToCollectionBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Sammlung hinzu",
|
||||
@@ -181,11 +183,15 @@
|
||||
"LabelBackupsMaxBackupSizeHelp": "Zum Schutz vor Fehlkonfigurationen schlagen Sicherungen fehl, wenn sie die konfigurierte Größe überschreiten.",
|
||||
"LabelBackupsNumberToKeep": "Anzahl der aufzubewahrenden Sicherungen",
|
||||
"LabelBackupsNumberToKeepHelp": "Es wird immer nur 1 Sicherung auf einmal entfernt. Wenn Sie bereits mehrere Sicherungen als die definierte max. Anzahl haben, sollten Sie diese manuell entfernen.",
|
||||
"LabelBitrate": "Bitrate",
|
||||
"LabelBooks": "Bücher",
|
||||
"LabelChangePassword": "Passwort ändern",
|
||||
"LabelChannels": "Channels",
|
||||
"LabelChapters": "Chapters",
|
||||
"LabelChaptersFound": "gefundene Kapitel",
|
||||
"LabelChapterTitle": "Kapitelüberschrift",
|
||||
"LabelClosePlayer": "Player schließen",
|
||||
"LabelCodec": "Codec",
|
||||
"LabelCollapseSeries": "Serien zusammenfassen",
|
||||
"LabelCollections": "Sammlungen",
|
||||
"LabelComplete": "Vollständig",
|
||||
@@ -211,6 +217,7 @@
|
||||
"LabelDuration": "Laufzeit",
|
||||
"LabelDurationFound": "Gefundene Laufzeit:",
|
||||
"LabelEdit": "Bearbeiten",
|
||||
"LabelEmbeddedCover": "Embedded Cover",
|
||||
"LabelEnable": "Aktivieren",
|
||||
"LabelEnd": "Ende",
|
||||
"LabelEpisode": "Episode",
|
||||
@@ -228,6 +235,7 @@
|
||||
"LabelFinished": "beendet",
|
||||
"LabelFolder": "Ordner",
|
||||
"LabelFolders": "Verzeichnisse",
|
||||
"LabelFormat": "Format",
|
||||
"LabelGenre": "Kategorie",
|
||||
"LabelGenres": "Kategorien",
|
||||
"LabelHardDeleteFile": "Datei dauerhaft löschen",
|
||||
@@ -249,6 +257,8 @@
|
||||
"LabelItem": "Medium",
|
||||
"LabelLanguage": "Sprache",
|
||||
"LabelLanguageDefaultServer": "Standard-Server-Sprache",
|
||||
"LabelLastBookAdded": "Last Book Added",
|
||||
"LabelLastBookUpdated": "Last Book Updated",
|
||||
"LabelLastSeen": "Zuletzt angesehen",
|
||||
"LabelLastTime": "Letztes Mal",
|
||||
"LabelLastUpdate": "Letzte Aktualisierung",
|
||||
@@ -267,10 +277,12 @@
|
||||
"LabelMediaType": "Medientyp",
|
||||
"LabelMetadataProvider": "Metadatenanbieter",
|
||||
"LabelMetaTag": "Meta Schlagwort",
|
||||
"LabelMetaTags": "Meta Tags",
|
||||
"LabelMinute": "Minute",
|
||||
"LabelMissing": "Fehlend",
|
||||
"LabelMissingParts": "Fehlende Teile",
|
||||
"LabelMore": "Mehr",
|
||||
"LabelMoreInfo": "More Info",
|
||||
"LabelName": "Name",
|
||||
"LabelNarrator": "Erzähler",
|
||||
"LabelNarrators": "Erzähler",
|
||||
@@ -324,12 +336,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",
|
||||
@@ -401,6 +413,7 @@
|
||||
"LabelTags": "Schlagwörter",
|
||||
"LabelTagsAccessibleToUser": "Für Benutzer zugängliche Schlagwörter",
|
||||
"LabelTasks": "Tasks Running",
|
||||
"LabelTimeBase": "Time Base",
|
||||
"LabelTimeListened": "Gehörte Zeit",
|
||||
"LabelTimeListenedToday": "Heute gehörte Zeit",
|
||||
"LabelTimeRemaining": "{0} verbleibend",
|
||||
@@ -420,6 +433,7 @@
|
||||
"LabelTracksMultiTrack": "Mehrfachdatei",
|
||||
"LabelTracksSingleTrack": "Einzeldatei",
|
||||
"LabelType": "Typ",
|
||||
"LabelUnabridged": "Ungekürzt",
|
||||
"LabelUnknown": "Unbekannt",
|
||||
"LabelUpdateCover": "Titelbild aktualisieren",
|
||||
"LabelUpdateCoverHelp": "Erlaube das Überschreiben bestehender Titelbilder für die ausgewählten Hörbücher wenn eine Übereinstimmung gefunden wird",
|
||||
@@ -463,6 +477,7 @@
|
||||
"MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?",
|
||||
"MessageConfirmMarkSeriesFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als abgeschlossen markieren wollen?",
|
||||
"MessageConfirmMarkSeriesNotFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als nicht abgeschlossen markieren wollen?",
|
||||
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
|
||||
"MessageConfirmRemoveCollection": "Sind Sie sicher, dass Sie die Sammlung \"{0}\" löschen wollen?",
|
||||
"MessageConfirmRemoveEpisode": "Sind Sie sicher, dass Sie die Episode \"{0}\" löschen möchten?",
|
||||
"MessageConfirmRemoveEpisodes": "Sind Sie sicher, dass Sie {0} Episoden löschen wollen?",
|
||||
@@ -502,8 +517,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 +581,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 +651,4 @@
|
||||
"ToastSocketFailedToConnect": "Verbindung zum WebSocket fehlgeschlagen",
|
||||
"ToastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden",
|
||||
"ToastUserDeleteSuccess": "Benutzer gelöscht"
|
||||
}
|
||||
}
|
||||
@@ -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,11 +155,13 @@
|
||||
"HeaderUpdateLibrary": "Update Library",
|
||||
"HeaderUsers": "Users",
|
||||
"HeaderYourStats": "Your Stats",
|
||||
"LabelAbridged": "Abridged",
|
||||
"LabelAccountType": "Account Type",
|
||||
"LabelAccountTypeAdmin": "Admin",
|
||||
"LabelAccountTypeGuest": "Guest",
|
||||
"LabelAccountTypeUser": "User",
|
||||
"LabelActivity": "Activity",
|
||||
"LabelAdded": "Added",
|
||||
"LabelAddedAt": "Added At",
|
||||
"LabelAddToCollection": "Add to Collection",
|
||||
"LabelAddToCollectionBatch": "Add {0} Books to Collection",
|
||||
@@ -181,11 +183,15 @@
|
||||
"LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.",
|
||||
"LabelBackupsNumberToKeep": "Number of backups to keep",
|
||||
"LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.",
|
||||
"LabelBitrate": "Bitrate",
|
||||
"LabelBooks": "Books",
|
||||
"LabelChangePassword": "Change Password",
|
||||
"LabelChannels": "Channels",
|
||||
"LabelChapters": "Chapters",
|
||||
"LabelChaptersFound": "chapters found",
|
||||
"LabelChapterTitle": "Chapter Title",
|
||||
"LabelClosePlayer": "Close player",
|
||||
"LabelCodec": "Codec",
|
||||
"LabelCollapseSeries": "Collapse Series",
|
||||
"LabelCollections": "Collections",
|
||||
"LabelComplete": "Complete",
|
||||
@@ -211,6 +217,7 @@
|
||||
"LabelDuration": "Duration",
|
||||
"LabelDurationFound": "Duration found:",
|
||||
"LabelEdit": "Edit",
|
||||
"LabelEmbeddedCover": "Embedded Cover",
|
||||
"LabelEnable": "Enable",
|
||||
"LabelEnd": "End",
|
||||
"LabelEpisode": "Episode",
|
||||
@@ -228,6 +235,7 @@
|
||||
"LabelFinished": "Finished",
|
||||
"LabelFolder": "Folder",
|
||||
"LabelFolders": "Folders",
|
||||
"LabelFormat": "Format",
|
||||
"LabelGenre": "Genre",
|
||||
"LabelGenres": "Genres",
|
||||
"LabelHardDeleteFile": "Hard delete file",
|
||||
@@ -249,6 +257,8 @@
|
||||
"LabelItem": "Item",
|
||||
"LabelLanguage": "Language",
|
||||
"LabelLanguageDefaultServer": "Default Server Language",
|
||||
"LabelLastBookAdded": "Last Book Added",
|
||||
"LabelLastBookUpdated": "Last Book Updated",
|
||||
"LabelLastSeen": "Last Seen",
|
||||
"LabelLastTime": "Last Time",
|
||||
"LabelLastUpdate": "Last Update",
|
||||
@@ -267,10 +277,12 @@
|
||||
"LabelMediaType": "Media Type",
|
||||
"LabelMetadataProvider": "Metadata Provider",
|
||||
"LabelMetaTag": "Meta Tag",
|
||||
"LabelMetaTags": "Meta Tags",
|
||||
"LabelMinute": "Minute",
|
||||
"LabelMissing": "Missing",
|
||||
"LabelMissingParts": "Missing Parts",
|
||||
"LabelMore": "More",
|
||||
"LabelMoreInfo": "More Info",
|
||||
"LabelName": "Name",
|
||||
"LabelNarrator": "Narrator",
|
||||
"LabelNarrators": "Narrators",
|
||||
@@ -324,12 +336,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",
|
||||
@@ -401,6 +413,7 @@
|
||||
"LabelTags": "Tags",
|
||||
"LabelTagsAccessibleToUser": "Tags Accessible to User",
|
||||
"LabelTasks": "Tasks Running",
|
||||
"LabelTimeBase": "Time Base",
|
||||
"LabelTimeListened": "Time Listened",
|
||||
"LabelTimeListenedToday": "Time Listened Today",
|
||||
"LabelTimeRemaining": "{0} remaining",
|
||||
@@ -420,6 +433,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",
|
||||
@@ -463,6 +477,7 @@
|
||||
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
|
||||
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
|
||||
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
|
||||
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
|
||||
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
|
||||
@@ -502,8 +517,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 +581,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 +651,4 @@
|
||||
"ToastSocketFailedToConnect": "Socket failed to connect",
|
||||
"ToastUserDeleteFailed": "Failed to delete user",
|
||||
"ToastUserDeleteSuccess": "User deleted"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,19 +8,19 @@
|
||||
"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 l’encodage",
|
||||
"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 l’analyse",
|
||||
"ButtonCreate": "Créer",
|
||||
"ButtonCreateBackup": "Créer une sauvegarde",
|
||||
"ButtonDelete": "Effacer",
|
||||
"ButtonDownloadQueue": "Queue",
|
||||
"ButtonDownloadQueue": "Queue de téléchargement",
|
||||
"ButtonEdit": "Modifier",
|
||||
"ButtonEditChapters": "Modifier les chapitres",
|
||||
"ButtonEditPodcast": "Modifier les podcasts",
|
||||
@@ -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 l’horodatage du livre",
|
||||
"ButtonShow": "Afficher",
|
||||
"ButtonStartM4BEncode": "Démarrer l'encodage M4B",
|
||||
"ButtonStartM4BEncode": "Démarrer l’encodage 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 l’utilisateur {0}",
|
||||
"ButtonUserEdit": "Modifier l’utilisateur {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",
|
||||
@@ -95,26 +95,26 @@
|
||||
"HeaderCover": "Couverture",
|
||||
"HeaderCurrentDownloads": "Current Downloads",
|
||||
"HeaderDetails": "Détails",
|
||||
"HeaderDownloadQueue": "Download Queue",
|
||||
"HeaderDownloadQueue": "Queue de téléchargement",
|
||||
"HeaderEpisodes": "Épisodes",
|
||||
"HeaderFiles": "Fichiers",
|
||||
"HeaderFindChapters": "Trouver les chapitres",
|
||||
"HeaderIgnoredFiles": "Fichiers Ignorés",
|
||||
"HeaderItemFiles": "Fichiers des Articles",
|
||||
"HeaderItemMetadataUtils": "Outils de gestion des métadonnées",
|
||||
"HeaderLastListeningSession": "Dernière Session d'é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",
|
||||
"HeaderRSSFeedGeneral": "Détails de flux RSS",
|
||||
"HeaderRSSFeedIsOpen": "Le Flux RSS est actif",
|
||||
"HeaderRSSFeedGeneral": "RSS Details",
|
||||
"HeaderSavedMediaProgress": "Progression de la sauvegarde des médias",
|
||||
"HeaderSchedule": "Programmation",
|
||||
"HeaderScheduleLibraryScans": "Analyse automatique de la bibliothèque",
|
||||
@@ -144,48 +144,54 @@
|
||||
"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 l’auteur",
|
||||
"HeaderUpdateDetails": "Mettre à jour les détails",
|
||||
"HeaderUpdateLibrary": "Mettre à jour la bibliothèque",
|
||||
"HeaderUsers": "Utilisateurs",
|
||||
"HeaderYourStats": "Vos statistiques",
|
||||
"LabelAbridged": "Version courte",
|
||||
"LabelAccountType": "Type de compte",
|
||||
"LabelAccountTypeAdmin": "Admin",
|
||||
"LabelAccountTypeGuest": "Invité",
|
||||
"LabelAccountTypeUser": "Utilisateur",
|
||||
"LabelActivity": "Activité",
|
||||
"LabelAddedAt": "Date d'ajout",
|
||||
"LabelAdded": "Added",
|
||||
"LabelAddedAt": "Date d’ajout",
|
||||
"LabelAddToCollection": "Ajouter à la collection",
|
||||
"LabelAddToCollectionBatch": "Ajout de {0} livres à la lollection",
|
||||
"LabelAddToPlaylist": "Ajouter à la liste de lecture",
|
||||
"LabelAddToPlaylistBatch": "{0} éléments ajoutés à la liste de lecture",
|
||||
"LabelAll": "Tout",
|
||||
"LabelAllUsers": "Tous les utilisateurs",
|
||||
"LabelAlreadyInYourLibrary": "Already in your library",
|
||||
"LabelAlreadyInYourLibrary": "Déjà dans la bibliothèque",
|
||||
"LabelAppend": "Ajouter",
|
||||
"LabelAuthor": "Auteur",
|
||||
"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 à l’Utilisateur",
|
||||
"LabelBackupsEnableAutomaticBackups": "Activer les sauvegardes automatiques",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "Sauvegardes Enregistrées dans /metadata/backups",
|
||||
"LabelBackupsMaxBackupSize": "Taille maximale de la sauvegarde (en Go)",
|
||||
"LabelBackupsMaxBackupSizeHelp": "Afin de prévenir les mauvaises configuration, la sauvegarde échouera si elle excède la taille limite.",
|
||||
"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.",
|
||||
"LabelBitrate": "Bitrate",
|
||||
"LabelBooks": "Livres",
|
||||
"LabelChangePassword": "Changer le mot de passe",
|
||||
"LabelChangePassword": "Modifier le mot de passe",
|
||||
"LabelChannels": "Channels",
|
||||
"LabelChapters": "Chapters",
|
||||
"LabelChaptersFound": "Chapitres trouvés",
|
||||
"LabelChapterTitle": "Titres du chapitre",
|
||||
"LabelClosePlayer": "Fermer le lecteur",
|
||||
"LabelCodec": "Codec",
|
||||
"LabelCollapseSeries": "Réduire les séries",
|
||||
"LabelCollections": "Collections",
|
||||
"LabelComplete": "Complet",
|
||||
@@ -193,17 +199,17 @@
|
||||
"LabelContinueListening": "Continuer la lecture",
|
||||
"LabelContinueSeries": "Continuer la série",
|
||||
"LabelCover": "Couverture",
|
||||
"LabelCoverImageURL": "URL vers l'image de couverture",
|
||||
"LabelCoverImageURL": "URL vers l’image de couverture",
|
||||
"LabelCreatedAt": "Créé le",
|
||||
"LabelCronExpression": "Expression Cron",
|
||||
"LabelCurrent": "Courrant",
|
||||
"LabelCurrently": "En ce moment :",
|
||||
"LabelCustomCronExpression": "Custom Cron Expression:",
|
||||
"LabelCustomCronExpression": "Expression cron personnalisée:",
|
||||
"LabelDatetime": "Datetime",
|
||||
"LabelDescription": "Description",
|
||||
"LabelDeselectAll": "Tout déselectionner",
|
||||
"LabelDevice": "Appareil",
|
||||
"LabelDeviceInfo": "Détail de l'appareil",
|
||||
"LabelDeviceInfo": "Détail de l’appareil",
|
||||
"LabelDirectory": "Répertoire",
|
||||
"LabelDiscFromFilename": "Disque depuis le fichier",
|
||||
"LabelDiscFromMetadata": "Disque depuis les métadonnées",
|
||||
@@ -211,23 +217,25 @@
|
||||
"LabelDuration": "Durée",
|
||||
"LabelDurationFound": "Durée trouvée :",
|
||||
"LabelEdit": "Modifier",
|
||||
"LabelEmbeddedCover": "Embedded Cover",
|
||||
"LabelEnable": "Activer",
|
||||
"LabelEnd": "Fin",
|
||||
"LabelEpisode": "Épisode",
|
||||
"LabelEpisodeTitle": "Titre de l'épisode",
|
||||
"LabelEpisodeType": "Type de l'épisode",
|
||||
"LabelExample": "Example",
|
||||
"LabelEpisodeTitle": "Titre de l’épisode",
|
||||
"LabelEpisodeType": "Type de l’épisode",
|
||||
"LabelExample": "Exemple",
|
||||
"LabelExplicit": "Restriction",
|
||||
"LabelFeedURL": "URL deu flux",
|
||||
"LabelFile": "Fichier",
|
||||
"LabelFileBirthtime": "Creation du fichier",
|
||||
"LabelFileModified": "Modification du fichier",
|
||||
"LabelFilename": "Nom de fichier",
|
||||
"LabelFilterByUser": "Filtrer par l'utilisateur",
|
||||
"LabelFilterByUser": "Filtrer par l’utilisateur",
|
||||
"LabelFindEpisodes": "Trouver des épisodes",
|
||||
"LabelFinished": "Fini(e)",
|
||||
"LabelFolder": "Dossier",
|
||||
"LabelFolders": "Dossiers",
|
||||
"LabelFormat": "Format",
|
||||
"LabelGenre": "Genre",
|
||||
"LabelGenres": "Genres",
|
||||
"LabelHardDeleteFile": "Suppression du fichier",
|
||||
@@ -249,11 +257,13 @@
|
||||
"LabelItem": "Article",
|
||||
"LabelLanguage": "Langue",
|
||||
"LabelLanguageDefaultServer": "Langue par défaut",
|
||||
"LabelLastBookAdded": "Last Book Added",
|
||||
"LabelLastBookUpdated": "Last Book Updated",
|
||||
"LabelLastSeen": "Vu dernièrement",
|
||||
"LabelLastTime": "Progression",
|
||||
"LabelLastUpdate": "Dernière mise à jour",
|
||||
"LabelLess": "Moins",
|
||||
"LabelLibrariesAccessibleToUser": "Bibliothèque accessible à l'utilisateur",
|
||||
"LabelLibrariesAccessibleToUser": "Bibliothèque accessible à l’utilisateur",
|
||||
"LabelLibrary": "Bibliothèque",
|
||||
"LabelLibraryItem": "Article de bibliothèque",
|
||||
"LabelLibraryName": "Nom de la bibliothèque",
|
||||
@@ -262,15 +272,17 @@
|
||||
"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",
|
||||
"LabelMetaTag": "Etiquette de métadonnée",
|
||||
"LabelMetaTags": "Meta Tags",
|
||||
"LabelMinute": "Minute",
|
||||
"LabelMissing": "Manquant",
|
||||
"LabelMissingParts": "Parties manquantes",
|
||||
"LabelMore": "Plus",
|
||||
"LabelMoreInfo": "More Info",
|
||||
"LabelName": "Nom",
|
||||
"LabelNarrator": "Narrateur",
|
||||
"LabelNarrators": "Narrateurs",
|
||||
@@ -278,58 +290,58 @@
|
||||
"LabelNewestAuthors": "Nouveaux auteurs",
|
||||
"LabelNewestEpisodes": "Derniers épisodes",
|
||||
"LabelNewPassword": "Nouveau mot de passe",
|
||||
"LabelNextBackupDate": "Next backup date",
|
||||
"LabelNextScheduledRun": "Next scheduled run",
|
||||
"LabelNextBackupDate": "Prochaine date de sauvegarde",
|
||||
"LabelNextScheduledRun": "Prochain lancement prévu",
|
||||
"LabelNotes": "Notes",
|
||||
"LabelNotFinished": "Non terminé(e)",
|
||||
"LabelNotificationAppriseURL": "URL(s) d'apprise",
|
||||
"LabelNotificationAppriseURL": "URL(s) d’apprise",
|
||||
"LabelNotificationAvailableVariables": "Variables disponibles",
|
||||
"LabelNotificationBodyTemplate": "Modèle de Message",
|
||||
"LabelNotificationEvent": "Evènement de Notification",
|
||||
"LabelNotificationsMaxFailedAttempts": "Nombres de tentatives d'envoi",
|
||||
"LabelNotificationsMaxFailedAttempts": "Nombres de tentatives d’envoi",
|
||||
"LabelNotificationsMaxFailedAttemptsHelp": "La notification est abandonnée une fois ce seuil atteint",
|
||||
"LabelNotificationsMaxQueueSize": "Nombres de notifications maximum à mettre en attente",
|
||||
"LabelNotificationsMaxQueueSizeHelp": "La limite de notification est de un évènement par seconde. Le notification seront ignorées si la file d'attente est à son maximum. Cela empêche un flot trop important.",
|
||||
"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.",
|
||||
"LabelNotificationTitleTemplate": "Modèle de Titre",
|
||||
"LabelNotStarted": "Non Démarré(e)",
|
||||
"LabelNumberOfBooks": "Nombre de Livres",
|
||||
"LabelNumberOfEpisodes": "Nombre d'Episodes",
|
||||
"LabelNumberOfEpisodes": "Nombre d’Episodes",
|
||||
"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",
|
||||
"LabelPodcastType": "Type de Podcast",
|
||||
"LabelPrefixesToIgnore": "Préfixes à Ignorer (Insensible à la Casse)",
|
||||
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
|
||||
"LabelProgress": "Progression",
|
||||
"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": "Email propriétaire personnalisé",
|
||||
"LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé",
|
||||
"LabelRSSFeedOpen": "Flux RSS ouvert",
|
||||
"LabelRSSFeedSlug": "Identificateur d'adresse du Flux RSS ",
|
||||
"LabelRSSFeedPreventIndexing": "Empêcher l'indexation",
|
||||
"LabelRSSFeedSlug": "Identificateur d’adresse 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 +350,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 l’active pour tous les utilisateurs (ou utiliser l’interrupteur « Fonctionnalités expérimentales » pour l’activer seulement pour vous)",
|
||||
"LabelSettingsExperimentalFeatures": "Fonctionnalités expérimentales",
|
||||
"LabelSettingsExperimentalFeaturesHelp": "Fonctionnalités en cours de développement sur lesquels nous attendons votre retour et expérience. Cliquer pour ouvrir la discussion Github.",
|
||||
"LabelSettingsFindCovers": "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, l’analyser tentera de récupérer une couverture.<br>Attention, cela peut augmenter le temps d’analyse.",
|
||||
"LabelSettingsHomePageBookshelfView": "La page d’accueil utilise la vue étagère",
|
||||
"LabelSettingsLibraryBookshelfView": "La bibliothèque utilise la vue étagère",
|
||||
"LabelSettingsOverdriveMediaMarkers": "Utiliser Overdrive Media Marker pour les chapitres",
|
||||
"LabelSettingsOverdriveMediaMarkersHelp": "Les fichiers MP3 d'Overdrive viennent avec les minutages des chapitres intégrés en métadonnées. Activer ce paramètre utilisera ces minutages pour les chapitres automatiquement.",
|
||||
"LabelSettingsOverdriveMediaMarkersHelp": "Les fichiers MP3 d’Overdrive viennent avec les minutages des chapitres intégrés en métadonnées. Activer ce paramètre utilisera ces minutages pour les chapitres automatiquement.",
|
||||
"LabelSettingsParseSubtitles": "Analyse des sous-titres",
|
||||
"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 l’article lors d’une 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 l’article. 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 l’article avec une extension « .abs ».",
|
||||
"LabelSettingsTimeFormat": "Time Format",
|
||||
"LabelShowAll": "Afficher Tout",
|
||||
"LabelSize": "Taille",
|
||||
@@ -385,13 +397,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": "d’affilé(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 +411,11 @@
|
||||
"LabelSupportedFileTypes": "Types de fichiers supportés",
|
||||
"LabelTag": "Étiquette",
|
||||
"LabelTags": "Étiquettes",
|
||||
"LabelTagsAccessibleToUser": "Étiquettes accessibles à l'utilisateur",
|
||||
"LabelTagsAccessibleToUser": "Étiquettes accessibles à l’utilisateur",
|
||||
"LabelTasks": "Tasks Running",
|
||||
"LabelTimeListened": "Temps d'écoute",
|
||||
"LabelTimeListenedToday": "Nombres d'écoutes Aujourd'hui",
|
||||
"LabelTimeBase": "Time Base",
|
||||
"LabelTimeListened": "Temps d’écoute",
|
||||
"LabelTimeListenedToday": "Nombres d’écoutes Aujourd’hui",
|
||||
"LabelTimeRemaining": "{0} restantes",
|
||||
"LabelTimeToShift": "Temps de décalage en secondes",
|
||||
"LabelTitle": "Titre",
|
||||
@@ -411,169 +424,172 @@
|
||||
"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, l’image 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": "Version intégrale",
|
||||
"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 lorsqu’une 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 lorsqu’une 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 d’utilisateur",
|
||||
"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 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",
|
||||
"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}\" ?",
|
||||
"MessageConfirmRemoveAllChapters": "Êtes-vous sûr de vouloir supprimer tous les chapitres ?",
|
||||
"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 l’ordre 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": "l’URL du Flux sera {0}",
|
||||
"MessageFetching": "Récupération…",
|
||||
"MessageForceReScanDescription": "Analysera tous les fichiers de nouveau. Les étiquettes ID3 des fichiers audios, fichiers OPF, et les fichiers textes seront analysés comme s’ils étaient nouveaux.",
|
||||
"MessageImportantNotice": "Information Importante !",
|
||||
"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 l’an dernier",
|
||||
"MessageLoading": "Chargement…",
|
||||
"MessageLoadingFolders": "Chargement des dossiers…",
|
||||
"MessageM4BFailed": "M4B en échec !",
|
||||
"MessageM4BFinished": "M4B terminé !",
|
||||
"MessageMapChapterTitles": "Faire correspondre les titres des chapitres aux chapitres existants de votre livre audio sans ajuster l'horodatage.",
|
||||
"MessageMapChapterTitles": "Faire correspondre les titres des chapitres aux chapitres existants de votre livre audio sans ajuster l’horodatage.",
|
||||
"MessageMarkAsFinished": "Marquer comme terminé",
|
||||
"MessageMarkAsNotFinished": "Marquer comme non Terminé",
|
||||
"MessageMatchBooksDescription": "tentera de faire correspondre les livres de la bibliothèque avec les livres du fournisseur sélectionné pour combler les détails et couverture manquants. N'écrase pas les données existantes.",
|
||||
"MessageNoAudioTracks": "Pas de pistes audio",
|
||||
"MessageNoAuthors": "Pas d'Auteurs",
|
||||
"MessageNoBackups": "Pas de Sauvegardes",
|
||||
"MessageNoBookmarks": "Pas de signets",
|
||||
"MessageNoChapters": "Pas de chapitres",
|
||||
"MessageNoCollections": "Pas de collections",
|
||||
"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 d’attente",
|
||||
"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 n’avez 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 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 ?",
|
||||
"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 l’utilisateur « {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 l’utilisateur « root » peut utiliser un mot de passe vide",
|
||||
"NoteChapterEditorTimes": "Information : l’horodatage du premier chapitre doit être à 0:00 et celui du dernier chapitre ne peut se situer au-delà de la durée du Livre Audio.",
|
||||
"NoteFolderPicker": "Information : Les dossiers déjà surveillés ne sont pas affichés",
|
||||
"NoteFolderPickerDebian": "Information : La sélection de dossier sur une installation debian n’est pas finalisée. Merci de renseigner le chemin complet vers votre bibliothèque manuellement.",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Attention : la majorité des application de podcast nécessite une 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 l’image",
|
||||
"ToastAuthorImageRemoveSuccess": "Image de l’auteur supprimée",
|
||||
"ToastAuthorUpdateFailed": "Échec de la mise à jour de l’auteur",
|
||||
"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 +613,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 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 sur les détails de l’article",
|
||||
"ToastItemMarkedAsFinishedFailed": "Échec de l’annotation terminée",
|
||||
"ToastItemMarkedAsFinishedSuccess": "Article marqué comme terminé",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "Échec de l'annotation non-terminée",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "Échec de l’annotation 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 l’analyse",
|
||||
"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 +638,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 l’article 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 l’utilisateur",
|
||||
"ToastUserDeleteSuccess": "Utilisateur supprimé"
|
||||
}
|
||||
}
|
||||
654
client/strings/gu.json
Normal file
654
client/strings/gu.json
Normal file
@@ -0,0 +1,654 @@
|
||||
{
|
||||
"ButtonAdd": "ઉમેરો",
|
||||
"ButtonAddChapters": "પ્રકરણો ઉમેરો",
|
||||
"ButtonAddPodcasts": "પોડકાસ્ટ ઉમેરો",
|
||||
"ButtonAddYourFirstLibrary": "તમારી પ્રથમ પુસ્તકાલય ઉમેરો",
|
||||
"ButtonApply": "લાગુ કરો",
|
||||
"ButtonApplyChapters": "પ્રકરણો લાગુ કરો",
|
||||
"ButtonAuthors": "લેખકો",
|
||||
"ButtonBrowseForFolder": "ફોલ્ડર માટે જુઓ",
|
||||
"ButtonCancel": "રદ કરો",
|
||||
"ButtonCancelEncode": "એન્કોડ રદ કરો",
|
||||
"ButtonChangeRootPassword": "રૂટ પાસવર્ડ બદલો",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "નવા એપિસોડ્સ ચેક કરો અને ડાઉનલોડ કરો",
|
||||
"ButtonChooseAFolder": "ફોલ્ડર પસંદ કરો",
|
||||
"ButtonChooseFiles": "ફાઇલો પસંદ કરો",
|
||||
"ButtonClearFilter": "ફિલ્ટર જતુ કરો ",
|
||||
"ButtonCloseFeed": "ફીડ બંધ કરો",
|
||||
"ButtonCollections": "સંગ્રહ",
|
||||
"ButtonConfigureScanner": "સ્કેનર સેટિંગ બદલો",
|
||||
"ButtonCreate": "બનાવો",
|
||||
"ButtonCreateBackup": "બેકઅપ બનાવો",
|
||||
"ButtonDelete": "કાઢી નાખો",
|
||||
"ButtonDownloadQueue": "કતાર ડાઉનલોડ કરો",
|
||||
"ButtonEdit": "સંપાદિત કરો",
|
||||
"ButtonEditChapters": "પ્રકરણો સંપાદિત કરો",
|
||||
"ButtonEditPodcast": "પોડકાસ્ટ સંપાદિત કરો",
|
||||
"ButtonForceReScan": "બળપૂર્વક ફરીથી સ્કેન કરો",
|
||||
"ButtonFullPath": "સંપૂર્ણ પથ",
|
||||
"ButtonHide": "છુપાવો",
|
||||
"ButtonHome": "ઘર",
|
||||
"ButtonIssues": "સમસ્યાઓ",
|
||||
"ButtonLatest": "નવીનતમ",
|
||||
"ButtonLibrary": "પુસ્તકાલય",
|
||||
"ButtonLogout": "લૉગ આઉટ",
|
||||
"ButtonLookup": "શોધો",
|
||||
"ButtonManageTracks": "ટ્રેક્સ મેનેજ કરો",
|
||||
"ButtonMapChapterTitles": "પ્રકરણ શીર્ષકો મેપ કરો",
|
||||
"ButtonMatchAllAuthors": "બધા મેળ ખાતા લેખકો શોધો",
|
||||
"ButtonMatchBooks": "મેળ ખાતી પુસ્તકો શોધો",
|
||||
"ButtonNevermind": "કંઈ વાંધો નહીં",
|
||||
"ButtonOk": "ઓકે",
|
||||
"ButtonOpenFeed": "ફીડ ખોલો",
|
||||
"ButtonOpenManager": "મેનેજર ખોલો",
|
||||
"ButtonPlay": "ચલાવો",
|
||||
"ButtonPlaying": "ચલાવી રહ્યું છે",
|
||||
"ButtonPlaylists": "પ્લેલિસ્ટ",
|
||||
"ButtonPurgeAllCache": "બધો Cache કાઢી નાખો",
|
||||
"ButtonPurgeItemsCache": "વસ્તુઓનો Cache કાઢી નાખો",
|
||||
"ButtonPurgeMediaProgress": "બધું સાંભળ્યું કાઢી નાખો",
|
||||
"ButtonQueueAddItem": "કતારમાં ઉમેરો",
|
||||
"ButtonQueueRemoveItem": "કતારથી કાઢી નાખો",
|
||||
"ButtonQuickMatch": "ઝડપી મેળ ખવડાવો",
|
||||
"ButtonRead": "વાંચો",
|
||||
"ButtonRemove": "કાઢી નાખો",
|
||||
"ButtonRemoveAll": "બધું કાઢી નાખો",
|
||||
"ButtonRemoveAllLibraryItems": "બધું પુસ્તકાલય વસ્તુઓ કાઢી નાખો",
|
||||
"ButtonRemoveFromContinueListening": "સાંભળતી પુસ્તકો માંથી કાઢી નાખો",
|
||||
"ButtonRemoveSeriesFromContinueSeries": "સાંભળતી સિરીઝ માંથી કાઢી નાખો",
|
||||
"ButtonReScan": "ફરીથી સ્કેન કરો",
|
||||
"ButtonReset": "રીસેટ કરો",
|
||||
"ButtonRestore": "પુનઃસ્થાપિત કરો",
|
||||
"ButtonSave": "સાચવો",
|
||||
"ButtonSaveAndClose": "સાચવો અને બંધ કરો",
|
||||
"ButtonSaveTracklist": "ટ્રેક યાદી સાચવો",
|
||||
"ButtonScan": "સ્કેન કરો",
|
||||
"ButtonScanLibrary": "પુસ્તકાલય સ્કેન કરો",
|
||||
"ButtonSearch": "શોધો",
|
||||
"ButtonSelectFolderPath": "ફોલ્ડર પથ પસંદ કરો",
|
||||
"ButtonSeries": "સિરીઝ",
|
||||
"ButtonSetChaptersFromTracks": "ટ્રેક્સથી પ્રકરણો સેટ કરો",
|
||||
"ButtonShiftTimes": "સમય શિફ્ટ કરો",
|
||||
"ButtonShow": "બતાવો",
|
||||
"ButtonStartM4BEncode": "M4B એન્કોડ શરૂ કરો",
|
||||
"ButtonStartMetadataEmbed": "મેટાડેટા એમ્બેડ શરૂ કરો",
|
||||
"ButtonSubmit": "સબમિટ કરો",
|
||||
"ButtonUpload": "અપલોડ કરો",
|
||||
"ButtonUploadBackup": "બેકઅપ અપલોડ કરો",
|
||||
"ButtonUploadCover": "કવર અપલોડ કરો",
|
||||
"ButtonUploadOPMLFile": "OPML ફાઇલ અપલોડ કરો",
|
||||
"ButtonUserDelete": "વપરાશકર્તા {0} કાઢી નાખો",
|
||||
"ButtonUserEdit": "વપરાશકર્તા {0} સંપાદિત કરો",
|
||||
"ButtonViewAll": "બધું જુઓ",
|
||||
"ButtonYes": "હા",
|
||||
"HeaderAccount": "એકાઉન્ટ",
|
||||
"HeaderAdvanced": "અડ્વાન્સડ",
|
||||
"HeaderAppriseNotificationSettings": "Apprise સૂચના સેટિંગ્સ",
|
||||
"HeaderAudiobookTools": "Audiobook File Management Tools",
|
||||
"HeaderAudioTracks": "Audio Tracks",
|
||||
"HeaderBackups": "Backups",
|
||||
"HeaderChangePassword": "Change Password",
|
||||
"HeaderChapters": "Chapters",
|
||||
"HeaderChooseAFolder": "Choose a Folder",
|
||||
"HeaderCollection": "Collection",
|
||||
"HeaderCollectionItems": "Collection Items",
|
||||
"HeaderCover": "Cover",
|
||||
"HeaderCurrentDownloads": "Current Downloads",
|
||||
"HeaderDetails": "Details",
|
||||
"HeaderDownloadQueue": "Download Queue",
|
||||
"HeaderEpisodes": "Episodes",
|
||||
"HeaderFiles": "Files",
|
||||
"HeaderFindChapters": "Find Chapters",
|
||||
"HeaderIgnoredFiles": "Ignored Files",
|
||||
"HeaderItemFiles": "Item Files",
|
||||
"HeaderItemMetadataUtils": "Item Metadata Utils",
|
||||
"HeaderLastListeningSession": "Last Listening Session",
|
||||
"HeaderLatestEpisodes": "Latest episodes",
|
||||
"HeaderLibraries": "Libraries",
|
||||
"HeaderLibraryFiles": "Library Files",
|
||||
"HeaderLibraryStats": "Library Stats",
|
||||
"HeaderListeningSessions": "Listening Sessions",
|
||||
"HeaderListeningStats": "Listening Stats",
|
||||
"HeaderLogin": "Login",
|
||||
"HeaderLogs": "Logs",
|
||||
"HeaderManageGenres": "Manage Genres",
|
||||
"HeaderManageTags": "Manage Tags",
|
||||
"HeaderMapDetails": "Map details",
|
||||
"HeaderMatch": "Match",
|
||||
"HeaderMetadataToEmbed": "Metadata to embed",
|
||||
"HeaderNewAccount": "New Account",
|
||||
"HeaderNewLibrary": "New Library",
|
||||
"HeaderNotifications": "Notifications",
|
||||
"HeaderOpenRSSFeed": "Open RSS Feed",
|
||||
"HeaderOtherFiles": "Other Files",
|
||||
"HeaderPermissions": "Permissions",
|
||||
"HeaderPlayerQueue": "Player Queue",
|
||||
"HeaderPlaylist": "Playlist",
|
||||
"HeaderPlaylistItems": "Playlist Items",
|
||||
"HeaderPodcastsToAdd": "Podcasts to Add",
|
||||
"HeaderPreviewCover": "Preview Cover",
|
||||
"HeaderRemoveEpisode": "Remove Episode",
|
||||
"HeaderRemoveEpisodes": "Remove {0} Episodes",
|
||||
"HeaderRSSFeedGeneral": "RSS Details",
|
||||
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
|
||||
"HeaderSavedMediaProgress": "Saved Media Progress",
|
||||
"HeaderSchedule": "Schedule",
|
||||
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
|
||||
"HeaderSession": "Session",
|
||||
"HeaderSetBackupSchedule": "Set Backup Schedule",
|
||||
"HeaderSettings": "Settings",
|
||||
"HeaderSettingsDisplay": "Display",
|
||||
"HeaderSettingsExperimental": "Experimental Features",
|
||||
"HeaderSettingsGeneral": "General",
|
||||
"HeaderSettingsScanner": "Scanner",
|
||||
"HeaderSleepTimer": "Sleep Timer",
|
||||
"HeaderStatsLargestItems": "Largest Items",
|
||||
"HeaderStatsLongestItems": "Longest Items (hrs)",
|
||||
"HeaderStatsMinutesListeningChart": "Minutes Listening (last 7 days)",
|
||||
"HeaderStatsRecentSessions": "Recent Sessions",
|
||||
"HeaderStatsTop10Authors": "Top 10 Authors",
|
||||
"HeaderStatsTop5Genres": "Top 5 Genres",
|
||||
"HeaderTools": "Tools",
|
||||
"HeaderUpdateAccount": "Update Account",
|
||||
"HeaderUpdateAuthor": "Update Author",
|
||||
"HeaderUpdateDetails": "Update Details",
|
||||
"HeaderUpdateLibrary": "Update Library",
|
||||
"HeaderUsers": "Users",
|
||||
"HeaderYourStats": "Your Stats",
|
||||
"LabelAbridged": "Abridged",
|
||||
"LabelAccountType": "Account Type",
|
||||
"LabelAccountTypeAdmin": "Admin",
|
||||
"LabelAccountTypeGuest": "Guest",
|
||||
"LabelAccountTypeUser": "User",
|
||||
"LabelActivity": "Activity",
|
||||
"LabelAdded": "Added",
|
||||
"LabelAddedAt": "Added At",
|
||||
"LabelAddToCollection": "Add to Collection",
|
||||
"LabelAddToCollectionBatch": "Add {0} Books to Collection",
|
||||
"LabelAddToPlaylist": "Add to Playlist",
|
||||
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
|
||||
"LabelAll": "All",
|
||||
"LabelAllUsers": "All Users",
|
||||
"LabelAlreadyInYourLibrary": "Already in your library",
|
||||
"LabelAppend": "Append",
|
||||
"LabelAuthor": "Author",
|
||||
"LabelAuthorFirstLast": "Author (First Last)",
|
||||
"LabelAuthorLastFirst": "Author (Last, First)",
|
||||
"LabelAuthors": "Authors",
|
||||
"LabelAutoDownloadEpisodes": "Auto Download Episodes",
|
||||
"LabelBackToUser": "Back to User",
|
||||
"LabelBackupsEnableAutomaticBackups": "Enable automatic backups",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "Backups saved in /metadata/backups",
|
||||
"LabelBackupsMaxBackupSize": "Maximum backup size (in GB)",
|
||||
"LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.",
|
||||
"LabelBackupsNumberToKeep": "Number of backups to keep",
|
||||
"LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.",
|
||||
"LabelBitrate": "Bitrate",
|
||||
"LabelBooks": "Books",
|
||||
"LabelChangePassword": "Change Password",
|
||||
"LabelChannels": "Channels",
|
||||
"LabelChapters": "Chapters",
|
||||
"LabelChaptersFound": "chapters found",
|
||||
"LabelChapterTitle": "Chapter Title",
|
||||
"LabelClosePlayer": "Close player",
|
||||
"LabelCodec": "Codec",
|
||||
"LabelCollapseSeries": "Collapse Series",
|
||||
"LabelCollections": "Collections",
|
||||
"LabelComplete": "Complete",
|
||||
"LabelConfirmPassword": "Confirm Password",
|
||||
"LabelContinueListening": "Continue Listening",
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCover": "Cover",
|
||||
"LabelCoverImageURL": "Cover Image URL",
|
||||
"LabelCreatedAt": "Created At",
|
||||
"LabelCronExpression": "Cron Expression",
|
||||
"LabelCurrent": "Current",
|
||||
"LabelCurrently": "Currently:",
|
||||
"LabelCustomCronExpression": "Custom Cron Expression:",
|
||||
"LabelDatetime": "Datetime",
|
||||
"LabelDescription": "Description",
|
||||
"LabelDeselectAll": "Deselect All",
|
||||
"LabelDevice": "Device",
|
||||
"LabelDeviceInfo": "Device Info",
|
||||
"LabelDirectory": "Directory",
|
||||
"LabelDiscFromFilename": "Disc from Filename",
|
||||
"LabelDiscFromMetadata": "Disc from Metadata",
|
||||
"LabelDownload": "Download",
|
||||
"LabelDuration": "Duration",
|
||||
"LabelDurationFound": "Duration found:",
|
||||
"LabelEdit": "Edit",
|
||||
"LabelEmbeddedCover": "Embedded Cover",
|
||||
"LabelEnable": "Enable",
|
||||
"LabelEnd": "End",
|
||||
"LabelEpisode": "Episode",
|
||||
"LabelEpisodeTitle": "Episode Title",
|
||||
"LabelEpisodeType": "Episode Type",
|
||||
"LabelExample": "Example",
|
||||
"LabelExplicit": "Explicit",
|
||||
"LabelFeedURL": "Feed URL",
|
||||
"LabelFile": "File",
|
||||
"LabelFileBirthtime": "File Birthtime",
|
||||
"LabelFileModified": "File Modified",
|
||||
"LabelFilename": "Filename",
|
||||
"LabelFilterByUser": "Filter by User",
|
||||
"LabelFindEpisodes": "Find Episodes",
|
||||
"LabelFinished": "Finished",
|
||||
"LabelFolder": "Folder",
|
||||
"LabelFolders": "Folders",
|
||||
"LabelFormat": "Format",
|
||||
"LabelGenre": "Genre",
|
||||
"LabelGenres": "Genres",
|
||||
"LabelHardDeleteFile": "Hard delete file",
|
||||
"LabelHour": "Hour",
|
||||
"LabelIcon": "Icon",
|
||||
"LabelIncludeInTracklist": "Include in Tracklist",
|
||||
"LabelIncomplete": "Incomplete",
|
||||
"LabelInProgress": "In Progress",
|
||||
"LabelInterval": "Interval",
|
||||
"LabelIntervalCustomDailyWeekly": "Custom daily/weekly",
|
||||
"LabelIntervalEvery12Hours": "Every 12 hours",
|
||||
"LabelIntervalEvery15Minutes": "Every 15 minutes",
|
||||
"LabelIntervalEvery2Hours": "Every 2 hours",
|
||||
"LabelIntervalEvery30Minutes": "Every 30 minutes",
|
||||
"LabelIntervalEvery6Hours": "Every 6 hours",
|
||||
"LabelIntervalEveryDay": "Every day",
|
||||
"LabelIntervalEveryHour": "Every hour",
|
||||
"LabelInvalidParts": "Invalid Parts",
|
||||
"LabelItem": "Item",
|
||||
"LabelLanguage": "Language",
|
||||
"LabelLanguageDefaultServer": "Default Server Language",
|
||||
"LabelLastBookAdded": "Last Book Added",
|
||||
"LabelLastBookUpdated": "Last Book Updated",
|
||||
"LabelLastSeen": "Last Seen",
|
||||
"LabelLastTime": "Last Time",
|
||||
"LabelLastUpdate": "Last Update",
|
||||
"LabelLess": "Less",
|
||||
"LabelLibrariesAccessibleToUser": "Libraries Accessible to User",
|
||||
"LabelLibrary": "Library",
|
||||
"LabelLibraryItem": "Library Item",
|
||||
"LabelLibraryName": "Library Name",
|
||||
"LabelLimit": "Limit",
|
||||
"LabelListenAgain": "Listen Again",
|
||||
"LabelLogLevelDebug": "Debug",
|
||||
"LabelLogLevelInfo": "Info",
|
||||
"LabelLogLevelWarn": "Warn",
|
||||
"LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date",
|
||||
"LabelMediaPlayer": "Media Player",
|
||||
"LabelMediaType": "Media Type",
|
||||
"LabelMetadataProvider": "Metadata Provider",
|
||||
"LabelMetaTag": "Meta Tag",
|
||||
"LabelMetaTags": "Meta Tags",
|
||||
"LabelMinute": "Minute",
|
||||
"LabelMissing": "Missing",
|
||||
"LabelMissingParts": "Missing Parts",
|
||||
"LabelMore": "More",
|
||||
"LabelMoreInfo": "More Info",
|
||||
"LabelName": "Name",
|
||||
"LabelNarrator": "Narrator",
|
||||
"LabelNarrators": "Narrators",
|
||||
"LabelNew": "New",
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNewPassword": "New Password",
|
||||
"LabelNextBackupDate": "Next backup date",
|
||||
"LabelNextScheduledRun": "Next scheduled run",
|
||||
"LabelNotes": "Notes",
|
||||
"LabelNotFinished": "Not Finished",
|
||||
"LabelNotificationAppriseURL": "Apprise URL(s)",
|
||||
"LabelNotificationAvailableVariables": "Available variables",
|
||||
"LabelNotificationBodyTemplate": "Body Template",
|
||||
"LabelNotificationEvent": "Notification Event",
|
||||
"LabelNotificationsMaxFailedAttempts": "Max failed attempts",
|
||||
"LabelNotificationsMaxFailedAttemptsHelp": "Notifications are disabled once they fail to send this many times",
|
||||
"LabelNotificationsMaxQueueSize": "Max queue size for notification events",
|
||||
"LabelNotificationsMaxQueueSizeHelp": "Events are limited to firing 1 per second. Events will be ignored if the queue is at max size. This prevents notification spamming.",
|
||||
"LabelNotificationTitleTemplate": "Title Template",
|
||||
"LabelNotStarted": "Not Started",
|
||||
"LabelNumberOfBooks": "Number of Books",
|
||||
"LabelNumberOfEpisodes": "# of Episodes",
|
||||
"LabelOpenRSSFeed": "Open RSS Feed",
|
||||
"LabelOverwrite": "Overwrite",
|
||||
"LabelPassword": "Password",
|
||||
"LabelPath": "Path",
|
||||
"LabelPermissionsAccessAllLibraries": "Can Access All Libraries",
|
||||
"LabelPermissionsAccessAllTags": "Can Access All Tags",
|
||||
"LabelPermissionsAccessExplicitContent": "Can Access Explicit Content",
|
||||
"LabelPermissionsDelete": "Can Delete",
|
||||
"LabelPermissionsDownload": "Can Download",
|
||||
"LabelPermissionsUpdate": "Can Update",
|
||||
"LabelPermissionsUpload": "Can Upload",
|
||||
"LabelPhotoPathURL": "Photo Path/URL",
|
||||
"LabelPlaylists": "Playlists",
|
||||
"LabelPlayMethod": "Play Method",
|
||||
"LabelPodcast": "Podcast",
|
||||
"LabelPodcasts": "Podcasts",
|
||||
"LabelPodcastType": "Podcast Type",
|
||||
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
|
||||
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
|
||||
"LabelProgress": "Progress",
|
||||
"LabelProvider": "Provider",
|
||||
"LabelPubDate": "Pub Date",
|
||||
"LabelPublisher": "Publisher",
|
||||
"LabelPublishYear": "Publish Year",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelRecommended": "Recommended",
|
||||
"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",
|
||||
"LabelSearchTerm": "Search Term",
|
||||
"LabelSearchTitle": "Search Title",
|
||||
"LabelSearchTitleOrASIN": "Search Title or ASIN",
|
||||
"LabelSeason": "Season",
|
||||
"LabelSequence": "Sequence",
|
||||
"LabelSeries": "Series",
|
||||
"LabelSeriesName": "Series Name",
|
||||
"LabelSeriesProgress": "Series Progress",
|
||||
"LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves",
|
||||
"LabelSettingsChromecastSupport": "Chromecast support",
|
||||
"LabelSettingsDateFormat": "Date Format",
|
||||
"LabelSettingsDisableWatcher": "Disable Watcher",
|
||||
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
|
||||
"LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
|
||||
"LabelSettingsEnableEReader": "Enable e-reader for all users",
|
||||
"LabelSettingsEnableEReaderHelp": "E-reader is still a work in progress, but use this setting to open it up to all your users (or use the \"Experimental Features\" toggle just for use by you)",
|
||||
"LabelSettingsExperimentalFeatures": "Experimental features",
|
||||
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
|
||||
"LabelSettingsFindCovers": "Find covers",
|
||||
"LabelSettingsFindCoversHelp": "If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time",
|
||||
"LabelSettingsHomePageBookshelfView": "Home page use bookshelf view",
|
||||
"LabelSettingsLibraryBookshelfView": "Library use bookshelf view",
|
||||
"LabelSettingsOverdriveMediaMarkers": "Use Overdrive Media Markers for chapters",
|
||||
"LabelSettingsOverdriveMediaMarkersHelp": "MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically",
|
||||
"LabelSettingsParseSubtitles": "Parse subtitles",
|
||||
"LabelSettingsParseSubtitlesHelp": "Extract subtitles from audiobook folder names.<br>Subtitle must be seperated by \" - \"<br>i.e. \"Book Title - A Subtitle Here\" has the subtitle \"A Subtitle Here\"",
|
||||
"LabelSettingsPreferAudioMetadata": "Prefer audio metadata",
|
||||
"LabelSettingsPreferAudioMetadataHelp": "Audio file ID3 meta tags will be used for book details over folder names",
|
||||
"LabelSettingsPreferMatchedMetadata": "Prefer matched metadata",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Matched data will overide item details when using Quick Match. By default Quick Match will only fill in missing details.",
|
||||
"LabelSettingsPreferOPFMetadata": "Prefer OPF metadata",
|
||||
"LabelSettingsPreferOPFMetadataHelp": "OPF file metadata will be used for book details over folder names",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Skip matching books that already have an ASIN",
|
||||
"LabelSettingsSkipMatchingBooksWithISBN": "Skip matching books that already have an ISBN",
|
||||
"LabelSettingsSortingIgnorePrefixes": "Ignore prefixes when sorting",
|
||||
"LabelSettingsSortingIgnorePrefixesHelp": "i.e. for prefix \"the\" book title \"The Book Title\" would sort as \"Book Title, The\"",
|
||||
"LabelSettingsSquareBookCovers": "Use square book covers",
|
||||
"LabelSettingsSquareBookCoversHelp": "Prefer to use square covers over standard 1.6:1 book covers",
|
||||
"LabelSettingsStoreCoversWithItem": "Store covers with item",
|
||||
"LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept",
|
||||
"LabelSettingsStoreMetadataWithItem": "Store metadata with item",
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension",
|
||||
"LabelSettingsTimeFormat": "Time Format",
|
||||
"LabelShowAll": "Show All",
|
||||
"LabelSize": "Size",
|
||||
"LabelSleepTimer": "Sleep timer",
|
||||
"LabelStart": "Start",
|
||||
"LabelStarted": "Started",
|
||||
"LabelStartedAt": "Started At",
|
||||
"LabelStartTime": "Start Time",
|
||||
"LabelStatsAudioTracks": "Audio Tracks",
|
||||
"LabelStatsAuthors": "Authors",
|
||||
"LabelStatsBestDay": "Best Day",
|
||||
"LabelStatsDailyAverage": "Daily Average",
|
||||
"LabelStatsDays": "Days",
|
||||
"LabelStatsDaysListened": "Days Listened",
|
||||
"LabelStatsHours": "Hours",
|
||||
"LabelStatsInARow": "in a row",
|
||||
"LabelStatsItemsFinished": "Items Finished",
|
||||
"LabelStatsItemsInLibrary": "Items in Library",
|
||||
"LabelStatsMinutes": "minutes",
|
||||
"LabelStatsMinutesListening": "Minutes Listening",
|
||||
"LabelStatsOverallDays": "Overall Days",
|
||||
"LabelStatsOverallHours": "Overall Hours",
|
||||
"LabelStatsWeekListening": "Week Listening",
|
||||
"LabelSubtitle": "Subtitle",
|
||||
"LabelSupportedFileTypes": "Supported File Types",
|
||||
"LabelTag": "Tag",
|
||||
"LabelTags": "Tags",
|
||||
"LabelTagsAccessibleToUser": "Tags Accessible to User",
|
||||
"LabelTasks": "Tasks Running",
|
||||
"LabelTimeBase": "Time Base",
|
||||
"LabelTimeListened": "Time Listened",
|
||||
"LabelTimeListenedToday": "Time Listened Today",
|
||||
"LabelTimeRemaining": "{0} remaining",
|
||||
"LabelTimeToShift": "Time to shift in seconds",
|
||||
"LabelTitle": "Title",
|
||||
"LabelToolsEmbedMetadata": "Embed Metadata",
|
||||
"LabelToolsEmbedMetadataDescription": "Embed metadata into audio files including cover image and chapters.",
|
||||
"LabelToolsMakeM4b": "Make M4B Audiobook File",
|
||||
"LabelToolsMakeM4bDescription": "Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.",
|
||||
"LabelToolsSplitM4b": "Split M4B to MP3's",
|
||||
"LabelToolsSplitM4bDescription": "Create MP3's from an M4B split by chapters with embedded metadata, cover image, and chapters.",
|
||||
"LabelTotalDuration": "Total Duration",
|
||||
"LabelTotalTimeListened": "Total Time Listened",
|
||||
"LabelTrackFromFilename": "Track from Filename",
|
||||
"LabelTrackFromMetadata": "Track from Metadata",
|
||||
"LabelTracks": "Tracks",
|
||||
"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",
|
||||
"LabelUpdatedAt": "Updated At",
|
||||
"LabelUpdateDetails": "Update Details",
|
||||
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
|
||||
"LabelUploaderDragAndDrop": "Drag & drop files or folders",
|
||||
"LabelUploaderDropFiles": "Drop files",
|
||||
"LabelUseChapterTrack": "Use chapter track",
|
||||
"LabelUseFullTrack": "Use full track",
|
||||
"LabelUser": "User",
|
||||
"LabelUsername": "Username",
|
||||
"LabelValue": "Value",
|
||||
"LabelVersion": "Version",
|
||||
"LabelViewBookmarks": "View bookmarks",
|
||||
"LabelViewChapters": "View chapters",
|
||||
"LabelViewQueue": "View player queue",
|
||||
"LabelVolume": "Volume",
|
||||
"LabelWeekdaysToRun": "Weekdays to run",
|
||||
"LabelYourAudiobookDuration": "Your audiobook duration",
|
||||
"LabelYourBookmarks": "Your Bookmarks",
|
||||
"LabelYourPlaylists": "Your Playlists",
|
||||
"LabelYourProgress": "Your Progress",
|
||||
"MessageAddToPlayerQueue": "Add to player queue",
|
||||
"MessageAppriseDescription": "To use this feature you will need to have an instance of <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at <code>http://192.168.1.1:8337</code> then you would put <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in <code>/metadata/items</code> & <code>/metadata/authors</code>. Backups <strong>do not</strong> include any files stored in your library folders.",
|
||||
"MessageBatchQuickMatchDescription": "Quick Match will attempt to add missing covers and metadata for the selected items. Enable the options below to allow Quick Match to overwrite existing covers and/or metadata.",
|
||||
"MessageBookshelfNoCollections": "You haven't made any collections yet",
|
||||
"MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"",
|
||||
"MessageBookshelfNoRSSFeeds": "No RSS feeds are open",
|
||||
"MessageBookshelfNoSeries": "You have no series",
|
||||
"MessageChapterEndIsAfter": "Chapter end is after the end of your audiobook",
|
||||
"MessageChapterErrorFirstNotZero": "First chapter must start at 0",
|
||||
"MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration",
|
||||
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
|
||||
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
|
||||
"MessageCheckingCron": "Checking cron...",
|
||||
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
|
||||
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
|
||||
"MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
|
||||
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
|
||||
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
|
||||
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
|
||||
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
|
||||
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
|
||||
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
|
||||
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
|
||||
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
|
||||
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
|
||||
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
|
||||
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
|
||||
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
|
||||
"MessageDownloadingEpisode": "Downloading episode",
|
||||
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
|
||||
"MessageEmbedFinished": "Embed Finished!",
|
||||
"MessageEpisodesQueuedForDownload": "{0} Episode(s) queued for download",
|
||||
"MessageFeedURLWillBe": "Feed URL will be {0}",
|
||||
"MessageFetching": "Fetching...",
|
||||
"MessageForceReScanDescription": "will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be scanned as new.",
|
||||
"MessageImportantNotice": "Important Notice!",
|
||||
"MessageInsertChapterBelow": "Insert chapter below",
|
||||
"MessageItemsSelected": "{0} Items Selected",
|
||||
"MessageItemsUpdated": "{0} Items Updated",
|
||||
"MessageJoinUsOn": "Join us on",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} listening sessions in the last year",
|
||||
"MessageLoading": "Loading...",
|
||||
"MessageLoadingFolders": "Loading folders...",
|
||||
"MessageM4BFailed": "M4B Failed!",
|
||||
"MessageM4BFinished": "M4B Finished!",
|
||||
"MessageMapChapterTitles": "Map chapter titles to your existing audiobook chapters without adjusting timestamps",
|
||||
"MessageMarkAsFinished": "Mark as Finished",
|
||||
"MessageMarkAsNotFinished": "Mark as Not Finished",
|
||||
"MessageMatchBooksDescription": "will attempt to match books in the library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.",
|
||||
"MessageNoAudioTracks": "No audio tracks",
|
||||
"MessageNoAuthors": "No Authors",
|
||||
"MessageNoBackups": "No Backups",
|
||||
"MessageNoBookmarks": "No Bookmarks",
|
||||
"MessageNoChapters": "No Chapters",
|
||||
"MessageNoCollections": "No Collections",
|
||||
"MessageNoCoversFound": "No Covers Found",
|
||||
"MessageNoDescription": "No description",
|
||||
"MessageNoDownloadsInProgress": "No downloads currently in progress",
|
||||
"MessageNoDownloadsQueued": "No downloads queued",
|
||||
"MessageNoEpisodeMatchesFound": "No episode matches found",
|
||||
"MessageNoEpisodes": "No Episodes",
|
||||
"MessageNoFoldersAvailable": "No Folders Available",
|
||||
"MessageNoGenres": "No Genres",
|
||||
"MessageNoIssues": "No Issues",
|
||||
"MessageNoItems": "No Items",
|
||||
"MessageNoItemsFound": "No items found",
|
||||
"MessageNoListeningSessions": "No Listening Sessions",
|
||||
"MessageNoLogs": "No Logs",
|
||||
"MessageNoMediaProgress": "No Media Progress",
|
||||
"MessageNoNotifications": "No Notifications",
|
||||
"MessageNoPodcastsFound": "No podcasts found",
|
||||
"MessageNoResults": "No Results",
|
||||
"MessageNoSearchResultsFor": "No search results for \"{0}\"",
|
||||
"MessageNoSeries": "No Series",
|
||||
"MessageNoTags": "No Tags",
|
||||
"MessageNoTasksRunning": "No Tasks Running",
|
||||
"MessageNotYetImplemented": "Not yet implemented",
|
||||
"MessageNoUpdateNecessary": "No update necessary",
|
||||
"MessageNoUpdatesWereNecessary": "No updates were necessary",
|
||||
"MessageNoUserPlaylists": "You have no playlists",
|
||||
"MessageOr": "or",
|
||||
"MessagePauseChapter": "Pause chapter playback",
|
||||
"MessagePlayChapter": "Listen to beginning of chapter",
|
||||
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
|
||||
"MessagePodcastHasNoRSSFeedForMatching": "Podcast has no RSS feed url to use for matching",
|
||||
"MessageQuickMatchDescription": "Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.",
|
||||
"MessageRemoveAllItemsWarning": "WARNING! This action will remove all library items from the database including any updates or matches you have made. This does not do anything to your actual files. Are you sure?",
|
||||
"MessageRemoveChapter": "Remove chapter",
|
||||
"MessageRemoveEpisodes": "Remove {0} episode(s)",
|
||||
"MessageRemoveFromPlayerQueue": "Remove from player queue",
|
||||
"MessageRemoveUserWarning": "Are you sure you want to permanently delete user \"{0}\"?",
|
||||
"MessageReportBugsAndContribute": "Report bugs, request features, and contribute on",
|
||||
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?",
|
||||
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
|
||||
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
|
||||
"MessageSearchResultsFor": "Search results for",
|
||||
"MessageServerCouldNotBeReached": "Server could not be reached",
|
||||
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
|
||||
"MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?",
|
||||
"MessageThinking": "Thinking...",
|
||||
"MessageUploaderItemFailed": "Failed to upload",
|
||||
"MessageUploaderItemSuccess": "Successfully Uploaded!",
|
||||
"MessageUploading": "Uploading...",
|
||||
"MessageValidCronExpression": "Valid cron expression",
|
||||
"MessageWatcherIsDisabledGlobally": "Watcher is disabled globally in server settings",
|
||||
"MessageXLibraryIsEmpty": "{0} Library is empty!",
|
||||
"MessageYourAudiobookDurationIsLonger": "Your audiobook duration is longer than the duration found",
|
||||
"MessageYourAudiobookDurationIsShorter": "Your audiobook duration is shorter than duration found",
|
||||
"NoteChangeRootPassword": "Root user is the only user that can have an empty password",
|
||||
"NoteChapterEditorTimes": "Note: First chapter start time must remain at 0:00 and the last chapter start time cannot exceed this audiobooks duration.",
|
||||
"NoteFolderPicker": "Note: folders already mapped will not be shown",
|
||||
"NoteFolderPickerDebian": "Note: Folder picker for the debian install is not fully implemented. You should enter the path to your library directly.",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Warning: Most podcast apps will require the RSS feed URL is using HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.",
|
||||
"NoteUploaderFoldersWithMediaFiles": "Folders with media files will be handled as separate library items.",
|
||||
"NoteUploaderOnlyAudioFiles": "If uploading only audio files then each audio file will be handled as a separate audiobook.",
|
||||
"NoteUploaderUnsupportedFiles": "Unsupported files are ignored. When choosing or dropping a folder, other files that are not in an item folder are ignored.",
|
||||
"PlaceholderNewCollection": "New collection name",
|
||||
"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",
|
||||
"ToastAuthorImageRemoveSuccess": "Author image removed",
|
||||
"ToastAuthorUpdateFailed": "Failed to update author",
|
||||
"ToastAuthorUpdateMerged": "Author merged",
|
||||
"ToastAuthorUpdateSuccess": "Author updated",
|
||||
"ToastAuthorUpdateSuccessNoImageFound": "Author updated (no image found)",
|
||||
"ToastBackupCreateFailed": "Failed to create backup",
|
||||
"ToastBackupCreateSuccess": "Backup created",
|
||||
"ToastBackupDeleteFailed": "Failed to delete backup",
|
||||
"ToastBackupDeleteSuccess": "Backup deleted",
|
||||
"ToastBackupRestoreFailed": "Failed to restore backup",
|
||||
"ToastBackupUploadFailed": "Failed to upload backup",
|
||||
"ToastBackupUploadSuccess": "Backup uploaded",
|
||||
"ToastBatchUpdateFailed": "Batch update failed",
|
||||
"ToastBatchUpdateSuccess": "Batch update success",
|
||||
"ToastBookmarkCreateFailed": "Failed to create bookmark",
|
||||
"ToastBookmarkCreateSuccess": "Bookmark added",
|
||||
"ToastBookmarkRemoveFailed": "Failed to remove bookmark",
|
||||
"ToastBookmarkRemoveSuccess": "Bookmark removed",
|
||||
"ToastBookmarkUpdateFailed": "Failed to update bookmark",
|
||||
"ToastBookmarkUpdateSuccess": "Bookmark updated",
|
||||
"ToastChaptersHaveErrors": "Chapters have errors",
|
||||
"ToastChaptersMustHaveTitles": "Chapters must have titles",
|
||||
"ToastCollectionItemsRemoveFailed": "Failed to remove item(s) from collection",
|
||||
"ToastCollectionItemsRemoveSuccess": "Item(s) removed from collection",
|
||||
"ToastCollectionRemoveFailed": "Failed to remove collection",
|
||||
"ToastCollectionRemoveSuccess": "Collection removed",
|
||||
"ToastCollectionUpdateFailed": "Failed to update collection",
|
||||
"ToastCollectionUpdateSuccess": "Collection updated",
|
||||
"ToastItemCoverUpdateFailed": "Failed to update item cover",
|
||||
"ToastItemCoverUpdateSuccess": "Item cover updated",
|
||||
"ToastItemDetailsUpdateFailed": "Failed to update item details",
|
||||
"ToastItemDetailsUpdateSuccess": "Item details updated",
|
||||
"ToastItemDetailsUpdateUnneeded": "No updates needed for item details",
|
||||
"ToastItemMarkedAsFinishedFailed": "Failed to mark as Finished",
|
||||
"ToastItemMarkedAsFinishedSuccess": "Item marked as Finished",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "Failed to mark as Not Finished",
|
||||
"ToastItemMarkedAsNotFinishedSuccess": "Item marked as Not Finished",
|
||||
"ToastLibraryCreateFailed": "Failed to create library",
|
||||
"ToastLibraryCreateSuccess": "Library \"{0}\" created",
|
||||
"ToastLibraryDeleteFailed": "Failed to delete library",
|
||||
"ToastLibraryDeleteSuccess": "Library deleted",
|
||||
"ToastLibraryScanFailedToStart": "Failed to start scan",
|
||||
"ToastLibraryScanStarted": "Library scan started",
|
||||
"ToastLibraryUpdateFailed": "Failed to update library",
|
||||
"ToastLibraryUpdateSuccess": "Library \"{0}\" updated",
|
||||
"ToastPlaylistCreateFailed": "Failed to create playlist",
|
||||
"ToastPlaylistCreateSuccess": "Playlist created",
|
||||
"ToastPlaylistRemoveFailed": "Failed to remove playlist",
|
||||
"ToastPlaylistRemoveSuccess": "Playlist removed",
|
||||
"ToastPlaylistUpdateFailed": "Failed to update playlist",
|
||||
"ToastPlaylistUpdateSuccess": "Playlist updated",
|
||||
"ToastPodcastCreateFailed": "Failed to create podcast",
|
||||
"ToastPodcastCreateSuccess": "Podcast created successfully",
|
||||
"ToastRemoveItemFromCollectionFailed": "Failed to remove item from collection",
|
||||
"ToastRemoveItemFromCollectionSuccess": "Item removed from collection",
|
||||
"ToastRSSFeedCloseFailed": "Failed to close RSS feed",
|
||||
"ToastRSSFeedCloseSuccess": "RSS feed closed",
|
||||
"ToastSeriesUpdateFailed": "Series update failed",
|
||||
"ToastSeriesUpdateSuccess": "Series update success",
|
||||
"ToastSessionDeleteFailed": "Failed to delete session",
|
||||
"ToastSessionDeleteSuccess": "Session deleted",
|
||||
"ToastSocketConnected": "Socket connected",
|
||||
"ToastSocketDisconnected": "Socket disconnected",
|
||||
"ToastSocketFailedToConnect": "Socket failed to connect",
|
||||
"ToastUserDeleteFailed": "Failed to delete user",
|
||||
"ToastUserDeleteSuccess": "User deleted"
|
||||
}
|
||||
654
client/strings/hi.json
Normal file
654
client/strings/hi.json
Normal file
@@ -0,0 +1,654 @@
|
||||
{
|
||||
"ButtonAdd": "जोड़ें",
|
||||
"ButtonAddChapters": "अध्याय जोड़ें",
|
||||
"ButtonAddPodcasts": "पॉडकास्ट जोड़ें",
|
||||
"ButtonAddYourFirstLibrary": "अपनी पहली पुस्तकालय जोड़ें",
|
||||
"ButtonApply": "लागू करें",
|
||||
"ButtonApplyChapters": "अध्यायों में परिवर्तन लागू करें",
|
||||
"ButtonAuthors": "लेखक",
|
||||
"ButtonBrowseForFolder": "फ़ोल्डर खोजें",
|
||||
"ButtonCancel": "रद्द करें",
|
||||
"ButtonCancelEncode": "एनकोड रद्द करें",
|
||||
"ButtonChangeRootPassword": "रूट का पासवर्ड बदलें",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "नए एपिसोड खोजें और डाउनलोड करें",
|
||||
"ButtonChooseAFolder": "एक फ़ोल्डर चुनें",
|
||||
"ButtonChooseFiles": "फ़ाइलें चुनें",
|
||||
"ButtonClearFilter": "लागू फ़िल्टर साफ़ करें",
|
||||
"ButtonCloseFeed": "फ़ीड बंद करें",
|
||||
"ButtonCollections": "संग्रह",
|
||||
"ButtonConfigureScanner": "स्कैनर सेटिंग्स बदलें",
|
||||
"ButtonCreate": "बनाएं",
|
||||
"ButtonCreateBackup": "बैकअप लें",
|
||||
"ButtonDelete": "हटाएं",
|
||||
"ButtonDownloadQueue": "कतार डाउनलोड करें",
|
||||
"ButtonEdit": "संपादित करें",
|
||||
"ButtonEditChapters": "अध्याय संपादित करें",
|
||||
"ButtonEditPodcast": "पॉडकास्ट संपादित करें",
|
||||
"ButtonForceReScan": "बलपूर्वक पुन: स्कैन करें",
|
||||
"ButtonFullPath": "पूर्ण पथ",
|
||||
"ButtonHide": "छुपाएं",
|
||||
"ButtonHome": "घर",
|
||||
"ButtonIssues": "समस्याएं",
|
||||
"ButtonLatest": "नवीनतम",
|
||||
"ButtonLibrary": "पुस्तकालय",
|
||||
"ButtonLogout": "लॉग आउट",
|
||||
"ButtonLookup": "तलाश करें",
|
||||
"ButtonManageTracks": "ट्रैक्स मैनेज करें",
|
||||
"ButtonMapChapterTitles": "अध्यायों का मिलान करें",
|
||||
"ButtonMatchAllAuthors": "सभी लेखकों को तलाश करें",
|
||||
"ButtonMatchBooks": "संबंधित पुस्तकों का मिलान करें",
|
||||
"ButtonNevermind": "कोई बात नहीं",
|
||||
"ButtonOk": "ठीक है",
|
||||
"ButtonOpenFeed": "फ़ीड खोलें",
|
||||
"ButtonOpenManager": "मैनेजर खोलें",
|
||||
"ButtonPlay": "चलाएँ",
|
||||
"ButtonPlaying": "चल रही है",
|
||||
"ButtonPlaylists": "प्लेलिस्ट्स",
|
||||
"ButtonPurgeAllCache": "सभी Cache मिटाएं",
|
||||
"ButtonPurgeItemsCache": "आइटम Cache मिटाएं",
|
||||
"ButtonPurgeMediaProgress": "अभी तक सुना हुआ सब हटा दे",
|
||||
"ButtonQueueAddItem": "क़तार में जोड़ें",
|
||||
"ButtonQueueRemoveItem": "कतार से हटाएं",
|
||||
"ButtonQuickMatch": "जल्दी से समानता की तलाश करें",
|
||||
"ButtonRead": "पढ़ लिया",
|
||||
"ButtonRemove": "हटाएं",
|
||||
"ButtonRemoveAll": "सभी हटाएं",
|
||||
"ButtonRemoveAllLibraryItems": "पुस्तकालय की सभी आइटम हटाएं",
|
||||
"ButtonRemoveFromContinueListening": "सुनना जारी रखें से हटाएं",
|
||||
"ButtonRemoveSeriesFromContinueSeries": "इस सीरीज को कंटिन्यू सीरीज से हटा दें",
|
||||
"ButtonReScan": "पुन: स्कैन करें",
|
||||
"ButtonReset": "रीसेट करें",
|
||||
"ButtonRestore": "पुनर्स्थापित करें",
|
||||
"ButtonSave": "सहेजें",
|
||||
"ButtonSaveAndClose": "सहेजें और बंद करें",
|
||||
"ButtonSaveTracklist": "ट्रैक सूची सहेजें",
|
||||
"ButtonScan": "स्कैन करें",
|
||||
"ButtonScanLibrary": "पुस्तकालय स्कैन करें",
|
||||
"ButtonSearch": "खोजें",
|
||||
"ButtonSelectFolderPath": "फ़ोल्डर का पथ चुनें",
|
||||
"ButtonSeries": "सीरीज",
|
||||
"ButtonSetChaptersFromTracks": "ट्रैक्स से अध्याय बनाएं",
|
||||
"ButtonShiftTimes": "समय खिसकाए",
|
||||
"ButtonShow": "दिखाएं",
|
||||
"ButtonStartM4BEncode": "M4B एन्कोडिंग शुरू करें",
|
||||
"ButtonStartMetadataEmbed": "मेटाडेटा एम्बेडिंग शुरू करें",
|
||||
"ButtonSubmit": "जमा करें",
|
||||
"ButtonUpload": "अपलोड करें",
|
||||
"ButtonUploadBackup": "बैकअप अपलोड करें",
|
||||
"ButtonUploadCover": "कवर अपलोड करें",
|
||||
"ButtonUploadOPMLFile": "OPML फ़ाइल अपलोड करें",
|
||||
"ButtonUserDelete": "उपयोगकर्ता {0} को हटाएं",
|
||||
"ButtonUserEdit": "उपयोगकर्ता {0} को संपादित करें",
|
||||
"ButtonViewAll": "सभी को देखें",
|
||||
"ButtonYes": "हाँ",
|
||||
"HeaderAccount": "खाता",
|
||||
"HeaderAdvanced": "विकसित",
|
||||
"HeaderAppriseNotificationSettings": "Apprise अधिसूचना सेटिंग्स",
|
||||
"HeaderAudiobookTools": "Audiobook File Management Tools",
|
||||
"HeaderAudioTracks": "Audio Tracks",
|
||||
"HeaderBackups": "Backups",
|
||||
"HeaderChangePassword": "Change Password",
|
||||
"HeaderChapters": "Chapters",
|
||||
"HeaderChooseAFolder": "Choose a Folder",
|
||||
"HeaderCollection": "Collection",
|
||||
"HeaderCollectionItems": "Collection Items",
|
||||
"HeaderCover": "Cover",
|
||||
"HeaderCurrentDownloads": "Current Downloads",
|
||||
"HeaderDetails": "Details",
|
||||
"HeaderDownloadQueue": "Download Queue",
|
||||
"HeaderEpisodes": "Episodes",
|
||||
"HeaderFiles": "Files",
|
||||
"HeaderFindChapters": "Find Chapters",
|
||||
"HeaderIgnoredFiles": "Ignored Files",
|
||||
"HeaderItemFiles": "Item Files",
|
||||
"HeaderItemMetadataUtils": "Item Metadata Utils",
|
||||
"HeaderLastListeningSession": "Last Listening Session",
|
||||
"HeaderLatestEpisodes": "Latest episodes",
|
||||
"HeaderLibraries": "Libraries",
|
||||
"HeaderLibraryFiles": "Library Files",
|
||||
"HeaderLibraryStats": "Library Stats",
|
||||
"HeaderListeningSessions": "Listening Sessions",
|
||||
"HeaderListeningStats": "Listening Stats",
|
||||
"HeaderLogin": "Login",
|
||||
"HeaderLogs": "Logs",
|
||||
"HeaderManageGenres": "Manage Genres",
|
||||
"HeaderManageTags": "Manage Tags",
|
||||
"HeaderMapDetails": "Map details",
|
||||
"HeaderMatch": "Match",
|
||||
"HeaderMetadataToEmbed": "Metadata to embed",
|
||||
"HeaderNewAccount": "New Account",
|
||||
"HeaderNewLibrary": "New Library",
|
||||
"HeaderNotifications": "Notifications",
|
||||
"HeaderOpenRSSFeed": "Open RSS Feed",
|
||||
"HeaderOtherFiles": "Other Files",
|
||||
"HeaderPermissions": "Permissions",
|
||||
"HeaderPlayerQueue": "Player Queue",
|
||||
"HeaderPlaylist": "Playlist",
|
||||
"HeaderPlaylistItems": "Playlist Items",
|
||||
"HeaderPodcastsToAdd": "Podcasts to Add",
|
||||
"HeaderPreviewCover": "Preview Cover",
|
||||
"HeaderRemoveEpisode": "Remove Episode",
|
||||
"HeaderRemoveEpisodes": "Remove {0} Episodes",
|
||||
"HeaderRSSFeedGeneral": "RSS Details",
|
||||
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
|
||||
"HeaderSavedMediaProgress": "Saved Media Progress",
|
||||
"HeaderSchedule": "Schedule",
|
||||
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
|
||||
"HeaderSession": "Session",
|
||||
"HeaderSetBackupSchedule": "Set Backup Schedule",
|
||||
"HeaderSettings": "Settings",
|
||||
"HeaderSettingsDisplay": "Display",
|
||||
"HeaderSettingsExperimental": "Experimental Features",
|
||||
"HeaderSettingsGeneral": "General",
|
||||
"HeaderSettingsScanner": "Scanner",
|
||||
"HeaderSleepTimer": "Sleep Timer",
|
||||
"HeaderStatsLargestItems": "Largest Items",
|
||||
"HeaderStatsLongestItems": "Longest Items (hrs)",
|
||||
"HeaderStatsMinutesListeningChart": "Minutes Listening (last 7 days)",
|
||||
"HeaderStatsRecentSessions": "Recent Sessions",
|
||||
"HeaderStatsTop10Authors": "Top 10 Authors",
|
||||
"HeaderStatsTop5Genres": "Top 5 Genres",
|
||||
"HeaderTools": "Tools",
|
||||
"HeaderUpdateAccount": "Update Account",
|
||||
"HeaderUpdateAuthor": "Update Author",
|
||||
"HeaderUpdateDetails": "Update Details",
|
||||
"HeaderUpdateLibrary": "Update Library",
|
||||
"HeaderUsers": "Users",
|
||||
"HeaderYourStats": "Your Stats",
|
||||
"LabelAbridged": "Abridged",
|
||||
"LabelAccountType": "Account Type",
|
||||
"LabelAccountTypeAdmin": "Admin",
|
||||
"LabelAccountTypeGuest": "Guest",
|
||||
"LabelAccountTypeUser": "User",
|
||||
"LabelActivity": "Activity",
|
||||
"LabelAdded": "Added",
|
||||
"LabelAddedAt": "Added At",
|
||||
"LabelAddToCollection": "Add to Collection",
|
||||
"LabelAddToCollectionBatch": "Add {0} Books to Collection",
|
||||
"LabelAddToPlaylist": "Add to Playlist",
|
||||
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
|
||||
"LabelAll": "All",
|
||||
"LabelAllUsers": "All Users",
|
||||
"LabelAlreadyInYourLibrary": "Already in your library",
|
||||
"LabelAppend": "Append",
|
||||
"LabelAuthor": "Author",
|
||||
"LabelAuthorFirstLast": "Author (First Last)",
|
||||
"LabelAuthorLastFirst": "Author (Last, First)",
|
||||
"LabelAuthors": "Authors",
|
||||
"LabelAutoDownloadEpisodes": "Auto Download Episodes",
|
||||
"LabelBackToUser": "Back to User",
|
||||
"LabelBackupsEnableAutomaticBackups": "Enable automatic backups",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "Backups saved in /metadata/backups",
|
||||
"LabelBackupsMaxBackupSize": "Maximum backup size (in GB)",
|
||||
"LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.",
|
||||
"LabelBackupsNumberToKeep": "Number of backups to keep",
|
||||
"LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.",
|
||||
"LabelBitrate": "Bitrate",
|
||||
"LabelBooks": "Books",
|
||||
"LabelChangePassword": "Change Password",
|
||||
"LabelChannels": "Channels",
|
||||
"LabelChapters": "Chapters",
|
||||
"LabelChaptersFound": "chapters found",
|
||||
"LabelChapterTitle": "Chapter Title",
|
||||
"LabelClosePlayer": "Close player",
|
||||
"LabelCodec": "Codec",
|
||||
"LabelCollapseSeries": "Collapse Series",
|
||||
"LabelCollections": "Collections",
|
||||
"LabelComplete": "Complete",
|
||||
"LabelConfirmPassword": "Confirm Password",
|
||||
"LabelContinueListening": "Continue Listening",
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCover": "Cover",
|
||||
"LabelCoverImageURL": "Cover Image URL",
|
||||
"LabelCreatedAt": "Created At",
|
||||
"LabelCronExpression": "Cron Expression",
|
||||
"LabelCurrent": "Current",
|
||||
"LabelCurrently": "Currently:",
|
||||
"LabelCustomCronExpression": "Custom Cron Expression:",
|
||||
"LabelDatetime": "Datetime",
|
||||
"LabelDescription": "Description",
|
||||
"LabelDeselectAll": "Deselect All",
|
||||
"LabelDevice": "Device",
|
||||
"LabelDeviceInfo": "Device Info",
|
||||
"LabelDirectory": "Directory",
|
||||
"LabelDiscFromFilename": "Disc from Filename",
|
||||
"LabelDiscFromMetadata": "Disc from Metadata",
|
||||
"LabelDownload": "Download",
|
||||
"LabelDuration": "Duration",
|
||||
"LabelDurationFound": "Duration found:",
|
||||
"LabelEdit": "Edit",
|
||||
"LabelEmbeddedCover": "Embedded Cover",
|
||||
"LabelEnable": "Enable",
|
||||
"LabelEnd": "End",
|
||||
"LabelEpisode": "Episode",
|
||||
"LabelEpisodeTitle": "Episode Title",
|
||||
"LabelEpisodeType": "Episode Type",
|
||||
"LabelExample": "Example",
|
||||
"LabelExplicit": "Explicit",
|
||||
"LabelFeedURL": "Feed URL",
|
||||
"LabelFile": "File",
|
||||
"LabelFileBirthtime": "File Birthtime",
|
||||
"LabelFileModified": "File Modified",
|
||||
"LabelFilename": "Filename",
|
||||
"LabelFilterByUser": "Filter by User",
|
||||
"LabelFindEpisodes": "Find Episodes",
|
||||
"LabelFinished": "Finished",
|
||||
"LabelFolder": "Folder",
|
||||
"LabelFolders": "Folders",
|
||||
"LabelFormat": "Format",
|
||||
"LabelGenre": "Genre",
|
||||
"LabelGenres": "Genres",
|
||||
"LabelHardDeleteFile": "Hard delete file",
|
||||
"LabelHour": "Hour",
|
||||
"LabelIcon": "Icon",
|
||||
"LabelIncludeInTracklist": "Include in Tracklist",
|
||||
"LabelIncomplete": "Incomplete",
|
||||
"LabelInProgress": "In Progress",
|
||||
"LabelInterval": "Interval",
|
||||
"LabelIntervalCustomDailyWeekly": "Custom daily/weekly",
|
||||
"LabelIntervalEvery12Hours": "Every 12 hours",
|
||||
"LabelIntervalEvery15Minutes": "Every 15 minutes",
|
||||
"LabelIntervalEvery2Hours": "Every 2 hours",
|
||||
"LabelIntervalEvery30Minutes": "Every 30 minutes",
|
||||
"LabelIntervalEvery6Hours": "Every 6 hours",
|
||||
"LabelIntervalEveryDay": "Every day",
|
||||
"LabelIntervalEveryHour": "Every hour",
|
||||
"LabelInvalidParts": "Invalid Parts",
|
||||
"LabelItem": "Item",
|
||||
"LabelLanguage": "Language",
|
||||
"LabelLanguageDefaultServer": "Default Server Language",
|
||||
"LabelLastBookAdded": "Last Book Added",
|
||||
"LabelLastBookUpdated": "Last Book Updated",
|
||||
"LabelLastSeen": "Last Seen",
|
||||
"LabelLastTime": "Last Time",
|
||||
"LabelLastUpdate": "Last Update",
|
||||
"LabelLess": "Less",
|
||||
"LabelLibrariesAccessibleToUser": "Libraries Accessible to User",
|
||||
"LabelLibrary": "Library",
|
||||
"LabelLibraryItem": "Library Item",
|
||||
"LabelLibraryName": "Library Name",
|
||||
"LabelLimit": "Limit",
|
||||
"LabelListenAgain": "Listen Again",
|
||||
"LabelLogLevelDebug": "Debug",
|
||||
"LabelLogLevelInfo": "Info",
|
||||
"LabelLogLevelWarn": "Warn",
|
||||
"LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date",
|
||||
"LabelMediaPlayer": "Media Player",
|
||||
"LabelMediaType": "Media Type",
|
||||
"LabelMetadataProvider": "Metadata Provider",
|
||||
"LabelMetaTag": "Meta Tag",
|
||||
"LabelMetaTags": "Meta Tags",
|
||||
"LabelMinute": "Minute",
|
||||
"LabelMissing": "Missing",
|
||||
"LabelMissingParts": "Missing Parts",
|
||||
"LabelMore": "More",
|
||||
"LabelMoreInfo": "More Info",
|
||||
"LabelName": "Name",
|
||||
"LabelNarrator": "Narrator",
|
||||
"LabelNarrators": "Narrators",
|
||||
"LabelNew": "New",
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNewPassword": "New Password",
|
||||
"LabelNextBackupDate": "Next backup date",
|
||||
"LabelNextScheduledRun": "Next scheduled run",
|
||||
"LabelNotes": "Notes",
|
||||
"LabelNotFinished": "Not Finished",
|
||||
"LabelNotificationAppriseURL": "Apprise URL(s)",
|
||||
"LabelNotificationAvailableVariables": "Available variables",
|
||||
"LabelNotificationBodyTemplate": "Body Template",
|
||||
"LabelNotificationEvent": "Notification Event",
|
||||
"LabelNotificationsMaxFailedAttempts": "Max failed attempts",
|
||||
"LabelNotificationsMaxFailedAttemptsHelp": "Notifications are disabled once they fail to send this many times",
|
||||
"LabelNotificationsMaxQueueSize": "Max queue size for notification events",
|
||||
"LabelNotificationsMaxQueueSizeHelp": "Events are limited to firing 1 per second. Events will be ignored if the queue is at max size. This prevents notification spamming.",
|
||||
"LabelNotificationTitleTemplate": "Title Template",
|
||||
"LabelNotStarted": "Not Started",
|
||||
"LabelNumberOfBooks": "Number of Books",
|
||||
"LabelNumberOfEpisodes": "# of Episodes",
|
||||
"LabelOpenRSSFeed": "Open RSS Feed",
|
||||
"LabelOverwrite": "Overwrite",
|
||||
"LabelPassword": "Password",
|
||||
"LabelPath": "Path",
|
||||
"LabelPermissionsAccessAllLibraries": "Can Access All Libraries",
|
||||
"LabelPermissionsAccessAllTags": "Can Access All Tags",
|
||||
"LabelPermissionsAccessExplicitContent": "Can Access Explicit Content",
|
||||
"LabelPermissionsDelete": "Can Delete",
|
||||
"LabelPermissionsDownload": "Can Download",
|
||||
"LabelPermissionsUpdate": "Can Update",
|
||||
"LabelPermissionsUpload": "Can Upload",
|
||||
"LabelPhotoPathURL": "Photo Path/URL",
|
||||
"LabelPlaylists": "Playlists",
|
||||
"LabelPlayMethod": "Play Method",
|
||||
"LabelPodcast": "Podcast",
|
||||
"LabelPodcasts": "Podcasts",
|
||||
"LabelPodcastType": "Podcast Type",
|
||||
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
|
||||
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
|
||||
"LabelProgress": "Progress",
|
||||
"LabelProvider": "Provider",
|
||||
"LabelPubDate": "Pub Date",
|
||||
"LabelPublisher": "Publisher",
|
||||
"LabelPublishYear": "Publish Year",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelRecommended": "Recommended",
|
||||
"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",
|
||||
"LabelSearchTerm": "Search Term",
|
||||
"LabelSearchTitle": "Search Title",
|
||||
"LabelSearchTitleOrASIN": "Search Title or ASIN",
|
||||
"LabelSeason": "Season",
|
||||
"LabelSequence": "Sequence",
|
||||
"LabelSeries": "Series",
|
||||
"LabelSeriesName": "Series Name",
|
||||
"LabelSeriesProgress": "Series Progress",
|
||||
"LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves",
|
||||
"LabelSettingsChromecastSupport": "Chromecast support",
|
||||
"LabelSettingsDateFormat": "Date Format",
|
||||
"LabelSettingsDisableWatcher": "Disable Watcher",
|
||||
"LabelSettingsDisableWatcherForLibrary": "Disable folder watcher for library",
|
||||
"LabelSettingsDisableWatcherHelp": "Disables the automatic adding/updating of items when file changes are detected. *Requires server restart",
|
||||
"LabelSettingsEnableEReader": "Enable e-reader for all users",
|
||||
"LabelSettingsEnableEReaderHelp": "E-reader is still a work in progress, but use this setting to open it up to all your users (or use the \"Experimental Features\" toggle just for use by you)",
|
||||
"LabelSettingsExperimentalFeatures": "Experimental features",
|
||||
"LabelSettingsExperimentalFeaturesHelp": "Features in development that could use your feedback and help testing. Click to open github discussion.",
|
||||
"LabelSettingsFindCovers": "Find covers",
|
||||
"LabelSettingsFindCoversHelp": "If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time",
|
||||
"LabelSettingsHomePageBookshelfView": "Home page use bookshelf view",
|
||||
"LabelSettingsLibraryBookshelfView": "Library use bookshelf view",
|
||||
"LabelSettingsOverdriveMediaMarkers": "Use Overdrive Media Markers for chapters",
|
||||
"LabelSettingsOverdriveMediaMarkersHelp": "MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically",
|
||||
"LabelSettingsParseSubtitles": "Parse subtitles",
|
||||
"LabelSettingsParseSubtitlesHelp": "Extract subtitles from audiobook folder names.<br>Subtitle must be seperated by \" - \"<br>i.e. \"Book Title - A Subtitle Here\" has the subtitle \"A Subtitle Here\"",
|
||||
"LabelSettingsPreferAudioMetadata": "Prefer audio metadata",
|
||||
"LabelSettingsPreferAudioMetadataHelp": "Audio file ID3 meta tags will be used for book details over folder names",
|
||||
"LabelSettingsPreferMatchedMetadata": "Prefer matched metadata",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Matched data will overide item details when using Quick Match. By default Quick Match will only fill in missing details.",
|
||||
"LabelSettingsPreferOPFMetadata": "Prefer OPF metadata",
|
||||
"LabelSettingsPreferOPFMetadataHelp": "OPF file metadata will be used for book details over folder names",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Skip matching books that already have an ASIN",
|
||||
"LabelSettingsSkipMatchingBooksWithISBN": "Skip matching books that already have an ISBN",
|
||||
"LabelSettingsSortingIgnorePrefixes": "Ignore prefixes when sorting",
|
||||
"LabelSettingsSortingIgnorePrefixesHelp": "i.e. for prefix \"the\" book title \"The Book Title\" would sort as \"Book Title, The\"",
|
||||
"LabelSettingsSquareBookCovers": "Use square book covers",
|
||||
"LabelSettingsSquareBookCoversHelp": "Prefer to use square covers over standard 1.6:1 book covers",
|
||||
"LabelSettingsStoreCoversWithItem": "Store covers with item",
|
||||
"LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept",
|
||||
"LabelSettingsStoreMetadataWithItem": "Store metadata with item",
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension",
|
||||
"LabelSettingsTimeFormat": "Time Format",
|
||||
"LabelShowAll": "Show All",
|
||||
"LabelSize": "Size",
|
||||
"LabelSleepTimer": "Sleep timer",
|
||||
"LabelStart": "Start",
|
||||
"LabelStarted": "Started",
|
||||
"LabelStartedAt": "Started At",
|
||||
"LabelStartTime": "Start Time",
|
||||
"LabelStatsAudioTracks": "Audio Tracks",
|
||||
"LabelStatsAuthors": "Authors",
|
||||
"LabelStatsBestDay": "Best Day",
|
||||
"LabelStatsDailyAverage": "Daily Average",
|
||||
"LabelStatsDays": "Days",
|
||||
"LabelStatsDaysListened": "Days Listened",
|
||||
"LabelStatsHours": "Hours",
|
||||
"LabelStatsInARow": "in a row",
|
||||
"LabelStatsItemsFinished": "Items Finished",
|
||||
"LabelStatsItemsInLibrary": "Items in Library",
|
||||
"LabelStatsMinutes": "minutes",
|
||||
"LabelStatsMinutesListening": "Minutes Listening",
|
||||
"LabelStatsOverallDays": "Overall Days",
|
||||
"LabelStatsOverallHours": "Overall Hours",
|
||||
"LabelStatsWeekListening": "Week Listening",
|
||||
"LabelSubtitle": "Subtitle",
|
||||
"LabelSupportedFileTypes": "Supported File Types",
|
||||
"LabelTag": "Tag",
|
||||
"LabelTags": "Tags",
|
||||
"LabelTagsAccessibleToUser": "Tags Accessible to User",
|
||||
"LabelTasks": "Tasks Running",
|
||||
"LabelTimeBase": "Time Base",
|
||||
"LabelTimeListened": "Time Listened",
|
||||
"LabelTimeListenedToday": "Time Listened Today",
|
||||
"LabelTimeRemaining": "{0} remaining",
|
||||
"LabelTimeToShift": "Time to shift in seconds",
|
||||
"LabelTitle": "Title",
|
||||
"LabelToolsEmbedMetadata": "Embed Metadata",
|
||||
"LabelToolsEmbedMetadataDescription": "Embed metadata into audio files including cover image and chapters.",
|
||||
"LabelToolsMakeM4b": "Make M4B Audiobook File",
|
||||
"LabelToolsMakeM4bDescription": "Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.",
|
||||
"LabelToolsSplitM4b": "Split M4B to MP3's",
|
||||
"LabelToolsSplitM4bDescription": "Create MP3's from an M4B split by chapters with embedded metadata, cover image, and chapters.",
|
||||
"LabelTotalDuration": "Total Duration",
|
||||
"LabelTotalTimeListened": "Total Time Listened",
|
||||
"LabelTrackFromFilename": "Track from Filename",
|
||||
"LabelTrackFromMetadata": "Track from Metadata",
|
||||
"LabelTracks": "Tracks",
|
||||
"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",
|
||||
"LabelUpdatedAt": "Updated At",
|
||||
"LabelUpdateDetails": "Update Details",
|
||||
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
|
||||
"LabelUploaderDragAndDrop": "Drag & drop files or folders",
|
||||
"LabelUploaderDropFiles": "Drop files",
|
||||
"LabelUseChapterTrack": "Use chapter track",
|
||||
"LabelUseFullTrack": "Use full track",
|
||||
"LabelUser": "User",
|
||||
"LabelUsername": "Username",
|
||||
"LabelValue": "Value",
|
||||
"LabelVersion": "Version",
|
||||
"LabelViewBookmarks": "View bookmarks",
|
||||
"LabelViewChapters": "View chapters",
|
||||
"LabelViewQueue": "View player queue",
|
||||
"LabelVolume": "Volume",
|
||||
"LabelWeekdaysToRun": "Weekdays to run",
|
||||
"LabelYourAudiobookDuration": "Your audiobook duration",
|
||||
"LabelYourBookmarks": "Your Bookmarks",
|
||||
"LabelYourPlaylists": "Your Playlists",
|
||||
"LabelYourProgress": "Your Progress",
|
||||
"MessageAddToPlayerQueue": "Add to player queue",
|
||||
"MessageAppriseDescription": "To use this feature you will need to have an instance of <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at <code>http://192.168.1.1:8337</code> then you would put <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in <code>/metadata/items</code> & <code>/metadata/authors</code>. Backups <strong>do not</strong> include any files stored in your library folders.",
|
||||
"MessageBatchQuickMatchDescription": "Quick Match will attempt to add missing covers and metadata for the selected items. Enable the options below to allow Quick Match to overwrite existing covers and/or metadata.",
|
||||
"MessageBookshelfNoCollections": "You haven't made any collections yet",
|
||||
"MessageBookshelfNoResultsForFilter": "No Results for filter \"{0}: {1}\"",
|
||||
"MessageBookshelfNoRSSFeeds": "No RSS feeds are open",
|
||||
"MessageBookshelfNoSeries": "You have no series",
|
||||
"MessageChapterEndIsAfter": "Chapter end is after the end of your audiobook",
|
||||
"MessageChapterErrorFirstNotZero": "First chapter must start at 0",
|
||||
"MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration",
|
||||
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
|
||||
"MessageChapterStartIsAfter": "Chapter start is after the end of your audiobook",
|
||||
"MessageCheckingCron": "Checking cron...",
|
||||
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
|
||||
"MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?",
|
||||
"MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
|
||||
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
|
||||
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
|
||||
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
|
||||
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
|
||||
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
|
||||
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
|
||||
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
|
||||
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
|
||||
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
|
||||
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
|
||||
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
|
||||
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
|
||||
"MessageDownloadingEpisode": "Downloading episode",
|
||||
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
|
||||
"MessageEmbedFinished": "Embed Finished!",
|
||||
"MessageEpisodesQueuedForDownload": "{0} Episode(s) queued for download",
|
||||
"MessageFeedURLWillBe": "Feed URL will be {0}",
|
||||
"MessageFetching": "Fetching...",
|
||||
"MessageForceReScanDescription": "will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be scanned as new.",
|
||||
"MessageImportantNotice": "Important Notice!",
|
||||
"MessageInsertChapterBelow": "Insert chapter below",
|
||||
"MessageItemsSelected": "{0} Items Selected",
|
||||
"MessageItemsUpdated": "{0} Items Updated",
|
||||
"MessageJoinUsOn": "Join us on",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} listening sessions in the last year",
|
||||
"MessageLoading": "Loading...",
|
||||
"MessageLoadingFolders": "Loading folders...",
|
||||
"MessageM4BFailed": "M4B Failed!",
|
||||
"MessageM4BFinished": "M4B Finished!",
|
||||
"MessageMapChapterTitles": "Map chapter titles to your existing audiobook chapters without adjusting timestamps",
|
||||
"MessageMarkAsFinished": "Mark as Finished",
|
||||
"MessageMarkAsNotFinished": "Mark as Not Finished",
|
||||
"MessageMatchBooksDescription": "will attempt to match books in the library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.",
|
||||
"MessageNoAudioTracks": "No audio tracks",
|
||||
"MessageNoAuthors": "No Authors",
|
||||
"MessageNoBackups": "No Backups",
|
||||
"MessageNoBookmarks": "No Bookmarks",
|
||||
"MessageNoChapters": "No Chapters",
|
||||
"MessageNoCollections": "No Collections",
|
||||
"MessageNoCoversFound": "No Covers Found",
|
||||
"MessageNoDescription": "No description",
|
||||
"MessageNoDownloadsInProgress": "No downloads currently in progress",
|
||||
"MessageNoDownloadsQueued": "No downloads queued",
|
||||
"MessageNoEpisodeMatchesFound": "No episode matches found",
|
||||
"MessageNoEpisodes": "No Episodes",
|
||||
"MessageNoFoldersAvailable": "No Folders Available",
|
||||
"MessageNoGenres": "No Genres",
|
||||
"MessageNoIssues": "No Issues",
|
||||
"MessageNoItems": "No Items",
|
||||
"MessageNoItemsFound": "No items found",
|
||||
"MessageNoListeningSessions": "No Listening Sessions",
|
||||
"MessageNoLogs": "No Logs",
|
||||
"MessageNoMediaProgress": "No Media Progress",
|
||||
"MessageNoNotifications": "No Notifications",
|
||||
"MessageNoPodcastsFound": "No podcasts found",
|
||||
"MessageNoResults": "No Results",
|
||||
"MessageNoSearchResultsFor": "No search results for \"{0}\"",
|
||||
"MessageNoSeries": "No Series",
|
||||
"MessageNoTags": "No Tags",
|
||||
"MessageNoTasksRunning": "No Tasks Running",
|
||||
"MessageNotYetImplemented": "Not yet implemented",
|
||||
"MessageNoUpdateNecessary": "No update necessary",
|
||||
"MessageNoUpdatesWereNecessary": "No updates were necessary",
|
||||
"MessageNoUserPlaylists": "You have no playlists",
|
||||
"MessageOr": "or",
|
||||
"MessagePauseChapter": "Pause chapter playback",
|
||||
"MessagePlayChapter": "Listen to beginning of chapter",
|
||||
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
|
||||
"MessagePodcastHasNoRSSFeedForMatching": "Podcast has no RSS feed url to use for matching",
|
||||
"MessageQuickMatchDescription": "Populate empty item details & cover with first match result from '{0}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.",
|
||||
"MessageRemoveAllItemsWarning": "WARNING! This action will remove all library items from the database including any updates or matches you have made. This does not do anything to your actual files. Are you sure?",
|
||||
"MessageRemoveChapter": "Remove chapter",
|
||||
"MessageRemoveEpisodes": "Remove {0} episode(s)",
|
||||
"MessageRemoveFromPlayerQueue": "Remove from player queue",
|
||||
"MessageRemoveUserWarning": "Are you sure you want to permanently delete user \"{0}\"?",
|
||||
"MessageReportBugsAndContribute": "Report bugs, request features, and contribute on",
|
||||
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?",
|
||||
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
|
||||
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
|
||||
"MessageSearchResultsFor": "Search results for",
|
||||
"MessageServerCouldNotBeReached": "Server could not be reached",
|
||||
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
|
||||
"MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?",
|
||||
"MessageThinking": "Thinking...",
|
||||
"MessageUploaderItemFailed": "Failed to upload",
|
||||
"MessageUploaderItemSuccess": "Successfully Uploaded!",
|
||||
"MessageUploading": "Uploading...",
|
||||
"MessageValidCronExpression": "Valid cron expression",
|
||||
"MessageWatcherIsDisabledGlobally": "Watcher is disabled globally in server settings",
|
||||
"MessageXLibraryIsEmpty": "{0} Library is empty!",
|
||||
"MessageYourAudiobookDurationIsLonger": "Your audiobook duration is longer than the duration found",
|
||||
"MessageYourAudiobookDurationIsShorter": "Your audiobook duration is shorter than duration found",
|
||||
"NoteChangeRootPassword": "रूट user is the only user that can have an empty password",
|
||||
"NoteChapterEditorTimes": "Note: First chapter start time must remain at 0:00 and the last chapter start time cannot exceed this audiobooks duration.",
|
||||
"NoteFolderPicker": "Note: folders already mapped will not be shown",
|
||||
"NoteFolderPickerDebian": "Note: Folder picker for the debian install is not fully implemented. You should enter the path to your library directly.",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Warning: Most podcast apps will require the RSS feed URL is using HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.",
|
||||
"NoteUploaderFoldersWithMediaFiles": "Folders with media files will be handled as separate library items.",
|
||||
"NoteUploaderOnlyAudioFiles": "If uploading only audio files then each audio file will be handled as a separate audiobook.",
|
||||
"NoteUploaderUnsupportedFiles": "Unsupported files are ignored. When choosing or dropping a folder, other files that are not in an item folder are ignored.",
|
||||
"PlaceholderNewCollection": "New collection name",
|
||||
"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",
|
||||
"ToastAuthorImageRemoveSuccess": "Author image removed",
|
||||
"ToastAuthorUpdateFailed": "Failed to update author",
|
||||
"ToastAuthorUpdateMerged": "Author merged",
|
||||
"ToastAuthorUpdateSuccess": "Author updated",
|
||||
"ToastAuthorUpdateSuccessNoImageFound": "Author updated (no image found)",
|
||||
"ToastBackupCreateFailed": "Failed to create backup",
|
||||
"ToastBackupCreateSuccess": "Backup created",
|
||||
"ToastBackupDeleteFailed": "Failed to delete backup",
|
||||
"ToastBackupDeleteSuccess": "Backup deleted",
|
||||
"ToastBackupRestoreFailed": "Failed to restore backup",
|
||||
"ToastBackupUploadFailed": "Failed to upload backup",
|
||||
"ToastBackupUploadSuccess": "Backup uploaded",
|
||||
"ToastBatchUpdateFailed": "Batch update failed",
|
||||
"ToastBatchUpdateSuccess": "Batch update success",
|
||||
"ToastBookmarkCreateFailed": "Failed to create bookmark",
|
||||
"ToastBookmarkCreateSuccess": "Bookmark added",
|
||||
"ToastBookmarkRemoveFailed": "Failed to remove bookmark",
|
||||
"ToastBookmarkRemoveSuccess": "Bookmark removed",
|
||||
"ToastBookmarkUpdateFailed": "Failed to update bookmark",
|
||||
"ToastBookmarkUpdateSuccess": "Bookmark updated",
|
||||
"ToastChaptersHaveErrors": "Chapters have errors",
|
||||
"ToastChaptersMustHaveTitles": "Chapters must have titles",
|
||||
"ToastCollectionItemsRemoveFailed": "Failed to remove item(s) from collection",
|
||||
"ToastCollectionItemsRemoveSuccess": "Item(s) removed from collection",
|
||||
"ToastCollectionRemoveFailed": "Failed to remove collection",
|
||||
"ToastCollectionRemoveSuccess": "Collection removed",
|
||||
"ToastCollectionUpdateFailed": "Failed to update collection",
|
||||
"ToastCollectionUpdateSuccess": "Collection updated",
|
||||
"ToastItemCoverUpdateFailed": "Failed to update item cover",
|
||||
"ToastItemCoverUpdateSuccess": "Item cover updated",
|
||||
"ToastItemDetailsUpdateFailed": "Failed to update item details",
|
||||
"ToastItemDetailsUpdateSuccess": "Item details updated",
|
||||
"ToastItemDetailsUpdateUnneeded": "No updates needed for item details",
|
||||
"ToastItemMarkedAsFinishedFailed": "Failed to mark as Finished",
|
||||
"ToastItemMarkedAsFinishedSuccess": "Item marked as Finished",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "Failed to mark as Not Finished",
|
||||
"ToastItemMarkedAsNotFinishedSuccess": "Item marked as Not Finished",
|
||||
"ToastLibraryCreateFailed": "Failed to create library",
|
||||
"ToastLibraryCreateSuccess": "Library \"{0}\" created",
|
||||
"ToastLibraryDeleteFailed": "Failed to delete library",
|
||||
"ToastLibraryDeleteSuccess": "Library deleted",
|
||||
"ToastLibraryScanFailedToStart": "Failed to start scan",
|
||||
"ToastLibraryScanStarted": "Library scan started",
|
||||
"ToastLibraryUpdateFailed": "Failed to update library",
|
||||
"ToastLibraryUpdateSuccess": "Library \"{0}\" updated",
|
||||
"ToastPlaylistCreateFailed": "Failed to create playlist",
|
||||
"ToastPlaylistCreateSuccess": "Playlist created",
|
||||
"ToastPlaylistRemoveFailed": "Failed to remove playlist",
|
||||
"ToastPlaylistRemoveSuccess": "Playlist removed",
|
||||
"ToastPlaylistUpdateFailed": "Failed to update playlist",
|
||||
"ToastPlaylistUpdateSuccess": "Playlist updated",
|
||||
"ToastPodcastCreateFailed": "Failed to create podcast",
|
||||
"ToastPodcastCreateSuccess": "Podcast created successfully",
|
||||
"ToastRemoveItemFromCollectionFailed": "Failed to remove item from collection",
|
||||
"ToastRemoveItemFromCollectionSuccess": "Item removed from collection",
|
||||
"ToastRSSFeedCloseFailed": "Failed to close RSS feed",
|
||||
"ToastRSSFeedCloseSuccess": "RSS feed closed",
|
||||
"ToastSeriesUpdateFailed": "Series update failed",
|
||||
"ToastSeriesUpdateSuccess": "Series update success",
|
||||
"ToastSessionDeleteFailed": "Failed to delete session",
|
||||
"ToastSessionDeleteSuccess": "Session deleted",
|
||||
"ToastSocketConnected": "Socket connected",
|
||||
"ToastSocketDisconnected": "Socket disconnected",
|
||||
"ToastSocketFailedToConnect": "Socket failed to connect",
|
||||
"ToastUserDeleteFailed": "Failed to delete user",
|
||||
"ToastUserDeleteSuccess": "User deleted"
|
||||
}
|
||||
@@ -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,11 +155,13 @@
|
||||
"HeaderUpdateLibrary": "Aktualiziraj biblioteku",
|
||||
"HeaderUsers": "Korinici",
|
||||
"HeaderYourStats": "Tvoja statistika",
|
||||
"LabelAbridged": "Abridged",
|
||||
"LabelAccountType": "Vrsta korisničkog računa",
|
||||
"LabelAccountTypeAdmin": "Administrator",
|
||||
"LabelAccountTypeGuest": "Gost",
|
||||
"LabelAccountTypeUser": "Korisnik",
|
||||
"LabelActivity": "Aktivnost",
|
||||
"LabelAdded": "Added",
|
||||
"LabelAddedAt": "Added At",
|
||||
"LabelAddToCollection": "Dodaj u kolekciju",
|
||||
"LabelAddToCollectionBatch": "Add {0} Books to Collection",
|
||||
@@ -181,11 +183,15 @@
|
||||
"LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.",
|
||||
"LabelBackupsNumberToKeep": "Broj backupa zadržati",
|
||||
"LabelBackupsNumberToKeepHelp": "Samo 1 backup će biti odjednom obrisan. Ako koristite više njih, morati ćete ih ručno ukloniti.",
|
||||
"LabelBitrate": "Bitrate",
|
||||
"LabelBooks": "Knjige",
|
||||
"LabelChangePassword": "Promijeni lozinku",
|
||||
"LabelChannels": "Channels",
|
||||
"LabelChapters": "Chapters",
|
||||
"LabelChaptersFound": "poglavlja pronađena",
|
||||
"LabelChapterTitle": "Ime poglavlja",
|
||||
"LabelClosePlayer": "Close player",
|
||||
"LabelCodec": "Codec",
|
||||
"LabelCollapseSeries": "Collapse Series",
|
||||
"LabelCollections": "Kolekcije",
|
||||
"LabelComplete": "Complete",
|
||||
@@ -211,6 +217,7 @@
|
||||
"LabelDuration": "Trajanje",
|
||||
"LabelDurationFound": "Pronađeno trajanje:",
|
||||
"LabelEdit": "Uredi",
|
||||
"LabelEmbeddedCover": "Embedded Cover",
|
||||
"LabelEnable": "Uključi",
|
||||
"LabelEnd": "Kraj",
|
||||
"LabelEpisode": "Epizoda",
|
||||
@@ -228,6 +235,7 @@
|
||||
"LabelFinished": "Finished",
|
||||
"LabelFolder": "Folder",
|
||||
"LabelFolders": "Folderi",
|
||||
"LabelFormat": "Format",
|
||||
"LabelGenre": "Genre",
|
||||
"LabelGenres": "Žanrovi",
|
||||
"LabelHardDeleteFile": "Obriši datoteku zauvijek",
|
||||
@@ -249,6 +257,8 @@
|
||||
"LabelItem": "Stavka",
|
||||
"LabelLanguage": "Jezik",
|
||||
"LabelLanguageDefaultServer": "Default jezik servera",
|
||||
"LabelLastBookAdded": "Last Book Added",
|
||||
"LabelLastBookUpdated": "Last Book Updated",
|
||||
"LabelLastSeen": "Zadnje pogledano",
|
||||
"LabelLastTime": "Prošli put",
|
||||
"LabelLastUpdate": "Zadnja aktualizacija",
|
||||
@@ -267,10 +277,12 @@
|
||||
"LabelMediaType": "Media Type",
|
||||
"LabelMetadataProvider": "Poslužitelj metapodataka ",
|
||||
"LabelMetaTag": "Meta Tag",
|
||||
"LabelMetaTags": "Meta Tags",
|
||||
"LabelMinute": "Minuta",
|
||||
"LabelMissing": "Nedostaje",
|
||||
"LabelMissingParts": "Nedostajali dijelovi",
|
||||
"LabelMore": "Više",
|
||||
"LabelMoreInfo": "More Info",
|
||||
"LabelName": "Ime",
|
||||
"LabelNarrator": "Narrator",
|
||||
"LabelNarrators": "Naratori",
|
||||
@@ -324,12 +336,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",
|
||||
@@ -401,6 +413,7 @@
|
||||
"LabelTags": "Tags",
|
||||
"LabelTagsAccessibleToUser": "Tags dostupni korisniku",
|
||||
"LabelTasks": "Tasks Running",
|
||||
"LabelTimeBase": "Time Base",
|
||||
"LabelTimeListened": "Vremena odslušano",
|
||||
"LabelTimeListenedToday": "Vremena odslušano danas",
|
||||
"LabelTimeRemaining": "{0} preostalo",
|
||||
@@ -420,6 +433,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.",
|
||||
@@ -463,6 +477,7 @@
|
||||
"MessageConfirmForceReScan": "Jeste li sigurni da želite ponovno skenirati?",
|
||||
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
|
||||
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
|
||||
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
|
||||
"MessageConfirmRemoveCollection": "AJeste li sigurni da želite obrisati kolekciju \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisode": "Jeste li sigurni da želite obrisati epizodu \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodes": "Jeste li sigurni da želite obrisati {0} epizoda/-u?",
|
||||
@@ -502,8 +517,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 +581,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 +651,4 @@
|
||||
"ToastSocketFailedToConnect": "Socket failed to connect",
|
||||
"ToastUserDeleteFailed": "Neuspješno brisanje korisnika",
|
||||
"ToastUserDeleteSuccess": "Korisnik obrisan"
|
||||
}
|
||||
}
|
||||
@@ -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,11 +155,13 @@
|
||||
"HeaderUpdateLibrary": "Aggiorna Libreria",
|
||||
"HeaderUsers": "Utenti",
|
||||
"HeaderYourStats": "Statistiche Personali",
|
||||
"LabelAbridged": "Abridged",
|
||||
"LabelAccountType": "Tipo di Account",
|
||||
"LabelAccountTypeAdmin": "Admin",
|
||||
"LabelAccountTypeGuest": "Ospite",
|
||||
"LabelAccountTypeUser": "Utente",
|
||||
"LabelActivity": "Attività",
|
||||
"LabelAdded": "Added",
|
||||
"LabelAddedAt": "Aggiunto il",
|
||||
"LabelAddToCollection": "Aggiungi alla Raccolta",
|
||||
"LabelAddToCollectionBatch": "Aggiungi {0} Libri alla Raccolta",
|
||||
@@ -167,7 +169,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)",
|
||||
@@ -181,11 +183,15 @@
|
||||
"LabelBackupsMaxBackupSizeHelp": "Come protezione contro gli errori di config, i backup falliranno se superano la dimensione configurata.",
|
||||
"LabelBackupsNumberToKeep": "Numero di backup da mantenere",
|
||||
"LabelBackupsNumberToKeepHelp": "Verrà rimosso solo 1 backup alla volta, quindi se hai più backup, dovrai rimuoverli manualmente.",
|
||||
"LabelBitrate": "Bitrate",
|
||||
"LabelBooks": "Libri",
|
||||
"LabelChangePassword": "Cambia Password",
|
||||
"LabelChannels": "Channels",
|
||||
"LabelChapters": "Chapters",
|
||||
"LabelChaptersFound": "Capitoli Trovati",
|
||||
"LabelChapterTitle": "Titoli dei Capitoli",
|
||||
"LabelClosePlayer": "Chiudi player",
|
||||
"LabelCodec": "Codec",
|
||||
"LabelCollapseSeries": "Comprimi Serie",
|
||||
"LabelCollections": "Raccolte",
|
||||
"LabelComplete": "Completo",
|
||||
@@ -211,6 +217,7 @@
|
||||
"LabelDuration": "Durata",
|
||||
"LabelDurationFound": "Durata Trovata:",
|
||||
"LabelEdit": "Modifica",
|
||||
"LabelEmbeddedCover": "Embedded Cover",
|
||||
"LabelEnable": "Abilita",
|
||||
"LabelEnd": "Fine",
|
||||
"LabelEpisode": "Episodio",
|
||||
@@ -228,6 +235,7 @@
|
||||
"LabelFinished": "Finita",
|
||||
"LabelFolder": "Cartella",
|
||||
"LabelFolders": "Cartelle",
|
||||
"LabelFormat": "Format",
|
||||
"LabelGenre": "Genere",
|
||||
"LabelGenres": "Generi",
|
||||
"LabelHardDeleteFile": "Elimina Definitivamente",
|
||||
@@ -238,7 +246,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",
|
||||
@@ -249,6 +257,8 @@
|
||||
"LabelItem": "Oggetti",
|
||||
"LabelLanguage": "Lingua",
|
||||
"LabelLanguageDefaultServer": "Lingua di Default",
|
||||
"LabelLastBookAdded": "Last Book Added",
|
||||
"LabelLastBookUpdated": "Last Book Updated",
|
||||
"LabelLastSeen": "Ultimi Visti",
|
||||
"LabelLastTime": "Ultima Volta",
|
||||
"LabelLastUpdate": "Ultimo Aggiornamento",
|
||||
@@ -267,10 +277,12 @@
|
||||
"LabelMediaType": "Tipo Media",
|
||||
"LabelMetadataProvider": "Metadata Provider",
|
||||
"LabelMetaTag": "Meta Tag",
|
||||
"LabelMetaTags": "Meta Tags",
|
||||
"LabelMinute": "Minuto",
|
||||
"LabelMissing": "Altro",
|
||||
"LabelMissingParts": "Parti rimantenti",
|
||||
"LabelMore": "Molto",
|
||||
"LabelMoreInfo": "More Info",
|
||||
"LabelName": "Nome",
|
||||
"LabelNarrator": "Narratore",
|
||||
"LabelNarrators": "Narratori",
|
||||
@@ -278,8 +290,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 +307,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 +322,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 +332,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 +384,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 +412,8 @@
|
||||
"LabelTag": "Tag",
|
||||
"LabelTags": "Tags",
|
||||
"LabelTagsAccessibleToUser": "Tags permessi agli Utenti",
|
||||
"LabelTasks": "Tasks Running",
|
||||
"LabelTasks": "Processi in esecuzione",
|
||||
"LabelTimeBase": "Time Base",
|
||||
"LabelTimeListened": "Tempo di Ascolto",
|
||||
"LabelTimeListenedToday": "Tempo di Ascolto Oggi",
|
||||
"LabelTimeRemaining": "{0} rimanente",
|
||||
@@ -420,6 +433,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",
|
||||
@@ -463,6 +477,7 @@
|
||||
"MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?",
|
||||
"MessageConfirmMarkSeriesFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come completati?",
|
||||
"MessageConfirmMarkSeriesNotFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come non completati?",
|
||||
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
|
||||
"MessageConfirmRemoveCollection": "Sei sicuro di voler rimuovere la Raccolta \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?",
|
||||
@@ -502,8 +517,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 +535,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 +581,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 +642,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 +651,4 @@
|
||||
"ToastSocketFailedToConnect": "Socket non riesce a connettersi",
|
||||
"ToastUserDeleteFailed": "Errore eliminazione utente",
|
||||
"ToastUserDeleteSuccess": "Utente eliminato"
|
||||
}
|
||||
}
|
||||
@@ -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,11 +155,13 @@
|
||||
"HeaderUpdateLibrary": "Zaktualizuj bibliotekę",
|
||||
"HeaderUsers": "Użytkownicy",
|
||||
"HeaderYourStats": "Twoje statystyki",
|
||||
"LabelAbridged": "Abridged",
|
||||
"LabelAccountType": "Typ konta",
|
||||
"LabelAccountTypeAdmin": "Administrator",
|
||||
"LabelAccountTypeGuest": "Gość",
|
||||
"LabelAccountTypeUser": "Użytkownik",
|
||||
"LabelActivity": "Aktywność",
|
||||
"LabelAdded": "Added",
|
||||
"LabelAddedAt": "Dodano",
|
||||
"LabelAddToCollection": "Dodaj do kolekcji",
|
||||
"LabelAddToCollectionBatch": "Dodaj {0} książki do kolekcji",
|
||||
@@ -181,11 +183,15 @@
|
||||
"LabelBackupsMaxBackupSizeHelp": "Jako zabezpieczenie przed błędną konfiguracją, kopie zapasowe nie będą wykonywane, jeśli przekroczą skonfigurowany rozmiar.",
|
||||
"LabelBackupsNumberToKeep": "Liczba kopii zapasowych do przechowywania",
|
||||
"LabelBackupsNumberToKeepHelp": "Tylko 1 kopia zapasowa zostanie usunięta, więc jeśli masz już więcej kopii zapasowych, powinieneś je ręcznie usunąć.",
|
||||
"LabelBitrate": "Bitrate",
|
||||
"LabelBooks": "Książki",
|
||||
"LabelChangePassword": "Zmień hasło",
|
||||
"LabelChannels": "Channels",
|
||||
"LabelChapters": "Chapters",
|
||||
"LabelChaptersFound": "Znalezione rozdziały",
|
||||
"LabelChapterTitle": "Tytuł rozdziału",
|
||||
"LabelClosePlayer": "Zamknij odtwarzacz",
|
||||
"LabelCodec": "Codec",
|
||||
"LabelCollapseSeries": "Podsumuj serię",
|
||||
"LabelCollections": "Kolekcje",
|
||||
"LabelComplete": "Ukończone",
|
||||
@@ -211,6 +217,7 @@
|
||||
"LabelDuration": "Czas trwania",
|
||||
"LabelDurationFound": "Znaleziona długość:",
|
||||
"LabelEdit": "Edytuj",
|
||||
"LabelEmbeddedCover": "Embedded Cover",
|
||||
"LabelEnable": "Włącz",
|
||||
"LabelEnd": "Zakończ",
|
||||
"LabelEpisode": "Odcinek",
|
||||
@@ -228,6 +235,7 @@
|
||||
"LabelFinished": "Zakończone",
|
||||
"LabelFolder": "Folder",
|
||||
"LabelFolders": "Foldery",
|
||||
"LabelFormat": "Format",
|
||||
"LabelGenre": "Gatunek",
|
||||
"LabelGenres": "Gatunki",
|
||||
"LabelHardDeleteFile": "Usuń trwale plik",
|
||||
@@ -249,6 +257,8 @@
|
||||
"LabelItem": "Pozycja",
|
||||
"LabelLanguage": "Język",
|
||||
"LabelLanguageDefaultServer": "Domyślny język serwera",
|
||||
"LabelLastBookAdded": "Last Book Added",
|
||||
"LabelLastBookUpdated": "Last Book Updated",
|
||||
"LabelLastSeen": "Ostatnio widziany",
|
||||
"LabelLastTime": "Ostatni czas",
|
||||
"LabelLastUpdate": "Ostatnia aktualizacja",
|
||||
@@ -267,10 +277,12 @@
|
||||
"LabelMediaType": "Typ mediów",
|
||||
"LabelMetadataProvider": "Dostawca metadanych",
|
||||
"LabelMetaTag": "Tag",
|
||||
"LabelMetaTags": "Meta Tags",
|
||||
"LabelMinute": "Minuta",
|
||||
"LabelMissing": "Brakujący",
|
||||
"LabelMissingParts": "Brakujące cześci",
|
||||
"LabelMore": "Więcej",
|
||||
"LabelMoreInfo": "More Info",
|
||||
"LabelName": "Nazwa",
|
||||
"LabelNarrator": "Narrator",
|
||||
"LabelNarrators": "Lektorzy",
|
||||
@@ -324,12 +336,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",
|
||||
@@ -401,6 +413,7 @@
|
||||
"LabelTags": "Tagi",
|
||||
"LabelTagsAccessibleToUser": "Tagi dostępne dla użytkownika",
|
||||
"LabelTasks": "Tasks Running",
|
||||
"LabelTimeBase": "Time Base",
|
||||
"LabelTimeListened": "Czas odtwarzania",
|
||||
"LabelTimeListenedToday": "Czas odtwarzania dzisiaj",
|
||||
"LabelTimeRemaining": "Pozostało {0}",
|
||||
@@ -420,6 +433,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",
|
||||
@@ -463,6 +477,7 @@
|
||||
"MessageConfirmForceReScan": "Czy na pewno chcesz wymusić ponowne skanowanie?",
|
||||
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
|
||||
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
|
||||
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
|
||||
"MessageConfirmRemoveCollection": "Czy na pewno chcesz usunąć kolekcję \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisode": "Czy na pewno chcesz usunąć odcinek \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodes": "Czy na pewno chcesz usunąć {0} odcinki?",
|
||||
@@ -502,8 +517,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 +581,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 +651,4 @@
|
||||
"ToastSocketFailedToConnect": "Poączenie z serwerem nie powiodło się",
|
||||
"ToastUserDeleteFailed": "Nie udało się usunąć użytkownika",
|
||||
"ToastUserDeleteSuccess": "Użytkownik usunięty"
|
||||
}
|
||||
}
|
||||
@@ -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,177 +33,183 @@
|
||||
"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": "Сокращенное издание",
|
||||
"LabelAccountType": "Тип учетной записи",
|
||||
"LabelAccountTypeAdmin": "Администратор",
|
||||
"LabelAccountTypeGuest": "Гость",
|
||||
"LabelAccountTypeUser": "Пользователь",
|
||||
"LabelActivity": "Активность",
|
||||
"LabelAddedAt": "Добавить В",
|
||||
"LabelAddToCollection": "Добавить в Коллекцию",
|
||||
"LabelAddToCollectionBatch": "Добавить {0} Книг в Коллекцию",
|
||||
"LabelAddToPlaylist": "Добавить в Плейлист",
|
||||
"LabelAddToPlaylistBatch": "Добавить {0} Элементов в Плейлист",
|
||||
"LabelAdded": "Added",
|
||||
"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)",
|
||||
"LabelBackupsMaxBackupSizeHelp": "В качестве защиты процесс бэкапирования будет завершаться ошибкой, если будет превышен настроенный размер.",
|
||||
"LabelBackupsNumberToKeep": "Сохранять бэкапов",
|
||||
"LabelBackupsNumberToKeepHelp": "За один раз только 1 бэкап будет удален, так что если у вас будет больше бэкапов, то их нужно удалить вручную.",
|
||||
"LabelBitrate": "Bitrate",
|
||||
"LabelBooks": "Книги",
|
||||
"LabelChangePassword": "Изменить Пароль",
|
||||
"LabelChangePassword": "Изменить пароль",
|
||||
"LabelChannels": "Channels",
|
||||
"LabelChapters": "Chapters",
|
||||
"LabelChaptersFound": "глав найдено",
|
||||
"LabelChapterTitle": "Название Главы",
|
||||
"LabelChapterTitle": "Название главы",
|
||||
"LabelClosePlayer": "Закрыть проигрыватель",
|
||||
"LabelCollapseSeries": "Свернуть Серии",
|
||||
"LabelCodec": "Codec",
|
||||
"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": "Диск из Метаданных",
|
||||
@@ -211,29 +217,31 @@
|
||||
"LabelDuration": "Длина",
|
||||
"LabelDurationFound": "Найденная длина:",
|
||||
"LabelEdit": "Редактировать",
|
||||
"LabelEmbeddedCover": "Embedded Cover",
|
||||
"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": "Папки",
|
||||
"LabelFormat": "Format",
|
||||
"LabelGenre": "Жанр",
|
||||
"LabelGenres": "Жанры",
|
||||
"LabelHardDeleteFile": "Жесткое удаление файла",
|
||||
"LabelHour": "Часы",
|
||||
"LabelIcon": "Иконка",
|
||||
"LabelIncludeInTracklist": "Включать в Список воспроизведения",
|
||||
"LabelIncludeInTracklist": "Включать в список воспроизведения",
|
||||
"LabelIncomplete": "Не завершен",
|
||||
"LabelInProgress": "В процессе",
|
||||
"LabelInterval": "Интервал",
|
||||
@@ -245,103 +253,107 @@
|
||||
"LabelIntervalEvery6Hours": "Каждые 6 часов",
|
||||
"LabelIntervalEveryDay": "Каждый день",
|
||||
"LabelIntervalEveryHour": "Каждый час",
|
||||
"LabelInvalidParts": "Неверные Части",
|
||||
"LabelInvalidParts": "Неверные части",
|
||||
"LabelItem": "Элемент",
|
||||
"LabelLanguage": "Язык",
|
||||
"LabelLanguageDefaultServer": "Язык Сервера по Умолчанию",
|
||||
"LabelLastSeen": "Последнее Сканирование",
|
||||
"LabelLastTime": "Последний по Времени",
|
||||
"LabelLastUpdate": "Последний Обновленный",
|
||||
"LabelLanguageDefaultServer": "Язык сервера по умолчанию",
|
||||
"LabelLastBookAdded": "Last Book Added",
|
||||
"LabelLastBookUpdated": "Last Book Updated",
|
||||
"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": "Мета тег",
|
||||
"LabelMetaTags": "Meta Tags",
|
||||
"LabelMinute": "Минуты",
|
||||
"LabelMissing": "Потеряно",
|
||||
"LabelMissingParts": "Потерянные Части",
|
||||
"LabelMissingParts": "Потерянные части",
|
||||
"LabelMore": "Еще",
|
||||
"LabelMoreInfo": "More Info",
|
||||
"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 +362,7 @@
|
||||
"LabelSettingsExperimentalFeaturesHelp": "Функционал в разработке на который Вы могли бы дать отзыв или помочь в тестировании. Нажмите для открытия обсуждения на github.",
|
||||
"LabelSettingsFindCovers": "Найти обложки",
|
||||
"LabelSettingsFindCoversHelp": "Если у Ваших аудиокниг нет встроенной обложки или файла обложки в папке книги, то сканер попробует найти обложку.<br>Примечание: Это увеличит время сканирования",
|
||||
"LabelSettingsHomePageBookshelfView": "Вид книжной полки на Домашней Странице",
|
||||
"LabelSettingsHomePageBookshelfView": "Вид книжной полки на Домашней странице",
|
||||
"LabelSettingsLibraryBookshelfView": "Вид книжной полки в Библиотеке",
|
||||
"LabelSettingsOverdriveMediaMarkers": "Overdrive Media Markers для глав",
|
||||
"LabelSettingsOverdriveMediaMarkersHelp": "MP3 файлы из Overdrive поставляется с таймингами глав, встроенными в виде пользовательских метаданных. При включении этого параметра эти теги будут автоматически использоваться для таймингов глав",
|
||||
@@ -372,59 +384,61 @@
|
||||
"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": "Запущенные задачи",
|
||||
"LabelTimeBase": "Time Base",
|
||||
"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": "Полное издание",
|
||||
"LabelUnknown": "Неизвестно",
|
||||
"LabelUpdateCover": "Обновить Обложку",
|
||||
"LabelUpdateCover": "Обновить обложку",
|
||||
"LabelUpdateCoverHelp": "Позволяет перезаписывать существующие обложки для выбранных книг если будут найдены",
|
||||
"LabelUpdatedAt": "Обновлено в",
|
||||
"LabelUpdateDetails": "Обновить Подробности",
|
||||
"LabelUpdateDetails": "Обновить подробности",
|
||||
"LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены",
|
||||
"LabelUploaderDragAndDrop": "Перетащите файлы или каталоги",
|
||||
"LabelUploaderDropFiles": "Перетащите файлы",
|
||||
@@ -439,10 +453,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> сохраняют файлы из папок библиотек.",
|
||||
@@ -463,6 +477,7 @@
|
||||
"MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?",
|
||||
"MessageConfirmMarkSeriesFinished": "Вы уверены, что хотите отметить все книги этой серии как законченные?",
|
||||
"MessageConfirmMarkSeriesNotFinished": "Вы уверены, что хотите отметить все книги этой серии как незаконченные?",
|
||||
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
|
||||
"MessageConfirmRemoveCollection": "Вы уверены, что хотите удалить коллекцию \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodes": "Вы уверены, что хотите удалить {0} эпизодов?",
|
||||
@@ -482,8 +497,8 @@
|
||||
"MessageForceReScanDescription": "будет сканировать все файлы снова, как свежее сканирование. Теги ID3 аудиофайлов, OPF-файлы и текстовые файлы будут сканироваться как новые.",
|
||||
"MessageImportantNotice": "Важное замечание!",
|
||||
"MessageInsertChapterBelow": "Вставить главу ниже",
|
||||
"MessageItemsSelected": "{0} Элементов Выделено",
|
||||
"MessageItemsUpdated": "{0} Элементов Обновлено",
|
||||
"MessageItemsSelected": "{0} Элементов выделено",
|
||||
"MessageItemsUpdated": "{0} Элементов обновлено",
|
||||
"MessageJoinUsOn": "Присоединяйтесь к нам в",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} сеансов прослушивания в прошлом году",
|
||||
"MessageLoading": "Загрузка...",
|
||||
@@ -491,36 +506,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 +561,7 @@
|
||||
"MessageStartPlaybackAtTime": "Начать воспроизведение для \"{0}\" с {1}?",
|
||||
"MessageThinking": "Думаю...",
|
||||
"MessageUploaderItemFailed": "Не удалось загрузить",
|
||||
"MessageUploaderItemSuccess": "Успешно Загружено!",
|
||||
"MessageUploaderItemSuccess": "Успешно загружено!",
|
||||
"MessageUploading": "Загрузка...",
|
||||
"MessageValidCronExpression": "Верное cron выражение",
|
||||
"MessageWatcherIsDisabledGlobally": "Наблюдатель отключен глобально в настройках сервера",
|
||||
@@ -566,6 +581,7 @@
|
||||
"PlaceholderNewFolderPath": "Путь к новой папке",
|
||||
"PlaceholderNewPlaylist": "Новое название плейлиста",
|
||||
"PlaceholderSearch": "Поиск...",
|
||||
"PlaceholderSearchEpisode": "Поиск эпизода...",
|
||||
"ToastAccountUpdateFailed": "Не удалось обновить учетную запись",
|
||||
"ToastAccountUpdateSuccess": "Учетная запись обновлена",
|
||||
"ToastAuthorImageRemoveFailed": "Не удалось удалить изображение",
|
||||
@@ -635,4 +651,4 @@
|
||||
"ToastSocketFailedToConnect": "Не удалось подключить сокет",
|
||||
"ToastUserDeleteFailed": "Не удалось удалить пользователя",
|
||||
"ToastUserDeleteSuccess": "Пользователь удален"
|
||||
}
|
||||
}
|
||||
@@ -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,11 +155,13 @@
|
||||
"HeaderUpdateLibrary": "更新媒体库",
|
||||
"HeaderUsers": "用户",
|
||||
"HeaderYourStats": "你的统计数据",
|
||||
"LabelAbridged": "Abridged",
|
||||
"LabelAccountType": "帐户类型",
|
||||
"LabelAccountTypeAdmin": "管理员",
|
||||
"LabelAccountTypeGuest": "来宾",
|
||||
"LabelAccountTypeUser": "用户",
|
||||
"LabelActivity": "活动",
|
||||
"LabelAdded": "Added",
|
||||
"LabelAddedAt": "添加于",
|
||||
"LabelAddToCollection": "添加到收藏",
|
||||
"LabelAddToCollectionBatch": "批量添加 {0} 个媒体到收藏",
|
||||
@@ -167,7 +169,7 @@
|
||||
"LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表",
|
||||
"LabelAll": "全部",
|
||||
"LabelAllUsers": "所有用户",
|
||||
"LabelAlreadyInYourLibrary": "Already in your library",
|
||||
"LabelAlreadyInYourLibrary": "已存在你的库中",
|
||||
"LabelAppend": "附加",
|
||||
"LabelAuthor": "作者",
|
||||
"LabelAuthorFirstLast": "作者 (姓 名)",
|
||||
@@ -181,11 +183,15 @@
|
||||
"LabelBackupsMaxBackupSizeHelp": "为了防止错误配置, 如果备份超过配置的大小, 备份将失败.",
|
||||
"LabelBackupsNumberToKeep": "要保留的备份个数",
|
||||
"LabelBackupsNumberToKeepHelp": "一次只能删除一个备份, 因此如果你已经有超过此数量的备份, 则应手动删除它们.",
|
||||
"LabelBitrate": "Bitrate",
|
||||
"LabelBooks": "图书",
|
||||
"LabelChangePassword": "修改密码",
|
||||
"LabelChannels": "Channels",
|
||||
"LabelChapters": "Chapters",
|
||||
"LabelChaptersFound": "找到的章节",
|
||||
"LabelChapterTitle": "章节标题",
|
||||
"LabelClosePlayer": "关闭播放器",
|
||||
"LabelCodec": "Codec",
|
||||
"LabelCollapseSeries": "折叠系列",
|
||||
"LabelCollections": "收藏",
|
||||
"LabelComplete": "已完成",
|
||||
@@ -198,7 +204,7 @@
|
||||
"LabelCronExpression": "计划任务表达式",
|
||||
"LabelCurrent": "当前",
|
||||
"LabelCurrently": "当前:",
|
||||
"LabelCustomCronExpression": "Custom Cron Expression:",
|
||||
"LabelCustomCronExpression": "自定义计划任务表达式:",
|
||||
"LabelDatetime": "日期时间",
|
||||
"LabelDescription": "描述",
|
||||
"LabelDeselectAll": "全部取消选择",
|
||||
@@ -211,12 +217,13 @@
|
||||
"LabelDuration": "持续时间",
|
||||
"LabelDurationFound": "找到持续时间:",
|
||||
"LabelEdit": "编辑",
|
||||
"LabelEmbeddedCover": "Embedded Cover",
|
||||
"LabelEnable": "启用",
|
||||
"LabelEnd": "结束",
|
||||
"LabelEpisode": "剧集",
|
||||
"LabelEpisodeTitle": "剧集标题",
|
||||
"LabelEpisodeType": "剧集类型",
|
||||
"LabelExample": "Example",
|
||||
"LabelExample": "示例",
|
||||
"LabelExplicit": "信息准确",
|
||||
"LabelFeedURL": "源 URL",
|
||||
"LabelFile": "文件",
|
||||
@@ -228,6 +235,7 @@
|
||||
"LabelFinished": "已听完",
|
||||
"LabelFolder": "文件夹",
|
||||
"LabelFolders": "文件夹",
|
||||
"LabelFormat": "Format",
|
||||
"LabelGenre": "流派",
|
||||
"LabelGenres": "流派",
|
||||
"LabelHardDeleteFile": "完全删除文件",
|
||||
@@ -249,6 +257,8 @@
|
||||
"LabelItem": "项目",
|
||||
"LabelLanguage": "语言",
|
||||
"LabelLanguageDefaultServer": "默认服务器语言",
|
||||
"LabelLastBookAdded": "Last Book Added",
|
||||
"LabelLastBookUpdated": "Last Book Updated",
|
||||
"LabelLastSeen": "上次查看时间",
|
||||
"LabelLastTime": "最近一次",
|
||||
"LabelLastUpdate": "最近更新",
|
||||
@@ -267,10 +277,12 @@
|
||||
"LabelMediaType": "媒体类型",
|
||||
"LabelMetadataProvider": "元数据提供者",
|
||||
"LabelMetaTag": "元数据标签",
|
||||
"LabelMetaTags": "Meta Tags",
|
||||
"LabelMinute": "分钟",
|
||||
"LabelMissing": "丢失",
|
||||
"LabelMissingParts": "丢失的部分",
|
||||
"LabelMore": "更多",
|
||||
"LabelMoreInfo": "More Info",
|
||||
"LabelName": "名称",
|
||||
"LabelNarrator": "演播者",
|
||||
"LabelNarrators": "演播者",
|
||||
@@ -278,8 +290,8 @@
|
||||
"LabelNewestAuthors": "最新作者",
|
||||
"LabelNewestEpisodes": "最新剧集",
|
||||
"LabelNewPassword": "新密码",
|
||||
"LabelNextBackupDate": "Next backup date",
|
||||
"LabelNextScheduledRun": "Next scheduled run",
|
||||
"LabelNextBackupDate": "下次备份日期",
|
||||
"LabelNextScheduledRun": "下次任务运行",
|
||||
"LabelNotes": "注释",
|
||||
"LabelNotFinished": "未听完",
|
||||
"LabelNotificationAppriseURL": "通知 URL(s)",
|
||||
@@ -310,9 +322,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 +336,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 +384,7 @@
|
||||
"LabelSettingsStoreCoversWithItemHelp": "默认情况下封面存储在/metadata/items文件夹中, 启用此设置将存储封面在你媒体项目文件夹中. 只保留一个名为 \"cover\" 的文件",
|
||||
"LabelSettingsStoreMetadataWithItem": "存储项目元数据",
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "默认情况下元数据文件存储在/metadata/items文件夹中, 启用此设置将存储元数据在你媒体项目文件夹中. 使 .abs 文件护展名",
|
||||
"LabelSettingsTimeFormat": "Time Format",
|
||||
"LabelSettingsTimeFormat": "时间格式",
|
||||
"LabelShowAll": "全部显示",
|
||||
"LabelSize": "文件大小",
|
||||
"LabelSleepTimer": "睡眠定时",
|
||||
@@ -400,7 +412,8 @@
|
||||
"LabelTag": "标签",
|
||||
"LabelTags": "标签",
|
||||
"LabelTagsAccessibleToUser": "用户可访问的标签",
|
||||
"LabelTasks": "Tasks Running",
|
||||
"LabelTasks": "正在运行的任务",
|
||||
"LabelTimeBase": "Time Base",
|
||||
"LabelTimeListened": "收听时间",
|
||||
"LabelTimeListenedToday": "今日收听的时间",
|
||||
"LabelTimeRemaining": "剩余 {0}",
|
||||
@@ -420,6 +433,7 @@
|
||||
"LabelTracksMultiTrack": "多轨",
|
||||
"LabelTracksSingleTrack": "单轨",
|
||||
"LabelType": "类型",
|
||||
"LabelUnabridged": "Unabridged",
|
||||
"LabelUnknown": "未知",
|
||||
"LabelUpdateCover": "更新封面",
|
||||
"LabelUpdateCoverHelp": "找到匹配项时允许覆盖所选书籍存在的封面",
|
||||
@@ -463,6 +477,7 @@
|
||||
"MessageConfirmForceReScan": "你确定要强制重新扫描吗?",
|
||||
"MessageConfirmMarkSeriesFinished": "你确定要将此系列中的所有书籍都标记为已听完吗?",
|
||||
"MessageConfirmMarkSeriesNotFinished": "你确定要将此系列中的所有书籍都标记为未听完吗?",
|
||||
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
|
||||
"MessageConfirmRemoveCollection": "您确定要移除收藏 \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisode": "您确定要移除剧集 \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?",
|
||||
@@ -502,8 +517,8 @@
|
||||
"MessageNoCollections": "没有收藏",
|
||||
"MessageNoCoversFound": "没有找到封面",
|
||||
"MessageNoDescription": "没有描述",
|
||||
"MessageNoDownloadsQueued": "No downloads queued",
|
||||
"MessageNoDownloadsInProgress": "No downloads currently in progress",
|
||||
"MessageNoDownloadsInProgress": "当前没有正在进行的下载",
|
||||
"MessageNoDownloadsQueued": "下载队列无任务",
|
||||
"MessageNoEpisodeMatchesFound": "没有找到任何剧集匹配项",
|
||||
"MessageNoEpisodes": "没有剧集",
|
||||
"MessageNoFoldersAvailable": "没有可用文件夹",
|
||||
@@ -520,7 +535,7 @@
|
||||
"MessageNoSearchResultsFor": "没有搜索到结果 \"{0}\"",
|
||||
"MessageNoSeries": "无系列",
|
||||
"MessageNoTags": "无标签",
|
||||
"MessageNoTasksRunning": "No Tasks Running",
|
||||
"MessageNoTasksRunning": "没有正在运行的任务",
|
||||
"MessageNotYetImplemented": "尚未实施",
|
||||
"MessageNoUpdateNecessary": "无需更新",
|
||||
"MessageNoUpdatesWereNecessary": "无需更新",
|
||||
@@ -566,6 +581,7 @@
|
||||
"PlaceholderNewFolderPath": "输入文件夹路径",
|
||||
"PlaceholderNewPlaylist": "输入播放列表名称",
|
||||
"PlaceholderSearch": "查找..",
|
||||
"PlaceholderSearchEpisode": "Search episode..",
|
||||
"ToastAccountUpdateFailed": "账户更新失败",
|
||||
"ToastAccountUpdateSuccess": "帐户已更新",
|
||||
"ToastAuthorImageRemoveFailed": "作者图像删除失败",
|
||||
@@ -635,4 +651,4 @@
|
||||
"ToastSocketFailedToConnect": "网络连接失败",
|
||||
"ToastUserDeleteFailed": "删除用户失败",
|
||||
"ToastUserDeleteSuccess": "用户已删除"
|
||||
}
|
||||
}
|
||||
BIN
images/DemoLibrary.png
Normal file
BIN
images/DemoLibrary.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 221 KiB |
18
package-lock.json
generated
18
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.2.16",
|
||||
"version": "2.2.19",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.2.16",
|
||||
"version": "2.2.19",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
@@ -15,7 +15,7 @@
|
||||
"htmlparser2": "^8.0.1",
|
||||
"node-tone": "^1.0.1",
|
||||
"socket.io": "^4.5.4",
|
||||
"xml2js": "^0.4.23"
|
||||
"xml2js": "^0.5.0"
|
||||
},
|
||||
"bin": {
|
||||
"audiobookshelf": "prod.js"
|
||||
@@ -1329,9 +1329,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/xml2js": {
|
||||
"version": "0.4.23",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
||||
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
|
||||
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
|
||||
"dependencies": {
|
||||
"sax": ">=0.6.0",
|
||||
"xmlbuilder": "~11.0.0"
|
||||
@@ -2300,9 +2300,9 @@
|
||||
"requires": {}
|
||||
},
|
||||
"xml2js": {
|
||||
"version": "0.4.23",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
||||
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
|
||||
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
|
||||
"requires": {
|
||||
"sax": ">=0.6.0",
|
||||
"xmlbuilder": "~11.0.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.2.16",
|
||||
"version": "2.2.19",
|
||||
"description": "Self-hosted audiobook and podcast server",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -36,7 +36,7 @@
|
||||
"htmlparser2": "^8.0.1",
|
||||
"node-tone": "^1.0.1",
|
||||
"socket.io": "^4.5.4",
|
||||
"xml2js": "^0.4.23"
|
||||
"xml2js": "^0.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^2.0.20"
|
||||
|
||||
97
readme.md
97
readme.md
@@ -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 />
|
||||
|
||||
@@ -185,8 +185,99 @@ subdomain.domain.com {
|
||||
|
||||
# Run from source
|
||||
|
||||
[See discussion](https://github.com/advplyr/audiobookshelf/discussions/259#discussioncomment-1869729)
|
||||
# Contributing
|
||||
|
||||
# Contributing / How to Support
|
||||
This application is built using [NodeJs](https://nodejs.org/).
|
||||
|
||||
### Dev Container Setup
|
||||
The easiest way to begin developing this project is to use a dev container. An introduction to dev containers in VSCode can be found [here](https://code.visualstudio.com/docs/devcontainers/containers).
|
||||
|
||||
Required Software:
|
||||
* [Docker Desktop](https://www.docker.com/products/docker-desktop/)
|
||||
* [VSCode](https://code.visualstudio.com/download)
|
||||
|
||||
*Note, it is possible to use other container software than Docker and IDEs other than VSCode. However, this setup is more complicated and not covered here.*
|
||||
|
||||
<div>
|
||||
<details>
|
||||
<summary>Install the required software on Windows with <a href=(https://docs.microsoft.com/en-us/windows/package-manager/winget/#production-recommended)>winget</a></summary>
|
||||
|
||||
<p>
|
||||
Note: This requires a PowerShell prompt with winget installed. You should be able to copy and paste the code block to install. If you use an elevated PowerShell prompt, UAC will not pop up during the installs.
|
||||
|
||||
```PowerShell
|
||||
winget install -e --id Docker.DockerDesktop; `
|
||||
winget install -e --id Microsoft.VisualStudioCode
|
||||
```
|
||||
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<details>
|
||||
<summary>Install the required software on MacOS with <a href=(https://snapcraft.io/)>homebrew</a></summary>
|
||||
|
||||
<p>
|
||||
|
||||
```sh
|
||||
brew install --cask docker visual-studio-code
|
||||
```
|
||||
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div style="padding-bottom: 1em">
|
||||
<details>
|
||||
<summary>Install the required software on Linux with <a href=(https://brew.sh/)>snap</a></summary>
|
||||
|
||||
<p>
|
||||
|
||||
```sh
|
||||
sudo snap install docker; \
|
||||
sudo snap install code --classic
|
||||
```
|
||||
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
After installing these packages, you can now install the [Remote Development](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.vscode-remote-extensionpack) extension for VSCode. After installing this extension open the command pallet (`ctrl+shift+p` or `cmd+shift+p`) and select the command `>Dev Containers: Rebuild and Reopen in Container`. This will cause the development environment container to be built and launched.
|
||||
|
||||
You are now ready to start development!
|
||||
|
||||
### Manual Environment Setup
|
||||
|
||||
If you don't want to use the dev container, you can still develop this project. First, you will need to install [NodeJs](https://nodejs.org/) (version 16) and [FFmpeg](https://ffmpeg.org/).
|
||||
|
||||
Next you will need to create a `dev.js` file in the project's root directory. This contains configuration information and paths unique to your development environment. You can find an example of this file in `.devcontainer/dev.js`.
|
||||
|
||||
You are now ready to build the client:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
cd client
|
||||
npm ci
|
||||
npm run generate
|
||||
cd ..
|
||||
```
|
||||
|
||||
### Development Commands
|
||||
|
||||
After setting up your development environment, either using the dev container or using your own custom environment, the following commands will help you run the server and client.
|
||||
|
||||
To run the server, you can use the command `npm run dev`. This will use the client that was built when you ran `npm run generate` in the client directory or when you started the dev container. If you make changes to the server, you will need to restart the server. If you make changes to the client, you will need to run the command `(cd client; npm run generate)` and then restart the server. By default the client runs at `localhost:3333`, though the port can be configured in `dev.js`.
|
||||
|
||||
You can also build a version of the client that supports live reloading. To do this, start the server, then run the command `(cd client; npm run dev)`. This will run a separate instance of the client at `localhost:3000` that will be automatically updated as you make changes to the client.
|
||||
|
||||
If you are using VSCode, this project includes a couple of pre-defined targets to speed up this process. First, if you build the project (`ctrl+shift+b` or `cmd+shift+b`) it will automatically generate the client. Next, there are debug commands for running the server and client. You can view these targets using the debug panel (bring it up with (`ctrl+shift+d` or `cmd+shift+d`):
|
||||
|
||||
* `Debug server`—Run the server.
|
||||
* `Debug client (nuxt)`—Run the client with live reload.
|
||||
* `Debug server and client (nuxt)`—Runs both the preceding two debug targets.
|
||||
|
||||
|
||||
# How to Support
|
||||
|
||||
[See the incomplete "How to Support" page](https://www.audiobookshelf.org/support)
|
||||
|
||||
57
server/Db.js
57
server/Db.js
@@ -27,17 +27,16 @@ class Db {
|
||||
this.SeriesPath = Path.join(global.ConfigPath, 'series')
|
||||
this.FeedsPath = Path.join(global.ConfigPath, 'feeds')
|
||||
|
||||
const staleTime = 1000 * 60 * 2
|
||||
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath, { lockoptions: { stale: staleTime } })
|
||||
this.usersDb = new njodb.Database(this.UsersPath, { lockoptions: { stale: staleTime } })
|
||||
this.sessionsDb = new njodb.Database(this.SessionsPath, { lockoptions: { stale: staleTime } })
|
||||
this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2, lockoptions: { stale: staleTime } })
|
||||
this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2, lockoptions: { stale: staleTime } })
|
||||
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2, lockoptions: { stale: staleTime } })
|
||||
this.playlistsDb = new njodb.Database(this.PlaylistsPath, { datastores: 2, lockoptions: { stale: staleTime } })
|
||||
this.authorsDb = new njodb.Database(this.AuthorsPath, { lockoptions: { stale: staleTime } })
|
||||
this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2, lockoptions: { stale: staleTime } })
|
||||
this.feedsDb = new njodb.Database(this.FeedsPath, { datastores: 2, lockoptions: { stale: staleTime } })
|
||||
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath, this.getNjodbOptions())
|
||||
this.usersDb = new njodb.Database(this.UsersPath, this.getNjodbOptions())
|
||||
this.sessionsDb = new njodb.Database(this.SessionsPath, this.getNjodbOptions())
|
||||
this.librariesDb = new njodb.Database(this.LibrariesPath, this.getNjodbOptions())
|
||||
this.settingsDb = new njodb.Database(this.SettingsPath, this.getNjodbOptions())
|
||||
this.collectionsDb = new njodb.Database(this.CollectionsPath, this.getNjodbOptions())
|
||||
this.playlistsDb = new njodb.Database(this.PlaylistsPath, this.getNjodbOptions())
|
||||
this.authorsDb = new njodb.Database(this.AuthorsPath, this.getNjodbOptions())
|
||||
this.seriesDb = new njodb.Database(this.SeriesPath, this.getNjodbOptions())
|
||||
this.feedsDb = new njodb.Database(this.FeedsPath, this.getNjodbOptions())
|
||||
|
||||
this.libraryItems = []
|
||||
this.users = []
|
||||
@@ -59,6 +58,21 @@ class Db {
|
||||
return this.users.some(u => u.id === 'root')
|
||||
}
|
||||
|
||||
getNjodbOptions() {
|
||||
return {
|
||||
lockoptions: {
|
||||
stale: 1000 * 20, // 20 seconds
|
||||
update: 2500,
|
||||
retries: {
|
||||
retries: 20,
|
||||
minTimeout: 250,
|
||||
maxTimeout: 5000,
|
||||
factor: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getEntityDb(entityName) {
|
||||
if (entityName === 'user') return this.usersDb
|
||||
else if (entityName === 'session') return this.sessionsDb
|
||||
@@ -88,17 +102,16 @@ class Db {
|
||||
}
|
||||
|
||||
reinit() {
|
||||
const staleTime = 1000 * 60 * 2
|
||||
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath, { lockoptions: { stale: staleTime } })
|
||||
this.usersDb = new njodb.Database(this.UsersPath, { lockoptions: { stale: staleTime } })
|
||||
this.sessionsDb = new njodb.Database(this.SessionsPath, { lockoptions: { stale: staleTime } })
|
||||
this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2, lockoptions: { stale: staleTime } })
|
||||
this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2, lockoptions: { stale: staleTime } })
|
||||
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2, lockoptions: { stale: staleTime } })
|
||||
this.playlistsDb = new njodb.Database(this.PlaylistsPath, { datastores: 2, lockoptions: { stale: staleTime } })
|
||||
this.authorsDb = new njodb.Database(this.AuthorsPath, { lockoptions: { stale: staleTime } })
|
||||
this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2, lockoptions: { stale: staleTime } })
|
||||
this.feedsDb = new njodb.Database(this.FeedsPath, { datastores: 2, lockoptions: { stale: staleTime } })
|
||||
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath, this.getNjodbOptions())
|
||||
this.usersDb = new njodb.Database(this.UsersPath, this.getNjodbOptions())
|
||||
this.sessionsDb = new njodb.Database(this.SessionsPath, this.getNjodbOptions())
|
||||
this.librariesDb = new njodb.Database(this.LibrariesPath, this.getNjodbOptions())
|
||||
this.settingsDb = new njodb.Database(this.SettingsPath, this.getNjodbOptions())
|
||||
this.collectionsDb = new njodb.Database(this.CollectionsPath, this.getNjodbOptions())
|
||||
this.playlistsDb = new njodb.Database(this.PlaylistsPath, this.getNjodbOptions())
|
||||
this.authorsDb = new njodb.Database(this.AuthorsPath, this.getNjodbOptions())
|
||||
this.seriesDb = new njodb.Database(this.SeriesPath, this.getNjodbOptions())
|
||||
this.feedsDb = new njodb.Database(this.FeedsPath, this.getNjodbOptions())
|
||||
return this.init()
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
|
||||
const RssFeedManager = require('./managers/RssFeedManager')
|
||||
const CronManager = require('./managers/CronManager')
|
||||
const TaskManager = require('./managers/TaskManager')
|
||||
const EBookManager = require('./managers/EBookManager')
|
||||
|
||||
class Server {
|
||||
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) {
|
||||
@@ -75,7 +74,6 @@ class Server {
|
||||
this.podcastManager = new PodcastManager(this.db, this.watcher, this.notificationManager, this.taskManager)
|
||||
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager)
|
||||
this.rssFeedManager = new RssFeedManager(this.db)
|
||||
this.eBookManager = new EBookManager(this.db)
|
||||
|
||||
this.scanner = new Scanner(this.db, this.coverManager)
|
||||
this.cronManager = new CronManager(this.db, this.scanner, this.podcastManager)
|
||||
@@ -119,7 +117,6 @@ class Server {
|
||||
await this.purgeMetadata() // Remove metadata folders without library item
|
||||
await this.playbackSessionManager.removeInvalidSessions()
|
||||
await this.cacheManager.ensureCachePaths()
|
||||
await this.abMergeManager.ensureDownloadDirPath()
|
||||
|
||||
await this.backupManager.init()
|
||||
await this.logManager.init()
|
||||
|
||||
@@ -167,18 +167,19 @@ class AuthorController {
|
||||
}
|
||||
|
||||
async match(req, res) {
|
||||
var authorData = null
|
||||
let authorData = null
|
||||
const region = req.body.region || 'us'
|
||||
if (req.body.asin) {
|
||||
authorData = await this.authorFinder.findAuthorByASIN(req.body.asin)
|
||||
authorData = await this.authorFinder.findAuthorByASIN(req.body.asin, region)
|
||||
} else {
|
||||
authorData = await this.authorFinder.findAuthorByName(req.body.q)
|
||||
authorData = await this.authorFinder.findAuthorByName(req.body.q, region)
|
||||
}
|
||||
if (!authorData) {
|
||||
return res.status(404).send('Author not found')
|
||||
}
|
||||
Logger.debug(`[AuthorController] match author with "${req.body.q || req.body.asin}"`, authorData)
|
||||
|
||||
var hasUpdates = false
|
||||
let hasUpdates = false
|
||||
if (authorData.asin && req.author.asin !== authorData.asin) {
|
||||
req.author.asin = authorData.asin
|
||||
hasUpdates = true
|
||||
@@ -188,7 +189,7 @@ class AuthorController {
|
||||
if (authorData.image && (!req.author.imagePath || hasUpdates)) {
|
||||
this.cacheManager.purgeImageCache(req.author.id)
|
||||
|
||||
var imageData = await this.authorFinder.saveAuthorImage(req.author.id, authorData.image)
|
||||
const imageData = await this.authorFinder.saveAuthorImage(req.author.id, authorData.image)
|
||||
if (imageData) {
|
||||
req.author.imagePath = imageData.path
|
||||
hasUpdates = true
|
||||
@@ -204,7 +205,7 @@ class AuthorController {
|
||||
req.author.updatedAt = Date.now()
|
||||
|
||||
await this.db.updateEntity('author', req.author)
|
||||
var numBooks = this.db.libraryItems.filter(li => {
|
||||
const numBooks = this.db.libraryItems.filter(li => {
|
||||
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
|
||||
}).length
|
||||
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
const Logger = require('../Logger')
|
||||
const { isNullOrNaN } = require('../utils/index')
|
||||
|
||||
class EBookController {
|
||||
constructor() { }
|
||||
|
||||
async getEbookInfo(req, res) {
|
||||
const isDev = req.query.dev == 1
|
||||
const json = await this.eBookManager.getBookInfo(req.libraryItem, req.user, isDev)
|
||||
res.json(json)
|
||||
}
|
||||
|
||||
async getEbookPage(req, res) {
|
||||
if (isNullOrNaN(req.params.page)) {
|
||||
return res.status(400).send('Invalid page params')
|
||||
}
|
||||
const isDev = req.query.dev == 1
|
||||
const pageIndex = Number(req.params.page)
|
||||
const page = await this.eBookManager.getBookPage(req.libraryItem, req.user, pageIndex, isDev)
|
||||
if (!page) {
|
||||
return res.status(500).send('Failed to get page')
|
||||
}
|
||||
|
||||
res.send(page)
|
||||
}
|
||||
|
||||
async getEbookResource(req, res) {
|
||||
if (!req.query.path) {
|
||||
return res.status(400).send('Invalid query path')
|
||||
}
|
||||
const isDev = req.query.dev == 1
|
||||
this.eBookManager.getBookResource(req.libraryItem, req.user, req.query.path, isDev, res)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
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)
|
||||
}
|
||||
|
||||
if (!item.isBook || !item.media.ebookFile) {
|
||||
return res.status(400).send('Invalid ebook library item')
|
||||
}
|
||||
|
||||
req.libraryItem = item
|
||||
next()
|
||||
}
|
||||
}
|
||||
module.exports = new EBookController()
|
||||
@@ -417,6 +417,10 @@ class LibraryController {
|
||||
return se.totalDuration
|
||||
} else if (payload.sortBy === 'addedAt') {
|
||||
return se.addedAt
|
||||
} else if (payload.sortBy === 'lastBookUpdated') {
|
||||
return Math.max(...(se.books).map(x => x.updatedAt), 0)
|
||||
} else if (payload.sortBy === 'lastBookAdded') {
|
||||
return Math.max(...(se.books).map(x => x.addedAt), 0)
|
||||
} else { // sort by name
|
||||
return this.db.serverSettings.sortingIgnorePrefix ? se.nameIgnorePrefixSort : se.name
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ const fs = require('../libs/fsExtra')
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
|
||||
const zipHelpers = require('../utils/zipHelpers')
|
||||
const { reqSupportsWebp, isNullOrNaN } = require('../utils/index')
|
||||
const { ScanResult } = require('../utils/constants')
|
||||
|
||||
@@ -65,10 +66,29 @@ class LibraryItemController {
|
||||
}
|
||||
|
||||
async delete(req, res) {
|
||||
const hardDelete = req.query.hard == 1 // Delete from file system
|
||||
const libraryItemPath = req.libraryItem.path
|
||||
await this.handleDeleteLibraryItem(req.libraryItem)
|
||||
if (hardDelete) {
|
||||
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
||||
await fs.remove(libraryItemPath).catch((error) => {
|
||||
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
|
||||
})
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
download(req, res) {
|
||||
if (!req.user.canDownload) {
|
||||
Logger.warn('User attempted to download without permission', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const libraryItemPath = req.libraryItem.path
|
||||
const filename = `${req.libraryItem.media.metadata.title}.zip`
|
||||
zipHelpers.zipDirectoryPipe(libraryItemPath, filename, res)
|
||||
}
|
||||
|
||||
//
|
||||
// PATCH: will create new authors & series if in payload
|
||||
//
|
||||
@@ -162,12 +182,12 @@ class LibraryItemController {
|
||||
|
||||
// PATCH: api/items/:id/cover
|
||||
async updateCover(req, res) {
|
||||
var libraryItem = req.libraryItem
|
||||
const libraryItem = req.libraryItem
|
||||
if (!req.body.cover) {
|
||||
return res.status(400).error('Invalid request no cover path')
|
||||
return res.status(400).send('Invalid request no cover path')
|
||||
}
|
||||
|
||||
var validationResult = await this.coverManager.validateCoverPath(req.body.cover, libraryItem)
|
||||
const validationResult = await this.coverManager.validateCoverPath(req.body.cover, libraryItem)
|
||||
if (validationResult.error) {
|
||||
return res.status(500).send(validationResult.error)
|
||||
}
|
||||
@@ -280,19 +300,27 @@ class LibraryItemController {
|
||||
Logger.warn(`[LibraryItemController] User attempted to delete without permission`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
const hardDelete = req.query.hard == 1 // Delete files from filesystem
|
||||
|
||||
var { libraryItemIds } = req.body
|
||||
const { libraryItemIds } = req.body
|
||||
if (!libraryItemIds || !libraryItemIds.length) {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
var itemsToDelete = this.db.libraryItems.filter(li => libraryItemIds.includes(li.id))
|
||||
const itemsToDelete = this.db.libraryItems.filter(li => libraryItemIds.includes(li.id))
|
||||
if (!itemsToDelete.length) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
for (let i = 0; i < itemsToDelete.length; i++) {
|
||||
const libraryItemPath = itemsToDelete[i].path
|
||||
Logger.info(`[LibraryItemController] Deleting Library Item "${itemsToDelete[i].media.metadata.title}"`)
|
||||
await this.handleDeleteLibraryItem(itemsToDelete[i])
|
||||
if (hardDelete) {
|
||||
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
||||
await fs.remove(libraryItemPath).catch((error) => {
|
||||
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
|
||||
})
|
||||
}
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
@@ -436,12 +464,12 @@ class LibraryItemController {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
const chapters = req.body.chapters || []
|
||||
if (!chapters.length) {
|
||||
if (!req.body.chapters) {
|
||||
Logger.error(`[LibraryItemController] Invalid payload`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
const chapters = req.body.chapters || []
|
||||
const wasUpdated = req.libraryItem.media.updateChapters(chapters)
|
||||
if (wasUpdated) {
|
||||
await this.db.updateLibraryItem(req.libraryItem)
|
||||
@@ -470,6 +498,30 @@ class LibraryItemController {
|
||||
res.json(toneData)
|
||||
}
|
||||
|
||||
async deleteLibraryFile(req, res) {
|
||||
const libraryFile = req.libraryItem.libraryFiles.find(lf => lf.ino === req.params.ino)
|
||||
if (!libraryFile) {
|
||||
Logger.error(`[LibraryItemController] Unable to delete library file. Not found. "${req.params.ino}"`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
await fs.remove(libraryFile.metadata.path).catch((error) => {
|
||||
Logger.error(`[LibraryItemController] Failed to delete library file at "${libraryFile.metadata.path}"`, error)
|
||||
})
|
||||
req.libraryItem.removeLibraryFile(req.params.ino)
|
||||
|
||||
if (req.libraryItem.media.removeFileWithInode(req.params.ino)) {
|
||||
// If book has no more media files then mark it as missing
|
||||
if (req.libraryItem.mediaType === 'book' && !req.libraryItem.media.hasMediaEntities) {
|
||||
req.libraryItem.setMissing()
|
||||
}
|
||||
}
|
||||
req.libraryItem.updatedAt = Date.now()
|
||||
await this.db.updateLibraryItem(req.libraryItem)
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
const item = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||
if (!item || !item.media) return res.sendStatus(404)
|
||||
|
||||
@@ -171,23 +171,6 @@ class MeController {
|
||||
this.auth.userChangePassword(req, res)
|
||||
}
|
||||
|
||||
// TODO: Remove after mobile release v0.9.61-beta
|
||||
// PATCH: api/me/settings
|
||||
async updateSettings(req, res) {
|
||||
var settingsUpdate = req.body
|
||||
if (!settingsUpdate || !isObject(settingsUpdate)) {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
var madeUpdates = req.user.updateSettings(settingsUpdate)
|
||||
if (madeUpdates) {
|
||||
await this.db.updateEntity('user', req.user)
|
||||
}
|
||||
return res.json({
|
||||
success: true,
|
||||
settings: req.user.settings
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Deprecated. Removed from Android. Only used in iOS app now.
|
||||
// POST: api/me/sync-local-progress
|
||||
async syncLocalMediaProgress(req, res) {
|
||||
@@ -256,13 +239,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -14,7 +14,7 @@ class SessionController {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
var listeningSessions = []
|
||||
let listeningSessions = []
|
||||
if (req.query.user) {
|
||||
listeningSessions = await this.getUserListeningSessionsHelper(req.query.user)
|
||||
} else {
|
||||
@@ -42,6 +42,25 @@ class SessionController {
|
||||
res.json(payload)
|
||||
}
|
||||
|
||||
getOpenSessions(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[SessionController] getOpenSessions: Non-admin user requested open session data ${req.user.id}/"${req.user.username}"`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const openSessions = this.playbackSessionManager.sessions.map(se => {
|
||||
const user = this.db.users.find(u => u.id === se.userId) || null
|
||||
return {
|
||||
...se.toJSON(),
|
||||
user: user ? { id: user.id, username: user.username } : null
|
||||
}
|
||||
})
|
||||
|
||||
res.json({
|
||||
sessions: openSessions
|
||||
})
|
||||
}
|
||||
|
||||
getOpenSession(req, res) {
|
||||
var libraryItem = this.db.getLibraryItem(req.session.libraryItemId)
|
||||
var sessionForClient = req.session.toJSONForClient(libraryItem)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -20,16 +20,16 @@ class AuthorFinder {
|
||||
})
|
||||
}
|
||||
|
||||
findAuthorByASIN(asin) {
|
||||
findAuthorByASIN(asin, region) {
|
||||
if (!asin) return null
|
||||
return this.audnexus.findAuthorByASIN(asin)
|
||||
return this.audnexus.findAuthorByASIN(asin, region)
|
||||
}
|
||||
|
||||
async findAuthorByName(name, options = {}) {
|
||||
async findAuthorByName(name, region, options = {}) {
|
||||
if (!name) return null
|
||||
const maxLevenshtein = !isNaN(options.maxLevenshtein) ? Number(options.maxLevenshtein) : 3
|
||||
|
||||
var author = await this.audnexus.findAuthorByName(name, maxLevenshtein)
|
||||
const author = await this.audnexus.findAuthorByName(name, region, maxLevenshtein)
|
||||
if (!author || !author.name) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@ function updateLock(file, options) {
|
||||
// the lockfile was deleted or we are over the threshold
|
||||
if (err) {
|
||||
if (err.code === 'ENOENT' || isOverThreshold) {
|
||||
console.error(`lockfile "${file}" compromised. stat code=${err.code}, isOverThreshold=${isOverThreshold}`)
|
||||
return setLockAsCompromised(file, lock, Object.assign(err, { code: 'ECOMPROMISED' }));
|
||||
}
|
||||
|
||||
@@ -129,6 +130,7 @@ function updateLock(file, options) {
|
||||
const isMtimeOurs = lock.mtime.getTime() === stat.mtime.getTime();
|
||||
|
||||
if (!isMtimeOurs) {
|
||||
console.error(`lockfile "${file}" compromised. mtime is not ours`)
|
||||
return setLockAsCompromised(
|
||||
file,
|
||||
lock,
|
||||
@@ -152,6 +154,7 @@ function updateLock(file, options) {
|
||||
// the lockfile was deleted or we are over the threshold
|
||||
if (err) {
|
||||
if (err.code === 'ENOENT' || isOverThreshold) {
|
||||
console.error(`lockfile "${file}" compromised. utimes code=${err.code}, isOverThreshold=${isOverThreshold}`)
|
||||
return setLockAsCompromised(file, lock, Object.assign(err, { code: 'ECOMPROMISED' }));
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@ class AbMergeManager {
|
||||
this.taskManager = taskManager
|
||||
|
||||
this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items')
|
||||
this.downloadDirPath = Path.join(global.MetadataPath, 'downloads')
|
||||
this.downloadDirPathExist = false
|
||||
|
||||
this.pendingTasks = []
|
||||
}
|
||||
@@ -29,22 +27,6 @@ class AbMergeManager {
|
||||
return this.removeTask(task, true)
|
||||
}
|
||||
|
||||
async ensureDownloadDirPath() { // Creates download path if necessary and sets owner and permissions
|
||||
if (this.downloadDirPathExist) return
|
||||
|
||||
var pathCreated = false
|
||||
if (!(await fs.pathExists(this.downloadDirPath))) {
|
||||
await fs.mkdir(this.downloadDirPath)
|
||||
pathCreated = true
|
||||
}
|
||||
|
||||
if (pathCreated) {
|
||||
await filePerms.setDefault(this.downloadDirPath)
|
||||
}
|
||||
|
||||
this.downloadDirPathExist = true
|
||||
}
|
||||
|
||||
async startAudiobookMerge(user, libraryItem, options = {}) {
|
||||
const task = new Task()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
const Logger = require('../Logger')
|
||||
const StreamZip = require('../libs/nodeStreamZip')
|
||||
|
||||
const parseEpub = require('../utils/parsers/parseEpub')
|
||||
|
||||
class EBookManager {
|
||||
constructor() {
|
||||
this.extractedEpubs = {}
|
||||
}
|
||||
|
||||
async extractBookData(libraryItem, user, isDev = false) {
|
||||
if (!libraryItem || !libraryItem.isBook || !libraryItem.media.ebookFile) return null
|
||||
|
||||
if (this.extractedEpubs[libraryItem.id]) return this.extractedEpubs[libraryItem.id]
|
||||
|
||||
const ebookFile = libraryItem.media.ebookFile
|
||||
if (!ebookFile.isEpub) {
|
||||
Logger.error(`[EBookManager] get book data is not supported for format ${ebookFile.ebookFormat}`)
|
||||
return null
|
||||
}
|
||||
|
||||
this.extractedEpubs[libraryItem.id] = await parseEpub.parse(ebookFile, libraryItem.id, user.token, isDev)
|
||||
|
||||
return this.extractedEpubs[libraryItem.id]
|
||||
}
|
||||
|
||||
async getBookInfo(libraryItem, user, isDev = false) {
|
||||
if (!libraryItem || !libraryItem.isBook || !libraryItem.media.ebookFile) return null
|
||||
|
||||
const bookData = await this.extractBookData(libraryItem, user, isDev)
|
||||
|
||||
return {
|
||||
title: libraryItem.media.metadata.title,
|
||||
pages: bookData.pages.length
|
||||
}
|
||||
}
|
||||
|
||||
async getBookPage(libraryItem, user, pageIndex, isDev = false) {
|
||||
if (!libraryItem || !libraryItem.isBook || !libraryItem.media.ebookFile) return null
|
||||
|
||||
const bookData = await this.extractBookData(libraryItem, user, isDev)
|
||||
|
||||
const pageObj = bookData.pages[pageIndex]
|
||||
|
||||
if (!pageObj) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = await parseEpub.parsePage(pageObj.path, bookData, libraryItem.id, user.token, isDev)
|
||||
|
||||
if (parsed.error) {
|
||||
Logger.error(`[EBookManager] Failed to parse epub page at "${pageObj.path}"`, parsed.error)
|
||||
return null
|
||||
}
|
||||
|
||||
return parsed.html
|
||||
}
|
||||
|
||||
async getBookResource(libraryItem, user, resourcePath, isDev = false, res) {
|
||||
if (!libraryItem || !libraryItem.isBook || !libraryItem.media.ebookFile) return res.sendStatus(500)
|
||||
const bookData = await this.extractBookData(libraryItem, user, isDev)
|
||||
const resourceItem = bookData.resources.find(r => r.path === resourcePath)
|
||||
|
||||
if (!resourceItem) {
|
||||
return res.status(404).send('Resource not found')
|
||||
}
|
||||
|
||||
const zip = new StreamZip.async({ file: bookData.filepath })
|
||||
const stm = await zip.stream(resourceItem.path)
|
||||
|
||||
res.set('content-type', resourceItem['media-type'])
|
||||
|
||||
stm.pipe(res)
|
||||
stm.on('end', () => {
|
||||
zip.close()
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
module.exports = EBookManager
|
||||
@@ -14,7 +14,6 @@ const PlaybackSession = require('../objects/PlaybackSession')
|
||||
const DeviceInfo = require('../objects/DeviceInfo')
|
||||
const Stream = require('../objects/Stream')
|
||||
|
||||
|
||||
class PlaybackSessionManager {
|
||||
constructor(db) {
|
||||
this.db = db
|
||||
@@ -31,13 +30,14 @@ class PlaybackSessionManager {
|
||||
}
|
||||
getStream(sessionId) {
|
||||
const session = this.getSession(sessionId)
|
||||
return session ? session.stream : null
|
||||
return session?.stream || null
|
||||
}
|
||||
|
||||
getDeviceInfo(req) {
|
||||
const ua = uaParserJs(req.headers['user-agent'])
|
||||
const ip = requestIp.getClientIp(req)
|
||||
const clientDeviceInfo = req.body ? req.body.deviceInfo || null : null // From mobile client
|
||||
|
||||
const clientDeviceInfo = req.body?.deviceInfo || null
|
||||
|
||||
const deviceInfo = new DeviceInfo()
|
||||
deviceInfo.setData(ip, ua, clientDeviceInfo, serverVersion)
|
||||
@@ -138,18 +138,6 @@ class PlaybackSessionManager {
|
||||
}
|
||||
|
||||
async syncLocalSessionRequest(user, sessionJson, res) {
|
||||
// If server session is open for this same media item then close it
|
||||
const userSessionForThisItem = this.sessions.find(playbackSession => {
|
||||
if (playbackSession.userId !== user.id) return false
|
||||
if (sessionJson.episodeId) return playbackSession.episodeId !== sessionJson.episodeId
|
||||
return playbackSession.libraryItemId === sessionJson.libraryItemId
|
||||
})
|
||||
if (userSessionForThisItem) {
|
||||
Logger.info(`[PlaybackSessionManager] syncLocalSessionRequest: Closing open session "${userSessionForThisItem.displayTitle}" for user "${user.username}"`)
|
||||
await this.closeSession(user, userSessionForThisItem, null)
|
||||
}
|
||||
|
||||
// Sync
|
||||
const result = await this.syncLocalSession(user, sessionJson)
|
||||
if (result.error) {
|
||||
res.status(500).send(result.error)
|
||||
@@ -164,8 +152,8 @@ class PlaybackSessionManager {
|
||||
}
|
||||
|
||||
async startSession(user, deviceInfo, libraryItem, episodeId, options) {
|
||||
// Close any sessions already open for user
|
||||
const userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id)
|
||||
// Close any sessions already open for user and device
|
||||
const userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id && playbackSession.deviceId === deviceInfo.deviceId)
|
||||
for (const session of userSessions) {
|
||||
Logger.info(`[PlaybackSessionManager] startSession: Closing open session "${session.displayTitle}" for user "${user.username}" (Device: ${session.deviceDescription})`)
|
||||
await this.closeSession(user, session, null)
|
||||
@@ -268,6 +256,7 @@ class PlaybackSessionManager {
|
||||
}
|
||||
Logger.debug(`[PlaybackSessionManager] closeSession "${session.id}"`)
|
||||
SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions, this.db.libraryItems))
|
||||
SocketAuthority.clientEmitter(session.userId, 'user_session_closed', session.id)
|
||||
return this.removeSession(session.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
@@ -52,15 +53,15 @@ class PodcastManager {
|
||||
}
|
||||
|
||||
async downloadPodcastEpisodes(libraryItem, episodesToDownload, isAutoDownload) {
|
||||
var index = libraryItem.media.episodes.length + 1
|
||||
episodesToDownload.forEach((ep) => {
|
||||
var newPe = new PodcastEpisode()
|
||||
let index = libraryItem.media.episodes.length + 1
|
||||
for (const ep of episodesToDownload) {
|
||||
const newPe = new PodcastEpisode()
|
||||
newPe.setData(ep, index++)
|
||||
newPe.libraryItemId = libraryItem.id
|
||||
var newPeDl = new PodcastEpisodeDownload()
|
||||
const newPeDl = new PodcastEpisodeDownload()
|
||||
newPeDl.setData(newPe, libraryItem, isAutoDownload, libraryItem.libraryId)
|
||||
this.startPodcastEpisodeDownload(newPeDl)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async startPodcastEpisodeDownload(podcastEpisodeDownload) {
|
||||
@@ -93,10 +94,21 @@ 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,23 +138,28 @@ 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
|
||||
|
||||
if (audioFile.chapters?.length) {
|
||||
podcastEpisode.chapters = audioFile.chapters.map(ch => ({ ...ch }))
|
||||
}
|
||||
|
||||
libraryItem.media.addPodcastEpisode(podcastEpisode)
|
||||
if (libraryItem.isInvalid) {
|
||||
// First episode added to an empty podcast
|
||||
@@ -201,13 +218,13 @@ class PodcastManager {
|
||||
}
|
||||
|
||||
async probeAudioFile(libraryFile) {
|
||||
var path = libraryFile.metadata.path
|
||||
var mediaProbeData = await prober.probe(path)
|
||||
const path = libraryFile.metadata.path
|
||||
const mediaProbeData = await prober.probe(path)
|
||||
if (mediaProbeData.error) {
|
||||
Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, mediaProbeData.error)
|
||||
return false
|
||||
}
|
||||
var newAudioFile = new AudioFile()
|
||||
const newAudioFile = new AudioFile()
|
||||
newAudioFile.setDataFromProbe(libraryFile, mediaProbeData)
|
||||
return newAudioFile
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class DeviceInfo {
|
||||
constructor(deviceInfo = null) {
|
||||
this.deviceId = null
|
||||
this.ipAddress = null
|
||||
|
||||
// From User Agent (see: https://www.npmjs.com/package/ua-parser-js)
|
||||
@@ -32,6 +33,7 @@ class DeviceInfo {
|
||||
|
||||
toJSON() {
|
||||
const obj = {
|
||||
deviceId: this.deviceId,
|
||||
ipAddress: this.ipAddress,
|
||||
browserName: this.browserName,
|
||||
browserVersion: this.browserVersion,
|
||||
@@ -60,23 +62,42 @@ class DeviceInfo {
|
||||
return `${this.osName} ${this.osVersion} / ${this.browserName}`
|
||||
}
|
||||
|
||||
// When client doesn't send a device id
|
||||
getTempDeviceId() {
|
||||
const keys = [
|
||||
this.browserName,
|
||||
this.browserVersion,
|
||||
this.osName,
|
||||
this.osVersion,
|
||||
this.clientVersion,
|
||||
this.manufacturer,
|
||||
this.model,
|
||||
this.sdkVersion,
|
||||
this.ipAddress
|
||||
].map(k => k || '')
|
||||
return 'temp-' + Buffer.from(keys.join('-'), 'utf-8').toString('base64')
|
||||
}
|
||||
|
||||
setData(ip, ua, clientDeviceInfo, serverVersion) {
|
||||
this.deviceId = clientDeviceInfo?.deviceId || null
|
||||
this.ipAddress = ip || null
|
||||
|
||||
const uaObj = ua || {}
|
||||
this.browserName = uaObj.browser.name || null
|
||||
this.browserVersion = uaObj.browser.version || null
|
||||
this.osName = uaObj.os.name || null
|
||||
this.osVersion = uaObj.os.version || null
|
||||
this.deviceType = uaObj.device.type || null
|
||||
this.browserName = ua?.browser.name || null
|
||||
this.browserVersion = ua?.browser.version || null
|
||||
this.osName = ua?.os.name || null
|
||||
this.osVersion = ua?.os.version || null
|
||||
this.deviceType = ua?.device.type || null
|
||||
|
||||
const cdi = clientDeviceInfo || {}
|
||||
this.clientVersion = cdi.clientVersion || null
|
||||
this.manufacturer = cdi.manufacturer || null
|
||||
this.model = cdi.model || null
|
||||
this.sdkVersion = cdi.sdkVersion || null
|
||||
this.clientVersion = clientDeviceInfo?.clientVersion || null
|
||||
this.manufacturer = clientDeviceInfo?.manufacturer || null
|
||||
this.model = clientDeviceInfo?.model || null
|
||||
this.sdkVersion = clientDeviceInfo?.sdkVersion || null
|
||||
|
||||
this.serverVersion = serverVersion || null
|
||||
|
||||
if (!this.deviceId) {
|
||||
this.deviceId = this.getTempDeviceId()
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = DeviceInfo
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user