Merge remote-tracking branch 'origin/master' into auth_passportjs

This commit is contained in:
lukeIam 2023-04-14 20:27:43 +02:00
commit 812395b21b
90 changed files with 3469 additions and 1148 deletions

View File

@ -1,4 +1,15 @@
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:16 # [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
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ ARG VARIANT=16
&& apt-get install ffmpeg gnupg2 -y FROM mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT} as base
# Setup the node environment
ENV NODE_ENV=development 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
View 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'
}

View File

@ -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" }, "name": "Audiobookshelf",
"mounts": [ "build": {
"source=abs-node-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume" "dockerfile": "Dockerfile",
], // Update 'VARIANT' to pick a Node version: 18, 16, 14.
"features": { // Append -bullseye or -buster to pin to an OS version.
"fish": "latest" // Use -bullseye variants on local arm64/Apple Silicon.
"args": {
"VARIANT": "16"
}
}, },
"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": [ "extensions": [
"eamodio.gitlens" "dbaeumer.vscode-eslint",
"octref.vetur"
] ]
}
}
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
} }

View 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
View 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
View File

@ -1,6 +1,6 @@
.env .env
dev.js /dev.js
node_modules/ **/node_modules/
/config/ /config/
/audiobooks/ /audiobooks/
/audiobooks2/ /audiobooks2/

44
.vscode/launch.json vendored Normal file
View 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
View 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
}
}
]
}

View File

@ -58,9 +58,6 @@
<span class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span> <span class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
{{ $strings.ButtonPlay }} {{ $strings.ButtonPlay }}
</ui-btn> </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-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-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
</ui-tooltip> </ui-tooltip>
@ -75,8 +72,11 @@
<ui-tooltip v-if="userCanDelete" :text="$strings.ButtonRemove" direction="bottom"> <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-icon-btn :disabled="processingBatch" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
</ui-tooltip> </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> </ui-tooltip>
</div> </div>
</div> </div>
@ -160,9 +160,59 @@ export default {
}, },
isHttps() { isHttps() {
return location.protocol === 'https:' || process.env.NODE_ENV === 'development' 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: { 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() { async playSelectedItems() {
this.$store.commit('setProcessingBatch', true) this.$store.commit('setProcessingBatch', true)

View File

@ -163,6 +163,14 @@ export default {
text: this.$strings.LabelAddedAt, text: this.$strings.LabelAddedAt,
value: 'addedAt' value: 'addedAt'
}, },
{
text: this.$strings.LabelLastBookAdded,
value: 'lastBookAdded'
},
{
text: this.$strings.LabelLastBookUpdated,
value: 'lastBookUpdated'
},
{ {
text: this.$strings.LabelTotalDuration, text: this.$strings.LabelTotalDuration,
value: 'totalDuration' value: 'totalDuration'

View File

@ -81,7 +81,7 @@ export default {
sleepTimerRemaining: 0, sleepTimerRemaining: 0,
sleepTimer: null, sleepTimer: null,
displayTitle: null, displayTitle: null,
initialPlaybackRate: 1, currentPlaybackRate: 1,
syncFailedToast: null syncFailedToast: null
} }
}, },
@ -120,17 +120,22 @@ export default {
streamLibraryItem() { streamLibraryItem() {
return this.$store.state.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() { libraryItemId() {
return this.streamLibraryItem ? this.streamLibraryItem.id : null return this.streamLibraryItem?.id || null
}, },
media() { media() {
return this.streamLibraryItem ? this.streamLibraryItem.media || {} : {} return this.streamLibraryItem?.media || {}
}, },
isPodcast() { isPodcast() {
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'podcast' : false return this.streamLibraryItem?.mediaType === 'podcast'
}, },
isMusic() { isMusic() {
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'music' : false return this.streamLibraryItem?.mediaType === 'music'
}, },
isExplicit() { isExplicit() {
return this.mediaMetadata.explicit || false return this.mediaMetadata.explicit || false
@ -139,6 +144,7 @@ export default {
return this.media.metadata || {} return this.media.metadata || {}
}, },
chapters() { chapters() {
if (this.streamEpisode) return this.streamEpisode.chapters || []
return this.media.chapters || [] return this.media.chapters || []
}, },
title() { title() {
@ -152,7 +158,8 @@ export default {
return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null
}, },
totalDurationPretty() { totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration) // Adjusted by playback rate
return this.$secondsToTimestamp(this.totalDuration / this.currentPlaybackRate)
}, },
podcastAuthor() { podcastAuthor() {
if (!this.isPodcast) return null if (!this.isPodcast) return null
@ -255,7 +262,7 @@ export default {
this.playerHandler.setVolume(volume) this.playerHandler.setVolume(volume)
}, },
setPlaybackRate(playbackRate) { setPlaybackRate(playbackRate) {
this.initialPlaybackRate = playbackRate this.currentPlaybackRate = playbackRate
this.playerHandler.setPlaybackRate(playbackRate) this.playerHandler.setPlaybackRate(playbackRate)
}, },
seek(time) { seek(time) {
@ -384,7 +391,7 @@ export default {
libraryItem: session.libraryItem, libraryItem: session.libraryItem,
episodeId: session.episodeId episodeId: session.episodeId
}) })
this.playerHandler.prepareOpenSession(session, this.initialPlaybackRate) this.playerHandler.prepareOpenSession(session, this.currentPlaybackRate)
}, },
streamOpen(session) { streamOpen(session) {
console.log(`[StreamContainer] Stream session open`, session) console.log(`[StreamContainer] Stream session open`, session)
@ -451,7 +458,7 @@ export default {
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack() 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() { pauseItem() {
this.playerHandler.pause() this.playerHandler.pause()
@ -459,6 +466,13 @@ export default {
showFailedProgressSyncs() { showFailedProgressSyncs() {
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast) if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
this.syncFailedToast = this.$toast('Progress is not being synced. Restart playback', { timeout: false, type: 'error' }) 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() { mounted() {

View File

@ -325,8 +325,13 @@ export default {
if (this.episodeProgress) return this.episodeProgress if (this.episodeProgress) return this.episodeProgress
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId) return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
}, },
useEBookProgress() {
if (!this.userProgress || this.userProgress.progress) return false
return this.userProgress.ebookProgress > 0
},
userProgressPercent() { 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() { itemIsFinished() {
return this.userProgress ? !!this.userProgress.isFinished : false return this.userProgress ? !!this.userProgress.isFinished : false

View File

@ -81,13 +81,20 @@ export default {
return this.title return this.title
}, },
displaySortLine() { displaySortLine() {
if (this.orderBy === 'addedAt') { switch (this.orderBy) {
// return this.addedAt case 'addedAt':
return 'Added ' + this.$formatDate(this.addedAt, this.dateFormat) return `${this.$strings.LabelAdded} ${this.$formatDate(this.addedAt, this.dateFormat)}`
} else if (this.orderBy === 'totalDuration') { case 'totalDuration':
return 'Duration: ' + this.$elapsedPrettyExtended(this.totalDuration, false) 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() { books() {
return this.series ? this.series.books || [] : [] return this.series ? this.series.books || [] : []

View File

@ -2,13 +2,13 @@
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'"> <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"> <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"> <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"> <p class="chapter-title truncate text-sm md:text-base">
{{ chap.title }} {{ chap.title }}
</p> </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="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 v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
</div> </div>
@ -28,7 +28,8 @@ export default {
currentChapter: { currentChapter: {
type: Object, type: Object,
default: () => null default: () => null
} },
playbackRate: Number
}, },
data() { data() {
return {} return {}
@ -47,11 +48,15 @@ export default {
this.$emit('input', val) this.$emit('input', val)
} }
}, },
_playbackRate() {
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
return this.playbackRate
},
currentChapterId() { currentChapterId() {
return this.currentChapter ? this.currentChapter.id : null return this.currentChapter ? this.currentChapter.id : null
}, },
currentChapterStart() { currentChapterStart() {
return this.currentChapter ? this.currentChapter.start : 0 return (this.currentChapter?.start || 0) / this._playbackRate
} }
}, },
methods: { methods: {
@ -61,13 +66,11 @@ export default {
scrollToChapter() { scrollToChapter() {
if (!this.currentChapterId) return if (!this.currentChapterId) return
var container = this.$refs.container if (this.$refs.container) {
if (container) { const currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
var currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
if (currChapterEl) { if (currChapterEl) {
var offsetTop = currChapterEl.offsetTop const containerHeight = this.$refs.container.clientHeight
var containerHeight = container.clientHeight this.$refs.container.scrollTo({ top: currChapterEl.offsetTop - containerHeight / 2 })
container.scrollTo({ top: offsetTop - containerHeight / 2 })
} }
} }
} }

View File

@ -98,7 +98,8 @@
</div> </div>
<div class="flex items-center"> <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>
</div> </div>
</modals-modal> </modals-modal>
@ -157,6 +158,9 @@ export default {
}, },
timeFormat() { timeFormat() {
return this.$store.state.serverSettings.timeFormat return this.$store.state.serverSettings.timeFormat
},
isOpenSession() {
return !!this._session.open
} }
}, },
methods: { methods: {
@ -188,6 +192,24 @@ export default {
var errMsg = error.response ? error.response.data || '' : '' var errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(errMsg || this.$strings.ToastSessionDeleteFailed) 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() {} mounted() {}

View File

@ -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 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"> <div v-if="!timerSet" class="w-full">
<template v-for="time in sleepTimes"> <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> <p class="text-xl text-center">{{ time.text }}</p>
</div> </div>
</template> </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>
<div v-else class="w-full p-4"> <div v-else class="w-full p-4">
<div class="mb-4 flex items-center justify-center"> <div class="mb-4 flex items-center justify-center">
@ -48,19 +52,28 @@ export default {
}, },
data() { data() {
return { return {
customTime: null,
sleepTimes: [ sleepTimes: [
{
seconds: 10,
text: '10 seconds'
},
{ {
seconds: 60 * 5, seconds: 60 * 5,
text: '5 minutes' text: '5 minutes'
}, },
{
seconds: 60 * 15,
text: '15 minutes'
},
{
seconds: 60 * 20,
text: '20 minutes'
},
{ {
seconds: 60 * 30, seconds: 60 * 30,
text: '30 minutes' text: '30 minutes'
}, },
{
seconds: 60 * 45,
text: '45 minutes'
},
{ {
seconds: 60 * 60, seconds: 60 * 60,
text: '60 minutes' text: '60 minutes'
@ -72,10 +85,6 @@ export default {
{ {
seconds: 60 * 120, seconds: 60 * 120,
text: '2 hours' text: '2 hours'
},
{
seconds: 60 * 180,
text: '3 hours'
} }
] ]
} }
@ -97,8 +106,17 @@ export default {
} }
}, },
methods: { methods: {
setTime(time) { submitCustomTime() {
this.$emit('set', time.seconds) 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) { increment(amount) {
this.$emit('increment', amount) this.$emit('increment', amount)

View File

@ -2,7 +2,8 @@
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative"> <div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
<div class="flex flex-wrap"> <div class="flex flex-wrap">
<div class="relative"> <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 --> <!-- 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 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" /> <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> </form>
</div> </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"> <div class="flex items-center justify-center py-2">
<p>{{ localCovers.length }} local image{{ localCovers.length !== 1 ? 's' : '' }}</p> <p>{{ localCovers.length }} local image{{ localCovers.length !== 1 ? 's' : '' }}</p>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? $strings.ButtonHide : $strings.ButtonShow }}</ui-btn> <ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? $strings.ButtonHide : $strings.ButtonShow }}</ui-btn>
</div> </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"> <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 :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' }"> <div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">

View File

@ -34,14 +34,26 @@
</div> </div>
<ui-checkbox v-model="selectAll" checkbox-bg="bg" @input="selectAllToggled" /> <ui-checkbox v-model="selectAll" checkbox-bg="bg" @input="selectAllToggled" />
<form @submit.prevent="submitMatchUpdate"> <form @submit.prevent="submitMatchUpdate">
<div v-if="selectedMatchOrig.cover" class="flex items-center py-2"> <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-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" /> <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"> </div>
<a :href="selectedMatch.cover" target="_blank" class="w-full bg-primary">
<img :src="selectedMatch.cover" class="h-full w-full object-contain" /> <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> </a>
</div> </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>
<div v-if="selectedMatchOrig.title" class="flex items-center py-2"> <div v-if="selectedMatchOrig.title" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.title" checkbox-bg="bg" @input="checkboxToggled" /> <ui-checkbox v-model="selectedMatchUsage.title" checkbox-bg="bg" @input="checkboxToggled" />

View File

@ -46,8 +46,20 @@
>{{ $strings.ButtonOpenManager }} >{{ $strings.ButtonOpenManager }}
<span class="material-icons text-lg ml-2">launch</span> <span class="material-icons text-lg ml-2">launch</span>
</ui-btn> </ui-btn>
<ui-btn v-if="!isMetadataEmbedQueued && !isEmbedTaskRunning" class="w-full mt-4" small @click.stop="quickEmbed">Quick Embed</ui-btn>
</div> </div>
</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> </div>
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">{{ $strings.MessageNoAudioTracks }}</p> <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 return this.$store.state.showExperimentalFeatures
}, },
libraryItemId() { libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null return this.libraryItem?.id || null
}, },
media() { media() {
return this.libraryItem ? this.libraryItem.media || {} : {} return this.libraryItem?.media || {}
}, },
mediaTracks() { mediaTracks() {
return this.media.tracks || [] return this.media.tracks || []
@ -92,9 +104,49 @@ export default {
showMp3Split() { showMp3Split() {
if (!this.mediaTracks.length) return false if (!this.mediaTracks.length) return false
return this.isSingleM4b && this.chapters.length 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: {}, methods: {
mounted() {} 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> </script>

View File

@ -38,7 +38,8 @@ export default {
currentChapter: { currentChapter: {
type: Object, type: Object,
default: () => {} default: () => {}
} },
playbackRate: Number
}, },
data() { data() {
return { return {
@ -63,6 +64,10 @@ export default {
} }
}, },
computed: { computed: {
_playbackRate() {
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
return this.playbackRate
},
currentChapterDuration() { currentChapterDuration() {
if (!this.currentChapter) return 0 if (!this.currentChapter) return 0
return this.currentChapter.end - this.currentChapter.start return this.currentChapter.end - this.currentChapter.start
@ -81,8 +86,8 @@ export default {
clickTrack(e) { clickTrack(e) {
if (this.loading) return if (this.loading) return
var offsetX = e.offsetX const offsetX = e.offsetX
var perc = offsetX / this.trackWidth const perc = offsetX / this.trackWidth
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0 const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
const time = baseTime + perc * duration const time = baseTime + perc * duration
@ -111,7 +116,7 @@ export default {
this.updateReadyTrack() this.updateReadyTrack()
}, },
updateReadyTrack() { updateReadyTrack() {
var widthReady = Math.round(this.trackWidth * this.percentReady) const widthReady = Math.round(this.trackWidth * this.percentReady)
if (this.readyTrackWidth === widthReady) return if (this.readyTrackWidth === widthReady) return
this.readyTrackWidth = widthReady this.readyTrackWidth = widthReady
if (this.$refs.readyTrack) this.$refs.readyTrack.style.width = widthReady + 'px' 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 time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration 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) { if (this.playedTrackWidth === ptWidth) {
return return
} }
@ -133,7 +138,7 @@ export default {
}, },
setChapterTicks() { setChapterTicks() {
this.chapterTicks = this.chapters.map((chap) => { this.chapterTicks = this.chapters.map((chap) => {
var perc = chap.start / this.duration const perc = chap.start / this.duration
return { return {
title: chap.title, title: chap.title,
left: perc * this.trackWidth left: perc * this.trackWidth
@ -141,7 +146,7 @@ export default {
}) })
}, },
mousemoveTrack(e) { mousemoveTrack(e) {
var offsetX = e.offsetX const offsetX = e.offsetX
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0 const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
@ -167,7 +172,7 @@ export default {
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px' this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
} }
if (this.$refs.hoverTimestampText) { 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) var chapter = this.chapters.find((chapter) => chapter.start <= totalTime && totalTime < chapter.end)
if (chapter && chapter.title) { if (chapter && chapter.title) {

View File

@ -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" /> <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> </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"> <div class="flex">
<p ref="currentTimestamp" class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">00:00:00</p> <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> <p class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
</div> </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> </div>
</template> </template>
@ -92,6 +92,11 @@ export default {
useChapterTrack: false useChapterTrack: false
} }
}, },
watch: {
playbackRate() {
this.updateTimestamp()
}
},
computed: { computed: {
sleepTimerRemainingString() { sleepTimerRemainingString() {
var rounded = Math.round(this.sleepTimerRemaining) var rounded = Math.round(this.sleepTimerRemaining)
@ -213,18 +218,14 @@ export default {
} }
}, },
increasePlaybackRate() { increasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3] if (this.playbackRate >= 10) return
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate) this.playbackRate = Number((this.playbackRate + 0.1).toFixed(1))
if (currentRateIndex >= rates.length - 1) return this.setPlaybackRate(this.playbackRate)
this.playbackRate = rates[currentRateIndex + 1] || 1
this.playbackRateChanged(this.playbackRate)
}, },
decreasePlaybackRate() { decreasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3] if (this.playbackRate <= 0.5) return
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate) this.playbackRate = Number((this.playbackRate - 0.1).toFixed(1))
if (currentRateIndex <= 0) return this.setPlaybackRate(this.playbackRate)
this.playbackRate = rates[currentRateIndex - 1] || 1
this.playbackRateChanged(this.playbackRate)
}, },
setPlaybackRate(playbackRate) { setPlaybackRate(playbackRate) {
this.$emit('setPlaybackRate', playbackRate) this.$emit('setPlaybackRate', playbackRate)
@ -289,14 +290,13 @@ export default {
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(percentageReady) if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(percentageReady)
}, },
updateTimestamp() { updateTimestamp() {
var ts = this.$refs.currentTimestamp const ts = this.$refs.currentTimestamp
if (!ts) { if (!ts) {
console.error('No timestamp el') console.error('No timestamp el')
return return
} }
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
var currTimeClean = this.$secondsToTimestamp(time) ts.innerText = this.$secondsToTimestamp(time / this.playbackRate)
ts.innerText = currTimeClean
}, },
setBufferTime(bufferTime) { setBufferTime(bufferTime) {
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime) if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
@ -312,7 +312,7 @@ export default {
this.useChapterTrack = this.chapters.length ? _useChapterTrack : false this.useChapterTrack = this.chapters.length ? _useChapterTrack : false
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack) if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
this.$emit('setPlaybackRate', this.playbackRate) this.setPlaybackRate(this.playbackRate)
}, },
settingsUpdated(settings) { settingsUpdated(settings) {
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) { if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {

View File

@ -1,18 +1,14 @@
<template> <template>
<div class="h-full w-full"> <div class="h-full w-full">
<div class="h-full flex items-center"> <div class="h-full flex items-center justify-center">
<div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden justify-center"> <div style="width: 100px; max-width: 100px" class="h-full hidden sm: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> <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>
<div id="frame" class="w-full" style="height: 650px"> <div id="frame" class="w-full" style="height: 80%">
<div id="viewer" class="border border-gray-100 bg-white shadow-md"></div> <div id="viewer"></div>
<div class="py-4 flex justify-center" style="height: 50px">
<p>{{ progress }}%</p>
</div> </div>
</div> <div style="width: 100px; max-width: 100px" class="h-full hidden sm:flex items-center justify-center overflow-x-hidden">
<div style="width: 100px; max-width: 100px" class="h-full 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>
<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> </div>
</div> </div>
</div> </div>
@ -21,108 +17,252 @@
<script> <script>
import ePub from 'epubjs' import ePub from 'epubjs'
/**
* @typedef {object} EpubReader
* @property {ePub.Book} book
* @property {ePub.Rendition} rendition
*/
export default { export default {
props: { props: {
url: String url: String,
libraryItem: {
type: Object,
default: () => {}
}
}, },
data() { data() {
return { return {
windowWidth: 0,
/** @type {ePub.Book} */
book: null, book: null,
rendition: null, /** @type {ePub.Rendition} */
chapters: [], rendition: null
title: '', }
author: '', },
progress: 0, computed: {
hasNext: true, /** @returns {string} */
hasPrev: false 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
} }
}, },
computed: {},
methods: { methods: {
changedChapter() {
if (this.rendition) {
this.rendition.display(this.selectedChapter)
}
},
prev() { prev() {
if (this.rendition) { return this.rendition?.prev()
this.rendition.prev()
}
}, },
next() { next() {
if (this.rendition) { return this.rendition?.next()
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) { * @param {object} payload
this.prev() * @param {string} payload.ebookLocation - CFI of the current location
} else if ((e.keyCode || e.which) == 39) { * @param {string} payload.ebookProgress - eBook Progress Percentage
this.next() */
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() { initEpub() {
// var book = ePub(this.url, { /** @type {EpubReader} */
// requestHeaders: { const reader = this
// Authorization: `Bearer ${this.userToken}`
// }
// })
var book = ePub(this.url)
this.book = book
this.rendition = book.renderTo('viewer', { /** @type {ePub.Book} */
width: window.innerWidth - 200, reader.book = new ePub(reader.url, {
height: 600, width: this.readerWidth,
ignoreClass: 'annotator-hl', height: window.innerHeight - 50
manager: 'continuous',
spread: 'always'
}) })
var displayed = this.rendition.display()
book.ready /** @type {ePub.Rendition} */
.then(() => { reader.rendition = reader.book.renderTo('viewer', {
console.log('Book ready') width: this.readerWidth,
return book.locations.generate(1600) height: window.innerHeight * 0.8
}) })
.then((locations) => {
// console.log('Loaded locations', locations) // load saved progress
// Wait for book to be rendered to get current page reader.rendition.display(this.userMediaProgress?.ebookLocation || reader.book.locations.start)
displayed.then(() => {
// Get the current CFI // load style
var currentLocation = this.rendition.currentLocation() reader.rendition.themes.default({ '*': { color: '#fff!important' } })
if (!currentLocation.start) {
console.error('No Start', currentLocation) reader.book.ready.then(() => {
} else { // set up event listeners
var currentPage = book.locations.percentageFromCfi(currentLocation.start.cfi) reader.rendition.on('relocated', reader.relocated)
// console.log('current page', currentPage) reader.rendition.on('keydown', reader.keyUp)
let touchStart = 0
let touchEnd = 0
reader.rendition.on('touchstart', (event) => {
touchStart = event.changedTouches[0].screenX
})
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()
} }
}) })
})
book.loaded.navigation.then((toc) => { // load ebook cfi locations
var _chapters = [] const savedLocations = this.loadLocations()
toc.forEach((chapter) => { if (savedLocations) {
_chapters.push(chapter) reader.book.locations.load(savedLocations)
} else {
reader.book.locations.generate().then(() => {
this.checkSaveLocations(reader.book.locations.save())
}) })
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() { mounted() {
this.windowWidth = window.innerWidth
window.addEventListener('resize', this.resize)
this.initEpub() this.initEpub()
} }
} }

View File

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

View File

@ -1,24 +1,48 @@
<template> <template>
<div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-60 bg-primary text-white"> <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"> <div class="absolute top-4 left-4 z-20">
<span class="material-icons cursor-pointer text-4xl" @click="close">close</span> <span v-if="hasToC && !tocOpen" ref="tocButton" class="material-icons cursor-pointer text-2xl" @click="toggleToC">menu</span>
</div> </div>
<div class="absolute top-4 left-4"> <div class="absolute top-4 left-1/2 transform -translate-x-1/2">
<h1 class="text-2xl mb-1">{{ abTitle }}</h1> <h1 class="text-lg sm:text-xl md:text-2xl mb-1" style="line-height: 1.15; font-weight: 100">
<p v-if="abAuthor">by {{ abAuthor }}</p> <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> </div>
<component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" :library-item="selectedLibraryItem" /> <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> </div>
</template> </template>
<script> <script>
export default { export default {
data() { data() {
return {} return {
chapters: [],
tocOpen: false
}
}, },
watch: { watch: {
show(newVal) { show(newVal) {
@ -37,13 +61,18 @@ export default {
} }
}, },
componentName() { componentName() {
if (this.ebookType === 'epub' && this.$isDev) return 'readers-epub-reader2' if (this.ebookType === 'epub') return 'readers-epub-reader'
else if (this.ebookType === 'epub') return 'readers-epub-reader'
else if (this.ebookType === 'mobi') return 'readers-mobi-reader' else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
else if (this.ebookType === 'pdf') return 'readers-pdf-reader' else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
else if (this.ebookType === 'comic') return 'readers-comic-reader' else if (this.ebookType === 'comic') return 'readers-comic-reader'
return null return null
}, },
hasToC() {
return this.isEpub
},
hasSettings() {
return false
},
abTitle() { abTitle() {
return this.mediaMetadata.title return this.mediaMetadata.title
}, },
@ -111,18 +140,29 @@ export default {
} }
}, },
methods: { methods: {
toggleToC() {
this.tocOpen = !this.tocOpen
this.chapters = this.$refs.readerComponent.chapters
},
openSettings() {},
hotkey(action) { hotkey(action) {
console.log('Reader hotkey', action) console.log('Reader hotkey', action)
if (!this.$refs.readerComponent) return if (!this.$refs.readerComponent) return
if (action === this.$hotkeys.EReader.NEXT_PAGE) { 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) { } 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) { } else if (action === this.$hotkeys.EReader.CLOSE) {
this.close() this.close()
} }
}, },
next() {
if (this.$refs.readerComponent?.next) this.$refs.readerComponent.next()
},
prev() {
if (this.$refs.readerComponent?.prev) this.$refs.readerComponent.prev()
},
registerListeners() { registerListeners() {
this.$eventBus.$on('reader-hotkey', this.hotkey) this.$eventBus.$on('reader-hotkey', this.hotkey)
}, },
@ -151,4 +191,8 @@ export default {
.ebook-viewer { .ebook-viewer {
height: calc(100% - 96px); height: calc(100% - 96px);
} }
.tocContent {
height: calc(100% - 36px);
overflow-y: auto;
}
</style> </style>

View File

@ -6,7 +6,7 @@
<span class="text-sm font-mono">{{ files.length }}</span> <span class="text-sm font-mono">{{ files.length }}</span>
</div> </div>
<div class="flex-grow" /> <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' : ''"> <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> <span class="material-icons text-4xl">expand_more</span>
</div> </div>
@ -18,25 +18,10 @@
<th class="text-left px-4">{{ $strings.LabelPath }}</th> <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 w-24 min-w-24">{{ $strings.LabelSize }}</th>
<th class="text-left px-4 w-24">{{ $strings.LabelType }}</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" class="text-center w-16"></th>
</tr> </tr>
<template v-for="file in files"> <template v-for="file in files">
<tr :key="file.path"> <tables-library-files-table-row :key="file.path" :libraryItemId="libraryItemId" :showFullPath="showFullPath" :file="file" />
<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> </template>
</table> </table>
</div> </div>
@ -67,6 +52,12 @@ export default {
}, },
userCanDownload() { userCanDownload() {
return this.$store.getters['user/getUserCanDownload'] return this.$store.getters['user/getUserCanDownload']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userIsAdmin() {
return this.$store.getters['user/getIsAdminOrUp']
} }
}, },
methods: { methods: {

View File

@ -0,0 +1,105 @@
<template>
<tr>
<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="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: () => {}
}
},
data() {
return {}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
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'
})
}
return items
}
},
methods: {
contextMenuAction(action) {
if (action === 'delete') {
this.deleteLibraryFile()
} else if (action === 'download') {
this.downloadLibraryFile()
}
},
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>

View File

@ -7,7 +7,7 @@
</div> </div>
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> --> <!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
<div class="flex-grow" /> <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> <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> <ui-btn small color="primary">{{ $strings.ButtonManageTracks }}</ui-btn>
</nuxt-link> </nuxt-link>
@ -90,6 +90,9 @@ export default {
userCanUpdate() { userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate'] return this.$store.getters['user/getUserCanUpdate']
}, },
userIsAdmin() {
return this.$store.getters['user/getIsAdminOrUp']
},
showExperimentalFeatures() { showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures return this.$store.state.showExperimentalFeatures
} }

View File

@ -12,6 +12,7 @@
<div class="flex justify-between pt-2 max-w-xl"> <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.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.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> <p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, dateFormat) }}</p>
</div> </div>

View File

@ -54,7 +54,7 @@ export default {
quickMatchingEpisodes: false, quickMatchingEpisodes: false,
search: null, search: null,
searchTimeout: null, searchTimeout: null,
searchText: null, searchText: null
} }
}, },
watch: { watch: {
@ -139,19 +139,25 @@ export default {
return episodeProgress && !episodeProgress.isFinished return episodeProgress && !episodeProgress.isFinished
}) })
.sort((a, b) => { .sort((a, b) => {
if (this.sortDesc) { let aValue = a[this.sortKey]
return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' }) 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() { episodesList() {
return this.episodesSorted.filter((episode) => { return this.episodesSorted.filter((episode) => {
if (!this.searchText) return true if (!this.searchText) return true
return ( return (episode.title && episode.title.toLowerCase().includes(this.searchText)) || (episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
(episode.title && episode.title.toLowerCase().includes(this.searchText)) ||
(episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
)
}) })
}, },
selectedIsFinished() { selectedIsFinished() {

View File

@ -1,11 +1,13 @@
<template> <template>
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj"> <div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
<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"> <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> <span class="material-icons" :class="iconClass">more_vert</span>
</button> </button>
</slot>
<transition name="menu"> <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"> <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)"> <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> <p>{{ item.text }}</p>
@ -27,6 +29,10 @@ export default {
iconClass: { iconClass: {
type: String, type: String,
default: '' default: ''
},
menuWidth: {
type: String,
default: '192px'
} }
}, },
data() { data() {

View File

@ -1,6 +1,6 @@
<template> <template>
<div ref="wrapper" class="relative"> <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"> <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> <span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
</div> </div>
@ -32,7 +32,9 @@ export default {
noSpinner: Boolean, noSpinner: Boolean,
textCenter: Boolean, textCenter: Boolean,
clearable: Boolean, clearable: Boolean,
inputId: String inputId: String,
step: [String, Number],
min: [String, Number]
}, },
data() { data() {
return { return {

View File

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

View File

@ -278,6 +278,13 @@ export default {
console.log('Task finished', task) console.log('Task finished', task)
this.$store.commit('tasks/addUpdateTask', 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) { userUpdated(user) {
if (this.$store.state.user.user.id === user.id) { if (this.$store.state.user.user.id === user.id) {
this.$store.commit('user/setUser', user) this.$store.commit('user/setUser', user)
@ -292,8 +299,17 @@ export default {
userStreamUpdate(user) { userStreamUpdate(user) {
this.$store.commit('users/updateUserOnline', user) this.$store.commit('users/updateUserOnline', user)
}, },
userSessionClosed(sessionId) {
if (this.$refs.streamContainer) this.$refs.streamContainer.sessionClosedEvent(sessionId)
},
userMediaProgressUpdate(payload) { userMediaProgressUpdate(payload) {
this.$store.commit('user/updateMediaProgress', 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) { collectionAdded(collection) {
if (this.currentLibraryId !== collection.libraryId) return if (this.currentLibraryId !== collection.libraryId) return
@ -398,6 +414,7 @@ export default {
this.socket.on('user_online', this.userOnline) this.socket.on('user_online', this.userOnline)
this.socket.on('user_offline', this.userOffline) this.socket.on('user_offline', this.userOffline)
this.socket.on('user_stream_update', this.userStreamUpdate) 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) this.socket.on('user_item_progress_updated', this.userMediaProgressUpdate)
// Collection Listeners // Collection Listeners
@ -418,6 +435,7 @@ export default {
// Task Listeners // Task Listeners
this.socket.on('task_started', this.taskStarted) this.socket.on('task_started', this.taskStarted)
this.socket.on('task_finished', this.taskFinished) this.socket.on('task_finished', this.taskFinished)
this.socket.on('metadata_embed_queue_update', this.metadataEmbedQueueUpdate)
this.socket.on('backup_applied', this.backupApplied) this.socket.on('backup_applied', this.backupApplied)
@ -531,12 +549,18 @@ export default {
}, },
loadTasks() { loadTasks() {
this.$axios this.$axios
.$get('/api/tasks') .$get('/api/tasks?include=queue')
.then((payload) => { .then((payload) => {
console.log('Fetched tasks', payload) console.log('Fetched tasks', payload)
if (payload.tasks) { if (payload.tasks) {
this.$store.commit('tasks/setTasks', 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) => { .catch((error) => {
console.error('Failed to load tasks', error) console.error('Failed to load tasks', error)

View File

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

View File

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

View File

@ -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" /> <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 class="w-32 hidden lg:block" />
</div> </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" /> <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 v-if="chapters.length" color="primary" small class="mx-1" @click.stop="removeAllChaptersClick">{{ $strings.ButtonRemoveAll }}</ui-btn>
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</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" /> <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" small class="mx-1" @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" color="success" class="mx-1" :disabled="!hasChanges" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn>
<div class="w-32 hidden lg:block" /> <div class="w-32 hidden lg:block" />
</div> </div>
@ -41,7 +42,7 @@
<ui-text-input v-model="shiftAmount" type="number" class="max-w-20" style="height: 30px" /> <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> <ui-btn color="primary" class="mx-1" small @click="shiftChapterTimes">{{ $strings.ButtonAdd }}</ui-btn>
<div class="flex-grow" /> <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> </div>
<p class="text-xs py-1.5 text-gray-300 max-w-md">{{ $strings.NoteChapterEditorTimes }}</p> <p class="text-xs py-1.5 text-gray-300 max-w-md">{{ $strings.NoteChapterEditorTimes }}</p>
</div> </div>
@ -329,6 +330,7 @@ export default {
chap.start = Math.max(0, chap.start + amount) chap.start = Math.max(0, chap.start + amount)
} }
} }
this.checkChapters()
}, },
editItem() { editItem() {
this.$store.commit('showEditModal', this.libraryItem) this.$store.commit('showEditModal', this.libraryItem)
@ -587,6 +589,45 @@ export default {
] ]
} }
this.checkChapters() 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() { mounted() {

View File

@ -62,14 +62,20 @@
<div class="w-full h-px bg-white bg-opacity-10 my-8" /> <div class="w-full h-px bg-white bg-opacity-10 my-8" />
<div class="w-full max-w-4xl mx-auto"> <div class="w-full max-w-4xl mx-auto">
<div v-if="isEmbedTool" class="w-full flex justify-end items-center mb-4"> <!-- queued alert -->
<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" /> <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" /> <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> <p v-else class="text-success text-lg font-semibold">{{ $strings.MessageEmbedFinished }}</p>
</div> </div>
<!-- m4b embed action buttons -->
<div v-else class="w-full flex items-center mb-4"> <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"> <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> <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> <p v-else class="text-success text-lg font-semibold">{{ $strings.MessageM4BFinished }}</p>
</div> </div>
<!-- advanced encoding options -->
<div v-if="isM4BTool" class="overflow-hidden"> <div v-if="isM4BTool" class="overflow-hidden">
<transition name="slide"> <transition name="slide">
<div v-if="showEncodeOptions" class="mb-4 pb-4 border-b border-white/10"> <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') cnosole.error('No audio files')
return redirect('/?error=no audio files') return redirect('/?error=no audio files')
} }
return { return {
libraryItem libraryItem
} }
@ -200,7 +208,6 @@ export default {
processing: false, processing: false,
audiofilesEncoding: {}, audiofilesEncoding: {},
audiofilesFinished: {}, audiofilesFinished: {},
isFinished: false,
toneObject: null, toneObject: null,
selectedTool: 'embed', selectedTool: 'embed',
isCancelingEncode: false, isCancelingEncode: false,
@ -272,11 +279,28 @@ export default {
isTaskFinished() { isTaskFinished() {
return this.task && this.task.isFinished 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() { 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() { taskRunning() {
return this.task && !this.task.isFinished return this.task && !this.task.isFinished
},
queuedEmbedLIds() {
return this.$store.state.tasks.queuedEmbedLIds || []
},
isMetadataEmbedQueued() {
return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId)
} }
}, },
methods: { methods: {
@ -322,7 +346,7 @@ export default {
.catch((error) => { .catch((error) => {
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error' var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.$toast.error(errorMsg) this.$toast.error(errorMsg)
this.processing = true this.processing = false
}) })
}, },
embedClick() { embedClick() {
@ -349,24 +373,6 @@ export default {
this.processing = false 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) { audiofileMetadataStarted(data) {
if (data.libraryItemId !== this.libraryItemId) return if (data.libraryItemId !== this.libraryItemId) return
this.$set(this.audiofilesEncoding, data.ino, true) this.$set(this.audiofilesEncoding, data.ino, true)
@ -412,14 +418,10 @@ export default {
}, },
mounted() { mounted() {
this.init() 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_started', this.audiofileMetadataStarted)
this.$root.socket.on('audiofile_metadata_finished', this.audiofileMetadataFinished) this.$root.socket.on('audiofile_metadata_finished', this.audiofileMetadataFinished)
}, },
beforeDestroy() { 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_started', this.audiofileMetadataStarted)
this.$root.socket.off('audiofile_metadata_finished', this.audiofileMetadataFinished) this.$root.socket.off('audiofile_metadata_finished', this.audiofileMetadataFinished)
} }

View File

@ -52,9 +52,53 @@
</div> </div>
</div> </div>
<p v-else class="text-white text-opacity-50">{{ $strings.MessageNoListeningSessions }}</p> <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> </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> </div>
</template> </template>
@ -81,6 +125,7 @@ export default {
showSessionModal: false, showSessionModal: false,
selectedSession: null, selectedSession: null,
listeningSessions: [], listeningSessions: [],
openListeningSessions: [],
numPages: 0, numPages: 0,
total: 0, total: 0,
currentPage: 0, currentPage: 0,
@ -114,6 +159,9 @@ export default {
} }
}, },
methods: { methods: {
closedSession() {
this.loadOpenSessions()
},
removedSession() { removedSession() {
// If on last page and this was the last session then load prev page // If on last page and this was the last session then load prev page
if (this.currentPage == this.numPages - 1) { if (this.currentPage == this.numPages - 1) {
@ -222,7 +270,7 @@ export default {
async loadSessions(page) { async loadSessions(page) {
var userFilterQuery = this.selectedUser ? `&user=${this.selectedUser}` : '' var userFilterQuery = this.selectedUser ? `&user=${this.selectedUser}` : ''
const data = await this.$axios.$get(`/api/sessions?page=${page}&itemsPerPage=${this.itemsPerPage}${userFilterQuery}`).catch((err) => { 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 return null
}) })
if (!data) { if (!data) {
@ -236,8 +284,24 @@ export default {
this.listeningSessions = data.sessions this.listeningSessions = data.sessions
this.userFilter = data.userFilter 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() { init() {
this.loadSessions(0) this.loadSessions(0)
this.loadOpenSessions()
} }
}, },
mounted() { mounted() {

View File

@ -164,7 +164,7 @@
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''"> <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-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-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> <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"> <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">
@ -201,27 +201,18 @@
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="mx-0.5" @click="toggleFinished" /> <ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="mx-0.5" @click="toggleFinished" />
</ui-tooltip> </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 --> <!-- Only admin or root user can download new episodes -->
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" :text="$strings.LabelFindEpisodes" direction="top"> <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-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
</ui-tooltip> </ui-tooltip>
<ui-tooltip v-if="bookmarks.length" :text="$strings.LabelYourBookmarks" direction="top"> <ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" menu-width="148px" @action="contextMenuAction">
<ui-icon-btn :icon="bookmarks.length ? 'bookmarks' : 'bookmark_border'" class="mx-0.5" @click="clickBookmarksBtn" /> <template #default="{ showMenu, clickShowMenu, disabled }">
</ui-tooltip> <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_vert</span>
<!-- RSS feed --> </button>
<ui-tooltip v-if="showRssFeedBtn" :text="$strings.LabelOpenRSSFeed" direction="top"> </template>
<ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeed ? 'success' : 'primary'" outlined @click="clickRSSFeed" /> </ui-context-menu-dropdown>
</ui-tooltip>
</div> </div>
<div class="my-4 max-w-2xl"> <div class="my-4 max-w-2xl">
@ -284,6 +275,12 @@ export default {
} }
}, },
computed: { computed: {
userToken() {
return this.$store.getters['user/getToken']
},
downloadUrl() {
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/download?token=${this.userToken}`
},
dateFormat() { dateFormat() {
return this.$store.state.serverSettings.dateFormat return this.$store.state.serverSettings.dateFormat
}, },
@ -296,9 +293,6 @@ export default {
userIsAdminOrUp() { userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp'] return this.$store.getters['user/getIsAdminOrUp']
}, },
isFile() {
return this.libraryItem.isFile
},
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
@ -308,6 +302,9 @@ export default {
isDeveloperMode() { isDeveloperMode() {
return this.$store.state.developerMode return this.$store.state.developerMode
}, },
isFile() {
return this.libraryItem.isFile
},
isBook() { isBook() {
return this.libraryItem.mediaType === 'book' return this.libraryItem.mediaType === 'book'
}, },
@ -482,7 +479,12 @@ export default {
const duration = this.userMediaProgress.duration || this.duration const duration = this.userMediaProgress.duration || this.duration
return duration - this.userMediaProgress.currentTime return duration - this.userMediaProgress.currentTime
}, },
useEBookProgress() {
if (!this.userMediaProgress || this.userMediaProgress.progress) return false
return this.userMediaProgress.ebookProgress > 0
},
progressPercent() { 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 return this.userMediaProgress ? Math.max(Math.min(1, this.userMediaProgress.progress), 0) : 0
}, },
userProgressStartedAt() { userProgressStartedAt() {
@ -521,12 +523,56 @@ export default {
}, },
showCollectionsButton() { showCollectionsButton() {
return this.isBook && this.userCanUpdate 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: { methods: {
clickBookmarksBtn() {
this.showBookmarksModal = true
},
selectBookmark(bookmark) { selectBookmark(bookmark) {
if (!bookmark) return if (!bookmark) return
if (this.isStreaming) { if (this.isStreaming) {
@ -702,14 +748,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() { clickRSSFeed() {
this.$store.commit('globals/setRSSFeedOpenCloseModal', { this.$store.commit('globals/setRSSFeedOpenCloseModal', {
id: this.libraryItemId, id: this.libraryItemId,
@ -767,6 +805,54 @@ export default {
} }
this.$store.commit('addItemToQueue', queueItem) 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 files from your file system. Are you sure?',
callback: (confirmed) => {
if (confirmed) {
this.$axios
.$delete(`/api/items/${this.libraryItemId}?hard=1`)
.then(() => {
this.$toast.success('Item deleted')
this.$router.replace(`/library/${this.libraryId}/bookshelf`)
})
.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() { mounted() {

View File

@ -123,7 +123,7 @@ export default class PlayerHandler {
playerError() { playerError() {
// Switch to HLS stream on error // 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`) console.log(`[PlayerHandler] Audio player error switching to HLS stream`)
this.prepare(true) this.prepare(true)
} }
@ -173,16 +173,30 @@ export default class PlayerHandler {
this.ctx.setBufferTime(buffertime) 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) { async prepare(forceTranscode = false) {
var payload = { this.currentSessionId = null // Reset session
const payload = {
deviceInfo: {
deviceId: this.getDeviceId()
},
supportedMimeTypes: this.player.playableMimeTypes, supportedMimeTypes: this.player.playableMimeTypes,
mediaPlayer: this.isCasting ? 'chromecast' : 'html5', mediaPlayer: this.isCasting ? 'chromecast' : 'html5',
forceTranscode, forceTranscode,
forceDirectPlay: this.isCasting || this.isVideo // TODO: add transcode support for chromecast 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` const 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 session = await this.ctx.$axios.$post(path, payload).catch((error) => {
console.error('Failed to start stream', error) console.error('Failed to start stream', error)
}) })
this.prepareSession(session) this.prepareSession(session)
@ -238,12 +252,17 @@ export default class PlayerHandler {
closePlayer() { closePlayer() {
console.log('[PlayerHandler] Close Player') console.log('[PlayerHandler] Close Player')
this.sendCloseSession() this.sendCloseSession()
this.resetPlayer()
}
resetPlayer() {
if (this.player) { if (this.player) {
this.player.destroy() this.player.destroy()
} }
this.player = null this.player = null
this.playerState = 'IDLE' this.playerState = 'IDLE'
this.libraryItem = null this.libraryItem = null
this.currentSessionId = null
this.startTime = 0 this.startTime = 0
this.stopPlayInterval() this.stopPlayInterval()
} }

View File

@ -1,5 +1,8 @@
import Vue from 'vue' import Vue from 'vue'
import cronParser from 'cron-parser' import cronParser from 'cron-parser'
import { nanoid } from 'nanoid'
Vue.prototype.$randomId = () => nanoid()
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => { Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
if (isNaN(bytes) || bytes == 0) { if (isNaN(bytes) || bytes == 0) {

View File

@ -99,14 +99,14 @@ export const getters = {
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}` 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 (!placeholder) placeholder = `${rootState.routerBasePath}/book_placeholder.jpg`
if (!libraryItemId) return placeholder if (!libraryItemId) return placeholder
var userToken = rootGetters['user/getToken'] var userToken = rootGetters['user/getToken']
if (process.env.NODE_ENV !== 'production') { // Testing 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) => { getIsBatchSelectingMediaItems: (state) => {
return state.selectedMediaItems.length return state.selectedMediaItems.length

View File

@ -1,11 +1,12 @@
export const state = () => ({ export const state = () => ({
tasks: [] tasks: [],
queuedEmbedLIds: []
}) })
export const getters = { export const getters = {
getTaskByLibraryItemId: (state) => (libraryItemId) => { getTasksByLibraryItemId: (state) => (libraryItemId) => {
return state.tasks.find(t => t.data && t.data.libraryItemId === libraryItemId) return state.tasks.filter(t => t.data && t.data.libraryItemId === libraryItemId)
} }
} }
@ -18,14 +19,31 @@ export const mutations = {
state.tasks = tasks state.tasks = tasks
}, },
addUpdateTask(state, task) { 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) { if (index >= 0) {
state.tasks.splice(index, 1, task) state.tasks.splice(index, 1, task)
} else { } 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) state.tasks.push(task)
} }
}, },
removeTask(state, task) { removeTask(state, task) {
state.tasks = state.tasks.filter(d => d.id !== task.id) state.tasks = state.tasks.filter(d => d.id !== task.id)
},
setQueuedEmbedLIds(state, libraryItemIds) {
state.queuedEmbedLIds = libraryItemIds
},
addQueuedEmbedLId(state, libraryItemId) {
if (!state.queuedEmbedLIds.some(lid => lid === libraryItemId)) {
state.queuedEmbedLIds.push(libraryItemId)
}
},
removeQueuedEmbedLId(state, libraryItemId) {
state.queuedEmbedLIds = state.queuedEmbedLIds.filter(lid => lid !== libraryItemId)
} }
} }

View File

@ -155,12 +155,13 @@
"HeaderUpdateLibrary": "Bibliothek aktualisieren", "HeaderUpdateLibrary": "Bibliothek aktualisieren",
"HeaderUsers": "Benutzer", "HeaderUsers": "Benutzer",
"HeaderYourStats": "Eigene Statistiken", "HeaderYourStats": "Eigene Statistiken",
"LabelAbridged": "Abridged", "LabelAbridged": "Gekürzt",
"LabelAccountType": "Kontoart", "LabelAccountType": "Kontoart",
"LabelAccountTypeAdmin": "Admin", "LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Gast", "LabelAccountTypeGuest": "Gast",
"LabelAccountTypeUser": "Benutzer", "LabelAccountTypeUser": "Benutzer",
"LabelActivity": "Aktivitäten", "LabelActivity": "Aktivitäten",
"LabelAdded": "Added",
"LabelAddedAt": "Hinzugefügt am", "LabelAddedAt": "Hinzugefügt am",
"LabelAddToCollection": "Zur Sammlung hinzufügen", "LabelAddToCollection": "Zur Sammlung hinzufügen",
"LabelAddToCollectionBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Sammlung hinzu", "LabelAddToCollectionBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Sammlung hinzu",
@ -250,6 +251,8 @@
"LabelItem": "Medium", "LabelItem": "Medium",
"LabelLanguage": "Sprache", "LabelLanguage": "Sprache",
"LabelLanguageDefaultServer": "Standard-Server-Sprache", "LabelLanguageDefaultServer": "Standard-Server-Sprache",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Zuletzt angesehen", "LabelLastSeen": "Zuletzt angesehen",
"LabelLastTime": "Letztes Mal", "LabelLastTime": "Letztes Mal",
"LabelLastUpdate": "Letzte Aktualisierung", "LabelLastUpdate": "Letzte Aktualisierung",
@ -421,7 +424,7 @@
"LabelTracksMultiTrack": "Mehrfachdatei", "LabelTracksMultiTrack": "Mehrfachdatei",
"LabelTracksSingleTrack": "Einzeldatei", "LabelTracksSingleTrack": "Einzeldatei",
"LabelType": "Typ", "LabelType": "Typ",
"LabelUnabridged": "Unabridged", "LabelUnabridged": "Ungekürzt",
"LabelUnknown": "Unbekannt", "LabelUnknown": "Unbekannt",
"LabelUpdateCover": "Titelbild aktualisieren", "LabelUpdateCover": "Titelbild aktualisieren",
"LabelUpdateCoverHelp": "Erlaube das Überschreiben bestehender Titelbilder für die ausgewählten Hörbücher wenn eine Übereinstimmung gefunden wird", "LabelUpdateCoverHelp": "Erlaube das Überschreiben bestehender Titelbilder für die ausgewählten Hörbücher wenn eine Übereinstimmung gefunden wird",
@ -465,6 +468,7 @@
"MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?", "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?", "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?", "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?", "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?", "MessageConfirmRemoveEpisode": "Sind Sie sicher, dass Sie die Episode \"{0}\" löschen möchten?",
"MessageConfirmRemoveEpisodes": "Sind Sie sicher, dass Sie {0} Episoden löschen wollen?", "MessageConfirmRemoveEpisodes": "Sind Sie sicher, dass Sie {0} Episoden löschen wollen?",

View File

@ -161,6 +161,7 @@
"LabelAccountTypeGuest": "Guest", "LabelAccountTypeGuest": "Guest",
"LabelAccountTypeUser": "User", "LabelAccountTypeUser": "User",
"LabelActivity": "Activity", "LabelActivity": "Activity",
"LabelAdded": "Added",
"LabelAddedAt": "Added At", "LabelAddedAt": "Added At",
"LabelAddToCollection": "Add to Collection", "LabelAddToCollection": "Add to Collection",
"LabelAddToCollectionBatch": "Add {0} Books to Collection", "LabelAddToCollectionBatch": "Add {0} Books to Collection",
@ -250,6 +251,8 @@
"LabelItem": "Item", "LabelItem": "Item",
"LabelLanguage": "Language", "LabelLanguage": "Language",
"LabelLanguageDefaultServer": "Default Server Language", "LabelLanguageDefaultServer": "Default Server Language",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Last Seen", "LabelLastSeen": "Last Seen",
"LabelLastTime": "Last Time", "LabelLastTime": "Last Time",
"LabelLastUpdate": "Last Update", "LabelLastUpdate": "Last Update",
@ -465,6 +468,7 @@
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?", "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?", "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?", "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}\"?", "MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?", "MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?", "MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",

View File

@ -161,6 +161,7 @@
"LabelAccountTypeGuest": "Invitado", "LabelAccountTypeGuest": "Invitado",
"LabelAccountTypeUser": "Usuario", "LabelAccountTypeUser": "Usuario",
"LabelActivity": "Actividad", "LabelActivity": "Actividad",
"LabelAdded": "Added",
"LabelAddedAt": "Añadido", "LabelAddedAt": "Añadido",
"LabelAddToCollection": "Añadido a la Colección", "LabelAddToCollection": "Añadido a la Colección",
"LabelAddToCollectionBatch": "Se Añadieron {0} Libros a la Colección", "LabelAddToCollectionBatch": "Se Añadieron {0} Libros a la Colección",
@ -250,6 +251,8 @@
"LabelItem": "Elemento", "LabelItem": "Elemento",
"LabelLanguage": "Lenguaje", "LabelLanguage": "Lenguaje",
"LabelLanguageDefaultServer": "Lenguaje Predeterminado del Servidor", "LabelLanguageDefaultServer": "Lenguaje Predeterminado del Servidor",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Ultima Vez Visto", "LabelLastSeen": "Ultima Vez Visto",
"LabelLastTime": "Ultima Vez", "LabelLastTime": "Ultima Vez",
"LabelLastUpdate": "Ultima Actualización", "LabelLastUpdate": "Ultima Actualización",
@ -465,6 +468,7 @@
"MessageConfirmForceReScan": "Esta seguro que desea forzar re-escanear?", "MessageConfirmForceReScan": "Esta seguro que desea forzar re-escanear?",
"MessageConfirmMarkSeriesFinished": "Esta seguro que desea marcar todos los libros en esta serie como terminados?", "MessageConfirmMarkSeriesFinished": "Esta seguro que desea marcar todos los libros en esta serie como terminados?",
"MessageConfirmMarkSeriesNotFinished": "Esta seguro que desea marcar todos los libros en esta serie como no terminados?", "MessageConfirmMarkSeriesNotFinished": "Esta seguro que desea marcar todos los libros en esta serie como no terminados?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveCollection": "Esta seguro que desea remover la colección \"{0}\"?", "MessageConfirmRemoveCollection": "Esta seguro que desea remover la colección \"{0}\"?",
"MessageConfirmRemoveEpisode": "Esta seguro que desea remover el episodio \"{0}\"?", "MessageConfirmRemoveEpisode": "Esta seguro que desea remover el episodio \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Esta seguro que desea remover {0} episodios?", "MessageConfirmRemoveEpisodes": "Esta seguro que desea remover {0} episodios?",

View File

@ -20,7 +20,7 @@
"ButtonCreate": "Créer", "ButtonCreate": "Créer",
"ButtonCreateBackup": "Créer une sauvegarde", "ButtonCreateBackup": "Créer une sauvegarde",
"ButtonDelete": "Effacer", "ButtonDelete": "Effacer",
"ButtonDownloadQueue": "Queue", "ButtonDownloadQueue": "Queue de téléchargement",
"ButtonEdit": "Modifier", "ButtonEdit": "Modifier",
"ButtonEditChapters": "Modifier les chapitres", "ButtonEditChapters": "Modifier les chapitres",
"ButtonEditPodcast": "Modifier les podcasts", "ButtonEditPodcast": "Modifier les podcasts",
@ -95,7 +95,7 @@
"HeaderCover": "Couverture", "HeaderCover": "Couverture",
"HeaderCurrentDownloads": "Current Downloads", "HeaderCurrentDownloads": "Current Downloads",
"HeaderDetails": "Détails", "HeaderDetails": "Détails",
"HeaderDownloadQueue": "Download Queue", "HeaderDownloadQueue": "Queue de téléchargement",
"HeaderEpisodes": "Épisodes", "HeaderEpisodes": "Épisodes",
"HeaderFiles": "Fichiers", "HeaderFiles": "Fichiers",
"HeaderFindChapters": "Trouver les chapitres", "HeaderFindChapters": "Trouver les chapitres",
@ -129,7 +129,7 @@
"HeaderPreviewCover": "Prévisualiser la couverture", "HeaderPreviewCover": "Prévisualiser la couverture",
"HeaderRemoveEpisode": "Supprimer lépisode", "HeaderRemoveEpisode": "Supprimer lépisode",
"HeaderRemoveEpisodes": "Suppression de {0} épisodes", "HeaderRemoveEpisodes": "Suppression de {0} épisodes",
"HeaderRSSFeedGeneral": "RSS Details", "HeaderRSSFeedGeneral": "Détails de flux RSS",
"HeaderRSSFeedIsOpen": "Le Flux RSS est actif", "HeaderRSSFeedIsOpen": "Le Flux RSS est actif",
"HeaderSavedMediaProgress": "Progression de la sauvegarde des médias", "HeaderSavedMediaProgress": "Progression de la sauvegarde des médias",
"HeaderSchedule": "Programmation", "HeaderSchedule": "Programmation",
@ -155,12 +155,13 @@
"HeaderUpdateLibrary": "Mettre à jour la bibliothèque", "HeaderUpdateLibrary": "Mettre à jour la bibliothèque",
"HeaderUsers": "Utilisateurs", "HeaderUsers": "Utilisateurs",
"HeaderYourStats": "Vos statistiques", "HeaderYourStats": "Vos statistiques",
"LabelAbridged": "Abridged", "LabelAbridged": "Version courte",
"LabelAccountType": "Type de compte", "LabelAccountType": "Type de compte",
"LabelAccountTypeAdmin": "Admin", "LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Invité", "LabelAccountTypeGuest": "Invité",
"LabelAccountTypeUser": "Utilisateur", "LabelAccountTypeUser": "Utilisateur",
"LabelActivity": "Activité", "LabelActivity": "Activité",
"LabelAdded": "Added",
"LabelAddedAt": "Date dajout", "LabelAddedAt": "Date dajout",
"LabelAddToCollection": "Ajouter à la collection", "LabelAddToCollection": "Ajouter à la collection",
"LabelAddToCollectionBatch": "Ajout de {0} livres à la lollection", "LabelAddToCollectionBatch": "Ajout de {0} livres à la lollection",
@ -168,7 +169,7 @@
"LabelAddToPlaylistBatch": "{0} éléments ajoutés à la liste de lecture", "LabelAddToPlaylistBatch": "{0} éléments ajoutés à la liste de lecture",
"LabelAll": "Tout", "LabelAll": "Tout",
"LabelAllUsers": "Tous les utilisateurs", "LabelAllUsers": "Tous les utilisateurs",
"LabelAlreadyInYourLibrary": "Already in your library", "LabelAlreadyInYourLibrary": "Déjà dans la bibliothèque",
"LabelAppend": "Ajouter", "LabelAppend": "Ajouter",
"LabelAuthor": "Auteur", "LabelAuthor": "Auteur",
"LabelAuthorFirstLast": "Auteur (Prénom Nom)", "LabelAuthorFirstLast": "Auteur (Prénom Nom)",
@ -199,7 +200,7 @@
"LabelCronExpression": "Expression Cron", "LabelCronExpression": "Expression Cron",
"LabelCurrent": "Courrant", "LabelCurrent": "Courrant",
"LabelCurrently": "En ce moment :", "LabelCurrently": "En ce moment :",
"LabelCustomCronExpression": "Custom Cron Expression:", "LabelCustomCronExpression": "Expression cron personnalisée:",
"LabelDatetime": "Datetime", "LabelDatetime": "Datetime",
"LabelDescription": "Description", "LabelDescription": "Description",
"LabelDeselectAll": "Tout déselectionner", "LabelDeselectAll": "Tout déselectionner",
@ -217,7 +218,7 @@
"LabelEpisode": "Épisode", "LabelEpisode": "Épisode",
"LabelEpisodeTitle": "Titre de lépisode", "LabelEpisodeTitle": "Titre de lépisode",
"LabelEpisodeType": "Type de lépisode", "LabelEpisodeType": "Type de lépisode",
"LabelExample": "Example", "LabelExample": "Exemple",
"LabelExplicit": "Restriction", "LabelExplicit": "Restriction",
"LabelFeedURL": "URL deu flux", "LabelFeedURL": "URL deu flux",
"LabelFile": "Fichier", "LabelFile": "Fichier",
@ -250,6 +251,8 @@
"LabelItem": "Article", "LabelItem": "Article",
"LabelLanguage": "Langue", "LabelLanguage": "Langue",
"LabelLanguageDefaultServer": "Langue par défaut", "LabelLanguageDefaultServer": "Langue par défaut",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Vu dernièrement", "LabelLastSeen": "Vu dernièrement",
"LabelLastTime": "Progression", "LabelLastTime": "Progression",
"LabelLastUpdate": "Dernière mise à jour", "LabelLastUpdate": "Dernière mise à jour",
@ -279,8 +282,8 @@
"LabelNewestAuthors": "Nouveaux auteurs", "LabelNewestAuthors": "Nouveaux auteurs",
"LabelNewestEpisodes": "Derniers épisodes", "LabelNewestEpisodes": "Derniers épisodes",
"LabelNewPassword": "Nouveau mot de passe", "LabelNewPassword": "Nouveau mot de passe",
"LabelNextBackupDate": "Next backup date", "LabelNextBackupDate": "Prochaine date de sauvegarde",
"LabelNextScheduledRun": "Next scheduled run", "LabelNextScheduledRun": "Prochain lancement prévu",
"LabelNotes": "Notes", "LabelNotes": "Notes",
"LabelNotFinished": "Non terminé(e)", "LabelNotFinished": "Non terminé(e)",
"LabelNotificationAppriseURL": "URL(s) dapprise", "LabelNotificationAppriseURL": "URL(s) dapprise",
@ -311,7 +314,7 @@
"LabelPlayMethod": "Méthode découte", "LabelPlayMethod": "Méthode découte",
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
"LabelPodcasts": "Podcasts", "LabelPodcasts": "Podcasts",
"LabelPodcastType": "Podcast Type", "LabelPodcastType": "Type de Podcast",
"LabelPrefixesToIgnore": "Préfixes à Ignorer (Insensible à la Casse)", "LabelPrefixesToIgnore": "Préfixes à Ignorer (Insensible à la Casse)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories", "LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelProgress": "Progression", "LabelProgress": "Progression",
@ -325,10 +328,10 @@
"LabelRegion": "Région", "LabelRegion": "Région",
"LabelReleaseDate": "Date de parution", "LabelReleaseDate": "Date de parution",
"LabelRemoveCover": "Supprimer la couverture", "LabelRemoveCover": "Supprimer la couverture",
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email", "LabelRSSFeedCustomOwnerEmail": "Email propriétaire personnalisé",
"LabelRSSFeedCustomOwnerName": "Custom owner Name", "LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé",
"LabelRSSFeedOpen": "Flux RSS ouvert", "LabelRSSFeedOpen": "Flux RSS ouvert",
"LabelRSSFeedPreventIndexing": "Prevent Indexing", "LabelRSSFeedPreventIndexing": "Empêcher l'indexation",
"LabelRSSFeedSlug": "Identificateur dadresse du Flux RSS ", "LabelRSSFeedSlug": "Identificateur dadresse du Flux RSS ",
"LabelRSSFeedURL": "Adresse du flux RSS", "LabelRSSFeedURL": "Adresse du flux RSS",
"LabelSearchTerm": "Terme de recherche", "LabelSearchTerm": "Terme de recherche",
@ -421,7 +424,7 @@
"LabelTracksMultiTrack": "Piste multiple", "LabelTracksMultiTrack": "Piste multiple",
"LabelTracksSingleTrack": "Piste simple", "LabelTracksSingleTrack": "Piste simple",
"LabelType": "Type", "LabelType": "Type",
"LabelUnabridged": "Unabridged", "LabelUnabridged": "Version intégrale",
"LabelUnknown": "Inconnu", "LabelUnknown": "Inconnu",
"LabelUpdateCover": "Mettre à jour la couverture", "LabelUpdateCover": "Mettre à jour la couverture",
"LabelUpdateCoverHelp": "Autoriser la mise à jour de la couverture existante lorsquune correspondance est trouvée", "LabelUpdateCoverHelp": "Autoriser la mise à jour de la couverture existante lorsquune correspondance est trouvée",
@ -465,6 +468,7 @@
"MessageConfirmForceReScan": "Êtes-vous sûr de vouloir lancer une Analyse Forcée ?", "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 ?", "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 ?", "MessageConfirmMarkSeriesNotFinished": "Êtes-vous sûr de vouloir marquer comme non terminé tous les livres de cette série ?",
"MessageConfirmRemoveAllChapters": "Êtes-vous sûr de vouloir supprimer tous les chapitres ?",
"MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection « {0} » ?", "MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection « {0} » ?",
"MessageConfirmRemoveEpisode": "Êtes-vous sûr de vouloir supprimer lépisode « {0} » ?", "MessageConfirmRemoveEpisode": "Êtes-vous sûr de vouloir supprimer lépisode « {0} » ?",
"MessageConfirmRemoveEpisodes": "Êtes-vous sûr de vouloir supprimer {0} épisodes ?", "MessageConfirmRemoveEpisodes": "Êtes-vous sûr de vouloir supprimer {0} épisodes ?",

645
client/strings/gu.json Normal file
View File

@ -0,0 +1,645 @@
{
"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.",
"LabelBooks": "Books",
"LabelChangePassword": "Change Password",
"LabelChaptersFound": "chapters found",
"LabelChapterTitle": "Chapter Title",
"LabelClosePlayer": "Close player",
"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",
"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",
"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",
"LabelMinute": "Minute",
"LabelMissing": "Missing",
"LabelMissingParts": "Missing Parts",
"LabelMore": "More",
"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",
"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"
}

645
client/strings/hi.json Normal file
View File

@ -0,0 +1,645 @@
{
"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.",
"LabelBooks": "Books",
"LabelChangePassword": "Change Password",
"LabelChaptersFound": "chapters found",
"LabelChapterTitle": "Chapter Title",
"LabelClosePlayer": "Close player",
"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",
"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",
"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",
"LabelMinute": "Minute",
"LabelMissing": "Missing",
"LabelMissingParts": "Missing Parts",
"LabelMore": "More",
"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",
"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"
}

View File

@ -161,6 +161,7 @@
"LabelAccountTypeGuest": "Gost", "LabelAccountTypeGuest": "Gost",
"LabelAccountTypeUser": "Korisnik", "LabelAccountTypeUser": "Korisnik",
"LabelActivity": "Aktivnost", "LabelActivity": "Aktivnost",
"LabelAdded": "Added",
"LabelAddedAt": "Added At", "LabelAddedAt": "Added At",
"LabelAddToCollection": "Dodaj u kolekciju", "LabelAddToCollection": "Dodaj u kolekciju",
"LabelAddToCollectionBatch": "Add {0} Books to Collection", "LabelAddToCollectionBatch": "Add {0} Books to Collection",
@ -250,6 +251,8 @@
"LabelItem": "Stavka", "LabelItem": "Stavka",
"LabelLanguage": "Jezik", "LabelLanguage": "Jezik",
"LabelLanguageDefaultServer": "Default jezik servera", "LabelLanguageDefaultServer": "Default jezik servera",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Zadnje pogledano", "LabelLastSeen": "Zadnje pogledano",
"LabelLastTime": "Prošli put", "LabelLastTime": "Prošli put",
"LabelLastUpdate": "Zadnja aktualizacija", "LabelLastUpdate": "Zadnja aktualizacija",
@ -465,6 +468,7 @@
"MessageConfirmForceReScan": "Jeste li sigurni da želite ponovno skenirati?", "MessageConfirmForceReScan": "Jeste li sigurni da želite ponovno skenirati?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?", "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?", "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}\"?", "MessageConfirmRemoveCollection": "AJeste li sigurni da želite obrisati kolekciju \"{0}\"?",
"MessageConfirmRemoveEpisode": "Jeste li sigurni da želite obrisati epizodu \"{0}\"?", "MessageConfirmRemoveEpisode": "Jeste li sigurni da želite obrisati epizodu \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Jeste li sigurni da želite obrisati {0} epizoda/-u?", "MessageConfirmRemoveEpisodes": "Jeste li sigurni da želite obrisati {0} epizoda/-u?",

View File

@ -161,6 +161,7 @@
"LabelAccountTypeGuest": "Ospite", "LabelAccountTypeGuest": "Ospite",
"LabelAccountTypeUser": "Utente", "LabelAccountTypeUser": "Utente",
"LabelActivity": "Attività", "LabelActivity": "Attività",
"LabelAdded": "Added",
"LabelAddedAt": "Aggiunto il", "LabelAddedAt": "Aggiunto il",
"LabelAddToCollection": "Aggiungi alla Raccolta", "LabelAddToCollection": "Aggiungi alla Raccolta",
"LabelAddToCollectionBatch": "Aggiungi {0} Libri alla Raccolta", "LabelAddToCollectionBatch": "Aggiungi {0} Libri alla Raccolta",
@ -250,6 +251,8 @@
"LabelItem": "Oggetti", "LabelItem": "Oggetti",
"LabelLanguage": "Lingua", "LabelLanguage": "Lingua",
"LabelLanguageDefaultServer": "Lingua di Default", "LabelLanguageDefaultServer": "Lingua di Default",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Ultimi Visti", "LabelLastSeen": "Ultimi Visti",
"LabelLastTime": "Ultima Volta", "LabelLastTime": "Ultima Volta",
"LabelLastUpdate": "Ultimo Aggiornamento", "LabelLastUpdate": "Ultimo Aggiornamento",
@ -465,6 +468,7 @@
"MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?", "MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?",
"MessageConfirmMarkSeriesFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come completati?", "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?", "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}\"?", "MessageConfirmRemoveCollection": "Sei sicuro di voler rimuovere la Raccolta \"{0}\"?",
"MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?", "MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?", "MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?",

View File

@ -161,6 +161,7 @@
"LabelAccountTypeGuest": "Gość", "LabelAccountTypeGuest": "Gość",
"LabelAccountTypeUser": "Użytkownik", "LabelAccountTypeUser": "Użytkownik",
"LabelActivity": "Aktywność", "LabelActivity": "Aktywność",
"LabelAdded": "Added",
"LabelAddedAt": "Dodano", "LabelAddedAt": "Dodano",
"LabelAddToCollection": "Dodaj do kolekcji", "LabelAddToCollection": "Dodaj do kolekcji",
"LabelAddToCollectionBatch": "Dodaj {0} książki do kolekcji", "LabelAddToCollectionBatch": "Dodaj {0} książki do kolekcji",
@ -250,6 +251,8 @@
"LabelItem": "Pozycja", "LabelItem": "Pozycja",
"LabelLanguage": "Język", "LabelLanguage": "Język",
"LabelLanguageDefaultServer": "Domyślny język serwera", "LabelLanguageDefaultServer": "Domyślny język serwera",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Ostatnio widziany", "LabelLastSeen": "Ostatnio widziany",
"LabelLastTime": "Ostatni czas", "LabelLastTime": "Ostatni czas",
"LabelLastUpdate": "Ostatnia aktualizacja", "LabelLastUpdate": "Ostatnia aktualizacja",
@ -465,6 +468,7 @@
"MessageConfirmForceReScan": "Czy na pewno chcesz wymusić ponowne skanowanie?", "MessageConfirmForceReScan": "Czy na pewno chcesz wymusić ponowne skanowanie?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?", "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?", "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}\"?", "MessageConfirmRemoveCollection": "Czy na pewno chcesz usunąć kolekcję \"{0}\"?",
"MessageConfirmRemoveEpisode": "Czy na pewno chcesz usunąć odcinek \"{0}\"?", "MessageConfirmRemoveEpisode": "Czy na pewno chcesz usunąć odcinek \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Czy na pewno chcesz usunąć {0} odcinki?", "MessageConfirmRemoveEpisodes": "Czy na pewno chcesz usunąć {0} odcinki?",

View File

@ -155,12 +155,13 @@
"HeaderUpdateLibrary": "Обновить библиотеку", "HeaderUpdateLibrary": "Обновить библиотеку",
"HeaderUsers": "Пользователи", "HeaderUsers": "Пользователи",
"HeaderYourStats": "Ваша статистика", "HeaderYourStats": "Ваша статистика",
"LabelAbridged": "Abridged", "LabelAbridged": "Сокращенное издание",
"LabelAccountType": "Тип учетной записи", "LabelAccountType": "Тип учетной записи",
"LabelAccountTypeAdmin": "Администратор", "LabelAccountTypeAdmin": "Администратор",
"LabelAccountTypeGuest": "Гость", "LabelAccountTypeGuest": "Гость",
"LabelAccountTypeUser": "Пользователь", "LabelAccountTypeUser": "Пользователь",
"LabelActivity": "Активность", "LabelActivity": "Активность",
"LabelAdded": "Added",
"LabelAddedAt": "Дата добавления", "LabelAddedAt": "Дата добавления",
"LabelAddToCollection": "Добавить в коллекцию", "LabelAddToCollection": "Добавить в коллекцию",
"LabelAddToCollectionBatch": "Добавить {0} книг в коллекцию", "LabelAddToCollectionBatch": "Добавить {0} книг в коллекцию",
@ -250,6 +251,8 @@
"LabelItem": "Элемент", "LabelItem": "Элемент",
"LabelLanguage": "Язык", "LabelLanguage": "Язык",
"LabelLanguageDefaultServer": "Язык сервера по умолчанию", "LabelLanguageDefaultServer": "Язык сервера по умолчанию",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "Последнее сканирование", "LabelLastSeen": "Последнее сканирование",
"LabelLastTime": "Последний по времени", "LabelLastTime": "Последний по времени",
"LabelLastUpdate": "Последний обновленный", "LabelLastUpdate": "Последний обновленный",
@ -394,7 +397,7 @@
"LabelStatsMinutes": "минут", "LabelStatsMinutes": "минут",
"LabelStatsMinutesListening": "Минут прослушано", "LabelStatsMinutesListening": "Минут прослушано",
"LabelStatsOverallDays": "Всего дней", "LabelStatsOverallDays": "Всего дней",
"LabelStatsOverallHours": "Всего сасов", "LabelStatsOverallHours": "Всего часов",
"LabelStatsWeekListening": "Недель прослушано", "LabelStatsWeekListening": "Недель прослушано",
"LabelSubtitle": "Подзаголовок", "LabelSubtitle": "Подзаголовок",
"LabelSupportedFileTypes": "Поддерживаемые типы файлов", "LabelSupportedFileTypes": "Поддерживаемые типы файлов",
@ -421,7 +424,7 @@
"LabelTracksMultiTrack": "Мультитрек", "LabelTracksMultiTrack": "Мультитрек",
"LabelTracksSingleTrack": "Один трек", "LabelTracksSingleTrack": "Один трек",
"LabelType": "Тип", "LabelType": "Тип",
"LabelUnabridged": "Unabridged", "LabelUnabridged": "Полное издание",
"LabelUnknown": "Неизвестно", "LabelUnknown": "Неизвестно",
"LabelUpdateCover": "Обновить обложку", "LabelUpdateCover": "Обновить обложку",
"LabelUpdateCoverHelp": "Позволяет перезаписывать существующие обложки для выбранных книг если будут найдены", "LabelUpdateCoverHelp": "Позволяет перезаписывать существующие обложки для выбранных книг если будут найдены",
@ -465,6 +468,7 @@
"MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?", "MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?",
"MessageConfirmMarkSeriesFinished": "Вы уверены, что хотите отметить все книги этой серии как законченные?", "MessageConfirmMarkSeriesFinished": "Вы уверены, что хотите отметить все книги этой серии как законченные?",
"MessageConfirmMarkSeriesNotFinished": "Вы уверены, что хотите отметить все книги этой серии как незаконченные?", "MessageConfirmMarkSeriesNotFinished": "Вы уверены, что хотите отметить все книги этой серии как незаконченные?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveCollection": "Вы уверены, что хотите удалить коллекцию \"{0}\"?", "MessageConfirmRemoveCollection": "Вы уверены, что хотите удалить коллекцию \"{0}\"?",
"MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?", "MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Вы уверены, что хотите удалить {0} эпизодов?", "MessageConfirmRemoveEpisodes": "Вы уверены, что хотите удалить {0} эпизодов?",
@ -568,7 +572,7 @@
"PlaceholderNewFolderPath": "Путь к новой папке", "PlaceholderNewFolderPath": "Путь к новой папке",
"PlaceholderNewPlaylist": "Новое название плейлиста", "PlaceholderNewPlaylist": "Новое название плейлиста",
"PlaceholderSearch": "Поиск...", "PlaceholderSearch": "Поиск...",
"PlaceholderSearchEpisode": "Search episode...", "PlaceholderSearchEpisode": "Поиск эпизода...",
"ToastAccountUpdateFailed": "Не удалось обновить учетную запись", "ToastAccountUpdateFailed": "Не удалось обновить учетную запись",
"ToastAccountUpdateSuccess": "Учетная запись обновлена", "ToastAccountUpdateSuccess": "Учетная запись обновлена",
"ToastAuthorImageRemoveFailed": "Не удалось удалить изображение", "ToastAuthorImageRemoveFailed": "Не удалось удалить изображение",

View File

@ -161,6 +161,7 @@
"LabelAccountTypeGuest": "来宾", "LabelAccountTypeGuest": "来宾",
"LabelAccountTypeUser": "用户", "LabelAccountTypeUser": "用户",
"LabelActivity": "活动", "LabelActivity": "活动",
"LabelAdded": "Added",
"LabelAddedAt": "添加于", "LabelAddedAt": "添加于",
"LabelAddToCollection": "添加到收藏", "LabelAddToCollection": "添加到收藏",
"LabelAddToCollectionBatch": "批量添加 {0} 个媒体到收藏", "LabelAddToCollectionBatch": "批量添加 {0} 个媒体到收藏",
@ -250,6 +251,8 @@
"LabelItem": "项目", "LabelItem": "项目",
"LabelLanguage": "语言", "LabelLanguage": "语言",
"LabelLanguageDefaultServer": "默认服务器语言", "LabelLanguageDefaultServer": "默认服务器语言",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLastSeen": "上次查看时间", "LabelLastSeen": "上次查看时间",
"LabelLastTime": "最近一次", "LabelLastTime": "最近一次",
"LabelLastUpdate": "最近更新", "LabelLastUpdate": "最近更新",
@ -465,6 +468,7 @@
"MessageConfirmForceReScan": "你确定要强制重新扫描吗?", "MessageConfirmForceReScan": "你确定要强制重新扫描吗?",
"MessageConfirmMarkSeriesFinished": "你确定要将此系列中的所有书籍都标记为已听完吗?", "MessageConfirmMarkSeriesFinished": "你确定要将此系列中的所有书籍都标记为已听完吗?",
"MessageConfirmMarkSeriesNotFinished": "你确定要将此系列中的所有书籍都标记为未听完吗?", "MessageConfirmMarkSeriesNotFinished": "你确定要将此系列中的所有书籍都标记为未听完吗?",
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
"MessageConfirmRemoveCollection": "您确定要移除收藏 \"{0}\"?", "MessageConfirmRemoveCollection": "您确定要移除收藏 \"{0}\"?",
"MessageConfirmRemoveEpisode": "您确定要移除剧集 \"{0}\"?", "MessageConfirmRemoveEpisode": "您确定要移除剧集 \"{0}\"?",
"MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?", "MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?",

4
package-lock.json generated
View File

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

View File

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

View File

@ -185,8 +185,99 @@ subdomain.domain.com {
# Run from source # 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) [See the incomplete "How to Support" page](https://www.audiobookshelf.org/support)

View File

@ -35,7 +35,6 @@ const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
const RssFeedManager = require('./managers/RssFeedManager') const RssFeedManager = require('./managers/RssFeedManager')
const CronManager = require('./managers/CronManager') const CronManager = require('./managers/CronManager')
const TaskManager = require('./managers/TaskManager') const TaskManager = require('./managers/TaskManager')
const EBookManager = require('./managers/EBookManager')
//Import the main Passport and Express-Session library //Import the main Passport and Express-Session library
const passport = require('passport') const passport = require('passport')
@ -80,7 +79,6 @@ class Server {
this.podcastManager = new PodcastManager(this.db, this.watcher, this.notificationManager, this.taskManager) this.podcastManager = new PodcastManager(this.db, this.watcher, this.notificationManager, this.taskManager)
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager) this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager)
this.rssFeedManager = new RssFeedManager(this.db) this.rssFeedManager = new RssFeedManager(this.db)
this.eBookManager = new EBookManager(this.db)
this.scanner = new Scanner(this.db, this.coverManager) this.scanner = new Scanner(this.db, this.coverManager)
this.cronManager = new CronManager(this.db, this.scanner, this.podcastManager) this.cronManager = new CronManager(this.db, this.scanner, this.podcastManager)
@ -124,7 +122,6 @@ class Server {
await this.purgeMetadata() // Remove metadata folders without library item await this.purgeMetadata() // Remove metadata folders without library item
await this.playbackSessionManager.removeInvalidSessions() await this.playbackSessionManager.removeInvalidSessions()
await this.cacheManager.ensureCachePaths() await this.cacheManager.ensureCachePaths()
await this.abMergeManager.ensureDownloadDirPath()
await this.backupManager.init() await this.backupManager.init()
await this.logManager.init() await this.logManager.init()

View File

@ -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()

View File

@ -417,6 +417,10 @@ class LibraryController {
return se.totalDuration return se.totalDuration
} else if (payload.sortBy === 'addedAt') { } else if (payload.sortBy === 'addedAt') {
return se.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 } else { // sort by name
return this.db.serverSettings.sortingIgnorePrefix ? se.nameIgnorePrefixSort : se.name return this.db.serverSettings.sortingIgnorePrefix ? se.nameIgnorePrefixSort : se.name
} }

View File

@ -2,6 +2,7 @@ const fs = require('../libs/fsExtra')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const zipHelpers = require('../utils/zipHelpers')
const { reqSupportsWebp, isNullOrNaN } = require('../utils/index') const { reqSupportsWebp, isNullOrNaN } = require('../utils/index')
const { ScanResult } = require('../utils/constants') const { ScanResult } = require('../utils/constants')
@ -69,6 +70,17 @@ class LibraryItemController {
res.sendStatus(200) 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 // PATCH: will create new authors & series if in payload
// //
@ -162,12 +174,12 @@ class LibraryItemController {
// PATCH: api/items/:id/cover // PATCH: api/items/:id/cover
async updateCover(req, res) { async updateCover(req, res) {
var libraryItem = req.libraryItem const libraryItem = req.libraryItem
if (!req.body.cover) { 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) { if (validationResult.error) {
return res.status(500).send(validationResult.error) return res.status(500).send(validationResult.error)
} }
@ -436,12 +448,12 @@ class LibraryItemController {
return res.sendStatus(500) return res.sendStatus(500)
} }
const chapters = req.body.chapters || [] if (!req.body.chapters) {
if (!chapters.length) {
Logger.error(`[LibraryItemController] Invalid payload`) Logger.error(`[LibraryItemController] Invalid payload`)
return res.sendStatus(400) return res.sendStatus(400)
} }
const chapters = req.body.chapters || []
const wasUpdated = req.libraryItem.media.updateChapters(chapters) const wasUpdated = req.libraryItem.media.updateChapters(chapters)
if (wasUpdated) { if (wasUpdated) {
await this.db.updateLibraryItem(req.libraryItem) await this.db.updateLibraryItem(req.libraryItem)
@ -470,6 +482,28 @@ class LibraryItemController {
res.json(toneData) 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)
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) { middleware(req, res, next) {
const item = this.db.libraryItems.find(li => li.id === req.params.id) const item = this.db.libraryItems.find(li => li.id === req.params.id)
if (!item || !item.media) return res.sendStatus(404) if (!item || !item.media) return res.sendStatus(404)

View File

@ -171,23 +171,6 @@ class MeController {
this.auth.userChangePassword(req, res) 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. // TODO: Deprecated. Removed from Android. Only used in iOS app now.
// POST: api/me/sync-local-progress // POST: api/me/sync-local-progress
async syncLocalMediaProgress(req, res) { async syncLocalMediaProgress(req, res) {
@ -256,13 +239,13 @@ class MeController {
} }
// GET: api/me/items-in-progress // 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 const limit = !isNaN(req.query.limit) ? Number(req.query.limit) || 25 : 25
var itemsInProgress = [] let itemsInProgress = []
for (const mediaProgress of req.user.mediaProgress) { for (const mediaProgress of req.user.mediaProgress) {
if (!mediaProgress.isFinished && mediaProgress.progress > 0) { if (!mediaProgress.isFinished && (mediaProgress.progress > 0 || mediaProgress.ebookProgress > 0)) {
const libraryItem = await this.db.getLibraryItem(mediaProgress.libraryItemId) const libraryItem = this.db.getLibraryItem(mediaProgress.libraryItemId)
if (libraryItem) { if (libraryItem) {
if (mediaProgress.episodeId && libraryItem.mediaType === 'podcast') { if (mediaProgress.episodeId && libraryItem.mediaType === 'podcast') {
const episode = libraryItem.media.episodes.find(ep => ep.id === mediaProgress.episodeId) const episode = libraryItem.media.episodes.find(ep => ep.id === mediaProgress.episodeId)

View File

@ -90,9 +90,19 @@ class MiscController {
// GET: api/tasks // GET: api/tasks
getTasks(req, res) { getTasks(req, res) {
res.json({ const includeArray = (req.query.include || '').split(',')
const data = {
tasks: this.taskManager.tasks.map(t => t.toJSON()) 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) // PATCH: api/settings (admin)

View File

@ -14,7 +14,7 @@ class SessionController {
return res.sendStatus(404) return res.sendStatus(404)
} }
var listeningSessions = [] let listeningSessions = []
if (req.query.user) { if (req.query.user) {
listeningSessions = await this.getUserListeningSessionsHelper(req.query.user) listeningSessions = await this.getUserListeningSessionsHelper(req.query.user)
} else { } else {
@ -42,6 +42,25 @@ class SessionController {
res.json(payload) 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) { getOpenSession(req, res) {
var libraryItem = this.db.getLibraryItem(req.session.libraryItemId) var libraryItem = this.db.getLibraryItem(req.session.libraryItemId)
var sessionForClient = req.session.toJSONForClient(libraryItem) var sessionForClient = req.session.toJSONForClient(libraryItem)

View File

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

View File

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

View File

@ -15,8 +15,6 @@ class AbMergeManager {
this.taskManager = taskManager this.taskManager = taskManager
this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items') this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items')
this.downloadDirPath = Path.join(global.MetadataPath, 'downloads')
this.downloadDirPathExist = false
this.pendingTasks = [] this.pendingTasks = []
} }
@ -29,22 +27,6 @@ class AbMergeManager {
return this.removeTask(task, true) 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 = {}) { async startAudiobookMerge(user, libraryItem, options = {}) {
const task = new Task() const task = new Task()

View File

@ -5,18 +5,42 @@ const Logger = require('../Logger')
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const { secondsToTimestamp } = require('../utils/index')
const toneHelpers = require('../utils/toneHelpers') const toneHelpers = require('../utils/toneHelpers')
const filePerms = require('../utils/filePerms')
const Task = require('../objects/Task')
class AudioMetadataMangaer { class AudioMetadataMangaer {
constructor(db, taskManager) { constructor(db, taskManager) {
this.db = db this.db = db
this.taskManager = taskManager 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) { 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 = {}) { async updateMetadataForItem(user, libraryItem, options = {}) {
@ -25,99 +49,144 @@ class AudioMetadataMangaer {
const audioFiles = libraryItem.media.includedAudioFiles const audioFiles = libraryItem.media.includedAudioFiles
const itemAudioMetadataPayload = { const task = new Task()
userId: user.id,
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, libraryItemId: libraryItem.id,
startedAt: Date.now(), libraryItemPath: libraryItem.path,
audioFiles: audioFiles.map(af => ({ index: af.index, ino: af.ino, filename: af.metadata.filename })) 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)
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)
}
} }
SocketAuthority.emitter('audio_metadata_started', itemAudioMetadataPayload) async runMetadataEmbed(task) {
this.tasksRunning.push(task)
this.taskManager.addTask(task)
// Ensure folder for backup files Logger.info(`[AudioMetadataManager] Starting metadata embed task`, task.description)
const itemCacheDir = Path.join(global.MetadataPath, `cache/items/${libraryItem.id}`)
// Ensure item cache dir exists
let cacheDirCreated = false let cacheDirCreated = false
if (!await fs.pathExists(itemCacheDir)) { if (!await fs.pathExists(task.data.itemCachePath)) {
await fs.mkdir(itemCacheDir) await fs.mkdir(task.data.itemCachePath)
await filePerms.setDefault(itemCacheDir, true)
cacheDirCreated = true cacheDirCreated = true
} }
// Write chapters file // Create metadata json file
const toneJsonPath = Path.join(itemCacheDir, 'metadata.json') const toneJsonPath = Path.join(task.data.itemCachePath, 'metadata.json')
try { try {
const chapters = (audioFiles.length == 1 || forceEmbedChapters) ? libraryItem.media.chapters : null await fs.writeFile(toneJsonPath, JSON.stringify({ meta: task.data.metadataObject }, null, 2))
await toneHelpers.writeToneMetadataJsonFile(libraryItem, chapters, toneJsonPath, audioFiles.length)
} catch (error) { } catch (error) {
Logger.error(`[AudioMetadataManager] Write metadata.json failed`, error) Logger.error(`[AudioMetadataManager] Write metadata.json failed`, error)
task.setFailed('Failed to write metadata.json')
itemAudioMetadataPayload.failed = true this.handleTaskFinished(task)
itemAudioMetadataPayload.error = 'Failed to write metadata.json'
SocketAuthority.emitter('audio_metadata_finished', itemAudioMetadataPayload)
return return
} }
const results = [] // Tag audio files
for (const af of audioFiles) { for (const af of task.data.audioFiles) {
const result = await this.updateAudioFileMetadataWithTone(libraryItem, af, toneJsonPath, itemCacheDir, backupFiles) SocketAuthority.adminEmitter('audiofile_metadata_started', {
results.push(result) libraryItemId: task.data.libraryItemId,
} ino: af.ino
})
// Remove temp cache file/folder if not backing up
if (!backupFiles) {
// If cache dir was created from this then remove it
if (cacheDirCreated) {
await fs.remove(itemCacheDir)
} 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)
}
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)
// Backup audio file // Backup audio file
if (backupFiles) { if (task.data.options.backupFiles) {
try { try {
const backupFilePath = Path.join(itemCacheDir, audioFile.metadata.filename) const backupFilePath = Path.join(task.data.itemCachePath, af.filename)
await fs.copy(audioFile.metadata.path, backupFilePath) await fs.copy(af.path, backupFilePath)
Logger.debug(`[AudioMetadataManager] Backed up audio file at "${backupFilePath}"`) Logger.debug(`[AudioMetadataManager] Backed up audio file at "${backupFilePath}"`)
} catch (err) { } catch (err) {
Logger.error(`[AudioMetadataManager] Failed to backup audio file "${audioFile.metadata.path}"`, err) Logger.error(`[AudioMetadataManager] Failed to backup audio file "${af.path}"`, err)
} }
} }
const _toneMetadataObject = { const _toneMetadataObject = {
'ToneJsonFile': toneJsonPath, 'ToneJsonFile': toneJsonPath,
'TrackNumber': audioFile.index, 'TrackNumber': af.index,
} }
if (libraryItem.media.coverPath) { if (task.data.coverPath) {
_toneMetadataObject['CoverFile'] = libraryItem.media.coverPath _toneMetadataObject['CoverFile'] = task.data.coverPath
} }
resultPayload.success = await toneHelpers.tagAudioFile(audioFile.metadata.path, _toneMetadataObject) const success = await toneHelpers.tagAudioFile(af.path, _toneMetadataObject)
if (resultPayload.success) { if (success) {
Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${audioFile.metadata.path}"`) Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${af.path}"`)
} }
SocketAuthority.emitter('audiofile_metadata_finished', resultPayload) SocketAuthority.adminEmitter('audiofile_metadata_finished', {
return resultPayload libraryItemId: task.data.libraryItemId,
ino: af.ino
})
}
// Remove temp cache file/folder if not backing up
if (!task.data.options.backupFiles) {
// If cache dir was created from this then remove it
if (cacheDirCreated) {
await fs.remove(task.data.itemCachePath)
} else {
await fs.remove(toneJsonPath)
}
}
task.setFinished()
this.handleTaskFinished(task)
}
handleTaskFinished(task) {
this.taskManager.taskFinished(task)
this.tasksRunning = this.tasksRunning.filter(t => t.id !== task.id)
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`)
}
} }
} }
module.exports = AudioMetadataMangaer module.exports = AudioMetadataMangaer

View File

@ -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

View File

@ -14,7 +14,6 @@ const PlaybackSession = require('../objects/PlaybackSession')
const DeviceInfo = require('../objects/DeviceInfo') const DeviceInfo = require('../objects/DeviceInfo')
const Stream = require('../objects/Stream') const Stream = require('../objects/Stream')
class PlaybackSessionManager { class PlaybackSessionManager {
constructor(db) { constructor(db) {
this.db = db this.db = db
@ -31,13 +30,14 @@ class PlaybackSessionManager {
} }
getStream(sessionId) { getStream(sessionId) {
const session = this.getSession(sessionId) const session = this.getSession(sessionId)
return session ? session.stream : null return session?.stream || null
} }
getDeviceInfo(req) { getDeviceInfo(req) {
const ua = uaParserJs(req.headers['user-agent']) const ua = uaParserJs(req.headers['user-agent'])
const ip = requestIp.getClientIp(req) 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() const deviceInfo = new DeviceInfo()
deviceInfo.setData(ip, ua, clientDeviceInfo, serverVersion) deviceInfo.setData(ip, ua, clientDeviceInfo, serverVersion)
@ -138,18 +138,6 @@ class PlaybackSessionManager {
} }
async syncLocalSessionRequest(user, sessionJson, res) { 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) const result = await this.syncLocalSession(user, sessionJson)
if (result.error) { if (result.error) {
res.status(500).send(result.error) res.status(500).send(result.error)
@ -164,8 +152,8 @@ class PlaybackSessionManager {
} }
async startSession(user, deviceInfo, libraryItem, episodeId, options) { async startSession(user, deviceInfo, libraryItem, episodeId, options) {
// Close any sessions already open for user // Close any sessions already open for user and device
const userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id) const userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id && playbackSession.deviceId === deviceInfo.deviceId)
for (const session of userSessions) { for (const session of userSessions) {
Logger.info(`[PlaybackSessionManager] startSession: Closing open session "${session.displayTitle}" for user "${user.username}" (Device: ${session.deviceDescription})`) Logger.info(`[PlaybackSessionManager] startSession: Closing open session "${session.displayTitle}" for user "${user.username}" (Device: ${session.deviceDescription})`)
await this.closeSession(user, session, null) await this.closeSession(user, session, null)
@ -268,6 +256,7 @@ class PlaybackSessionManager {
} }
Logger.debug(`[PlaybackSessionManager] closeSession "${session.id}"`) Logger.debug(`[PlaybackSessionManager] closeSession "${session.id}"`)
SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions, this.db.libraryItems)) 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) return this.removeSession(session.id)
} }

View File

@ -4,11 +4,12 @@ const SocketAuthority = require('../SocketAuthority')
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const { getPodcastFeed } = require('../utils/podcastUtils') const { getPodcastFeed } = require('../utils/podcastUtils')
const { downloadFile, removeFile } = require('../utils/fileUtils') const { removeFile, downloadFile } = require('../utils/fileUtils')
const filePerms = require('../utils/filePerms') const filePerms = require('../utils/filePerms')
const { levenshteinDistance } = require('../utils/index') const { levenshteinDistance } = require('../utils/index')
const opmlParser = require('../utils/parsers/parseOPML') const opmlParser = require('../utils/parsers/parseOPML')
const prober = require('../utils/prober') const prober = require('../utils/prober')
const ffmpegHelpers = require('../utils/ffmpegHelpers')
const LibraryFile = require('../objects/files/LibraryFile') const LibraryFile = require('../objects/files/LibraryFile')
const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload') const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload')
@ -52,15 +53,15 @@ class PodcastManager {
} }
async downloadPodcastEpisodes(libraryItem, episodesToDownload, isAutoDownload) { async downloadPodcastEpisodes(libraryItem, episodesToDownload, isAutoDownload) {
var index = libraryItem.media.episodes.length + 1 let index = libraryItem.media.episodes.length + 1
episodesToDownload.forEach((ep) => { for (const ep of episodesToDownload) {
var newPe = new PodcastEpisode() const newPe = new PodcastEpisode()
newPe.setData(ep, index++) newPe.setData(ep, index++)
newPe.libraryItemId = libraryItem.id newPe.libraryItemId = libraryItem.id
var newPeDl = new PodcastEpisodeDownload() const newPeDl = new PodcastEpisodeDownload()
newPeDl.setData(newPe, libraryItem, isAutoDownload, libraryItem.libraryId) newPeDl.setData(newPe, libraryItem, isAutoDownload, libraryItem.libraryId)
this.startPodcastEpisodeDownload(newPeDl) this.startPodcastEpisodeDownload(newPeDl)
}) }
} }
async startPodcastEpisodeDownload(podcastEpisodeDownload) { async startPodcastEpisodeDownload(podcastEpisodeDownload) {
@ -93,10 +94,21 @@ class PodcastManager {
await filePerms.setDefault(this.currentDownload.libraryItem.path) await filePerms.setDefault(this.currentDownload.libraryItem.path)
} }
let success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath).then(() => true).catch((error) => { 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) Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
return false 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) { if (success) {
success = await this.scanAddPodcastEpisodeAudioFile() success = await this.scanAddPodcastEpisodeAudioFile()
if (!success) { if (!success) {
@ -126,23 +138,28 @@ class PodcastManager {
} }
async scanAddPodcastEpisodeAudioFile() { 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 // TODO: Set meta tags on new audio file
var audioFile = await this.probeAudioFile(libraryFile) const audioFile = await this.probeAudioFile(libraryFile)
if (!audioFile) { if (!audioFile) {
return false 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) { if (!libraryItem) {
Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`) Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`)
return false return false
} }
var podcastEpisode = this.currentDownload.podcastEpisode const podcastEpisode = this.currentDownload.podcastEpisode
podcastEpisode.audioFile = audioFile podcastEpisode.audioFile = audioFile
if (audioFile.chapters?.length) {
podcastEpisode.chapters = audioFile.chapters.map(ch => ({ ...ch }))
}
libraryItem.media.addPodcastEpisode(podcastEpisode) libraryItem.media.addPodcastEpisode(podcastEpisode)
if (libraryItem.isInvalid) { if (libraryItem.isInvalid) {
// First episode added to an empty podcast // First episode added to an empty podcast
@ -201,13 +218,13 @@ class PodcastManager {
} }
async probeAudioFile(libraryFile) { async probeAudioFile(libraryFile) {
var path = libraryFile.metadata.path const path = libraryFile.metadata.path
var mediaProbeData = await prober.probe(path) const mediaProbeData = await prober.probe(path)
if (mediaProbeData.error) { if (mediaProbeData.error) {
Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, mediaProbeData.error) Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, mediaProbeData.error)
return false return false
} }
var newAudioFile = new AudioFile() const newAudioFile = new AudioFile()
newAudioFile.setDataFromProbe(libraryFile, mediaProbeData) newAudioFile.setDataFromProbe(libraryFile, mediaProbeData)
return newAudioFile return newAudioFile
} }

View File

@ -1,5 +1,6 @@
class DeviceInfo { class DeviceInfo {
constructor(deviceInfo = null) { constructor(deviceInfo = null) {
this.deviceId = null
this.ipAddress = null this.ipAddress = null
// From User Agent (see: https://www.npmjs.com/package/ua-parser-js) // From User Agent (see: https://www.npmjs.com/package/ua-parser-js)
@ -32,6 +33,7 @@ class DeviceInfo {
toJSON() { toJSON() {
const obj = { const obj = {
deviceId: this.deviceId,
ipAddress: this.ipAddress, ipAddress: this.ipAddress,
browserName: this.browserName, browserName: this.browserName,
browserVersion: this.browserVersion, browserVersion: this.browserVersion,
@ -60,23 +62,42 @@ class DeviceInfo {
return `${this.osName} ${this.osVersion} / ${this.browserName}` 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) { setData(ip, ua, clientDeviceInfo, serverVersion) {
this.deviceId = clientDeviceInfo?.deviceId || null
this.ipAddress = ip || null this.ipAddress = ip || null
const uaObj = ua || {} this.browserName = ua?.browser.name || null
this.browserName = uaObj.browser.name || null this.browserVersion = ua?.browser.version || null
this.browserVersion = uaObj.browser.version || null this.osName = ua?.os.name || null
this.osName = uaObj.os.name || null this.osVersion = ua?.os.version || null
this.osVersion = uaObj.os.version || null this.deviceType = ua?.device.type || null
this.deviceType = uaObj.device.type || null
const cdi = clientDeviceInfo || {} this.clientVersion = clientDeviceInfo?.clientVersion || null
this.clientVersion = cdi.clientVersion || null this.manufacturer = clientDeviceInfo?.manufacturer || null
this.manufacturer = cdi.manufacturer || null this.model = clientDeviceInfo?.model || null
this.model = cdi.model || null this.sdkVersion = clientDeviceInfo?.sdkVersion || null
this.sdkVersion = cdi.sdkVersion || null
this.serverVersion = serverVersion || null this.serverVersion = serverVersion || null
if (!this.deviceId) {
this.deviceId = this.getTempDeviceId()
}
} }
} }
module.exports = DeviceInfo module.exports = DeviceInfo

View File

@ -55,7 +55,7 @@ class PlaybackSession {
libraryItemId: this.libraryItemId, libraryItemId: this.libraryItemId,
episodeId: this.episodeId, episodeId: this.episodeId,
mediaType: this.mediaType, mediaType: this.mediaType,
mediaMetadata: this.mediaMetadata ? this.mediaMetadata.toJSON() : null, mediaMetadata: this.mediaMetadata?.toJSON() || null,
chapters: (this.chapters || []).map(c => ({ ...c })), chapters: (this.chapters || []).map(c => ({ ...c })),
displayTitle: this.displayTitle, displayTitle: this.displayTitle,
displayAuthor: this.displayAuthor, displayAuthor: this.displayAuthor,
@ -63,7 +63,7 @@ class PlaybackSession {
duration: this.duration, duration: this.duration,
playMethod: this.playMethod, playMethod: this.playMethod,
mediaPlayer: this.mediaPlayer, mediaPlayer: this.mediaPlayer,
deviceInfo: this.deviceInfo ? this.deviceInfo.toJSON() : null, deviceInfo: this.deviceInfo?.toJSON() || null,
date: this.date, date: this.date,
dayOfWeek: this.dayOfWeek, dayOfWeek: this.dayOfWeek,
timeListening: this.timeListening, timeListening: this.timeListening,
@ -82,7 +82,7 @@ class PlaybackSession {
libraryItemId: this.libraryItemId, libraryItemId: this.libraryItemId,
episodeId: this.episodeId, episodeId: this.episodeId,
mediaType: this.mediaType, mediaType: this.mediaType,
mediaMetadata: this.mediaMetadata ? this.mediaMetadata.toJSON() : null, mediaMetadata: this.mediaMetadata?.toJSON() || null,
chapters: (this.chapters || []).map(c => ({ ...c })), chapters: (this.chapters || []).map(c => ({ ...c })),
displayTitle: this.displayTitle, displayTitle: this.displayTitle,
displayAuthor: this.displayAuthor, displayAuthor: this.displayAuthor,
@ -90,7 +90,7 @@ class PlaybackSession {
duration: this.duration, duration: this.duration,
playMethod: this.playMethod, playMethod: this.playMethod,
mediaPlayer: this.mediaPlayer, mediaPlayer: this.mediaPlayer,
deviceInfo: this.deviceInfo ? this.deviceInfo.toJSON() : null, deviceInfo: this.deviceInfo?.toJSON() || null,
date: this.date, date: this.date,
dayOfWeek: this.dayOfWeek, dayOfWeek: this.dayOfWeek,
timeListening: this.timeListening, timeListening: this.timeListening,
@ -151,6 +151,10 @@ class PlaybackSession {
return Math.max(0, Math.min(this.currentTime / this.duration, 1)) return Math.max(0, Math.min(this.currentTime / this.duration, 1))
} }
get deviceId() {
return this.deviceInfo?.deviceId
}
get deviceDescription() { get deviceDescription() {
if (!this.deviceInfo) return 'No Device Info' if (!this.deviceInfo) return 'No Device Info'
return this.deviceInfo.deviceDescription return this.deviceInfo.deviceDescription

View File

@ -41,8 +41,12 @@ class PodcastEpisodeDownload {
} }
} }
get urlFileExtension() {
const cleanUrl = this.url.split('?')[0] // Remove query string
return Path.extname(cleanUrl).substring(1).toLowerCase()
}
get fileExtension() { get fileExtension() {
const extname = Path.extname(this.url).substring(1).toLowerCase() const extname = this.urlFileExtension
if (globals.SupportedAudioTypes.includes(extname)) return extname if (globals.SupportedAudioTypes.includes(extname)) return extname
return 'mp3' return 'mp3'
} }

View File

@ -1,5 +1,6 @@
const Path = require('path') const Path = require('path')
const { getId, cleanStringForSearch } = require('../../utils/index') const Logger = require('../../Logger')
const { getId, cleanStringForSearch, areEquivalent, copyValue } = require('../../utils/index')
const AudioFile = require('../files/AudioFile') const AudioFile = require('../files/AudioFile')
const AudioTrack = require('../files/AudioTrack') const AudioTrack = require('../files/AudioTrack')
@ -17,6 +18,7 @@ class PodcastEpisode {
this.description = null this.description = null
this.enclosure = null this.enclosure = null
this.pubDate = null this.pubDate = null
this.chapters = []
this.audioFile = null this.audioFile = null
this.publishedAt = null this.publishedAt = null
@ -40,6 +42,7 @@ class PodcastEpisode {
this.description = episode.description this.description = episode.description
this.enclosure = episode.enclosure ? { ...episode.enclosure } : null this.enclosure = episode.enclosure ? { ...episode.enclosure } : null
this.pubDate = episode.pubDate this.pubDate = episode.pubDate
this.chapters = episode.chapters?.map(ch => ({ ...ch })) || []
this.audioFile = new AudioFile(episode.audioFile) this.audioFile = new AudioFile(episode.audioFile)
this.publishedAt = episode.publishedAt this.publishedAt = episode.publishedAt
this.addedAt = episode.addedAt this.addedAt = episode.addedAt
@ -61,6 +64,7 @@ class PodcastEpisode {
description: this.description, description: this.description,
enclosure: this.enclosure ? { ...this.enclosure } : null, enclosure: this.enclosure ? { ...this.enclosure } : null,
pubDate: this.pubDate, pubDate: this.pubDate,
chapters: this.chapters.map(ch => ({ ...ch })),
audioFile: this.audioFile.toJSON(), audioFile: this.audioFile.toJSON(),
publishedAt: this.publishedAt, publishedAt: this.publishedAt,
addedAt: this.addedAt, addedAt: this.addedAt,
@ -81,6 +85,7 @@ class PodcastEpisode {
description: this.description, description: this.description,
enclosure: this.enclosure ? { ...this.enclosure } : null, enclosure: this.enclosure ? { ...this.enclosure } : null,
pubDate: this.pubDate, pubDate: this.pubDate,
chapters: this.chapters.map(ch => ({ ...ch })),
audioFile: this.audioFile.toJSON(), audioFile: this.audioFile.toJSON(),
audioTrack: this.audioTrack.toJSON(), audioTrack: this.audioTrack.toJSON(),
publishedAt: this.publishedAt, publishedAt: this.publishedAt,
@ -106,6 +111,10 @@ class PodcastEpisode {
get enclosureUrl() { get enclosureUrl() {
return this.enclosure ? this.enclosure.url : null return this.enclosure ? this.enclosure.url : null
} }
get pubYear() {
if (!this.publishedAt) return null
return new Date(this.publishedAt).getFullYear()
}
setData(data, index = 1) { setData(data, index = 1) {
this.id = getId('ep') this.id = getId('ep')
@ -128,6 +137,10 @@ class PodcastEpisode {
this.audioFile = audioFile this.audioFile = audioFile
this.title = Path.basename(audioFile.metadata.filename, Path.extname(audioFile.metadata.filename)) this.title = Path.basename(audioFile.metadata.filename, Path.extname(audioFile.metadata.filename))
this.index = index this.index = index
this.setDataFromAudioMetaTags(audioFile.metaTags, true)
this.chapters = audioFile.chapters?.map((c) => ({ ...c }))
this.addedAt = Date.now() this.addedAt = Date.now()
this.updatedAt = Date.now() this.updatedAt = Date.now()
} }
@ -135,8 +148,8 @@ class PodcastEpisode {
update(payload) { update(payload) {
let hasUpdates = false let hasUpdates = false
for (const key in this.toJSON()) { for (const key in this.toJSON()) {
if (payload[key] != undefined && payload[key] != this[key]) { if (payload[key] != undefined && !areEquivalent(payload[key], this[key])) {
this[key] = payload[key] this[key] = copyValue(payload[key])
hasUpdates = true hasUpdates = true
} }
} }
@ -164,5 +177,76 @@ class PodcastEpisode {
searchQuery(query) { searchQuery(query) {
return cleanStringForSearch(this.title).includes(query) return cleanStringForSearch(this.title).includes(query)
} }
setDataFromAudioMetaTags(audioFileMetaTags, overrideExistingDetails = false) {
if (!audioFileMetaTags) return false
const MetadataMapArray = [
{
tag: 'tagComment',
altTag: 'tagSubtitle',
key: 'description'
},
{
tag: 'tagSubtitle',
key: 'subtitle'
},
{
tag: 'tagDate',
key: 'pubDate'
},
{
tag: 'tagDisc',
key: 'season',
},
{
tag: 'tagTrack',
altTag: 'tagSeriesPart',
key: 'episode'
},
{
tag: 'tagTitle',
key: 'title'
},
{
tag: 'tagEpisodeType',
key: 'episodeType'
}
]
MetadataMapArray.forEach((mapping) => {
let value = audioFileMetaTags[mapping.tag]
let tagToUse = mapping.tag
if (!value && mapping.altTag) {
tagToUse = mapping.altTag
value = audioFileMetaTags[mapping.altTag]
}
if (value && typeof value === 'string') {
value = value.trim() // Trim whitespace
if (mapping.key === 'pubDate' && (!this.pubDate || overrideExistingDetails)) {
const pubJsDate = new Date(value)
if (pubJsDate && !isNaN(pubJsDate)) {
this.publishedAt = pubJsDate.valueOf()
this.pubDate = value
Logger.debug(`[PodcastEpisode] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${this[mapping.key]}`)
} else {
Logger.warn(`[PodcastEpisode] Mapping pubDate with tag ${tagToUse} has invalid date "${value}"`)
}
} else if (mapping.key === 'episodeType' && (!this.episodeType || overrideExistingDetails)) {
if (['full', 'trailer', 'bonus'].includes(value)) {
this.episodeType = value
Logger.debug(`[PodcastEpisode] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${this[mapping.key]}`)
} else {
Logger.warn(`[PodcastEpisode] Mapping episodeType with invalid value "${value}". Must be one of [full, trailer, bonus].`)
}
} else if (!this[mapping.key] || overrideExistingDetails) {
this[mapping.key] = value
Logger.debug(`[PodcastEpisode] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${this[mapping.key]}`)
}
}
})
}
} }
module.exports = PodcastEpisode module.exports = PodcastEpisode

View File

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

View File

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

View File

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

View File

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

View File

@ -18,7 +18,6 @@ class User {
this.seriesHideFromContinueListening = [] // Series IDs that should not show on home page continue listening this.seriesHideFromContinueListening = [] // Series IDs that should not show on home page continue listening
this.bookmarks = [] this.bookmarks = []
this.settings = {} // TODO: Remove after mobile release v0.9.61-beta
this.permissions = {} this.permissions = {}
this.librariesAccessible = [] // Library IDs (Empty if ALL libraries) this.librariesAccessible = [] // Library IDs (Empty if ALL libraries)
this.itemTagsAccessible = [] // Empty if ALL item tags accessible this.itemTagsAccessible = [] // Empty if ALL item tags accessible
@ -59,15 +58,6 @@ class User {
return !!this.pash && !!this.pash.length return !!this.pash && !!this.pash.length
} }
// TODO: Remove after mobile release v0.9.61-beta
getDefaultUserSettings() {
return {
mobileOrderBy: 'recent',
mobileOrderDesc: true,
mobileFilterBy: 'all'
}
}
getDefaultUserPermissions() { getDefaultUserPermissions() {
return { return {
download: true, download: true,
@ -94,19 +84,18 @@ class User {
isLocked: this.isLocked, isLocked: this.isLocked,
lastSeen: this.lastSeen, lastSeen: this.lastSeen,
createdAt: this.createdAt, createdAt: this.createdAt,
settings: this.settings, // TODO: Remove after mobile release v0.9.61-beta
permissions: this.permissions, permissions: this.permissions,
librariesAccessible: [...this.librariesAccessible], librariesAccessible: [...this.librariesAccessible],
itemTagsAccessible: [...this.itemTagsAccessible] itemTagsAccessible: [...this.itemTagsAccessible]
} }
} }
toJSONForBrowser() { toJSONForBrowser(hideRootToken = false, minimal = false) {
return { const json = {
id: this.id, id: this.id,
username: this.username, username: this.username,
type: this.type, type: this.type,
token: this.token, token: (this.type === 'root' && hideRootToken) ? '' : this.token,
mediaProgress: this.mediaProgress ? this.mediaProgress.map(li => li.toJSON()) : [], mediaProgress: this.mediaProgress ? this.mediaProgress.map(li => li.toJSON()) : [],
seriesHideFromContinueListening: [...this.seriesHideFromContinueListening], seriesHideFromContinueListening: [...this.seriesHideFromContinueListening],
bookmarks: this.bookmarks ? this.bookmarks.map(b => b.toJSON()) : [], bookmarks: this.bookmarks ? this.bookmarks.map(b => b.toJSON()) : [],
@ -114,11 +103,15 @@ class User {
isLocked: this.isLocked, isLocked: this.isLocked,
lastSeen: this.lastSeen, lastSeen: this.lastSeen,
createdAt: this.createdAt, createdAt: this.createdAt,
settings: this.settings, // TODO: Remove after mobile release v0.9.61-beta
permissions: this.permissions, permissions: this.permissions,
librariesAccessible: [...this.librariesAccessible], librariesAccessible: [...this.librariesAccessible],
itemTagsAccessible: [...this.itemTagsAccessible] itemTagsAccessible: [...this.itemTagsAccessible]
} }
if (minimal) {
delete json.mediaProgress
delete json.bookmarks
}
return json
} }
// Data broadcasted // Data broadcasted
@ -166,7 +159,6 @@ class User {
this.isLocked = user.type === 'root' ? false : !!user.isLocked this.isLocked = user.type === 'root' ? false : !!user.isLocked
this.lastSeen = user.lastSeen || null this.lastSeen = user.lastSeen || null
this.createdAt = user.createdAt || Date.now() this.createdAt = user.createdAt || Date.now()
this.settings = user.settings || this.getDefaultUserSettings() // TODO: Remove after mobile release v0.9.61-beta
this.permissions = user.permissions || this.getDefaultUserPermissions() this.permissions = user.permissions || this.getDefaultUserPermissions()
// Upload permission added v1.1.13, make sure root user has upload permissions // Upload permission added v1.1.13, make sure root user has upload permissions
if (this.type === 'root' && !this.permissions.upload) this.permissions.upload = true if (this.type === 'root' && !this.permissions.upload) this.permissions.upload = true
@ -343,33 +335,6 @@ class User {
return true return true
} }
// TODO: Remove after mobile release v0.9.61-beta
// Returns Boolean If update was made
updateSettings(settings) {
if (!this.settings) {
this.settings = { ...settings }
return true
}
var madeUpdates = false
for (const key in this.settings) {
if (settings[key] !== undefined && this.settings[key] !== settings[key]) {
this.settings[key] = settings[key]
madeUpdates = true
}
}
// Check if new settings update has keys not currently in user settings
for (const key in settings) {
if (settings[key] !== undefined && this.settings[key] === undefined) {
this.settings[key] = settings[key]
madeUpdates = true
}
}
return madeUpdates
}
checkCanAccessLibrary(libraryId) { checkCanAccessLibrary(libraryId) {
if (this.permissions.accessAllLibraries) return true if (this.permissions.accessAllLibraries) return true
if (!this.librariesAccessible) return false if (!this.librariesAccessible) return false

View File

@ -24,7 +24,6 @@ const SearchController = require('../controllers/SearchController')
const CacheController = require('../controllers/CacheController') const CacheController = require('../controllers/CacheController')
const ToolsController = require('../controllers/ToolsController') const ToolsController = require('../controllers/ToolsController')
const RSSFeedController = require('../controllers/RSSFeedController') const RSSFeedController = require('../controllers/RSSFeedController')
const EBookController = require('../controllers/EBookController')
const MiscController = require('../controllers/MiscController') const MiscController = require('../controllers/MiscController')
const BookFinder = require('../finders/BookFinder') const BookFinder = require('../finders/BookFinder')
@ -52,7 +51,6 @@ class ApiRouter {
this.cronManager = Server.cronManager this.cronManager = Server.cronManager
this.notificationManager = Server.notificationManager this.notificationManager = Server.notificationManager
this.taskManager = Server.taskManager this.taskManager = Server.taskManager
this.eBookManager = Server.eBookManager
this.bookFinder = new BookFinder() this.bookFinder = new BookFinder()
this.authorFinder = new AuthorFinder() this.authorFinder = new AuthorFinder()
@ -100,6 +98,7 @@ class ApiRouter {
this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this)) this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this))
this.router.patch('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.update.bind(this)) this.router.patch('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.update.bind(this))
this.router.delete('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.delete.bind(this)) this.router.delete('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.delete.bind(this))
this.router.get('/items/:id/download', LibraryItemController.middleware.bind(this), LibraryItemController.download.bind(this))
this.router.patch('/items/:id/media', LibraryItemController.middleware.bind(this), LibraryItemController.updateMedia.bind(this)) this.router.patch('/items/:id/media', LibraryItemController.middleware.bind(this), LibraryItemController.updateMedia.bind(this))
this.router.get('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.getCover.bind(this)) this.router.get('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.getCover.bind(this))
this.router.post('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.uploadCover.bind(this)) this.router.post('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.uploadCover.bind(this))
@ -113,6 +112,7 @@ class ApiRouter {
this.router.get('/items/:id/tone-object', LibraryItemController.middleware.bind(this), LibraryItemController.getToneMetadataObject.bind(this)) this.router.get('/items/:id/tone-object', LibraryItemController.middleware.bind(this), LibraryItemController.getToneMetadataObject.bind(this))
this.router.post('/items/:id/chapters', LibraryItemController.middleware.bind(this), LibraryItemController.updateMediaChapters.bind(this)) this.router.post('/items/:id/chapters', LibraryItemController.middleware.bind(this), LibraryItemController.updateMediaChapters.bind(this))
this.router.post('/items/:id/tone-scan/:index?', LibraryItemController.middleware.bind(this), LibraryItemController.toneScan.bind(this)) this.router.post('/items/:id/tone-scan/:index?', LibraryItemController.middleware.bind(this), LibraryItemController.toneScan.bind(this))
this.router.delete('/items/:id/file/:ino', LibraryItemController.middleware.bind(this), LibraryItemController.deleteLibraryFile.bind(this))
this.router.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this)) this.router.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this))
this.router.post('/items/batch/update', LibraryItemController.batchUpdate.bind(this)) this.router.post('/items/batch/update', LibraryItemController.batchUpdate.bind(this))
@ -176,7 +176,6 @@ class ApiRouter {
this.router.patch('/me/item/:id/bookmark', MeController.updateBookmark.bind(this)) this.router.patch('/me/item/:id/bookmark', MeController.updateBookmark.bind(this))
this.router.delete('/me/item/:id/bookmark/:time', MeController.removeBookmark.bind(this)) this.router.delete('/me/item/:id/bookmark/:time', MeController.removeBookmark.bind(this))
this.router.patch('/me/password', MeController.updatePassword.bind(this)) this.router.patch('/me/password', MeController.updatePassword.bind(this))
this.router.patch('/me/settings', MeController.updateSettings.bind(this)) // TODO: Deprecated. Remove after mobile release v0.9.61-beta
this.router.post('/me/sync-local-progress', MeController.syncLocalMediaProgress.bind(this)) // TODO: Deprecated. Removed from Android. Only used in iOS app now. this.router.post('/me/sync-local-progress', MeController.syncLocalMediaProgress.bind(this)) // TODO: Deprecated. Removed from Android. Only used in iOS app now.
this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this)) this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this))
this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this)) this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this))
@ -217,6 +216,7 @@ class ApiRouter {
// //
this.router.get('/sessions', SessionController.getAllWithUserData.bind(this)) this.router.get('/sessions', SessionController.getAllWithUserData.bind(this))
this.router.delete('/sessions/:id', SessionController.middleware.bind(this), SessionController.delete.bind(this)) this.router.delete('/sessions/:id', SessionController.middleware.bind(this), SessionController.delete.bind(this))
this.router.get('/sessions/open', SessionController.getOpenSessions.bind(this))
this.router.post('/session/local', SessionController.syncLocal.bind(this)) this.router.post('/session/local', SessionController.syncLocal.bind(this))
this.router.post('/session/local-all', SessionController.syncLocalSessions.bind(this)) this.router.post('/session/local-all', SessionController.syncLocalSessions.bind(this))
// TODO: Update these endpoints because they are only for open playback sessions // TODO: Update these endpoints because they are only for open playback sessions
@ -271,9 +271,10 @@ class ApiRouter {
// //
// Tools Routes (Admin and up) // Tools Routes (Admin and up)
// //
this.router.post('/tools/item/:id/encode-m4b', ToolsController.itemMiddleware.bind(this), ToolsController.encodeM4b.bind(this)) this.router.post('/tools/item/:id/encode-m4b', ToolsController.middleware.bind(this), ToolsController.encodeM4b.bind(this))
this.router.delete('/tools/item/:id/encode-m4b', ToolsController.itemMiddleware.bind(this), ToolsController.cancelM4bEncode.bind(this)) this.router.delete('/tools/item/:id/encode-m4b', ToolsController.middleware.bind(this), ToolsController.cancelM4bEncode.bind(this))
this.router.post('/tools/item/:id/embed-metadata', ToolsController.itemMiddleware.bind(this), ToolsController.embedAudioFileMetadata.bind(this)) this.router.post('/tools/item/:id/embed-metadata', ToolsController.middleware.bind(this), ToolsController.embedAudioFileMetadata.bind(this))
this.router.post('/tools/batch/embed-metadata', ToolsController.middleware.bind(this), ToolsController.batchEmbedMetadata.bind(this))
// //
// RSS Feed Routes (Admin and up) // RSS Feed Routes (Admin and up)
@ -283,13 +284,6 @@ class ApiRouter {
this.router.post('/feeds/series/:seriesId/open', RSSFeedController.middleware.bind(this), RSSFeedController.openRSSFeedForSeries.bind(this)) this.router.post('/feeds/series/:seriesId/open', RSSFeedController.middleware.bind(this), RSSFeedController.openRSSFeedForSeries.bind(this))
this.router.post('/feeds/:id/close', RSSFeedController.middleware.bind(this), RSSFeedController.closeRSSFeed.bind(this)) this.router.post('/feeds/:id/close', RSSFeedController.middleware.bind(this), RSSFeedController.closeRSSFeed.bind(this))
//
// EBook Routes
//
this.router.get('/ebooks/:id/info', EBookController.middleware.bind(this), EBookController.getEbookInfo.bind(this))
this.router.get('/ebooks/:id/page/:page', EBookController.middleware.bind(this), EBookController.getEbookPage.bind(this))
this.router.get('/ebooks/:id/resource', EBookController.middleware.bind(this), EBookController.getEbookResource.bind(this))
// //
// Misc Routes // Misc Routes
// //
@ -339,10 +333,7 @@ class ApiRouter {
// Helper Methods // Helper Methods
// //
userJsonWithItemProgressDetails(user, hideRootToken = false) { userJsonWithItemProgressDetails(user, hideRootToken = false) {
const json = user.toJSONForBrowser() const json = user.toJSONForBrowser(hideRootToken)
if (json.type === 'root' && hideRootToken) {
json.token = ''
}
json.mediaProgress = json.mediaProgress.map(lip => { json.mediaProgress = json.mediaProgress.map(lip => {
const libraryItem = this.db.libraryItems.find(li => li.id === lip.libraryItemId) const libraryItem = this.db.libraryItems.find(li => li.id === lip.libraryItemId)
@ -507,11 +498,16 @@ class ApiRouter {
// Create new authors if in payload // Create new authors if in payload
if (mediaMetadata.authors && mediaMetadata.authors.length) { if (mediaMetadata.authors && mediaMetadata.authors.length) {
// TODO: validate authors
const newAuthors = [] const newAuthors = []
for (let i = 0; i < mediaMetadata.authors.length; i++) { for (let i = 0; i < mediaMetadata.authors.length; i++) {
if (mediaMetadata.authors[i].id.startsWith('new')) { const authorName = (mediaMetadata.authors[i].name || '').trim()
let author = this.db.authors.find(au => au.checkNameEquals(mediaMetadata.authors[i].name)) if (!authorName) {
Logger.error(`[ApiRouter] Invalid author object, no name`, mediaMetadata.authors[i])
continue
}
if (!mediaMetadata.authors[i].id || mediaMetadata.authors[i].id.startsWith('new')) {
let author = this.db.authors.find(au => au.checkNameEquals(authorName))
if (!author) { if (!author) {
author = new Author() author = new Author()
author.setData(mediaMetadata.authors[i]) author.setData(mediaMetadata.authors[i])
@ -531,11 +527,16 @@ class ApiRouter {
// Create new series if in payload // Create new series if in payload
if (mediaMetadata.series && mediaMetadata.series.length) { if (mediaMetadata.series && mediaMetadata.series.length) {
// TODO: validate series
const newSeries = [] const newSeries = []
for (let i = 0; i < mediaMetadata.series.length; i++) { for (let i = 0; i < mediaMetadata.series.length; i++) {
if (mediaMetadata.series[i].id.startsWith('new')) { const seriesName = (mediaMetadata.series[i].name || '').trim()
let seriesItem = this.db.series.find(se => se.checkNameEquals(mediaMetadata.series[i].name)) if (!seriesName) {
Logger.error(`[ApiRouter] Invalid series object, no name`, mediaMetadata.series[i])
continue
}
if (!mediaMetadata.series[i].id || mediaMetadata.series[i].id.startsWith('new')) {
let seriesItem = this.db.series.find(se => se.checkNameEquals(seriesName))
if (!seriesItem) { if (!seriesItem) {
seriesItem = new Series() seriesItem = new Series()
seriesItem.setData(mediaMetadata.series[i]) seriesItem.setData(mediaMetadata.series[i])

View File

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

View File

@ -68,7 +68,7 @@ class Scanner {
async scanLibraryItem(libraryMediaType, folder, libraryItem) { async scanLibraryItem(libraryMediaType, folder, libraryItem) {
// TODO: Support for single media item // TODO: Support for single media item
const libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, libraryItem.path, false, this.db.serverSettings) const libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, libraryItem.path, false)
if (!libraryItemData) { if (!libraryItemData) {
return ScanResult.NOTHING return ScanResult.NOTHING
} }
@ -173,7 +173,7 @@ class Scanner {
// Scan each library // Scan each library
for (let i = 0; i < libraryScan.folders.length; i++) { for (let i = 0; i < libraryScan.folders.length; i++) {
const folder = libraryScan.folders[i] const folder = libraryScan.folders[i]
const itemDataFoundInFolder = await scanFolder(libraryScan.libraryMediaType, folder, this.db.serverSettings) const itemDataFoundInFolder = await scanFolder(libraryScan.libraryMediaType, folder)
libraryScan.addLog(LogLevel.INFO, `${itemDataFoundInFolder.length} item data found in folder "${folder.fullPath}"`) libraryScan.addLog(LogLevel.INFO, `${itemDataFoundInFolder.length} item data found in folder "${folder.fullPath}"`)
libraryItemDataFound = libraryItemDataFound.concat(itemDataFoundInFolder) libraryItemDataFound = libraryItemDataFound.concat(itemDataFoundInFolder)
} }
@ -200,11 +200,22 @@ class Scanner {
// Find library item folder with matching inode or matching path // Find library item folder with matching inode or matching path
const dataFound = libraryItemDataFound.find(lid => lid.ino === libraryItem.ino || comparePaths(lid.relPath, libraryItem.relPath)) const dataFound = libraryItemDataFound.find(lid => lid.ino === libraryItem.ino || comparePaths(lid.relPath, libraryItem.relPath))
if (!dataFound) { if (!dataFound) {
// Podcast folder can have no episodes and still be valid
if (libraryScan.libraryMediaType === 'podcast' && await fs.pathExists(libraryItem.path)) {
Logger.info(`[Scanner] Library item "${libraryItem.media.metadata.title}" folder exists but has no episodes`)
if (libraryItem.isMissing) {
libraryScan.resultsUpdated++
libraryItem.isMissing = false
libraryItem.setLastScan()
itemsToUpdate.push(libraryItem)
}
} else {
libraryScan.addLog(LogLevel.WARN, `Library Item "${libraryItem.media.metadata.title}" is missing`) libraryScan.addLog(LogLevel.WARN, `Library Item "${libraryItem.media.metadata.title}" is missing`)
Logger.warn(`[Scanner] Library item "${libraryItem.media.metadata.title}" is missing (inode "${libraryItem.ino}")`) Logger.warn(`[Scanner] Library item "${libraryItem.media.metadata.title}" is missing (inode "${libraryItem.ino}")`)
libraryScan.resultsMissing++ libraryScan.resultsMissing++
libraryItem.setMissing() libraryItem.setMissing()
itemsToUpdate.push(libraryItem) itemsToUpdate.push(libraryItem)
}
} else { } else {
const checkRes = libraryItem.checkScanData(dataFound) const checkRes = libraryItem.checkScanData(dataFound)
if (checkRes.newLibraryFiles.length || libraryScan.scanOptions.forceRescan) { // Item has new files if (checkRes.newLibraryFiles.length || libraryScan.scanOptions.forceRescan) { // Item has new files
@ -632,7 +643,7 @@ class Scanner {
} }
async scanPotentialNewLibraryItem(libraryMediaType, folder, fullPath, isSingleMediaItem = false) { async scanPotentialNewLibraryItem(libraryMediaType, folder, fullPath, isSingleMediaItem = false) {
const libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, fullPath, isSingleMediaItem, this.db.serverSettings) const libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, fullPath, isSingleMediaItem)
if (!libraryItemData) return null if (!libraryItemData) return null
return this.scanNewLibraryItem(libraryItemData, libraryMediaType) return this.scanNewLibraryItem(libraryItemData, libraryMediaType)
} }

View File

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

View File

@ -1,226 +0,0 @@
const Path = require('path')
const h = require('htmlparser2')
const ds = require('dom-serializer')
const Logger = require('../../Logger')
const StreamZip = require('../../libs/nodeStreamZip')
const css = require('../../libs/css')
const { xmlToJSON } = require('../index.js')
module.exports.parse = async (ebookFile, libraryItemId, token, isDev) => {
const zip = new StreamZip.async({ file: ebookFile.metadata.path })
const containerXml = await zip.entryData('META-INF/container.xml')
const containerJson = await xmlToJSON(containerXml.toString('utf8'))
const packageOpfPath = containerJson.container.rootfiles[0].rootfile[0].$['full-path']
const packageOpfDir = Path.dirname(packageOpfPath)
const packageDoc = await zip.entryData(packageOpfPath)
const packageJson = await xmlToJSON(packageDoc.toString('utf8'))
const pages = []
let manifestItems = packageJson.package.manifest[0].item.map(item => item.$)
const spineItems = packageJson.package.spine[0].itemref.map(ref => ref.$.idref)
for (const spineItem of spineItems) {
const mi = manifestItems.find(i => i.id === spineItem)
if (mi) {
manifestItems = manifestItems.filter(_mi => _mi.id !== mi.id) // Remove from manifest items
mi.path = Path.posix.join(packageOpfDir, mi.href)
pages.push(mi)
} else {
Logger.error('[parseEpub] Invalid spine item', spineItem)
}
}
const stylesheets = []
const resources = []
for (const manifestItem of manifestItems) {
manifestItem.path = Path.posix.join(packageOpfDir, manifestItem.href)
if (manifestItem['media-type'] === 'text/css') {
const stylesheetData = await zip.entryData(manifestItem.path)
const modifiedCss = this.parseStylesheet(stylesheetData.toString('utf8'), manifestItem.path, libraryItemId, token, isDev)
if (modifiedCss) {
manifestItem.style = modifiedCss
stylesheets.push(manifestItem)
} else {
Logger.error(`[parseEpub] Invalid stylesheet "${manifestItem.path}"`)
}
} else {
resources.push(manifestItem)
}
}
await zip.close()
return {
filepath: ebookFile.metadata.path,
epubVersion: packageJson.package.$.version,
packageDir: packageOpfDir,
resources,
stylesheets,
pages
}
}
module.exports.parsePage = async (pagePath, bookData, libraryItemId, token, isDev) => {
const pageDir = Path.dirname(pagePath)
const zip = new StreamZip.async({ file: bookData.filepath })
const pageData = await zip.entryData(pagePath)
await zip.close()
const rawHtml = pageData.toString('utf8')
const results = {}
const dh = new h.DomHandler((err, dom) => {
if (err) return results.error = err
// Get stylesheets
const isStylesheetLink = (elem) => elem.type == 'tag' && elem.name.toLowerCase() === 'link' && elem.attribs.rel === 'stylesheet' && elem.attribs.type === 'text/css'
const stylesheets = h.DomUtils.findAll(isStylesheetLink, dom)
// Get body tag
const isBodyTag = (elem) => elem.type == 'tag' && elem.name.toLowerCase() == 'body'
const body = h.DomUtils.findOne(isBodyTag, dom)
// Get all svg elements
const isSvgTag = (name) => ['svg'].includes((name || '').toLowerCase())
const svgElements = h.DomUtils.getElementsByTagName(isSvgTag, body.children)
svgElements.forEach((el) => {
if (el.attribs.class) el.attribs.class += ' abs-svg-scale'
else el.attribs.class = 'abs-svg-scale'
})
// Get all img elements
const isImageTag = (name) => ['img', 'image'].includes((name || '').toLowerCase())
const imgElements = h.DomUtils.getElementsByTagName(isImageTag, body.children)
imgElements.forEach(el => {
if (!el.attribs.src && !el.attribs['xlink:href']) {
Logger.warn('[parseEpub] parsePage: Invalid img element attribs', el.attribs)
return
}
if (el.attribs.class) el.attribs.class += ' abs-image-scale'
else el.attribs.class = 'abs-image-scale'
const srcKey = el.attribs.src ? 'src' : 'xlink:href'
const src = encodeURIComponent(Path.posix.join(pageDir, el.attribs[srcKey]))
const basePath = isDev ? 'http://localhost:3333' : ''
el.attribs[srcKey] = `${basePath}/api/ebooks/${libraryItemId}/resource?path=${src}&token=${token}`
})
let finalHtml = '<div class="abs-page-content" style="max-height: unset; margin-left: 15% !important; margin-right: 15% !important;">'
stylesheets.forEach((el) => {
const href = Path.posix.join(pageDir, el.attribs.href)
const ssObj = bookData.stylesheets.find(sso => sso.path === href)
// find @import css and add it
const importSheets = getStylesheetImports(ssObj.style, bookData.stylesheets)
if (importSheets) {
importSheets.forEach((sheet) => {
finalHtml += `<style>${sheet.style}</style>\n`
})
}
if (!ssObj) {
Logger.warn('[parseEpub] parsePage: Stylesheet object not found for href', href)
} else {
finalHtml += `<style>${ssObj.style}</style>\n`
}
})
finalHtml += `<style>
.abs-image-scale { max-width: 100%; object-fit: contain; object-position: top center; max-height: 100vh; }
.abs-svg-scale { width: auto; max-height: 80vh; }
</style>\n`
finalHtml += ds.render(body.children)
finalHtml += '\n</div>'
results.html = finalHtml
})
const parser = new h.Parser(dh)
parser.write(rawHtml)
parser.end()
return results
}
module.exports.parseStylesheet = (rawCss, stylesheetPath, libraryItemId, token, isDev) => {
try {
const stylesheetDir = Path.dirname(stylesheetPath)
const res = css.parse(rawCss)
res.stylesheet.rules.forEach((rule) => {
if (rule.type === 'rule') {
rule.selectors = rule.selectors.map(s => s === 'body' ? '.abs-page-content' : `.abs-page-content ${s}`)
} else if (rule.type === 'font-face' && rule.declarations) {
rule.declarations = rule.declarations.map(dec => {
if (dec.property === 'src') {
const match = dec.value.trim().split(' ').shift().match(/url\((.+)\)/)
if (match && match[1]) {
const fontPath = Path.posix.join(stylesheetDir, match[1])
const newSrc = encodeURIComponent(fontPath)
const basePath = isDev ? 'http://localhost:3333' : ''
dec.value = dec.value.replace(match[1], `"${basePath}/api/ebooks/${libraryItemId}/resource?path=${newSrc}&token=${token}"`)
}
}
return dec
})
} else if (rule.type === 'import') {
const importUrl = rule.import
const match = importUrl.match(/\"(.*)\"/)
const path = match ? match[1] || '' : ''
if (path) {
// const newSrc = encodeURIComponent(Path.posix.join(stylesheetDir, path))
// const basePath = isDev ? 'http://localhost:3333' : ''
// const newPath = `"${basePath}/api/ebooks/${libraryItemId}/resource?path=${newSrc}&token=${token}"`
// rule.import = rule.import.replace(path, newPath)
rule.import = Path.posix.join(stylesheetDir, path)
}
}
})
return css.stringify(res)
} catch (error) {
Logger.error('[parseEpub] parseStylesheet: Failed', error)
return null
}
}
function getStylesheetImports(rawCss, stylesheets) {
try {
const res = css.parse(rawCss)
const imports = []
res.stylesheet.rules.forEach((rule) => {
if (rule.type === 'import') {
const importUrl = rule.import.replaceAll('"', '')
const sheet = stylesheets.find(s => s.path === importUrl)
if (sheet) imports.push(sheet)
else {
Logger.error('[parseEpub] getStylesheetImports: Sheet not found', stylesheets)
}
}
})
return imports
} catch (error) {
Logger.error('[parseEpub] getStylesheetImports: Failed', error)
return null
}
}

View File

@ -41,7 +41,7 @@ function extractCategories(channel) {
} }
function extractPodcastMetadata(channel) { function extractPodcastMetadata(channel) {
var metadata = { const metadata = {
image: extractImage(channel), image: extractImage(channel),
categories: extractCategories(channel), categories: extractCategories(channel),
feedUrl: null, feedUrl: null,
@ -62,22 +62,24 @@ function extractPodcastMetadata(channel) {
metadata.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription) metadata.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription)
} }
var arrayFields = ['title', 'language', 'itunes:explicit', 'itunes:author', 'pubDate', 'link', 'itunes:type'] const arrayFields = ['title', 'language', 'itunes:explicit', 'itunes:author', 'pubDate', 'link', 'itunes:type']
arrayFields.forEach((key) => { arrayFields.forEach((key) => {
var cleanKey = key.split(':').pop() const cleanKey = key.split(':').pop()
metadata[cleanKey] = extractFirstArrayItem(channel, key) let value = extractFirstArrayItem(channel, key)
if (value && value['_']) value = value['_']
metadata[cleanKey] = value
}) })
return metadata return metadata
} }
function extractEpisodeData(item) { function extractEpisodeData(item) {
// Episode must have url // Episode must have url
if (!item.enclosure || !item.enclosure.length || !item.enclosure[0]['$'] || !item.enclosure[0]['$'].url) { if (!item.enclosure?.[0]?.['$']?.url) {
Logger.error(`[podcastUtils] Invalid podcast episode data`) Logger.error(`[podcastUtils] Invalid podcast episode data`)
return null return null
} }
var episode = { const episode = {
enclosure: { enclosure: {
...item.enclosure[0]['$'] ...item.enclosure[0]['$']
} }
@ -89,6 +91,12 @@ function extractEpisodeData(item) {
episode.description = htmlSanitizer.sanitize(rawDescription) episode.description = htmlSanitizer.sanitize(rawDescription)
} }
// Extract chapters
if (item['podcast:chapters']?.[0]?.['$']?.url) {
episode.chaptersUrl = item['podcast:chapters'][0]['$'].url
episode.chaptersType = item['podcast:chapters'][0]['$'].type || 'application/json'
}
// Supposed to be the plaintext description but not always followed // Supposed to be the plaintext description but not always followed
if (item['description']) { if (item['description']) {
const rawDescription = extractFirstArrayItem(item, 'description') || '' const rawDescription = extractFirstArrayItem(item, 'description') || ''
@ -107,9 +115,9 @@ function extractEpisodeData(item) {
} }
} }
var arrayFields = ['title', 'itunes:episodeType', 'itunes:season', 'itunes:episode', 'itunes:author', 'itunes:duration', 'itunes:explicit', 'itunes:subtitle'] const arrayFields = ['title', 'itunes:episodeType', 'itunes:season', 'itunes:episode', 'itunes:author', 'itunes:duration', 'itunes:explicit', 'itunes:subtitle']
arrayFields.forEach((key) => { arrayFields.forEach((key) => {
var cleanKey = key.split(':').pop() const cleanKey = key.split(':').pop()
episode[cleanKey] = extractFirstArrayItem(item, key) episode[cleanKey] = extractFirstArrayItem(item, key)
}) })
return episode return episode
@ -131,14 +139,16 @@ function cleanEpisodeData(data) {
duration: data.duration || '', duration: data.duration || '',
explicit: data.explicit || '', explicit: data.explicit || '',
publishedAt, publishedAt,
enclosure: data.enclosure enclosure: data.enclosure,
chaptersUrl: data.chaptersUrl || null,
chaptersType: data.chaptersType || null
} }
} }
function extractPodcastEpisodes(items) { function extractPodcastEpisodes(items) {
var episodes = [] const episodes = []
items.forEach((item) => { items.forEach((item) => {
var extracted = extractEpisodeData(item) const extracted = extractEpisodeData(item)
if (extracted) { if (extracted) {
episodes.push(cleanEpisodeData(extracted)) episodes.push(cleanEpisodeData(extracted))
} }

View File

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

View File

@ -175,7 +175,7 @@ function cleanFileObjects(libraryItemPath, files) {
} }
// Scan folder // Scan folder
async function scanFolder(libraryMediaType, folder, serverSettings = {}) { async function scanFolder(libraryMediaType, folder) {
const folderPath = filePathToPOSIX(folder.fullPath) const folderPath = filePathToPOSIX(folder.fullPath)
const pathExists = await fs.pathExists(folderPath) const pathExists = await fs.pathExists(folderPath)
@ -216,7 +216,7 @@ async function scanFolder(libraryMediaType, folder, serverSettings = {}) {
fileObjs = await cleanFileObjects(folderPath, [libraryItemPath]) fileObjs = await cleanFileObjects(folderPath, [libraryItemPath])
isFile = true isFile = true
} else { } else {
libraryItemData = getDataFromMediaDir(libraryMediaType, folderPath, libraryItemPath, serverSettings, libraryItemGrouping[libraryItemPath]) libraryItemData = getDataFromMediaDir(libraryMediaType, folderPath, libraryItemPath)
fileObjs = await cleanFileObjects(libraryItemData.path, libraryItemGrouping[libraryItemPath]) fileObjs = await cleanFileObjects(libraryItemData.path, libraryItemGrouping[libraryItemPath])
} }
@ -347,19 +347,18 @@ function getPodcastDataFromDir(folderPath, relPath) {
} }
} }
function getDataFromMediaDir(libraryMediaType, folderPath, relPath, serverSettings, fileNames) { function getDataFromMediaDir(libraryMediaType, folderPath, relPath) {
if (libraryMediaType === 'podcast') { if (libraryMediaType === 'podcast') {
return getPodcastDataFromDir(folderPath, relPath) return getPodcastDataFromDir(folderPath, relPath)
} else if (libraryMediaType === 'book') { } else if (libraryMediaType === 'book') {
var parseSubtitle = !!serverSettings.scannerParseSubtitle return getBookDataFromDir(folderPath, relPath, !!global.ServerSettings.scannerParseSubtitle)
return getBookDataFromDir(folderPath, relPath, parseSubtitle)
} else { } else {
return getPodcastDataFromDir(folderPath, relPath) return getPodcastDataFromDir(folderPath, relPath)
} }
} }
// Called from Scanner.js // Called from Scanner.js
async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, isSingleMediaItem, serverSettings = {}) { async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, isSingleMediaItem) {
libraryItemPath = filePathToPOSIX(libraryItemPath) libraryItemPath = filePathToPOSIX(libraryItemPath)
const folderFullPath = filePathToPOSIX(folder.fullPath) const folderFullPath = filePathToPOSIX(folder.fullPath)
@ -384,8 +383,7 @@ async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath,
} }
} else { } else {
fileItems = await recurseFiles(libraryItemPath) fileItems = await recurseFiles(libraryItemPath)
const fileNames = fileItems.map(i => i.name) libraryItemData = getDataFromMediaDir(libraryMediaType, folderFullPath, libraryItemDir)
libraryItemData = getDataFromMediaDir(libraryMediaType, folderFullPath, libraryItemDir, serverSettings, fileNames)
} }
const libraryItemDirStats = await getFileTimestampsWithIno(libraryItemData.path) const libraryItemDirStats = await getFileTimestampsWithIno(libraryItemData.path)

View File

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

View File

@ -0,0 +1,52 @@
const Logger = require('../Logger')
const archiver = require('../libs/archiver')
module.exports.zipDirectoryPipe = (path, filename, res) => {
return new Promise((resolve, reject) => {
// create a file to stream archive data to
res.attachment(filename)
const archive = archiver('zip', {
zlib: { level: 9 } // Sets the compression level.
})
// listen for all archive data to be written
// 'close' event is fired only when a file descriptor is involved
res.on('close', () => {
Logger.info(archive.pointer() + ' total bytes')
Logger.debug('archiver has been finalized and the output file descriptor has closed.')
resolve()
})
// This event is fired when the data source is drained no matter what was the data source.
// It is not part of this library but rather from the NodeJS Stream API.
// @see: https://nodejs.org/api/stream.html#stream_event_end
res.on('end', () => {
Logger.debug('Data has been drained')
})
// good practice to catch warnings (ie stat failures and other non-blocking errors)
archive.on('warning', function (err) {
if (err.code === 'ENOENT') {
// log warning
Logger.warn(`[DownloadManager] Archiver warning: ${err.message}`)
} else {
// throw error
Logger.error(`[DownloadManager] Archiver error: ${err.message}`)
// throw err
reject(err)
}
})
archive.on('error', function (err) {
Logger.error(`[DownloadManager] Archiver error: ${err.message}`)
reject(err)
})
// pipe archive data to the file
archive.pipe(res)
archive.directory(path, false)
archive.finalize()
})
}