mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-06-03 13:44:36 -04:00
Merge branch 'master' into show-subtitles
This commit is contained in:
commit
eb5af47bbf
@ -10,6 +10,3 @@ RUN apt-get update && \
|
|||||||
DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends \
|
DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends \
|
||||||
curl tzdata ffmpeg && \
|
curl tzdata ffmpeg && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Move tone executable to appropriate directory
|
|
||||||
COPY --from=sandreas/tone:v0.1.5 /usr/local/bin/tone /usr/local/bin/
|
|
||||||
|
8
.github/workflows/integration-test.yml
vendored
8
.github/workflows/integration-test.yml
vendored
@ -4,7 +4,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
branches-ignore:
|
branches-ignore:
|
||||||
- 'dependabot/**' # Don't run dependabot branches, as they are already covered by pull requests
|
- 'dependabot/**' # Don't run dependabot branches, as they are already covered by pull requests
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@ -18,8 +18,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
- name: install pkg
|
- name: install pkg (using yao-pkg fork for targetting node20)
|
||||||
run: npm install -g pkg
|
run: npm install -g @yao-pkg/pkg
|
||||||
|
|
||||||
- name: get client dependencies
|
- name: get client dependencies
|
||||||
working-directory: client
|
working-directory: client
|
||||||
@ -33,7 +33,7 @@ jobs:
|
|||||||
run: npm ci --only=production
|
run: npm ci --only=production
|
||||||
|
|
||||||
- name: build binary
|
- name: build binary
|
||||||
run: pkg -t node18-linux-x64 -o audiobookshelf .
|
run: pkg -t node20-linux-x64 -o audiobookshelf .
|
||||||
|
|
||||||
- name: run audiobookshelf
|
- name: run audiobookshelf
|
||||||
run: |
|
run: |
|
||||||
|
@ -6,7 +6,6 @@ RUN npm ci && npm cache clean --force
|
|||||||
RUN npm run generate
|
RUN npm run generate
|
||||||
|
|
||||||
### STAGE 1: Build server ###
|
### STAGE 1: Build server ###
|
||||||
FROM sandreas/tone:v0.1.5 AS tone
|
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
@ -21,7 +20,6 @@ RUN apk update && \
|
|||||||
g++ \
|
g++ \
|
||||||
tini
|
tini
|
||||||
|
|
||||||
COPY --from=tone /usr/local/bin/tone /usr/local/bin/
|
|
||||||
COPY --from=build /client/dist /client/dist
|
COPY --from=build /client/dist /client/dist
|
||||||
COPY index.js package* /
|
COPY index.js package* /
|
||||||
COPY server server
|
COPY server server
|
||||||
|
@ -50,7 +50,6 @@ install_ffmpeg() {
|
|||||||
echo "Starting FFMPEG Install"
|
echo "Starting FFMPEG Install"
|
||||||
|
|
||||||
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz --output-document=ffmpeg-git-amd64-static.tar.xz"
|
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz --output-document=ffmpeg-git-amd64-static.tar.xz"
|
||||||
WGET_TONE="wget https://github.com/sandreas/tone/releases/download/v0.1.5/tone-0.1.5-linux-x64.tar.gz --output-document=tone-0.1.5-linux-x64.tar.gz"
|
|
||||||
|
|
||||||
if ! cd "$FFMPEG_INSTALL_DIR"; then
|
if ! cd "$FFMPEG_INSTALL_DIR"; then
|
||||||
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
|
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
|
||||||
@ -63,13 +62,7 @@ install_ffmpeg() {
|
|||||||
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1 --no-same-owner
|
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1 --no-same-owner
|
||||||
rm ffmpeg-git-amd64-static.tar.xz
|
rm ffmpeg-git-amd64-static.tar.xz
|
||||||
|
|
||||||
# Temp downloading tone library to the ffmpeg dir
|
echo "Good to go on Ffmpeg... hopefully"
|
||||||
echo "Getting tone.."
|
|
||||||
$WGET_TONE
|
|
||||||
tar xvf tone-0.1.5-linux-x64.tar.gz --strip-components=1 --no-same-owner
|
|
||||||
rm tone-0.1.5-linux-x64.tar.gz
|
|
||||||
|
|
||||||
echo "Good to go on Ffmpeg (& tone)... hopefully"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setup_config() {
|
setup_config() {
|
||||||
@ -77,12 +70,6 @@ setup_config() {
|
|||||||
echo "Existing config found."
|
echo "Existing config found."
|
||||||
cat $CONFIG_PATH
|
cat $CONFIG_PATH
|
||||||
|
|
||||||
# TONE_PATH variable added in 2.1.6, if it doesnt exist then add it
|
|
||||||
if ! grep -q "TONE_PATH" "$CONFIG_PATH"; then
|
|
||||||
echo "Adding TONE_PATH to existing config"
|
|
||||||
echo "TONE_PATH=$FFMPEG_INSTALL_DIR/tone" >> "$CONFIG_PATH"
|
|
||||||
fi
|
|
||||||
|
|
||||||
else
|
else
|
||||||
|
|
||||||
if [ ! -d "$DEFAULT_DATA_DIR" ]; then
|
if [ ! -d "$DEFAULT_DATA_DIR" ]; then
|
||||||
@ -98,7 +85,6 @@ setup_config() {
|
|||||||
CONFIG_PATH=$DEFAULT_DATA_DIR/config
|
CONFIG_PATH=$DEFAULT_DATA_DIR/config
|
||||||
FFMPEG_PATH=$FFMPEG_INSTALL_DIR/ffmpeg
|
FFMPEG_PATH=$FFMPEG_INSTALL_DIR/ffmpeg
|
||||||
FFPROBE_PATH=$FFMPEG_INSTALL_DIR/ffprobe
|
FFPROBE_PATH=$FFMPEG_INSTALL_DIR/ffprobe
|
||||||
TONE_PATH=$FFMPEG_INSTALL_DIR/tone
|
|
||||||
PORT=$DEFAULT_PORT
|
PORT=$DEFAULT_PORT
|
||||||
HOST=$DEFAULT_HOST"
|
HOST=$DEFAULT_HOST"
|
||||||
|
|
||||||
|
@ -48,7 +48,7 @@ Description: $DESCRIPTION"
|
|||||||
echo "$controlfile" > dist/debian/DEBIAN/control;
|
echo "$controlfile" > dist/debian/DEBIAN/control;
|
||||||
|
|
||||||
# Package debian
|
# Package debian
|
||||||
pkg -t node18-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
|
pkg -t node20-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
|
||||||
|
|
||||||
fakeroot dpkg-deb -Zxz --build dist/debian
|
fakeroot dpkg-deb -Zxz --build dist/debian
|
||||||
|
|
||||||
|
@ -408,6 +408,36 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
shareOpen(mediaItemShare) {
|
||||||
|
this.shelves.forEach((shelf) => {
|
||||||
|
if (shelf.type == 'book') {
|
||||||
|
shelf.entities = shelf.entities.map((ent) => {
|
||||||
|
if (ent.media.id === mediaItemShare.mediaItemId) {
|
||||||
|
return {
|
||||||
|
...ent,
|
||||||
|
mediaItemShare
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ent
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
shareClosed(mediaItemShare) {
|
||||||
|
this.shelves.forEach((shelf) => {
|
||||||
|
if (shelf.type == 'book') {
|
||||||
|
shelf.entities = shelf.entities.map((ent) => {
|
||||||
|
if (ent.media.id === mediaItemShare.mediaItemId) {
|
||||||
|
return {
|
||||||
|
...ent,
|
||||||
|
mediaItemShare: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ent
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
initListeners() {
|
initListeners() {
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
this.$root.socket.on('user_updated', this.userUpdated)
|
this.$root.socket.on('user_updated', this.userUpdated)
|
||||||
@ -419,6 +449,8 @@ export default {
|
|||||||
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
|
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
|
||||||
this.$root.socket.on('items_added', this.libraryItemsAdded)
|
this.$root.socket.on('items_added', this.libraryItemsAdded)
|
||||||
this.$root.socket.on('episode_added', this.episodeAdded)
|
this.$root.socket.on('episode_added', this.episodeAdded)
|
||||||
|
this.$root.socket.on('share_open', this.shareOpen)
|
||||||
|
this.$root.socket.on('share_closed', this.shareClosed)
|
||||||
} else {
|
} else {
|
||||||
console.error('Error socket not initialized')
|
console.error('Error socket not initialized')
|
||||||
}
|
}
|
||||||
@ -434,6 +466,8 @@ export default {
|
|||||||
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
|
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
|
||||||
this.$root.socket.off('items_added', this.libraryItemsAdded)
|
this.$root.socket.off('items_added', this.libraryItemsAdded)
|
||||||
this.$root.socket.off('episode_added', this.episodeAdded)
|
this.$root.socket.off('episode_added', this.episodeAdded)
|
||||||
|
this.$root.socket.off('share_open', this.shareOpen)
|
||||||
|
this.$root.socket.off('share_closed', this.shareClosed)
|
||||||
} else {
|
} else {
|
||||||
console.error('Error socket not initialized')
|
console.error('Error socket not initialized')
|
||||||
}
|
}
|
||||||
|
@ -601,6 +601,30 @@ export default {
|
|||||||
this.executeRebuild()
|
this.executeRebuild()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
shareOpen(mediaItemShare) {
|
||||||
|
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||||
|
var indexOf = this.entities.findIndex((ent) => ent?.media?.id === mediaItemShare.mediaItemId)
|
||||||
|
if (indexOf >= 0) {
|
||||||
|
if (this.entityComponentRefs[indexOf]) {
|
||||||
|
const libraryItem = { ...this.entityComponentRefs[indexOf].libraryItem }
|
||||||
|
libraryItem.mediaItemShare = mediaItemShare
|
||||||
|
this.entityComponentRefs[indexOf].setEntity?.(libraryItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shareClosed(mediaItemShare) {
|
||||||
|
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||||
|
var indexOf = this.entities.findIndex((ent) => ent?.media?.id === mediaItemShare.mediaItemId)
|
||||||
|
if (indexOf >= 0) {
|
||||||
|
if (this.entityComponentRefs[indexOf]) {
|
||||||
|
const libraryItem = { ...this.entityComponentRefs[indexOf].libraryItem }
|
||||||
|
libraryItem.mediaItemShare = null
|
||||||
|
this.entityComponentRefs[indexOf].setEntity?.(libraryItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
updatePagesLoaded() {
|
updatePagesLoaded() {
|
||||||
let numPages = Math.ceil(this.totalEntities / this.booksPerFetch)
|
let numPages = Math.ceil(this.totalEntities / this.booksPerFetch)
|
||||||
for (let page = 0; page < numPages; page++) {
|
for (let page = 0; page < numPages; page++) {
|
||||||
@ -703,6 +727,8 @@ export default {
|
|||||||
this.$root.socket.on('playlist_added', this.playlistAdded)
|
this.$root.socket.on('playlist_added', this.playlistAdded)
|
||||||
this.$root.socket.on('playlist_updated', this.playlistUpdated)
|
this.$root.socket.on('playlist_updated', this.playlistUpdated)
|
||||||
this.$root.socket.on('playlist_removed', this.playlistRemoved)
|
this.$root.socket.on('playlist_removed', this.playlistRemoved)
|
||||||
|
this.$root.socket.on('share_open', this.shareOpen)
|
||||||
|
this.$root.socket.on('share_closed', this.shareClosed)
|
||||||
} else {
|
} else {
|
||||||
console.error('Bookshelf - Socket not initialized')
|
console.error('Bookshelf - Socket not initialized')
|
||||||
}
|
}
|
||||||
@ -730,6 +756,8 @@ export default {
|
|||||||
this.$root.socket.off('playlist_added', this.playlistAdded)
|
this.$root.socket.off('playlist_added', this.playlistAdded)
|
||||||
this.$root.socket.off('playlist_updated', this.playlistUpdated)
|
this.$root.socket.off('playlist_updated', this.playlistUpdated)
|
||||||
this.$root.socket.off('playlist_removed', this.playlistRemoved)
|
this.$root.socket.off('playlist_removed', this.playlistRemoved)
|
||||||
|
this.$root.socket.off('share_open', this.shareOpen)
|
||||||
|
this.$root.socket.off('share_closed', this.shareClosed)
|
||||||
} else {
|
} else {
|
||||||
console.error('Bookshelf - Socket not initialized')
|
console.error('Bookshelf - Socket not initialized')
|
||||||
}
|
}
|
||||||
|
@ -121,7 +121,7 @@
|
|||||||
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
|
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version" />
|
<modals-changelog-view-modal v-model="showChangelogModal" :versionData="versionData" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -219,9 +219,6 @@ export default {
|
|||||||
githubTagUrl() {
|
githubTagUrl() {
|
||||||
return this.versionData.githubTagUrl
|
return this.versionData.githubTagUrl
|
||||||
},
|
},
|
||||||
currentVersionChangelog() {
|
|
||||||
return this.versionData.currentVersionChangelog || 'No Changelog Available'
|
|
||||||
},
|
|
||||||
streamLibraryItem() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.streamLibraryItem
|
return this.$store.state.streamLibraryItem
|
||||||
},
|
},
|
||||||
@ -245,4 +242,4 @@ export default {
|
|||||||
#siderail-buttons-container.player-open {
|
#siderail-buttons-container.player-open {
|
||||||
max-height: calc(100vh - 64px - 48px - 160px);
|
max-height: calc(100vh - 64px - 48px - 160px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -539,6 +539,12 @@ export default {
|
|||||||
func: 'openPlaylists',
|
func: 'openPlaylists',
|
||||||
text: this.$strings.LabelAddToPlaylist
|
text: this.$strings.LabelAddToPlaylist
|
||||||
})
|
})
|
||||||
|
if (this.userIsAdminOrUp) {
|
||||||
|
items.push({
|
||||||
|
func: 'openShare',
|
||||||
|
text: this.$strings.LabelShare
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (this.ebookFormat && this.store.state.libraries.ereaderDevices?.length) {
|
if (this.ebookFormat && this.store.state.libraries.ereaderDevices?.length) {
|
||||||
items.push({
|
items.push({
|
||||||
@ -897,6 +903,10 @@ export default {
|
|||||||
this.store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.recentEpisode }])
|
this.store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.recentEpisode }])
|
||||||
this.store.commit('globals/setShowPlaylistsModal', true)
|
this.store.commit('globals/setShowPlaylistsModal', true)
|
||||||
},
|
},
|
||||||
|
openShare() {
|
||||||
|
this.store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||||
|
this.store.commit('globals/setShareModal', this.mediaItemShare)
|
||||||
|
},
|
||||||
deleteLibraryItem() {
|
deleteLibraryItem() {
|
||||||
const payload = {
|
const payload = {
|
||||||
message: this.$strings.MessageConfirmDeleteLibraryItem,
|
message: this.$strings.MessageConfirmDeleteLibraryItem,
|
||||||
|
@ -89,6 +89,9 @@ export default {
|
|||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
userIsAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
},
|
||||||
libraryMediaType() {
|
libraryMediaType() {
|
||||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||||
},
|
},
|
||||||
@ -148,7 +151,7 @@ export default {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
bookItems() {
|
bookItems() {
|
||||||
return [
|
const items = [
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelAll,
|
text: this.$strings.LabelAll,
|
||||||
value: 'all'
|
value: 'all'
|
||||||
@ -229,13 +232,16 @@ export default {
|
|||||||
text: this.$strings.LabelRSSFeedOpen,
|
text: this.$strings.LabelRSSFeedOpen,
|
||||||
value: 'feed-open',
|
value: 'feed-open',
|
||||||
sublist: false
|
sublist: false
|
||||||
},
|
}
|
||||||
{
|
]
|
||||||
|
if (this.userIsAdminOrUp) {
|
||||||
|
items.push({
|
||||||
text: this.$strings.LabelShareOpen,
|
text: this.$strings.LabelShareOpen,
|
||||||
value: 'share-open',
|
value: 'share-open',
|
||||||
sublist: false
|
sublist: false
|
||||||
}
|
})
|
||||||
]
|
}
|
||||||
|
return items
|
||||||
},
|
},
|
||||||
podcastItems() {
|
podcastItems() {
|
||||||
return [
|
return [
|
||||||
|
@ -6,6 +6,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="px-6 py-8 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
<div class="px-6 py-8 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||||
|
<div class="absolute top-0 right-0 p-4">
|
||||||
|
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||||
|
<a href="https://www.audiobookshelf.org/guides/media-item-shares" target="_blank" class="inline-flex">
|
||||||
|
<span class="material-icons text-xl w-5 text-gray-200">help_outline</span>
|
||||||
|
</a>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
<template v-if="currentShare">
|
<template v-if="currentShare">
|
||||||
<div class="w-full py-2">
|
<div class="w-full py-2">
|
||||||
<label class="px-1 text-sm font-semibold block">{{ $strings.LabelShareURL }}</label>
|
<label class="px-1 text-sm font-semibold block">{{ $strings.LabelShareURL }}</label>
|
||||||
@ -53,17 +60,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {},
|
||||||
value: Boolean,
|
|
||||||
libraryItem: {
|
|
||||||
type: Object,
|
|
||||||
default: () => null
|
|
||||||
},
|
|
||||||
mediaItemShare: {
|
|
||||||
type: Object,
|
|
||||||
default: () => null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
processing: false,
|
processing: false,
|
||||||
@ -99,12 +96,18 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
show: {
|
show: {
|
||||||
get() {
|
get() {
|
||||||
return this.value
|
return this.$store.state.globals.showShareModal
|
||||||
},
|
},
|
||||||
set(val) {
|
set(val) {
|
||||||
this.$emit('input', val)
|
this.$store.commit('globals/setShowShareModal', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
mediaItemShare() {
|
||||||
|
return this.$store.state.globals.selectedMediaItemShare
|
||||||
|
},
|
||||||
|
libraryItem() {
|
||||||
|
return this.$store.state.selectedLibraryItem
|
||||||
|
},
|
||||||
user() {
|
user() {
|
||||||
return this.$store.state.user.user
|
return this.$store.state.user.user
|
||||||
},
|
},
|
||||||
|
@ -6,7 +6,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">
|
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">
|
||||||
<p class="text-xl font-bold pb-4">Changelog v{{ currentVersionNumber }}</p>
|
<p class="text-xl font-bold pb-4">
|
||||||
|
Changelog <a :href="currentTagUrl" target="_blank" class="hover:underline">v{{ currentVersionNumber }}</a> ({{ currentVersionPubDate }})
|
||||||
|
</p>
|
||||||
<div class="custom-text" v-html="compiledMarkedown" />
|
<div class="custom-text" v-html="compiledMarkedown" />
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
@ -18,17 +20,9 @@ import { marked } from '@/static/libs/marked/index.js'
|
|||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
value: Boolean,
|
value: Boolean,
|
||||||
changelog: String,
|
versionData: {
|
||||||
currentVersion: String
|
type: Object,
|
||||||
},
|
default: () => {}
|
||||||
watch: {
|
|
||||||
show: {
|
|
||||||
immediate: true,
|
|
||||||
handler(newVal) {
|
|
||||||
if (newVal) {
|
|
||||||
this.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@ -40,16 +34,27 @@ export default {
|
|||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
dateFormat() {
|
||||||
|
return this.$store.state.serverSettings.dateFormat
|
||||||
|
},
|
||||||
|
changelog() {
|
||||||
|
return this.versionData?.currentVersionChangelog || 'No Changelog Available'
|
||||||
|
},
|
||||||
compiledMarkedown() {
|
compiledMarkedown() {
|
||||||
return marked.parse(this.changelog, { gfm: true, breaks: true })
|
return marked.parse(this.changelog, { gfm: true, breaks: true })
|
||||||
},
|
},
|
||||||
|
currentVersionPubDate() {
|
||||||
|
if (!this.versionData?.currentVersionPubDate) return 'Unknown release date'
|
||||||
|
return `${this.$formatDate(this.versionData.currentVersionPubDate, this.dateFormat)}`
|
||||||
|
},
|
||||||
|
currentTagUrl() {
|
||||||
|
return this.versionData?.currentTagUrl
|
||||||
|
},
|
||||||
currentVersionNumber() {
|
currentVersionNumber() {
|
||||||
return this.currentVersion
|
return this.$config.version
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {},
|
||||||
init() {}
|
|
||||||
},
|
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@ -57,7 +62,7 @@ export default {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
/*
|
/*
|
||||||
1. we need to manually define styles to apply to the parsed markdown elements,
|
1. we need to manually define styles to apply to the parsed markdown elements,
|
||||||
since we don't have access to the actual elements in this component
|
since we don't have access to the actual elements in this component
|
||||||
|
|
||||||
2. v-deep allows these to take effect on the content passed in to the v-html in the div above
|
2. v-deep allows these to take effect on the content passed in to the v-html in the div above
|
||||||
*/
|
*/
|
||||||
@ -70,4 +75,4 @@ since we don't have access to the actual elements in this component
|
|||||||
.custom-text ::v-deep > ul {
|
.custom-text ::v-deep > ul {
|
||||||
@apply list-disc list-inside pb-4;
|
@apply list-disc list-inside pb-4;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -2,11 +2,8 @@
|
|||||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
||||||
<p class="text-xl font-semibold mb-2">{{ $strings.HeaderAudiobookTools }}</p>
|
<p class="text-xl font-semibold mb-2">{{ $strings.HeaderAudiobookTools }}</p>
|
||||||
|
|
||||||
<!-- alert for windows install -->
|
|
||||||
<widgets-alert v-if="isWindowsInstall" type="warning" class="my-8 text-base">Not supported for the Windows install yet</widgets-alert>
|
|
||||||
|
|
||||||
<!-- Merge to m4b -->
|
<!-- Merge to m4b -->
|
||||||
<div v-if="showM4bDownload && !isWindowsInstall" class="w-full border border-black-200 p-4 my-8">
|
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
|
||||||
<div class="flex flex-wrap items-center">
|
<div class="flex flex-wrap items-center">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-lg">{{ $strings.LabelToolsMakeM4b }}</p>
|
<p class="text-lg">{{ $strings.LabelToolsMakeM4b }}</p>
|
||||||
@ -23,7 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Embed Metadata -->
|
<!-- Embed Metadata -->
|
||||||
<div v-if="mediaTracks.length && !isWindowsInstall" class="w-full border border-black-200 p-4 my-8">
|
<div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-lg">{{ $strings.LabelToolsEmbedMetadata }}</p>
|
<p class="text-lg">{{ $strings.LabelToolsEmbedMetadata }}</p>
|
||||||
@ -111,12 +108,6 @@ export default {
|
|||||||
},
|
},
|
||||||
isEncodeTaskRunning() {
|
isEncodeTaskRunning() {
|
||||||
return this.encodeTask && !this.encodeTask?.isFinished
|
return this.encodeTask && !this.encodeTask?.isFinished
|
||||||
},
|
|
||||||
isWindowsInstall() {
|
|
||||||
return this.Source == 'windows'
|
|
||||||
},
|
|
||||||
Source() {
|
|
||||||
return this.$store.state.Source
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@ -141,4 +132,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -171,7 +171,7 @@ export default {
|
|||||||
this.$axios
|
this.$axios
|
||||||
.$get('/api/backups')
|
.$get('/api/backups')
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
this.$emit('loaded', data.backupLocation)
|
this.$emit('loaded', data)
|
||||||
this.setBackups(data.backups || [])
|
this.setBackups(data.backups || [])
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
@ -20,6 +20,7 @@
|
|||||||
<modals-batch-quick-match-model />
|
<modals-batch-quick-match-model />
|
||||||
<modals-rssfeed-open-close-modal />
|
<modals-rssfeed-open-close-modal />
|
||||||
<modals-raw-cover-preview-modal />
|
<modals-raw-cover-preview-modal />
|
||||||
|
<modals-share-modal />
|
||||||
<prompt-confirm />
|
<prompt-confirm />
|
||||||
<readers-reader />
|
<readers-reader />
|
||||||
</div>
|
</div>
|
||||||
@ -598,4 +599,4 @@ export default {
|
|||||||
margin-left: 0px;
|
margin-left: 0px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.10.1",
|
"version": "2.11.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.10.1",
|
"version": "2.11.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/axios": "^5.13.6",
|
"@nuxtjs/axios": "^5.13.6",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.10.1",
|
"version": "2.11.0",
|
||||||
"buildNumber": 1,
|
"buildNumber": 1,
|
||||||
"description": "Self-hosted audiobook and podcast client",
|
"description": "Self-hosted audiobook and podcast client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
@ -11,10 +11,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center mb-2">
|
||||||
<div class="w-full max-w-2xl">
|
<div class="w-full max-w-2xl">
|
||||||
<p class="text-xl mb-1">{{ $strings.HeaderMetadataToEmbed }}</p>
|
<p class="text-xl">{{ $strings.HeaderMetadataToEmbed }}</p>
|
||||||
<p class="mb-2 text-base text-gray-300">audiobookshelf uses <a href="https://github.com/sandreas/tone" target="_blank" class="hover:underline text-blue-400 hover:text-blue-300">tone</a> to write metadata.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full max-w-2xl"></div>
|
<div class="w-full max-w-2xl"></div>
|
||||||
</div>
|
</div>
|
||||||
@ -26,7 +25,7 @@
|
|||||||
<div class="w-2/3 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelValue }}</div>
|
<div class="w-2/3 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelValue }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full max-h-72 overflow-auto">
|
<div class="w-full max-h-72 overflow-auto">
|
||||||
<template v-for="(value, key, index) in toneObject">
|
<template v-for="(value, key, index) in metadataObject">
|
||||||
<div :key="key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
|
<div :key="key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
|
||||||
<div class="w-1/3 font-semibold">{{ key }}</div>
|
<div class="w-1/3 font-semibold">{{ key }}</div>
|
||||||
<div class="w-2/3">
|
<div class="w-2/3">
|
||||||
@ -208,7 +207,7 @@ export default {
|
|||||||
processing: false,
|
processing: false,
|
||||||
audiofilesEncoding: {},
|
audiofilesEncoding: {},
|
||||||
audiofilesFinished: {},
|
audiofilesFinished: {},
|
||||||
toneObject: null,
|
metadataObject: null,
|
||||||
selectedTool: 'embed',
|
selectedTool: 'embed',
|
||||||
isCancelingEncode: false,
|
isCancelingEncode: false,
|
||||||
showEncodeOptions: false,
|
showEncodeOptions: false,
|
||||||
@ -387,7 +386,7 @@ export default {
|
|||||||
window.history.replaceState({ path: newurl }, '', newurl)
|
window.history.replaceState({ path: newurl }, '', newurl)
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.fetchToneObject()
|
this.fetchMetadataEmbedObject()
|
||||||
if (this.$route.query.tool === 'm4b') {
|
if (this.$route.query.tool === 'm4b') {
|
||||||
if (this.availableTools.some((t) => t.value === 'm4b')) {
|
if (this.availableTools.some((t) => t.value === 'm4b')) {
|
||||||
this.selectedTool = 'm4b'
|
this.selectedTool = 'm4b'
|
||||||
@ -401,15 +400,14 @@ export default {
|
|||||||
const shouldBackupAudioFiles = localStorage.getItem('embedMetadataShouldBackup')
|
const shouldBackupAudioFiles = localStorage.getItem('embedMetadataShouldBackup')
|
||||||
this.shouldBackupAudioFiles = shouldBackupAudioFiles != 0
|
this.shouldBackupAudioFiles = shouldBackupAudioFiles != 0
|
||||||
},
|
},
|
||||||
fetchToneObject() {
|
fetchMetadataEmbedObject() {
|
||||||
this.$axios
|
this.$axios
|
||||||
.$get(`/api/items/${this.libraryItemId}/tone-object`)
|
.$get(`/api/items/${this.libraryItemId}/metadata-object`)
|
||||||
.then((toneObject) => {
|
.then((metadataObject) => {
|
||||||
delete toneObject.CoverFile
|
this.metadataObject = metadataObject
|
||||||
this.toneObject = toneObject
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to fetch tone object', error)
|
console.error('Failed to fetch metadata object', error)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
taskUpdated(task) {
|
taskUpdated(task) {
|
||||||
@ -426,4 +424,4 @@ export default {
|
|||||||
this.$root.socket.off('audiofile_metadata_finished', this.audiofileMetadataFinished)
|
this.$root.socket.off('audiofile_metadata_finished', this.audiofileMetadataFinished)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -16,11 +16,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<form class="flex items-center w-full space-x-1" @submit.prevent="saveBackupPath">
|
<form class="flex items-center w-full space-x-1" @submit.prevent="saveBackupPath">
|
||||||
<ui-text-input v-model="newBackupLocation" :disabled="savingBackupPath" class="w-full max-w-[calc(100%-50px)] text-sm h-8" />
|
<ui-text-input v-model="newBackupLocation" :disabled="savingBackupPath || !canEditBackup" class="w-full max-w-[calc(100%-50px)] text-sm h-8" />
|
||||||
<ui-btn small :loading="savingBackupPath" color="success" type="submit" class="h-8">{{ $strings.ButtonSave }}</ui-btn>
|
<ui-btn v-if="canEditBackup" small :loading="savingBackupPath" color="success" type="submit" class="h-8">{{ $strings.ButtonSave }}</ui-btn>
|
||||||
<ui-btn small :disabled="savingBackupPath" type="button" class="h-8" @click="cancelEditBackupPath">{{ $strings.ButtonCancel }}</ui-btn>
|
<ui-btn small :disabled="savingBackupPath" type="button" class="h-8" @click="cancelEditBackupPath">{{ $strings.ButtonCancel }}</ui-btn>
|
||||||
</form>
|
</form>
|
||||||
<p class="text-sm text-warning/80 pt-1">{{ $strings.MessageBackupsLocationEditNote }}</p>
|
<p class="text-sm text-warning/80 pt-1">{{ canEditBackup ? $strings.MessageBackupsLocationEditNote : $strings.MessageBackupsLocationNoEditNote }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -92,6 +92,7 @@ export default {
|
|||||||
newServerSettings: {},
|
newServerSettings: {},
|
||||||
showCronBuilder: false,
|
showCronBuilder: false,
|
||||||
showEditBackupPath: false,
|
showEditBackupPath: false,
|
||||||
|
backupPathEnvSet: false,
|
||||||
backupLocation: '',
|
backupLocation: '',
|
||||||
newBackupLocation: '',
|
newBackupLocation: '',
|
||||||
savingBackupPath: false
|
savingBackupPath: false
|
||||||
@ -115,6 +116,10 @@ export default {
|
|||||||
timeFormat() {
|
timeFormat() {
|
||||||
return this.serverSettings.timeFormat
|
return this.serverSettings.timeFormat
|
||||||
},
|
},
|
||||||
|
canEditBackup() {
|
||||||
|
// Prevent editing of backup path if an environment variable is set
|
||||||
|
return !this.backupPathEnvSet
|
||||||
|
},
|
||||||
scheduleDescription() {
|
scheduleDescription() {
|
||||||
if (!this.cronExpression) return ''
|
if (!this.cronExpression) return ''
|
||||||
const parsed = this.$parseCronExpression(this.cronExpression)
|
const parsed = this.$parseCronExpression(this.cronExpression)
|
||||||
@ -127,9 +132,10 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
backupsLoaded(backupLocation) {
|
backupsLoaded(data) {
|
||||||
this.backupLocation = backupLocation
|
this.backupLocation = data.backupLocation
|
||||||
this.newBackupLocation = backupLocation
|
this.newBackupLocation = data.backupLocation
|
||||||
|
this.backupPathEnvSet = data.backupPathEnvSet
|
||||||
},
|
},
|
||||||
cancelEditBackupPath() {
|
cancelEditBackupPath() {
|
||||||
this.newBackupLocation = this.backupLocation
|
this.newBackupLocation = this.backupLocation
|
||||||
|
@ -147,7 +147,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-share-modal v-model="showShareModal" :media-item-share="mediaItemShare" :library-item="libraryItem" @opened="openedShare" @removed="removedShare" />
|
|
||||||
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
|
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
|
||||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :library-item-id="libraryItemId" hide-create @select="selectBookmark" />
|
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :library-item-id="libraryItemId" hide-create @select="selectBookmark" />
|
||||||
</div>
|
</div>
|
||||||
@ -186,8 +185,7 @@ export default {
|
|||||||
episodeDownloadsQueued: [],
|
episodeDownloadsQueued: [],
|
||||||
showBookmarksModal: false,
|
showBookmarksModal: false,
|
||||||
isDescriptionClamped: false,
|
isDescriptionClamped: false,
|
||||||
showFullDescription: false,
|
showFullDescription: false
|
||||||
showShareModal: false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@ -440,7 +438,7 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.userIsAdminOrUp && !this.isPodcast) {
|
if (this.userIsAdminOrUp && !this.isPodcast && this.tracks.length) {
|
||||||
items.push({
|
items.push({
|
||||||
text: this.$strings.LabelShare,
|
text: this.$strings.LabelShare,
|
||||||
action: 'share'
|
action: 'share'
|
||||||
@ -458,12 +456,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
openedShare(mediaItemShare) {
|
|
||||||
this.mediaItemShare = mediaItemShare
|
|
||||||
},
|
|
||||||
removedShare() {
|
|
||||||
this.mediaItemShare = null
|
|
||||||
},
|
|
||||||
selectBookmark(bookmark) {
|
selectBookmark(bookmark) {
|
||||||
if (!bookmark) return
|
if (!bookmark) return
|
||||||
if (this.isStreaming) {
|
if (this.isStreaming) {
|
||||||
@ -682,6 +674,16 @@ export default {
|
|||||||
this.rssFeed = null
|
this.rssFeed = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
shareOpen(mediaItemShare) {
|
||||||
|
if (mediaItemShare.mediaItemId === this.media.id) {
|
||||||
|
this.mediaItemShare = mediaItemShare
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shareClosed(mediaItemShare) {
|
||||||
|
if (mediaItemShare.mediaItemId === this.media.id) {
|
||||||
|
this.mediaItemShare = null
|
||||||
|
}
|
||||||
|
},
|
||||||
queueBtnClick() {
|
queueBtnClick() {
|
||||||
if (this.isQueued) {
|
if (this.isQueued) {
|
||||||
// Remove from queue
|
// Remove from queue
|
||||||
@ -778,7 +780,8 @@ export default {
|
|||||||
} else if (action === 'sendToDevice') {
|
} else if (action === 'sendToDevice') {
|
||||||
this.sendToDevice(data)
|
this.sendToDevice(data)
|
||||||
} else if (action === 'share') {
|
} else if (action === 'share') {
|
||||||
this.showShareModal = true
|
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||||
|
this.$store.commit('globals/setShareModal', this.mediaItemShare)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -796,6 +799,8 @@ export default {
|
|||||||
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
||||||
this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
|
this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
|
||||||
this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
|
this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
|
||||||
|
this.$root.socket.on('share_open', this.shareOpen)
|
||||||
|
this.$root.socket.on('share_closed', this.shareClosed)
|
||||||
this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued)
|
this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued)
|
||||||
this.$root.socket.on('episode_download_started', this.episodeDownloadStarted)
|
this.$root.socket.on('episode_download_started', this.episodeDownloadStarted)
|
||||||
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
|
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
|
||||||
@ -805,6 +810,8 @@ export default {
|
|||||||
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||||
this.$root.socket.off('rss_feed_open', this.rssFeedOpen)
|
this.$root.socket.off('rss_feed_open', this.rssFeedOpen)
|
||||||
this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)
|
this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)
|
||||||
|
this.$root.socket.off('share_open', this.shareOpen)
|
||||||
|
this.$root.socket.off('share_closed', this.shareClosed)
|
||||||
this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued)
|
this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued)
|
||||||
this.$root.socket.off('episode_download_started', this.episodeDownloadStarted)
|
this.$root.socket.off('episode_download_started', this.episodeDownloadStarted)
|
||||||
this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)
|
this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)
|
||||||
|
@ -49,11 +49,11 @@ export async function checkForUpdate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (verObj.version == currVerObj.version) {
|
if (verObj.version == currVerObj.version) {
|
||||||
|
currVerObj.pubdate = new Date(release.published_at)
|
||||||
currVerObj.changelog = release.body
|
currVerObj.changelog = release.body
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
if (!largestVer) {
|
if (!largestVer) {
|
||||||
console.error('No valid version tags to compare with')
|
console.error('No valid version tags to compare with')
|
||||||
@ -65,6 +65,8 @@ export async function checkForUpdate() {
|
|||||||
latestVersion: largestVer.version,
|
latestVersion: largestVer.version,
|
||||||
githubTagUrl: `https://github.com/advplyr/audiobookshelf/releases/tag/v${largestVer.version}`,
|
githubTagUrl: `https://github.com/advplyr/audiobookshelf/releases/tag/v${largestVer.version}`,
|
||||||
currentVersion: currVerObj.version,
|
currentVersion: currVerObj.version,
|
||||||
|
currentTagUrl: `https://github.com/advplyr/audiobookshelf/releases/tag/v${currVerObj.version}`,
|
||||||
|
currentVersionPubDate: currVerObj.pubdate,
|
||||||
currentVersionChangelog: currVerObj.changelog
|
currentVersionChangelog: currVerObj.changelog
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@ export const state = () => ({
|
|||||||
showEditPodcastEpisode: false,
|
showEditPodcastEpisode: false,
|
||||||
showViewPodcastEpisodeModal: false,
|
showViewPodcastEpisodeModal: false,
|
||||||
showRSSFeedOpenCloseModal: false,
|
showRSSFeedOpenCloseModal: false,
|
||||||
|
showShareModal: false,
|
||||||
showConfirmPrompt: false,
|
showConfirmPrompt: false,
|
||||||
showRawCoverPreviewModal: false,
|
showRawCoverPreviewModal: false,
|
||||||
confirmPromptOptions: null,
|
confirmPromptOptions: null,
|
||||||
@ -22,6 +23,7 @@ export const state = () => ({
|
|||||||
selectedAuthor: null,
|
selectedAuthor: null,
|
||||||
selectedMediaItems: [],
|
selectedMediaItems: [],
|
||||||
selectedRawCoverUrl: null,
|
selectedRawCoverUrl: null,
|
||||||
|
selectedMediaItemShare: null,
|
||||||
isCasting: false, // Actively casting
|
isCasting: false, // Actively casting
|
||||||
isChromecastInitialized: false, // Script loadeds
|
isChromecastInitialized: false, // Script loadeds
|
||||||
showBatchQuickMatchModal: false,
|
showBatchQuickMatchModal: false,
|
||||||
@ -157,6 +159,13 @@ export const mutations = {
|
|||||||
state.rssFeedEntity = entity
|
state.rssFeedEntity = entity
|
||||||
state.showRSSFeedOpenCloseModal = true
|
state.showRSSFeedOpenCloseModal = true
|
||||||
},
|
},
|
||||||
|
setShowShareModal(state, val) {
|
||||||
|
state.showShareModal = val
|
||||||
|
},
|
||||||
|
setShareModal(state, mediaItemShare) {
|
||||||
|
state.selectedMediaItemShare = mediaItemShare
|
||||||
|
state.showShareModal = true
|
||||||
|
},
|
||||||
setShowConfirmPrompt(state, val) {
|
setShowConfirmPrompt(state, val) {
|
||||||
state.showConfirmPrompt = val
|
state.showConfirmPrompt = val
|
||||||
},
|
},
|
||||||
|
@ -258,6 +258,7 @@
|
|||||||
"LabelCurrently": "Aktuell:",
|
"LabelCurrently": "Aktuell:",
|
||||||
"LabelCustomCronExpression": "Benutzerdefinierter Cron-Ausdruck:",
|
"LabelCustomCronExpression": "Benutzerdefinierter Cron-Ausdruck:",
|
||||||
"LabelDatetime": "Datum & Uhrzeit",
|
"LabelDatetime": "Datum & Uhrzeit",
|
||||||
|
"LabelDays": "Tage",
|
||||||
"LabelDeleteFromFileSystemCheckbox": "Löschen von der Festplatte + Datenbank (deaktivieren um nur aus der Datenbank zu löschen)",
|
"LabelDeleteFromFileSystemCheckbox": "Löschen von der Festplatte + Datenbank (deaktivieren um nur aus der Datenbank zu löschen)",
|
||||||
"LabelDescription": "Beschreibung",
|
"LabelDescription": "Beschreibung",
|
||||||
"LabelDeselectAll": "Alles abwählen",
|
"LabelDeselectAll": "Alles abwählen",
|
||||||
@ -321,6 +322,7 @@
|
|||||||
"LabelHighestPriority": "Höchste Priorität",
|
"LabelHighestPriority": "Höchste Priorität",
|
||||||
"LabelHost": "Host",
|
"LabelHost": "Host",
|
||||||
"LabelHour": "Stunde",
|
"LabelHour": "Stunde",
|
||||||
|
"LabelHours": "Stunden",
|
||||||
"LabelIcon": "Symbol",
|
"LabelIcon": "Symbol",
|
||||||
"LabelImageURLFromTheWeb": "Bild-URL vom Internet",
|
"LabelImageURLFromTheWeb": "Bild-URL vom Internet",
|
||||||
"LabelInProgress": "In Bearbeitung",
|
"LabelInProgress": "In Bearbeitung",
|
||||||
@ -371,6 +373,7 @@
|
|||||||
"LabelMetadataOrderOfPrecedenceDescription": "Höher priorisierte Quellen für Metadaten überschreiben Metadaten aus Quellen mit niedrigerer Priorität",
|
"LabelMetadataOrderOfPrecedenceDescription": "Höher priorisierte Quellen für Metadaten überschreiben Metadaten aus Quellen mit niedrigerer Priorität",
|
||||||
"LabelMetadataProvider": "Metadatenanbieter",
|
"LabelMetadataProvider": "Metadatenanbieter",
|
||||||
"LabelMinute": "Minute",
|
"LabelMinute": "Minute",
|
||||||
|
"LabelMinutes": "Minuten",
|
||||||
"LabelMissing": "Fehlend",
|
"LabelMissing": "Fehlend",
|
||||||
"LabelMissingEbook": "E-Book fehlt",
|
"LabelMissingEbook": "E-Book fehlt",
|
||||||
"LabelMissingSupplementaryEbook": "Ergänzendes E-Book fehlt",
|
"LabelMissingSupplementaryEbook": "Ergänzendes E-Book fehlt",
|
||||||
@ -410,6 +413,7 @@
|
|||||||
"LabelOverwrite": "Überschreiben",
|
"LabelOverwrite": "Überschreiben",
|
||||||
"LabelPassword": "Passwort",
|
"LabelPassword": "Passwort",
|
||||||
"LabelPath": "Pfad",
|
"LabelPath": "Pfad",
|
||||||
|
"LabelPermanent": "Dauerhaft",
|
||||||
"LabelPermissionsAccessAllLibraries": "Zugriff auf alle Bibliotheken",
|
"LabelPermissionsAccessAllLibraries": "Zugriff auf alle Bibliotheken",
|
||||||
"LabelPermissionsAccessAllTags": "Zugriff auf alle Schlagwörter",
|
"LabelPermissionsAccessAllTags": "Zugriff auf alle Schlagwörter",
|
||||||
"LabelPermissionsAccessExplicitContent": "Zugriff auf explizite (alterbeschränkte) Inhalte",
|
"LabelPermissionsAccessExplicitContent": "Zugriff auf explizite (alterbeschränkte) Inhalte",
|
||||||
@ -507,6 +511,9 @@
|
|||||||
"LabelSettingsStoreMetadataWithItem": "Metadaten als OPF-Datei im Medienordner speichern",
|
"LabelSettingsStoreMetadataWithItem": "Metadaten als OPF-Datei im Medienordner speichern",
|
||||||
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet",
|
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet",
|
||||||
"LabelSettingsTimeFormat": "Zeitformat",
|
"LabelSettingsTimeFormat": "Zeitformat",
|
||||||
|
"LabelShare": "Teilen",
|
||||||
|
"LabelShareOpen": "Teilen Offen",
|
||||||
|
"LabelShareURL": "URL teilen",
|
||||||
"LabelShowAll": "Alles anzeigen",
|
"LabelShowAll": "Alles anzeigen",
|
||||||
"LabelShowSeconds": "Zeige Sekunden",
|
"LabelShowSeconds": "Zeige Sekunden",
|
||||||
"LabelSize": "Größe",
|
"LabelSize": "Größe",
|
||||||
@ -598,6 +605,7 @@
|
|||||||
"MessageAppriseDescription": "Um diese Funktion nutzen zu können, musst du eine Instanz von <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> laufen haben oder eine API verwenden welche dieselbe Anfragen bearbeiten kann. <br />Die Apprise API Url muss der vollständige URL-Pfad sein, an den die Benachrichtigung gesendet werden soll, z.B. wenn Ihre API-Instanz unter <code>http://192.168.1.1:8337</code> läuft, würdest du <code>http://192.168.1.1:8337/notify</code> eingeben.",
|
"MessageAppriseDescription": "Um diese Funktion nutzen zu können, musst du eine Instanz von <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> laufen haben oder eine API verwenden welche dieselbe Anfragen bearbeiten kann. <br />Die Apprise API Url muss der vollständige URL-Pfad sein, an den die Benachrichtigung gesendet werden soll, z.B. wenn Ihre API-Instanz unter <code>http://192.168.1.1:8337</code> läuft, würdest du <code>http://192.168.1.1:8337/notify</code> eingeben.",
|
||||||
"MessageBackupsDescription": "In einer Sicherung werden Benutzer, Benutzerfortschritte, Details zu den Bibliotheksobjekten, Servereinstellungen und Bilder welche in <code>/metadata/items</code> & <code>/metadata/authors</code> gespeichert sind gespeichert. Sicherungen enthalten keine Dateien welche in den einzelnen Bibliotheksordnern (Medien-Ordnern) gespeichert sind.",
|
"MessageBackupsDescription": "In einer Sicherung werden Benutzer, Benutzerfortschritte, Details zu den Bibliotheksobjekten, Servereinstellungen und Bilder welche in <code>/metadata/items</code> & <code>/metadata/authors</code> gespeichert sind gespeichert. Sicherungen enthalten keine Dateien welche in den einzelnen Bibliotheksordnern (Medien-Ordnern) gespeichert sind.",
|
||||||
"MessageBackupsLocationEditNote": "Hinweis: Durch das Aktualisieren des Backup-Speicherorts werden vorhandene Sicherungen nicht verschoben oder geändert",
|
"MessageBackupsLocationEditNote": "Hinweis: Durch das Aktualisieren des Backup-Speicherorts werden vorhandene Sicherungen nicht verschoben oder geändert",
|
||||||
|
"MessageBackupsLocationNoEditNote": "Hinweis: Der Sicherungsspeicherort wird über eine Umgebungsvariable festgelegt und kann hier nicht geändert werden.",
|
||||||
"MessageBackupsLocationPathEmpty": "Der Backup-Pfad darf nicht leer sein",
|
"MessageBackupsLocationPathEmpty": "Der Backup-Pfad darf nicht leer sein",
|
||||||
"MessageBatchQuickMatchDescription": "Der Schnellabgleich versucht, fehlende Titelbilder und Metadaten für die ausgewählten Artikel hinzuzufügen. Aktiviere die nachstehenden Optionen, damit der Schnellabgleich vorhandene Titelbilder und/oder Metadaten überschreiben kann.",
|
"MessageBatchQuickMatchDescription": "Der Schnellabgleich versucht, fehlende Titelbilder und Metadaten für die ausgewählten Artikel hinzuzufügen. Aktiviere die nachstehenden Optionen, damit der Schnellabgleich vorhandene Titelbilder und/oder Metadaten überschreiben kann.",
|
||||||
"MessageBookshelfNoCollections": "Es wurden noch keine Sammlungen erstellt",
|
"MessageBookshelfNoCollections": "Es wurden noch keine Sammlungen erstellt",
|
||||||
@ -716,6 +724,9 @@
|
|||||||
"MessageSelected": "{0} ausgewählt",
|
"MessageSelected": "{0} ausgewählt",
|
||||||
"MessageServerCouldNotBeReached": "Server kann nicht erreicht werden",
|
"MessageServerCouldNotBeReached": "Server kann nicht erreicht werden",
|
||||||
"MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird",
|
"MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird",
|
||||||
|
"MessageShareExpirationWillBe": "Läuft am <strong>{0}</strong> ab",
|
||||||
|
"MessageShareExpiresIn": "Läuft in {0} ab",
|
||||||
|
"MessageShareURLWillBe": "Der geteilte Link wird <strong>{0}</strong> sein.",
|
||||||
"MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?",
|
"MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?",
|
||||||
"MessageThinking": "Nachdenken...",
|
"MessageThinking": "Nachdenken...",
|
||||||
"MessageUploaderItemFailed": "Hochladen fehlgeschlagen",
|
"MessageUploaderItemFailed": "Hochladen fehlgeschlagen",
|
||||||
|
@ -611,6 +611,7 @@
|
|||||||
"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>.",
|
"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.",
|
"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.",
|
||||||
"MessageBackupsLocationEditNote": "Note: Updating the backup location will not move or modify existing backups",
|
"MessageBackupsLocationEditNote": "Note: Updating the backup location will not move or modify existing backups",
|
||||||
|
"MessageBackupsLocationNoEditNote": "Note: The backup location is set through an environment variable and cannot be changed here.",
|
||||||
"MessageBackupsLocationPathEmpty": "Backup location path cannot be empty",
|
"MessageBackupsLocationPathEmpty": "Backup location path cannot be empty",
|
||||||
"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.",
|
"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",
|
"MessageBookshelfNoCollections": "You haven't made any collections yet",
|
||||||
|
@ -258,6 +258,7 @@
|
|||||||
"LabelCurrently": "En este momento:",
|
"LabelCurrently": "En este momento:",
|
||||||
"LabelCustomCronExpression": "Expresión de Cron Personalizada:",
|
"LabelCustomCronExpression": "Expresión de Cron Personalizada:",
|
||||||
"LabelDatetime": "Hora y Fecha",
|
"LabelDatetime": "Hora y Fecha",
|
||||||
|
"LabelDays": "Días",
|
||||||
"LabelDeleteFromFileSystemCheckbox": "Eliminar archivos del sistema (desmarcar para eliminar sólo de la base de datos)",
|
"LabelDeleteFromFileSystemCheckbox": "Eliminar archivos del sistema (desmarcar para eliminar sólo de la base de datos)",
|
||||||
"LabelDescription": "Descripción",
|
"LabelDescription": "Descripción",
|
||||||
"LabelDeselectAll": "Deseleccionar Todos",
|
"LabelDeselectAll": "Deseleccionar Todos",
|
||||||
@ -321,6 +322,7 @@
|
|||||||
"LabelHighestPriority": "Mayor prioridad",
|
"LabelHighestPriority": "Mayor prioridad",
|
||||||
"LabelHost": "Host",
|
"LabelHost": "Host",
|
||||||
"LabelHour": "Hora",
|
"LabelHour": "Hora",
|
||||||
|
"LabelHours": "Horas",
|
||||||
"LabelIcon": "Icono",
|
"LabelIcon": "Icono",
|
||||||
"LabelImageURLFromTheWeb": "URL de la imagen",
|
"LabelImageURLFromTheWeb": "URL de la imagen",
|
||||||
"LabelInProgress": "En proceso",
|
"LabelInProgress": "En proceso",
|
||||||
@ -371,6 +373,7 @@
|
|||||||
"LabelMetadataOrderOfPrecedenceDescription": "Las fuentes de metadatos de mayor prioridad prevalecerán sobre las de menor prioridad",
|
"LabelMetadataOrderOfPrecedenceDescription": "Las fuentes de metadatos de mayor prioridad prevalecerán sobre las de menor prioridad",
|
||||||
"LabelMetadataProvider": "Proveedor de Metadatos",
|
"LabelMetadataProvider": "Proveedor de Metadatos",
|
||||||
"LabelMinute": "Minuto",
|
"LabelMinute": "Minuto",
|
||||||
|
"LabelMinutes": "Minutos",
|
||||||
"LabelMissing": "Ausente",
|
"LabelMissing": "Ausente",
|
||||||
"LabelMissingEbook": "No tiene ebook",
|
"LabelMissingEbook": "No tiene ebook",
|
||||||
"LabelMissingSupplementaryEbook": "No tiene ebook suplementario",
|
"LabelMissingSupplementaryEbook": "No tiene ebook suplementario",
|
||||||
@ -410,6 +413,7 @@
|
|||||||
"LabelOverwrite": "Sobrescribir",
|
"LabelOverwrite": "Sobrescribir",
|
||||||
"LabelPassword": "Contraseña",
|
"LabelPassword": "Contraseña",
|
||||||
"LabelPath": "Ruta de carpeta",
|
"LabelPath": "Ruta de carpeta",
|
||||||
|
"LabelPermanent": "Permanente",
|
||||||
"LabelPermissionsAccessAllLibraries": "Puede Accesar a Todas las bibliotecas",
|
"LabelPermissionsAccessAllLibraries": "Puede Accesar a Todas las bibliotecas",
|
||||||
"LabelPermissionsAccessAllTags": "Pueda Accesar a Todas las Etiquetas",
|
"LabelPermissionsAccessAllTags": "Pueda Accesar a Todas las Etiquetas",
|
||||||
"LabelPermissionsAccessExplicitContent": "Puede Accesar a Contenido Explicito",
|
"LabelPermissionsAccessExplicitContent": "Puede Accesar a Contenido Explicito",
|
||||||
@ -507,6 +511,8 @@
|
|||||||
"LabelSettingsStoreMetadataWithItem": "Guardar metadatos con elementos",
|
"LabelSettingsStoreMetadataWithItem": "Guardar metadatos con elementos",
|
||||||
"LabelSettingsStoreMetadataWithItemHelp": "Por defecto, los archivos de metadatos se almacenan en /metadata/items. Si habilita esta opción, los archivos de metadatos se guardarán en la carpeta de elementos de su biblioteca",
|
"LabelSettingsStoreMetadataWithItemHelp": "Por defecto, los archivos de metadatos se almacenan en /metadata/items. Si habilita esta opción, los archivos de metadatos se guardarán en la carpeta de elementos de su biblioteca",
|
||||||
"LabelSettingsTimeFormat": "Formato de Tiempo",
|
"LabelSettingsTimeFormat": "Formato de Tiempo",
|
||||||
|
"LabelShare": "Compartir",
|
||||||
|
"LabelShareURL": "Compartir la URL",
|
||||||
"LabelShowAll": "Mostrar Todos",
|
"LabelShowAll": "Mostrar Todos",
|
||||||
"LabelShowSeconds": "Mostrar segundos",
|
"LabelShowSeconds": "Mostrar segundos",
|
||||||
"LabelSize": "Tamaño",
|
"LabelSize": "Tamaño",
|
||||||
@ -598,6 +604,7 @@
|
|||||||
"MessageAppriseDescription": "Para usar esta función deberás tener <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">la API de Apprise</a> corriendo o una API que maneje los mismos resultados. <br/>La URL de la API de Apprise debe tener la misma ruta de archivos que donde se envían las notificaciones. Por ejemplo: si su API esta en <code>http://192.168.1.1:8337</code> entonces pondría <code>http://192.168.1.1:8337/notify</code>.",
|
"MessageAppriseDescription": "Para usar esta función deberás tener <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">la API de Apprise</a> corriendo o una API que maneje los mismos resultados. <br/>La URL de la API de Apprise debe tener la misma ruta de archivos que donde se envían las notificaciones. Por ejemplo: si su API esta en <code>http://192.168.1.1:8337</code> entonces pondría <code>http://192.168.1.1:8337/notify</code>.",
|
||||||
"MessageBackupsDescription": "Los respaldos incluyen: usuarios, el progreso del los usuarios, los detalles de los elementos de la biblioteca, la configuración del servidor y las imágenes en <code>/metadata/items</code> y <code>/metadata/authors</code>. Los Respaldos <strong>NO</strong> incluyen ningún archivo guardado en la carpeta de tu biblioteca.",
|
"MessageBackupsDescription": "Los respaldos incluyen: usuarios, el progreso del los usuarios, los detalles de los elementos de la biblioteca, la configuración del servidor y las imágenes en <code>/metadata/items</code> y <code>/metadata/authors</code>. Los Respaldos <strong>NO</strong> incluyen ningún archivo guardado en la carpeta de tu biblioteca.",
|
||||||
"MessageBackupsLocationEditNote": "Nota: Actualizar la ubicación de la copia de seguridad no moverá ni modificará las copias de seguridad existentes",
|
"MessageBackupsLocationEditNote": "Nota: Actualizar la ubicación de la copia de seguridad no moverá ni modificará las copias de seguridad existentes",
|
||||||
|
"MessageBackupsLocationNoEditNote": "Nota: La ubicación de la copia de seguridad se establece a través de una variable de entorno y no se puede cambiar aquí.",
|
||||||
"MessageBackupsLocationPathEmpty": "La ruta de la copia de seguridad no puede estar vacía",
|
"MessageBackupsLocationPathEmpty": "La ruta de la copia de seguridad no puede estar vacía",
|
||||||
"MessageBatchQuickMatchDescription": "\"Encontrar Rápido\" tratará de agregar portadas y metadatos faltantes de los elementos seleccionados. Habilite la opción de abajo para que \"Encontrar Rápido\" pueda sobrescribir portadas y/o metadatos existentes.",
|
"MessageBatchQuickMatchDescription": "\"Encontrar Rápido\" tratará de agregar portadas y metadatos faltantes de los elementos seleccionados. Habilite la opción de abajo para que \"Encontrar Rápido\" pueda sobrescribir portadas y/o metadatos existentes.",
|
||||||
"MessageBookshelfNoCollections": "No tienes ninguna colección.",
|
"MessageBookshelfNoCollections": "No tienes ninguna colección.",
|
||||||
@ -716,6 +723,9 @@
|
|||||||
"MessageSelected": "{0} seleccionado(s)",
|
"MessageSelected": "{0} seleccionado(s)",
|
||||||
"MessageServerCouldNotBeReached": "No se pudo establecer la conexión con el servidor",
|
"MessageServerCouldNotBeReached": "No se pudo establecer la conexión con el servidor",
|
||||||
"MessageSetChaptersFromTracksDescription": "Establecer capítulos usando cada archivo de audio como un capítulo y el título del capítulo como el nombre del archivo de audio",
|
"MessageSetChaptersFromTracksDescription": "Establecer capítulos usando cada archivo de audio como un capítulo y el título del capítulo como el nombre del archivo de audio",
|
||||||
|
"MessageShareExpirationWillBe": "La caducidad será <strong>{0}</strong>",
|
||||||
|
"MessageShareExpiresIn": "Caduduca en {0}",
|
||||||
|
"MessageShareURLWillBe": "La URL para compartir será <strong> {0} </strong>",
|
||||||
"MessageStartPlaybackAtTime": "Iniciar reproducción para \"{0}\" en {1}?",
|
"MessageStartPlaybackAtTime": "Iniciar reproducción para \"{0}\" en {1}?",
|
||||||
"MessageThinking": "Pensando...",
|
"MessageThinking": "Pensando...",
|
||||||
"MessageUploaderItemFailed": "Error al Subir",
|
"MessageUploaderItemFailed": "Error al Subir",
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
"ButtonApply": "Zatwierdź",
|
"ButtonApply": "Zatwierdź",
|
||||||
"ButtonApplyChapters": "Zatwierdź rozdziały",
|
"ButtonApplyChapters": "Zatwierdź rozdziały",
|
||||||
"ButtonAuthors": "Autorzy",
|
"ButtonAuthors": "Autorzy",
|
||||||
"ButtonBack": "Back",
|
"ButtonBack": "Wstecz",
|
||||||
"ButtonBrowseForFolder": "Wyszukaj folder",
|
"ButtonBrowseForFolder": "Wyszukaj folder",
|
||||||
"ButtonCancel": "Anuluj",
|
"ButtonCancel": "Anuluj",
|
||||||
"ButtonCancelEncode": "Anuluj enkodowanie",
|
"ButtonCancelEncode": "Anuluj enkodowanie",
|
||||||
@ -25,7 +25,7 @@
|
|||||||
"ButtonCreateBackup": "Utwórz kopię zapasową",
|
"ButtonCreateBackup": "Utwórz kopię zapasową",
|
||||||
"ButtonDelete": "Usuń",
|
"ButtonDelete": "Usuń",
|
||||||
"ButtonDownloadQueue": "Kolejka",
|
"ButtonDownloadQueue": "Kolejka",
|
||||||
"ButtonEdit": "Edit",
|
"ButtonEdit": "Edycja",
|
||||||
"ButtonEditChapters": "Edytuj rozdziały",
|
"ButtonEditChapters": "Edytuj rozdziały",
|
||||||
"ButtonEditPodcast": "Edytuj podcast",
|
"ButtonEditPodcast": "Edytuj podcast",
|
||||||
"ButtonForceReScan": "Wymuś ponowne skanowanie",
|
"ButtonForceReScan": "Wymuś ponowne skanowanie",
|
||||||
@ -35,7 +35,7 @@
|
|||||||
"ButtonIssues": "Błędy",
|
"ButtonIssues": "Błędy",
|
||||||
"ButtonJumpBackward": "Skocz do tyłu",
|
"ButtonJumpBackward": "Skocz do tyłu",
|
||||||
"ButtonJumpForward": "Skocz do przodu",
|
"ButtonJumpForward": "Skocz do przodu",
|
||||||
"ButtonLatest": "Aktualna wersja:",
|
"ButtonLatest": "Aktualna wersja",
|
||||||
"ButtonLibrary": "Biblioteka",
|
"ButtonLibrary": "Biblioteka",
|
||||||
"ButtonLogout": "Wyloguj",
|
"ButtonLogout": "Wyloguj",
|
||||||
"ButtonLookup": "Importuj",
|
"ButtonLookup": "Importuj",
|
||||||
@ -83,7 +83,7 @@
|
|||||||
"ButtonSelectFolderPath": "Wybierz ścieżkę folderu",
|
"ButtonSelectFolderPath": "Wybierz ścieżkę folderu",
|
||||||
"ButtonSeries": "Seria",
|
"ButtonSeries": "Seria",
|
||||||
"ButtonSetChaptersFromTracks": "Ustawiaj rozdziały na podstawie utworów",
|
"ButtonSetChaptersFromTracks": "Ustawiaj rozdziały na podstawie utworów",
|
||||||
"ButtonShare": "Share",
|
"ButtonShare": "Udostępnij",
|
||||||
"ButtonShiftTimes": "Przesunięcie czasowe",
|
"ButtonShiftTimes": "Przesunięcie czasowe",
|
||||||
"ButtonShow": "Pokaż",
|
"ButtonShow": "Pokaż",
|
||||||
"ButtonStartM4BEncode": "Eksportuj jako plik M4B",
|
"ButtonStartM4BEncode": "Eksportuj jako plik M4B",
|
||||||
@ -114,17 +114,17 @@
|
|||||||
"HeaderCollection": "Kolekcja",
|
"HeaderCollection": "Kolekcja",
|
||||||
"HeaderCollectionItems": "Elementy kolekcji",
|
"HeaderCollectionItems": "Elementy kolekcji",
|
||||||
"HeaderCover": "Okładka",
|
"HeaderCover": "Okładka",
|
||||||
"HeaderCurrentDownloads": "Current Downloads",
|
"HeaderCurrentDownloads": "Obecnie ściągane",
|
||||||
"HeaderCustomMessageOnLogin": "Custom Message on Login",
|
"HeaderCustomMessageOnLogin": "Własny tekst podczas logowania",
|
||||||
"HeaderCustomMetadataProviders": "Custom Metadata Providers",
|
"HeaderCustomMetadataProviders": "Niestandardowi dostawcy metadanych",
|
||||||
"HeaderDetails": "Szczegóły",
|
"HeaderDetails": "Szczegóły",
|
||||||
"HeaderDownloadQueue": "Download Queue",
|
"HeaderDownloadQueue": "Kolejka do ściągania",
|
||||||
"HeaderEbookFiles": "Ebook Files",
|
"HeaderEbookFiles": "Pliki Ebook",
|
||||||
"HeaderEmail": "Email",
|
"HeaderEmail": "Email",
|
||||||
"HeaderEmailSettings": "Email Settings",
|
"HeaderEmailSettings": "Ustawienia e-mail",
|
||||||
"HeaderEpisodes": "Rozdziały",
|
"HeaderEpisodes": "Rozdziały",
|
||||||
"HeaderEreaderDevices": "Ereader Devices",
|
"HeaderEreaderDevices": "Czytniki",
|
||||||
"HeaderEreaderSettings": "Ereader Settings",
|
"HeaderEreaderSettings": "Ustawienia czytnika",
|
||||||
"HeaderFiles": "Pliki",
|
"HeaderFiles": "Pliki",
|
||||||
"HeaderFindChapters": "Wyszukaj rozdziały",
|
"HeaderFindChapters": "Wyszukaj rozdziały",
|
||||||
"HeaderIgnoredFiles": "Zignoruj pliki",
|
"HeaderIgnoredFiles": "Zignoruj pliki",
|
||||||
@ -141,9 +141,9 @@
|
|||||||
"HeaderLogs": "Logi",
|
"HeaderLogs": "Logi",
|
||||||
"HeaderManageGenres": "Zarządzaj gatunkami",
|
"HeaderManageGenres": "Zarządzaj gatunkami",
|
||||||
"HeaderManageTags": "Zarządzaj tagami",
|
"HeaderManageTags": "Zarządzaj tagami",
|
||||||
"HeaderMapDetails": "Map details",
|
"HeaderMapDetails": "Szczegóły mapowania",
|
||||||
"HeaderMatch": "Dopasuj",
|
"HeaderMatch": "Dopasuj",
|
||||||
"HeaderMetadataOrderOfPrecedence": "Metadata order of precedence",
|
"HeaderMetadataOrderOfPrecedence": "Kolejność metadanych",
|
||||||
"HeaderMetadataToEmbed": "Osadź metadane",
|
"HeaderMetadataToEmbed": "Osadź metadane",
|
||||||
"HeaderNewAccount": "Nowe konto",
|
"HeaderNewAccount": "Nowe konto",
|
||||||
"HeaderNewLibrary": "Nowa biblioteka",
|
"HeaderNewLibrary": "Nowa biblioteka",
|
||||||
@ -153,9 +153,9 @@
|
|||||||
"HeaderOtherFiles": "Inne pliki",
|
"HeaderOtherFiles": "Inne pliki",
|
||||||
"HeaderPasswordAuthentication": "Uwierzytelnianie hasłem",
|
"HeaderPasswordAuthentication": "Uwierzytelnianie hasłem",
|
||||||
"HeaderPermissions": "Uprawnienia",
|
"HeaderPermissions": "Uprawnienia",
|
||||||
"HeaderPlayerQueue": "Player Queue",
|
"HeaderPlayerQueue": "Kolejka odtwarzania",
|
||||||
"HeaderPlaylist": "Playlist",
|
"HeaderPlaylist": "Playlista",
|
||||||
"HeaderPlaylistItems": "Playlist Items",
|
"HeaderPlaylistItems": "Pozycje listy odtwarzania",
|
||||||
"HeaderPodcastsToAdd": "Podcasty do dodania",
|
"HeaderPodcastsToAdd": "Podcasty do dodania",
|
||||||
"HeaderPreviewCover": "Podgląd okładki",
|
"HeaderPreviewCover": "Podgląd okładki",
|
||||||
"HeaderRSSFeedGeneral": "RSS Details",
|
"HeaderRSSFeedGeneral": "RSS Details",
|
||||||
@ -174,25 +174,25 @@
|
|||||||
"HeaderSettingsGeneral": "Ogólne",
|
"HeaderSettingsGeneral": "Ogólne",
|
||||||
"HeaderSettingsScanner": "Skanowanie",
|
"HeaderSettingsScanner": "Skanowanie",
|
||||||
"HeaderSleepTimer": "Wyłącznik czasowy",
|
"HeaderSleepTimer": "Wyłącznik czasowy",
|
||||||
"HeaderStatsLargestItems": "Largest Items",
|
"HeaderStatsLargestItems": "Największe pozycje",
|
||||||
"HeaderStatsLongestItems": "Najdłuższe pozycje (hrs)",
|
"HeaderStatsLongestItems": "Najdłuższe pozycje (hrs)",
|
||||||
"HeaderStatsMinutesListeningChart": "Czas słuchania w minutach (ostatnie 7 dni)",
|
"HeaderStatsMinutesListeningChart": "Czas słuchania w minutach (ostatnie 7 dni)",
|
||||||
"HeaderStatsRecentSessions": "Ostatnie sesje",
|
"HeaderStatsRecentSessions": "Ostatnie sesje",
|
||||||
"HeaderStatsTop10Authors": "Top 10 Autorów",
|
"HeaderStatsTop10Authors": "Top 10 Autorów",
|
||||||
"HeaderStatsTop5Genres": "Top 5 Gatunków",
|
"HeaderStatsTop5Genres": "Top 5 Gatunków",
|
||||||
"HeaderTableOfContents": "Table of Contents",
|
"HeaderTableOfContents": "Spis treści",
|
||||||
"HeaderTools": "Narzędzia",
|
"HeaderTools": "Narzędzia",
|
||||||
"HeaderUpdateAccount": "Zaktualizuj konto",
|
"HeaderUpdateAccount": "Zaktualizuj konto",
|
||||||
"HeaderUpdateAuthor": "Zaktualizuj autorów",
|
"HeaderUpdateAuthor": "Zaktualizuj autorów",
|
||||||
"HeaderUpdateDetails": "Zaktualizuj szczegóły",
|
"HeaderUpdateDetails": "Zaktualizuj szczegóły",
|
||||||
"HeaderUpdateLibrary": "Zaktualizuj bibliotekę",
|
"HeaderUpdateLibrary": "Zaktualizuj bibliotekę",
|
||||||
"HeaderUsers": "Użytkownicy",
|
"HeaderUsers": "Użytkownicy",
|
||||||
"HeaderYearReview": "Year {0} in Review",
|
"HeaderYearReview": "Podsumowanie roku {0}",
|
||||||
"HeaderYourStats": "Twoje statystyki",
|
"HeaderYourStats": "Twoje statystyki",
|
||||||
"LabelAbridged": "Abridged",
|
"LabelAbridged": "Skrócony",
|
||||||
"LabelAbridgedChecked": "Abridged (checked)",
|
"LabelAbridgedChecked": "Skrócony (zaznaczono)",
|
||||||
"LabelAbridgedUnchecked": "Unabridged (unchecked)",
|
"LabelAbridgedUnchecked": "Nieskrócony (nie zaznaczone)",
|
||||||
"LabelAccessibleBy": "Accessible by",
|
"LabelAccessibleBy": "Dostęp przez",
|
||||||
"LabelAccountType": "Typ konta",
|
"LabelAccountType": "Typ konta",
|
||||||
"LabelAccountTypeAdmin": "Administrator",
|
"LabelAccountTypeAdmin": "Administrator",
|
||||||
"LabelAccountTypeGuest": "Gość",
|
"LabelAccountTypeGuest": "Gość",
|
||||||
@ -202,28 +202,28 @@
|
|||||||
"LabelAddToCollectionBatch": "Dodaj {0} książki do kolekcji",
|
"LabelAddToCollectionBatch": "Dodaj {0} książki do kolekcji",
|
||||||
"LabelAddToPlaylist": "Add to Playlist",
|
"LabelAddToPlaylist": "Add to Playlist",
|
||||||
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
|
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
|
||||||
"LabelAdded": "Added",
|
"LabelAdded": "Dodane",
|
||||||
"LabelAddedAt": "Dodano",
|
"LabelAddedAt": "Dodano",
|
||||||
"LabelAdminUsersOnly": "Tylko użytkownicy administracyjni",
|
"LabelAdminUsersOnly": "Tylko użytkownicy administracyjni",
|
||||||
"LabelAll": "All",
|
"LabelAll": "Wszystkie",
|
||||||
"LabelAllUsers": "Wszyscy użytkownicy",
|
"LabelAllUsers": "Wszyscy użytkownicy",
|
||||||
"LabelAllUsersExcludingGuests": "Wszyscy użytkownicy z wyłączeniem gości",
|
"LabelAllUsersExcludingGuests": "Wszyscy użytkownicy z wyłączeniem gości",
|
||||||
"LabelAllUsersIncludingGuests": "Wszyscy użytkownicy, łącznie z gośćmi",
|
"LabelAllUsersIncludingGuests": "Wszyscy użytkownicy, łącznie z gośćmi",
|
||||||
"LabelAlreadyInYourLibrary": "Already in your library",
|
"LabelAlreadyInYourLibrary": "Już istnieje w twojej bibliotece",
|
||||||
"LabelAppend": "Append",
|
"LabelAppend": "Dołącz",
|
||||||
"LabelAuthor": "Autor",
|
"LabelAuthor": "Autor",
|
||||||
"LabelAuthorFirstLast": "Autor (Rosnąco)",
|
"LabelAuthorFirstLast": "Autor (Rosnąco)",
|
||||||
"LabelAuthorLastFirst": "Author (Malejąco)",
|
"LabelAuthorLastFirst": "Author (Malejąco)",
|
||||||
"LabelAuthors": "Autorzy",
|
"LabelAuthors": "Autorzy",
|
||||||
"LabelAutoDownloadEpisodes": "Automatyczne pobieranie odcinków",
|
"LabelAutoDownloadEpisodes": "Automatyczne pobieranie odcinków",
|
||||||
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
|
"LabelAutoFetchMetadata": "Automatycznie pobierz metadane",
|
||||||
"LabelAutoFetchMetadataHelp": "Pobiera metadane dotyczące tytułu, autora i serii, aby usprawnić przesyłanie. Po przesłaniu może być konieczne dopasowanie dodatkowych metadanych.",
|
"LabelAutoFetchMetadataHelp": "Pobiera metadane dotyczące tytułu, autora i serii, aby usprawnić przesyłanie. Po przesłaniu może być konieczne dopasowanie dodatkowych metadanych.",
|
||||||
"LabelAutoLaunch": "Auto Launch",
|
"LabelAutoLaunch": "Uruchom automatycznie",
|
||||||
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
||||||
"LabelAutoRegister": "Auto Register",
|
"LabelAutoRegister": "Auto Register",
|
||||||
"LabelAutoRegisterDescription": "Automatically create new users after logging in",
|
"LabelAutoRegisterDescription": "Automatically create new users after logging in",
|
||||||
"LabelBackToUser": "Powrót",
|
"LabelBackToUser": "Powrót",
|
||||||
"LabelBackupLocation": "Backup Location",
|
"LabelBackupLocation": "Lokalizacja kopii zapasowej",
|
||||||
"LabelBackupsEnableAutomaticBackups": "Włącz automatyczne kopie zapasowe",
|
"LabelBackupsEnableAutomaticBackups": "Włącz automatyczne kopie zapasowe",
|
||||||
"LabelBackupsEnableAutomaticBackupsHelp": "Kopie zapasowe są zapisywane w folderze /metadata/backups",
|
"LabelBackupsEnableAutomaticBackupsHelp": "Kopie zapasowe są zapisywane w folderze /metadata/backups",
|
||||||
"LabelBackupsMaxBackupSize": "Maksymalny łączny rozmiar backupów (w GB)",
|
"LabelBackupsMaxBackupSize": "Maksymalny łączny rozmiar backupów (w GB)",
|
||||||
@ -235,20 +235,20 @@
|
|||||||
"LabelButtonText": "Button Text",
|
"LabelButtonText": "Button Text",
|
||||||
"LabelByAuthor": "by {0}",
|
"LabelByAuthor": "by {0}",
|
||||||
"LabelChangePassword": "Zmień hasło",
|
"LabelChangePassword": "Zmień hasło",
|
||||||
"LabelChannels": "Channels",
|
"LabelChannels": "Kanały",
|
||||||
"LabelChapterTitle": "Tytuł rozdziału",
|
"LabelChapterTitle": "Tytuł rozdziału",
|
||||||
"LabelChapters": "Chapters",
|
"LabelChapters": "Rozdziały",
|
||||||
"LabelChaptersFound": "Znalezione rozdziały",
|
"LabelChaptersFound": "Znalezione rozdziały",
|
||||||
"LabelClickForMoreInfo": "Click for more info",
|
"LabelClickForMoreInfo": "Kliknij po więcej szczegółów",
|
||||||
"LabelClosePlayer": "Zamknij odtwarzacz",
|
"LabelClosePlayer": "Zamknij odtwarzacz",
|
||||||
"LabelCodec": "Codec",
|
"LabelCodec": "Codec",
|
||||||
"LabelCollapseSeries": "Podsumuj serię",
|
"LabelCollapseSeries": "Podsumuj serię",
|
||||||
"LabelCollection": "Collection",
|
"LabelCollection": "Kolekcja",
|
||||||
"LabelCollections": "Kolekcje",
|
"LabelCollections": "Kolekcje",
|
||||||
"LabelComplete": "Ukończone",
|
"LabelComplete": "Ukończone",
|
||||||
"LabelConfirmPassword": "Potwierdź hasło",
|
"LabelConfirmPassword": "Potwierdź hasło",
|
||||||
"LabelContinueListening": "Kontynuuj odtwarzanie",
|
"LabelContinueListening": "Kontynuuj odtwarzanie",
|
||||||
"LabelContinueReading": "Continue Reading",
|
"LabelContinueReading": "Kontynuuj czytanie",
|
||||||
"LabelContinueSeries": "Kontynuuj serię",
|
"LabelContinueSeries": "Kontynuuj serię",
|
||||||
"LabelCover": "Okładka",
|
"LabelCover": "Okładka",
|
||||||
"LabelCoverImageURL": "URL okładki",
|
"LabelCoverImageURL": "URL okładki",
|
||||||
@ -258,6 +258,7 @@
|
|||||||
"LabelCurrently": "Obecnie:",
|
"LabelCurrently": "Obecnie:",
|
||||||
"LabelCustomCronExpression": "Custom Cron Expression:",
|
"LabelCustomCronExpression": "Custom Cron Expression:",
|
||||||
"LabelDatetime": "Data i godzina",
|
"LabelDatetime": "Data i godzina",
|
||||||
|
"LabelDays": "Dni",
|
||||||
"LabelDeleteFromFileSystemCheckbox": "Usuń z systemu plików (odznacz, aby usunąć tylko z bazy danych)",
|
"LabelDeleteFromFileSystemCheckbox": "Usuń z systemu plików (odznacz, aby usunąć tylko z bazy danych)",
|
||||||
"LabelDescription": "Opis",
|
"LabelDescription": "Opis",
|
||||||
"LabelDeselectAll": "Odznacz wszystko",
|
"LabelDeselectAll": "Odznacz wszystko",
|
||||||
@ -269,34 +270,34 @@
|
|||||||
"LabelDiscFromMetadata": "Oznaczenie dysku z metadanych",
|
"LabelDiscFromMetadata": "Oznaczenie dysku z metadanych",
|
||||||
"LabelDiscover": "Odkrywaj",
|
"LabelDiscover": "Odkrywaj",
|
||||||
"LabelDownload": "Pobierz",
|
"LabelDownload": "Pobierz",
|
||||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
"LabelDownloadNEpisodes": "Ściąganie {0} odcinków",
|
||||||
"LabelDuration": "Czas trwania",
|
"LabelDuration": "Czas trwania",
|
||||||
"LabelDurationComparisonExactMatch": "(exact match)",
|
"LabelDurationComparisonExactMatch": "(exact match)",
|
||||||
"LabelDurationComparisonLonger": "({0} longer)",
|
"LabelDurationComparisonLonger": "({0} dłużej)",
|
||||||
"LabelDurationComparisonShorter": "({0} shorter)",
|
"LabelDurationComparisonShorter": "({0} krócej)",
|
||||||
"LabelDurationFound": "Znaleziona długość:",
|
"LabelDurationFound": "Znaleziona długość:",
|
||||||
"LabelEbook": "Ebook",
|
"LabelEbook": "Ebook",
|
||||||
"LabelEbooks": "Ebooks",
|
"LabelEbooks": "Ebooki",
|
||||||
"LabelEdit": "Edytuj",
|
"LabelEdit": "Edytuj",
|
||||||
"LabelEmail": "Email",
|
"LabelEmail": "Email",
|
||||||
"LabelEmailSettingsFromAddress": "From Address",
|
"LabelEmailSettingsFromAddress": "Z adresu",
|
||||||
"LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates",
|
"LabelEmailSettingsRejectUnauthorized": "Odrzuć nieautoryzowane certyfikaty",
|
||||||
"LabelEmailSettingsRejectUnauthorizedHelp": "Disabling SSL certificate validation may expose your connection to security risks, such as man-in-the-middle attacks. Only disable this option if you understand the implications and trust the mail server you are connecting to.",
|
"LabelEmailSettingsRejectUnauthorizedHelp": "Wyłączenie walidacji certyfikatów SSL może narazić cię na ryzyka bezpieczeństwa, takie jak ataki man-in-the-middle. Wyłącz tą opcję wyłącznie jeśli rozumiesz tego skutki i ufasz serwerowi pocztowemu, do którego się podłączasz.",
|
||||||
"LabelEmailSettingsSecure": "Secure",
|
"LabelEmailSettingsSecure": "Bezpieczeństwo",
|
||||||
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
|
"LabelEmailSettingsSecureHelp": "Jeśli włączysz, połączenie będzie korzystać z TLS podczas łączenia do serwera. Jeśli wyłączysz, TLS będzie wykorzystane jeśli serwer wspiera rozszerzenie STARTTLS. W większości przypadków włącz to ustawienie jeśli łączysz się do portu 465. Dla portów 587 lub 25 pozostaw to ustawienie wyłączone. (na podstawie nodemailer.com/smtp/#authentication)",
|
||||||
"LabelEmailSettingsTestAddress": "Test Address",
|
"LabelEmailSettingsTestAddress": "Test Address",
|
||||||
"LabelEmbeddedCover": "Embedded Cover",
|
"LabelEmbeddedCover": "Wbudowana okładka",
|
||||||
"LabelEnable": "Włącz",
|
"LabelEnable": "Włącz",
|
||||||
"LabelEnd": "Zakończ",
|
"LabelEnd": "Zakończ",
|
||||||
"LabelEpisode": "Odcinek",
|
"LabelEpisode": "Odcinek",
|
||||||
"LabelEpisodeTitle": "Tytuł odcinka",
|
"LabelEpisodeTitle": "Tytuł odcinka",
|
||||||
"LabelEpisodeType": "Typ odcinka",
|
"LabelEpisodeType": "Typ odcinka",
|
||||||
"LabelExample": "Example",
|
"LabelExample": "Przykład",
|
||||||
"LabelExplicit": "Nieprzyzwoite",
|
"LabelExplicit": "Nieprzyzwoite",
|
||||||
"LabelExplicitChecked": "Explicit (checked)",
|
"LabelExplicitChecked": "Explicit (checked)",
|
||||||
"LabelExplicitUnchecked": "Not Explicit (unchecked)",
|
"LabelExplicitUnchecked": "Not Explicit (unchecked)",
|
||||||
"LabelFeedURL": "URL kanału",
|
"LabelFeedURL": "URL kanału",
|
||||||
"LabelFetchingMetadata": "Fetching Metadata",
|
"LabelFetchingMetadata": "Pobieranie metadanych",
|
||||||
"LabelFile": "Plik",
|
"LabelFile": "Plik",
|
||||||
"LabelFileBirthtime": "Data utworzenia pliku",
|
"LabelFileBirthtime": "Data utworzenia pliku",
|
||||||
"LabelFileModified": "Data modyfikacji pliku",
|
"LabelFileModified": "Data modyfikacji pliku",
|
||||||
@ -304,25 +305,26 @@
|
|||||||
"LabelFilterByUser": "Filtruj według danego użytkownika",
|
"LabelFilterByUser": "Filtruj według danego użytkownika",
|
||||||
"LabelFindEpisodes": "Znajdź odcinki",
|
"LabelFindEpisodes": "Znajdź odcinki",
|
||||||
"LabelFinished": "Zakończone",
|
"LabelFinished": "Zakończone",
|
||||||
"LabelFolder": "Folder",
|
"LabelFolder": "Katalog",
|
||||||
"LabelFolders": "Foldery",
|
"LabelFolders": "Foldery",
|
||||||
"LabelFontBold": "Bold",
|
"LabelFontBold": "Pogrubiony",
|
||||||
"LabelFontBoldness": "Font Boldness",
|
"LabelFontBoldness": "Grubość czcionki",
|
||||||
"LabelFontFamily": "Rodzina czcionek",
|
"LabelFontFamily": "Rodzina czcionek",
|
||||||
"LabelFontItalic": "Italic",
|
"LabelFontItalic": "Italic",
|
||||||
"LabelFontScale": "Font scale",
|
"LabelFontScale": "Rozmiar czcionki",
|
||||||
"LabelFontStrikethrough": "Strikethrough",
|
"LabelFontStrikethrough": "Przekreślony",
|
||||||
"LabelFormat": "Format",
|
"LabelFormat": "Format",
|
||||||
"LabelGenre": "Gatunek",
|
"LabelGenre": "Gatunek",
|
||||||
"LabelGenres": "Gatunki",
|
"LabelGenres": "Gatunki",
|
||||||
"LabelHardDeleteFile": "Usuń trwale plik",
|
"LabelHardDeleteFile": "Usuń trwale plik",
|
||||||
"LabelHasEbook": "Has ebook",
|
"LabelHasEbook": "Ma ebooka",
|
||||||
"LabelHasSupplementaryEbook": "Has supplementary ebook",
|
"LabelHasSupplementaryEbook": "Posiada dodatkowy ebook",
|
||||||
"LabelHighestPriority": "Highest priority",
|
"LabelHighestPriority": "Najwyższy priorytet",
|
||||||
"LabelHost": "Host",
|
"LabelHost": "Host",
|
||||||
"LabelHour": "Godzina",
|
"LabelHour": "Godzina",
|
||||||
|
"LabelHours": "Godziny",
|
||||||
"LabelIcon": "Ikona",
|
"LabelIcon": "Ikona",
|
||||||
"LabelImageURLFromTheWeb": "Image URL from the web",
|
"LabelImageURLFromTheWeb": "Link do obrazu w sieci",
|
||||||
"LabelInProgress": "W trakcie",
|
"LabelInProgress": "W trakcie",
|
||||||
"LabelIncludeInTracklist": "Dołącz do listy odtwarzania",
|
"LabelIncludeInTracklist": "Dołącz do listy odtwarzania",
|
||||||
"LabelIncomplete": "Nieukończone",
|
"LabelIncomplete": "Nieukończone",
|
||||||
@ -335,19 +337,19 @@
|
|||||||
"LabelIntervalEvery6Hours": "Co 6 godzin",
|
"LabelIntervalEvery6Hours": "Co 6 godzin",
|
||||||
"LabelIntervalEveryDay": "Każdego dnia",
|
"LabelIntervalEveryDay": "Każdego dnia",
|
||||||
"LabelIntervalEveryHour": "Każdej godziny",
|
"LabelIntervalEveryHour": "Każdej godziny",
|
||||||
"LabelInvert": "Invert",
|
"LabelInvert": "Inversja",
|
||||||
"LabelItem": "Pozycja",
|
"LabelItem": "Pozycja",
|
||||||
"LabelLanguage": "Język",
|
"LabelLanguage": "Język",
|
||||||
"LabelLanguageDefaultServer": "Domyślny język serwera",
|
"LabelLanguageDefaultServer": "Domyślny język serwera",
|
||||||
"LabelLanguages": "Languages",
|
"LabelLanguages": "Języki",
|
||||||
"LabelLastBookAdded": "Last Book Added",
|
"LabelLastBookAdded": "Ostatnio dodana książka",
|
||||||
"LabelLastBookUpdated": "Last Book Updated",
|
"LabelLastBookUpdated": "Ostatnio modyfikowana książka",
|
||||||
"LabelLastSeen": "Ostatnio widziany",
|
"LabelLastSeen": "Ostatnio widziany",
|
||||||
"LabelLastTime": "Ostatni czas",
|
"LabelLastTime": "Ostatni czas",
|
||||||
"LabelLastUpdate": "Ostatnia aktualizacja",
|
"LabelLastUpdate": "Ostatnia aktualizacja",
|
||||||
"LabelLayout": "Layout",
|
"LabelLayout": "Układ",
|
||||||
"LabelLayoutSinglePage": "Single page",
|
"LabelLayoutSinglePage": "Pojedyncza strona",
|
||||||
"LabelLayoutSplitPage": "Split page",
|
"LabelLayoutSplitPage": "Podział strony",
|
||||||
"LabelLess": "Mniej",
|
"LabelLess": "Mniej",
|
||||||
"LabelLibrariesAccessibleToUser": "Biblioteki dostępne dla użytkownika",
|
"LabelLibrariesAccessibleToUser": "Biblioteki dostępne dla użytkownika",
|
||||||
"LabelLibrary": "Biblioteka",
|
"LabelLibrary": "Biblioteka",
|
||||||
@ -355,42 +357,43 @@
|
|||||||
"LabelLibraryItem": "Element biblioteki",
|
"LabelLibraryItem": "Element biblioteki",
|
||||||
"LabelLibraryName": "Nazwa biblioteki",
|
"LabelLibraryName": "Nazwa biblioteki",
|
||||||
"LabelLimit": "Limit",
|
"LabelLimit": "Limit",
|
||||||
"LabelLineSpacing": "Line spacing",
|
"LabelLineSpacing": "Odstęp między wierszami",
|
||||||
"LabelListenAgain": "Słuchaj ponownie",
|
"LabelListenAgain": "Słuchaj ponownie",
|
||||||
"LabelLogLevelDebug": "Debug",
|
"LabelLogLevelDebug": "Debug",
|
||||||
"LabelLogLevelInfo": "Informacja",
|
"LabelLogLevelInfo": "Informacja",
|
||||||
"LabelLogLevelWarn": "Ostrzeżenie",
|
"LabelLogLevelWarn": "Ostrzeżenie",
|
||||||
"LabelLookForNewEpisodesAfterDate": "Szukaj nowych odcinków po dacie",
|
"LabelLookForNewEpisodesAfterDate": "Szukaj nowych odcinków po dacie",
|
||||||
"LabelLowestPriority": "Lowest Priority",
|
"LabelLowestPriority": "Najniższy priorytet",
|
||||||
"LabelMatchExistingUsersBy": "Match existing users by",
|
"LabelMatchExistingUsersBy": "Dopasuje istniejących użytkowników poprzez",
|
||||||
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
|
"LabelMatchExistingUsersByDescription": "Służy do łączenia istniejących użytkowników. Po połączeniu użytkownicy zostaną dopasowani za pomocą unikalnego identyfikatora od dostawcy SSO",
|
||||||
"LabelMediaPlayer": "Odtwarzacz",
|
"LabelMediaPlayer": "Odtwarzacz",
|
||||||
"LabelMediaType": "Typ mediów",
|
"LabelMediaType": "Typ mediów",
|
||||||
"LabelMetaTag": "Tag",
|
"LabelMetaTag": "Tag",
|
||||||
"LabelMetaTags": "Meta Tags",
|
"LabelMetaTags": "Meta Tags",
|
||||||
"LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
|
"LabelMetadataOrderOfPrecedenceDescription": "Źródła metadanych o wyższym priorytecie będą zastępują źródła o niższym priorytecie",
|
||||||
"LabelMetadataProvider": "Dostawca metadanych",
|
"LabelMetadataProvider": "Dostawca metadanych",
|
||||||
"LabelMinute": "Minuta",
|
"LabelMinute": "Minuta",
|
||||||
|
"LabelMinutes": "Minuty",
|
||||||
"LabelMissing": "Brakujący",
|
"LabelMissing": "Brakujący",
|
||||||
"LabelMissingEbook": "Has no ebook",
|
"LabelMissingEbook": "Nie posiada ebooka",
|
||||||
"LabelMissingSupplementaryEbook": "Has no supplementary ebook",
|
"LabelMissingSupplementaryEbook": "Nie posiada dodatkowego ebooka",
|
||||||
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
|
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
|
||||||
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
|
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
|
||||||
"LabelMore": "Więcej",
|
"LabelMore": "Więcej",
|
||||||
"LabelMoreInfo": "More Info",
|
"LabelMoreInfo": "Więcej informacji",
|
||||||
"LabelName": "Nazwa",
|
"LabelName": "Nazwa",
|
||||||
"LabelNarrator": "Narrator",
|
"LabelNarrator": "Lektor",
|
||||||
"LabelNarrators": "Lektorzy",
|
"LabelNarrators": "Lektorzy",
|
||||||
"LabelNew": "Nowy",
|
"LabelNew": "Nowy",
|
||||||
"LabelNewPassword": "Nowe hasło",
|
"LabelNewPassword": "Nowe hasło",
|
||||||
"LabelNewestAuthors": "Najnowsi autorzy",
|
"LabelNewestAuthors": "Najnowsi autorzy",
|
||||||
"LabelNewestEpisodes": "Najnowsze odcinki",
|
"LabelNewestEpisodes": "Najnowsze odcinki",
|
||||||
"LabelNextBackupDate": "Next backup date",
|
"LabelNextBackupDate": "Data kolejnej kopii zapasowej",
|
||||||
"LabelNextScheduledRun": "Next scheduled run",
|
"LabelNextScheduledRun": "Następne uruchomienie",
|
||||||
"LabelNoCustomMetadataProviders": "No custom metadata providers",
|
"LabelNoCustomMetadataProviders": "Brak niestandardowych dostawców metadanych",
|
||||||
"LabelNoEpisodesSelected": "No episodes selected",
|
"LabelNoEpisodesSelected": "Nie wybrano żadnych odcinków",
|
||||||
"LabelNotFinished": "Nieukończone",
|
"LabelNotFinished": "Nieukończone",
|
||||||
"LabelNotStarted": "Nie rozpoęczto",
|
"LabelNotStarted": "Nie rozpoczęto",
|
||||||
"LabelNotes": "Uwagi",
|
"LabelNotes": "Uwagi",
|
||||||
"LabelNotificationAppriseURL": "URLe Apprise",
|
"LabelNotificationAppriseURL": "URLe Apprise",
|
||||||
"LabelNotificationAvailableVariables": "Dostępne zmienne",
|
"LabelNotificationAvailableVariables": "Dostępne zmienne",
|
||||||
@ -407,9 +410,10 @@
|
|||||||
"LabelOpenIDClaims": "Leave the following options empty to disable advanced group and permissions assignment, automatically assigning 'User' group then.",
|
"LabelOpenIDClaims": "Leave the following options empty to disable advanced group and permissions assignment, automatically assigning 'User' group then.",
|
||||||
"LabelOpenIDGroupClaimDescription": "Name of the OpenID claim that contains a list of the user's groups. Commonly referred to as <code>groups</code>. <b>If configured</b>, the application will automatically assign roles based on the user's group memberships, provided that these groups are named case-insensitively 'admin', 'user', or 'guest' in the claim. The claim should contain a list, and if a user belongs to multiple groups, the application will assign the role corresponding to the highest level of access. If no group matches, access will be denied.",
|
"LabelOpenIDGroupClaimDescription": "Name of the OpenID claim that contains a list of the user's groups. Commonly referred to as <code>groups</code>. <b>If configured</b>, the application will automatically assign roles based on the user's group memberships, provided that these groups are named case-insensitively 'admin', 'user', or 'guest' in the claim. The claim should contain a list, and if a user belongs to multiple groups, the application will assign the role corresponding to the highest level of access. If no group matches, access will be denied.",
|
||||||
"LabelOpenRSSFeed": "Otwórz kanał RSS",
|
"LabelOpenRSSFeed": "Otwórz kanał RSS",
|
||||||
"LabelOverwrite": "Overwrite",
|
"LabelOverwrite": "Nadpisz",
|
||||||
"LabelPassword": "Hasło",
|
"LabelPassword": "Hasło",
|
||||||
"LabelPath": "Ścieżka",
|
"LabelPath": "Ścieżka",
|
||||||
|
"LabelPermanent": "Trwały",
|
||||||
"LabelPermissionsAccessAllLibraries": "Ma dostęp do wszystkich bibliotek",
|
"LabelPermissionsAccessAllLibraries": "Ma dostęp do wszystkich bibliotek",
|
||||||
"LabelPermissionsAccessAllTags": "Ma dostęp do wszystkich tagów",
|
"LabelPermissionsAccessAllTags": "Ma dostęp do wszystkich tagów",
|
||||||
"LabelPermissionsAccessExplicitContent": "Ma dostęp do treści oznacznych jako nieprzyzwoite",
|
"LabelPermissionsAccessExplicitContent": "Ma dostęp do treści oznacznych jako nieprzyzwoite",
|
||||||
@ -417,77 +421,77 @@
|
|||||||
"LabelPermissionsDownload": "Ma możliwość pobierania",
|
"LabelPermissionsDownload": "Ma możliwość pobierania",
|
||||||
"LabelPermissionsUpdate": "Ma możliwość aktualizowania",
|
"LabelPermissionsUpdate": "Ma możliwość aktualizowania",
|
||||||
"LabelPermissionsUpload": "Ma możliwość dodawania",
|
"LabelPermissionsUpload": "Ma możliwość dodawania",
|
||||||
"LabelPersonalYearReview": "Your Year in Review ({0})",
|
"LabelPersonalYearReview": "Podsumowanie twojego roku ({0})",
|
||||||
"LabelPhotoPathURL": "Scieżka/URL do zdjęcia",
|
"LabelPhotoPathURL": "Scieżka/URL do zdjęcia",
|
||||||
"LabelPlayMethod": "Metoda odtwarzania",
|
"LabelPlayMethod": "Metoda odtwarzania",
|
||||||
"LabelPlayerChapterNumberMarker": "{0} of {1}",
|
"LabelPlayerChapterNumberMarker": "{0} of {1}",
|
||||||
"LabelPlaylists": "Playlists",
|
"LabelPlaylists": "Listy odtwarzania",
|
||||||
"LabelPodcast": "Podcast",
|
"LabelPodcast": "Podcast",
|
||||||
"LabelPodcastSearchRegion": "Obszar wyszukiwania podcastów",
|
"LabelPodcastSearchRegion": "Obszar wyszukiwania podcastów",
|
||||||
"LabelPodcastType": "Podcast Type",
|
"LabelPodcastType": "Podcast Type",
|
||||||
"LabelPodcasts": "Podcasty",
|
"LabelPodcasts": "Podcasty",
|
||||||
"LabelPort": "Port",
|
"LabelPort": "Port",
|
||||||
"LabelPrefixesToIgnore": "Ignorowane prefiksy (wielkość liter nie ma znaczenia)",
|
"LabelPrefixesToIgnore": "Ignorowane prefiksy (wielkość liter nie ma znaczenia)",
|
||||||
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
|
"LabelPreventIndexing": "Zapobiega indeksowaniu przez iTunes i Google",
|
||||||
"LabelPrimaryEbook": "Primary ebook",
|
"LabelPrimaryEbook": "Główny ebook",
|
||||||
"LabelProgress": "Postęp",
|
"LabelProgress": "Postęp",
|
||||||
"LabelProvider": "Dostawca",
|
"LabelProvider": "Dostawca",
|
||||||
"LabelPubDate": "Data publikacji",
|
"LabelPubDate": "Data publikacji",
|
||||||
"LabelPublishYear": "Rok publikacji",
|
"LabelPublishYear": "Rok publikacji",
|
||||||
"LabelPublisher": "Wydawca",
|
"LabelPublisher": "Wydawca",
|
||||||
"LabelPublishers": "Publishers",
|
"LabelPublishers": "Wydawcy",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
|
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
|
||||||
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
|
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
|
||||||
"LabelRSSFeedOpen": "RSS Feed otwarty",
|
"LabelRSSFeedOpen": "RSS Feed otwarty",
|
||||||
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
|
"LabelRSSFeedPreventIndexing": "Zapobiegaj indeksowaniu",
|
||||||
"LabelRSSFeedSlug": "RSS Feed Slug",
|
"LabelRSSFeedSlug": "RSS Feed Slug",
|
||||||
"LabelRSSFeedURL": "URL kanały RSS",
|
"LabelRSSFeedURL": "URL kanały RSS",
|
||||||
"LabelRead": "Read",
|
"LabelRead": "Czytaj",
|
||||||
"LabelReadAgain": "Read Again",
|
"LabelReadAgain": "Czytaj ponownie",
|
||||||
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
|
"LabelReadEbookWithoutProgress": "Czytaj książkę bez zapamiętywania postępu",
|
||||||
"LabelRecentSeries": "Ostatnie serie",
|
"LabelRecentSeries": "Ostatnie serie",
|
||||||
"LabelRecentlyAdded": "Niedawno dodany",
|
"LabelRecentlyAdded": "Niedawno dodany",
|
||||||
"LabelRecommended": "Recommended",
|
"LabelRecommended": "Polecane",
|
||||||
"LabelRedo": "Redo",
|
"LabelRedo": "Wycofaj",
|
||||||
"LabelRegion": "Region",
|
"LabelRegion": "Region",
|
||||||
"LabelReleaseDate": "Data wydania",
|
"LabelReleaseDate": "Data wydania",
|
||||||
"LabelRemoveCover": "Remove cover",
|
"LabelRemoveCover": "Usuń okładkę",
|
||||||
"LabelRowsPerPage": "Rows per page",
|
"LabelRowsPerPage": "Wierszy na stronę",
|
||||||
"LabelSearchTerm": "Wyszukiwanie frazy",
|
"LabelSearchTerm": "Wyszukiwanie frazy",
|
||||||
"LabelSearchTitle": "Wyszukaj tytuł",
|
"LabelSearchTitle": "Wyszukaj tytuł",
|
||||||
"LabelSearchTitleOrASIN": "Szukaj tytuł lub ASIN",
|
"LabelSearchTitleOrASIN": "Szukaj tytuł lub ASIN",
|
||||||
"LabelSeason": "Sezon",
|
"LabelSeason": "Sezon",
|
||||||
"LabelSelectAll": "Select all",
|
"LabelSelectAll": "Wybierz wszystko",
|
||||||
"LabelSelectAllEpisodes": "Select all episodes",
|
"LabelSelectAllEpisodes": "Wybierz wszystkie odcinki",
|
||||||
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
|
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
|
||||||
"LabelSelectUsers": "Select users",
|
"LabelSelectUsers": "Wybór użytkowników",
|
||||||
"LabelSendEbookToDevice": "Send Ebook to...",
|
"LabelSendEbookToDevice": "Wyślij ebook do...",
|
||||||
"LabelSequence": "Kolejność",
|
"LabelSequence": "Kolejność",
|
||||||
"LabelSeries": "Serie",
|
"LabelSeries": "Serie",
|
||||||
"LabelSeriesName": "Nazwy serii",
|
"LabelSeriesName": "Nazwy serii",
|
||||||
"LabelSeriesProgress": "Postęp w serii",
|
"LabelSeriesProgress": "Postęp w serii",
|
||||||
"LabelServerYearReview": "Server Year in Review ({0})",
|
"LabelServerYearReview": "Podsumowanie serwera w roku ({0})",
|
||||||
"LabelSetEbookAsPrimary": "Set as primary",
|
"LabelSetEbookAsPrimary": "Ustaw jako pierwszy",
|
||||||
"LabelSetEbookAsSupplementary": "Set as supplementary",
|
"LabelSetEbookAsSupplementary": "Ustaw jako dodatkowy",
|
||||||
"LabelSettingsAudiobooksOnly": "Audiobooks only",
|
"LabelSettingsAudiobooksOnly": "Wyłącznie audiobooki",
|
||||||
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
|
"LabelSettingsAudiobooksOnlyHelp": "Włączenie tej funkcji spowoduje ignorowanie plików ebooków, chyba że znajdują się wewnątrz folderu audiobooka kiedy to będą pokazywane jako dodatkowe ebooki",
|
||||||
"LabelSettingsBookshelfViewHelp": "Widok półki z ksiązkami",
|
"LabelSettingsBookshelfViewHelp": "Widok półki z książkami",
|
||||||
"LabelSettingsChromecastSupport": "Wsparcie Chromecast",
|
"LabelSettingsChromecastSupport": "Wsparcie Chromecast",
|
||||||
"LabelSettingsDateFormat": "Format daty",
|
"LabelSettingsDateFormat": "Format daty",
|
||||||
"LabelSettingsDisableWatcher": "Wyłącz monitorowanie",
|
"LabelSettingsDisableWatcher": "Wyłącz monitorowanie",
|
||||||
"LabelSettingsDisableWatcherForLibrary": "Wyłącz monitorowanie folderów dla biblioteki",
|
"LabelSettingsDisableWatcherForLibrary": "Wyłącz monitorowanie folderów dla biblioteki",
|
||||||
"LabelSettingsDisableWatcherHelp": "Wyłącz automatyczne dodawanie/aktualizowanie elementów po wykryciu zmian w plikach. *Wymaga restartu serwera",
|
"LabelSettingsDisableWatcherHelp": "Wyłącz automatyczne dodawanie/aktualizowanie elementów po wykryciu zmian w plikach. *Wymaga restartu serwera",
|
||||||
"LabelSettingsEnableWatcher": "Enable Watcher",
|
"LabelSettingsEnableWatcher": "Włącz monitorowanie",
|
||||||
"LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
|
"LabelSettingsEnableWatcherForLibrary": "Włącz monitorowanie folderów dla biblioteki",
|
||||||
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
|
"LabelSettingsEnableWatcherHelp": "Włącza automatyczne dodawanie/aktualizację pozycji gdy wykryte zostaną zmiany w plikach. Wymaga restartu serwera",
|
||||||
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
|
"LabelSettingsEpubsAllowScriptedContent": "Zezwalanie na skrypty w plikach epub",
|
||||||
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
|
"LabelSettingsEpubsAllowScriptedContentHelp": "Zezwala plikom epub na wykonywanie skryptów. Zaleca się mieć to ustawienie wyłączone, chyba że ma się zaufanie do źródła plików epub.",
|
||||||
"LabelSettingsExperimentalFeatures": "Funkcje eksperymentalne",
|
"LabelSettingsExperimentalFeatures": "Funkcje eksperymentalne",
|
||||||
"LabelSettingsExperimentalFeaturesHelp": "Funkcje w trakcie rozwoju, które mogą zyskanć na Twojej opinii i pomocy w testowaniu. Kliknij, aby otworzyć dyskusję na githubie.",
|
"LabelSettingsExperimentalFeaturesHelp": "Funkcje w trakcie rozwoju, które mogą zyskanć na Twojej opinii i pomocy w testowaniu. Kliknij, aby otworzyć dyskusję na githubie.",
|
||||||
"LabelSettingsFindCovers": "Szukanie okładek",
|
"LabelSettingsFindCovers": "Szukanie okładek",
|
||||||
"LabelSettingsFindCoversHelp": "Jeśli audiobook nie posiada zintegrowanej okładki albo w folderze nie zostanie znaleziony plik okładki, skaner podejmie próbę pobrania okładki z sieci. <br>Uwaga: może to wydłuzyć proces skanowania",
|
"LabelSettingsFindCoversHelp": "Jeśli audiobook nie posiada zintegrowanej okładki albo w folderze nie zostanie znaleziony plik okładki, skaner podejmie próbę pobrania okładki z sieci. <br>Uwaga: może to wydłuzyć proces skanowania",
|
||||||
"LabelSettingsHideSingleBookSeries": "Hide single book series",
|
"LabelSettingsHideSingleBookSeries": "Ukryj serie z jedną książką",
|
||||||
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
|
"LabelSettingsHideSingleBookSeriesHelp": "Serie, które posiadają tylko jedną książkę, nie będą pokazywane na stronie z seriami i na stronie domowej z półkami.",
|
||||||
"LabelSettingsHomePageBookshelfView": "Widok półki z książkami na stronie głównej",
|
"LabelSettingsHomePageBookshelfView": "Widok półki z książkami na stronie głównej",
|
||||||
"LabelSettingsLibraryBookshelfView": "Widok półki z książkami na stronie biblioteki",
|
"LabelSettingsLibraryBookshelfView": "Widok półki z książkami na stronie biblioteki",
|
||||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Skip earlier books in Continue Series",
|
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Skip earlier books in Continue Series",
|
||||||
@ -503,12 +507,15 @@
|
|||||||
"LabelSettingsSquareBookCovers": "Używaj kwadratowych okładek książek",
|
"LabelSettingsSquareBookCovers": "Używaj kwadratowych okładek książek",
|
||||||
"LabelSettingsSquareBookCoversHelp": "Preferuj stosowanie kwadratowych okładek zamiast standardowych okładek książkowych o propocji 1,6:1",
|
"LabelSettingsSquareBookCoversHelp": "Preferuj stosowanie kwadratowych okładek zamiast standardowych okładek książkowych o propocji 1,6:1",
|
||||||
"LabelSettingsStoreCoversWithItem": "Przechowuj okładkę w folderze książki",
|
"LabelSettingsStoreCoversWithItem": "Przechowuj okładkę w folderze książki",
|
||||||
"LabelSettingsStoreCoversWithItemHelp": "Domyślnie okładki są przechowywane w folderze /metadata/items, włączenie tej opcji spowoduje, że okładka będzie przechowywana w folderze ksiązki. Tylko jedna okładka o nazwie pliku \"cover\" będzie przechowywana.",
|
"LabelSettingsStoreCoversWithItemHelp": "Domyślnie okładki są przechowywane w folderze /metadata/items, włączenie tej opcji spowoduje, że okładka będzie przechowywana w folderze ksiązki. Tylko jedna okładka o nazwie pliku \"cover\" będzie przechowywana",
|
||||||
"LabelSettingsStoreMetadataWithItem": "Przechowuj metadane w folderze książki",
|
"LabelSettingsStoreMetadataWithItem": "Przechowuj metadane w folderze książki",
|
||||||
"LabelSettingsStoreMetadataWithItemHelp": "Domyślnie metadane są przechowywane w folderze /metadata/items, włączenie tej opcji spowoduje, że okładka będzie przechowywana w folderze ksiązki. Tylko jedna okładka o nazwie pliku \"cover\" będzie przechowywana",
|
"LabelSettingsStoreMetadataWithItemHelp": "Domyślnie metadane są przechowywane w folderze /metadata/items, włączenie tej opcji spowoduje, że okładka będzie przechowywana w folderze ksiązki. Tylko jedna okładka o nazwie pliku \"cover\" będzie przechowywana",
|
||||||
"LabelSettingsTimeFormat": "Time Format",
|
"LabelSettingsTimeFormat": "Format czasu",
|
||||||
|
"LabelShare": "Udostępnij",
|
||||||
|
"LabelShareOpen": "Otwórz udział",
|
||||||
|
"LabelShareURL": "Link do udziału",
|
||||||
"LabelShowAll": "Pokaż wszystko",
|
"LabelShowAll": "Pokaż wszystko",
|
||||||
"LabelShowSeconds": "Show seconds",
|
"LabelShowSeconds": "Pokaż sekundy",
|
||||||
"LabelSize": "Rozmiar",
|
"LabelSize": "Rozmiar",
|
||||||
"LabelSleepTimer": "Wyłącznik czasowy",
|
"LabelSleepTimer": "Wyłącznik czasowy",
|
||||||
"LabelSlug": "Slug",
|
"LabelSlug": "Slug",
|
||||||
@ -543,8 +550,8 @@
|
|||||||
"LabelTextEditorNumberedList": "Numbered list",
|
"LabelTextEditorNumberedList": "Numbered list",
|
||||||
"LabelTextEditorUnlink": "Unlink",
|
"LabelTextEditorUnlink": "Unlink",
|
||||||
"LabelTheme": "Theme",
|
"LabelTheme": "Theme",
|
||||||
"LabelThemeDark": "Dark",
|
"LabelThemeDark": "Ciemny",
|
||||||
"LabelThemeLight": "Light",
|
"LabelThemeLight": "Jasny",
|
||||||
"LabelTimeBase": "Time Base",
|
"LabelTimeBase": "Time Base",
|
||||||
"LabelTimeListened": "Czas odtwarzania",
|
"LabelTimeListened": "Czas odtwarzania",
|
||||||
"LabelTimeListenedToday": "Czas odtwarzania dzisiaj",
|
"LabelTimeListenedToday": "Czas odtwarzania dzisiaj",
|
||||||
@ -552,7 +559,7 @@
|
|||||||
"LabelTimeToShift": "Czas do przesunięcia w sekundach",
|
"LabelTimeToShift": "Czas do przesunięcia w sekundach",
|
||||||
"LabelTitle": "Tytuł",
|
"LabelTitle": "Tytuł",
|
||||||
"LabelToolsEmbedMetadata": "Załącz metadane",
|
"LabelToolsEmbedMetadata": "Załącz metadane",
|
||||||
"LabelToolsEmbedMetadataDescription": "Załącz metadane do plików audio (okładkę oraz znaczniki rozdziałów)",
|
"LabelToolsEmbedMetadataDescription": "Załącz metadane do plików audio (okładkę oraz znaczniki rozdziałów).",
|
||||||
"LabelToolsMakeM4b": "Generuj plik M4B",
|
"LabelToolsMakeM4b": "Generuj plik M4B",
|
||||||
"LabelToolsMakeM4bDescription": "Tworzy plik w formacie .M4B, który zawiera metadane, okładkę oraz rozdziały.",
|
"LabelToolsMakeM4bDescription": "Tworzy plik w formacie .M4B, który zawiera metadane, okładkę oraz rozdziały.",
|
||||||
"LabelToolsSplitM4b": "Podziel plik .M4B na pliki .MP3",
|
"LabelToolsSplitM4b": "Podziel plik .M4B na pliki .MP3",
|
||||||
@ -561,13 +568,13 @@
|
|||||||
"LabelTotalTimeListened": "Całkowity czas odtwarzania",
|
"LabelTotalTimeListened": "Całkowity czas odtwarzania",
|
||||||
"LabelTrackFromFilename": "Ścieżka z nazwy pliku",
|
"LabelTrackFromFilename": "Ścieżka z nazwy pliku",
|
||||||
"LabelTrackFromMetadata": "Ścieżka z metadanych",
|
"LabelTrackFromMetadata": "Ścieżka z metadanych",
|
||||||
"LabelTracks": "Tracks",
|
"LabelTracks": "Ścieżki",
|
||||||
"LabelTracksMultiTrack": "Multi-track",
|
"LabelTracksMultiTrack": "Multi-track",
|
||||||
"LabelTracksNone": "No tracks",
|
"LabelTracksNone": "No tracks",
|
||||||
"LabelTracksSingleTrack": "Single-track",
|
"LabelTracksSingleTrack": "Single-track",
|
||||||
"LabelType": "Typ",
|
"LabelType": "Typ",
|
||||||
"LabelUnabridged": "Unabridged",
|
"LabelUnabridged": "Unabridged",
|
||||||
"LabelUndo": "Undo",
|
"LabelUndo": "Wycofaj",
|
||||||
"LabelUnknown": "Nieznany",
|
"LabelUnknown": "Nieznany",
|
||||||
"LabelUpdateCover": "Zaktalizuj odkładkę",
|
"LabelUpdateCover": "Zaktalizuj odkładkę",
|
||||||
"LabelUpdateCoverHelp": "Umożliwienie nadpisania istniejących okładek dla wybranych książek w przypadku znalezienia dopasowania",
|
"LabelUpdateCoverHelp": "Umożliwienie nadpisania istniejących okładek dla wybranych książek w przypadku znalezienia dopasowania",
|
||||||
@ -576,7 +583,7 @@
|
|||||||
"LabelUpdatedAt": "Zaktualizowano",
|
"LabelUpdatedAt": "Zaktualizowano",
|
||||||
"LabelUploaderDragAndDrop": "Przeciągnij i puść foldery lub pliki",
|
"LabelUploaderDragAndDrop": "Przeciągnij i puść foldery lub pliki",
|
||||||
"LabelUploaderDropFiles": "Puść pliki",
|
"LabelUploaderDropFiles": "Puść pliki",
|
||||||
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
"LabelUploaderItemFetchMetadataHelp": "Automatycznie pobierz tytuł, autora i serie",
|
||||||
"LabelUseChapterTrack": "Użyj ścieżki rozdziału",
|
"LabelUseChapterTrack": "Użyj ścieżki rozdziału",
|
||||||
"LabelUseFullTrack": "Użycie ścieżki rozdziału",
|
"LabelUseFullTrack": "Użycie ścieżki rozdziału",
|
||||||
"LabelUser": "Użytkownik",
|
"LabelUser": "Użytkownik",
|
||||||
@ -588,39 +595,42 @@
|
|||||||
"LabelViewQueue": "Wyświetlaj kolejkę odtwarzania",
|
"LabelViewQueue": "Wyświetlaj kolejkę odtwarzania",
|
||||||
"LabelVolume": "Głośność",
|
"LabelVolume": "Głośność",
|
||||||
"LabelWeekdaysToRun": "Dni tygodnia",
|
"LabelWeekdaysToRun": "Dni tygodnia",
|
||||||
"LabelYearReviewHide": "Hide Year in Review",
|
"LabelYearReviewHide": "Ukryj Podsumowanie Roku",
|
||||||
"LabelYearReviewShow": "See Year in Review",
|
"LabelYearReviewShow": "Pokaż Podsumowanie Roku",
|
||||||
"LabelYourAudiobookDuration": "Czas trwania audiobooka",
|
"LabelYourAudiobookDuration": "Czas trwania audiobooka",
|
||||||
"LabelYourBookmarks": "Twoje zakładki",
|
"LabelYourBookmarks": "Twoje zakładki",
|
||||||
"LabelYourPlaylists": "Your Playlists",
|
"LabelYourPlaylists": "Twoje playlisty",
|
||||||
"LabelYourProgress": "Twój postęp",
|
"LabelYourProgress": "Twój postęp",
|
||||||
"MessageAddToPlayerQueue": "Add to player queue",
|
"MessageAddToPlayerQueue": "Add to player queue",
|
||||||
"MessageAppriseDescription": "Aby użyć tej funkcji, konieczne jest posiadanie instancji <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> albo innego rozwiązania, które obsługuje schemat zapytań Apprise. <br />URL do interfejsu API powinno być całkowitą ścieżką, np., jeśli Twoje API do powiadomień jest dostępne pod adresem <code>http://192.168.1.1:8337</code> to wpisany tutaj URL powinien mieć postać: <code>http://192.168.1.1:8337/notify</code>.",
|
"MessageAppriseDescription": "Aby użyć tej funkcji, konieczne jest posiadanie instancji <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> albo innego rozwiązania, które obsługuje schemat zapytań Apprise. <br />URL do interfejsu API powinno być całkowitą ścieżką, np., jeśli Twoje API do powiadomień jest dostępne pod adresem <code>http://192.168.1.1:8337</code> to wpisany tutaj URL powinien mieć postać: <code>http://192.168.1.1:8337/notify</code>.",
|
||||||
"MessageBackupsDescription": "Kopie zapasowe obejmują użytkowników, postępy użytkowników, szczegóły pozycji biblioteki, ustawienia serwera i obrazy przechowywane w <code>/metadata/items</code> & <code>/metadata/authors</code>. Kopie zapasowe nie obejmują żadnych plików przechowywanych w folderach biblioteki.",
|
"MessageBackupsDescription": "Kopie zapasowe obejmują użytkowników, postępy użytkowników, szczegóły pozycji biblioteki, ustawienia serwera i obrazy przechowywane w <code>/metadata/items</code> & <code>/metadata/authors</code>. Kopie zapasowe nie obejmują żadnych plików przechowywanych w folderach biblioteki.",
|
||||||
|
"MessageBackupsLocationEditNote": "Uwaga: Zmiana lokalizacji kopii zapasowej nie przenosi ani nie modyfikuje istniejących kopii zapasowych",
|
||||||
|
"MessageBackupsLocationNoEditNote": "Uwaga: Lokalizacja kopii zapasowej jest ustawiona poprzez zmienną środowiskową i nie może być tutaj zmieniona.",
|
||||||
|
"MessageBackupsLocationPathEmpty": "Ścieżka do kopii zapasowej nie może być pusta",
|
||||||
"MessageBatchQuickMatchDescription": "Quick Match będzie próbował dodać brakujące okładki i metadane dla wybranych elementów. Włącz poniższe opcje, aby umożliwić Quick Match nadpisanie istniejących okładek i/lub metadanych.",
|
"MessageBatchQuickMatchDescription": "Quick Match będzie próbował dodać brakujące okładki i metadane dla wybranych elementów. Włącz poniższe opcje, aby umożliwić Quick Match nadpisanie istniejących okładek i/lub metadanych.",
|
||||||
"MessageBookshelfNoCollections": "Nie posiadasz jeszcze żadnych kolekcji",
|
"MessageBookshelfNoCollections": "Nie posiadasz jeszcze żadnych kolekcji",
|
||||||
"MessageBookshelfNoRSSFeeds": "Nie posiadasz żadnych otwartych feedów RSS",
|
"MessageBookshelfNoRSSFeeds": "Nie posiadasz żadnych otwartych feedów RSS",
|
||||||
"MessageBookshelfNoResultsForFilter": "Nie znaleziono żadnych pozycji przy aktualnym filtrowaniu \"{0}: {1}\"",
|
"MessageBookshelfNoResultsForFilter": "Nie znaleziono żadnych pozycji przy aktualnym filtrowaniu \"{0}: {1}\"",
|
||||||
"MessageBookshelfNoResultsForQuery": "No results for query",
|
"MessageBookshelfNoResultsForQuery": "Brak wyników zapytania",
|
||||||
"MessageBookshelfNoSeries": "Nie masz jeszcze żadnych serii",
|
"MessageBookshelfNoSeries": "Nie masz jeszcze żadnych serii",
|
||||||
"MessageChapterEndIsAfter": "Koniec rozdziału następuje po zakończeniu audiobooka",
|
"MessageChapterEndIsAfter": "Koniec rozdziału następuje po zakończeniu audiobooka",
|
||||||
"MessageChapterErrorFirstNotZero": "First chapter must start at 0",
|
"MessageChapterErrorFirstNotZero": "Pierwszy rozdział musi rozpoczynać się na 0",
|
||||||
"MessageChapterErrorStartGteDuration": "Invalid start time must be less than audiobook duration",
|
"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",
|
"MessageChapterErrorStartLtPrev": "Invalid start time must be greater than or equal to previous chapter start time",
|
||||||
"MessageChapterStartIsAfter": "Początek rozdziału następuje po zakończeniu audiobooka",
|
"MessageChapterStartIsAfter": "Początek rozdziału następuje po zakończeniu audiobooka",
|
||||||
"MessageCheckingCron": "Sprawdzanie cron...",
|
"MessageCheckingCron": "Sprawdzanie cron...",
|
||||||
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||||
"MessageConfirmDeleteBackup": "Czy na pewno chcesz usunąć kopię zapasową dla {0}?",
|
"MessageConfirmDeleteBackup": "Czy na pewno chcesz usunąć kopię zapasową dla {0}?",
|
||||||
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
"MessageConfirmDeleteFile": "Ta operacja usunie plik z twojego dysku. Jesteś pewien?",
|
||||||
"MessageConfirmDeleteLibrary": "Czy na pewno chcesz trwale usunąć bibliotekę \"{0}\"?",
|
"MessageConfirmDeleteLibrary": "Czy na pewno chcesz trwale usunąć bibliotekę \"{0}\"?",
|
||||||
"MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?",
|
"MessageConfirmDeleteLibraryItem": "Ta operacja usunie pozycję biblioteki z bazy danych i z dysku. Czy jesteś pewien?",
|
||||||
"MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?",
|
"MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?",
|
||||||
"MessageConfirmDeleteSession": "Czy na pewno chcesz usunąć tę sesję?",
|
"MessageConfirmDeleteSession": "Czy na pewno chcesz usunąć tę sesję?",
|
||||||
"MessageConfirmForceReScan": "Czy na pewno chcesz wymusić ponowne skanowanie?",
|
"MessageConfirmForceReScan": "Czy na pewno chcesz wymusić ponowne skanowanie?",
|
||||||
"MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?",
|
"MessageConfirmMarkAllEpisodesFinished": "Czy na pewno chcesz oznaczyć wszystkie odcinki jako ukończone?",
|
||||||
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
|
"MessageConfirmMarkAllEpisodesNotFinished": "Czy na pewno chcesz oznaczyć wszystkie odcinki jako nieukończone?",
|
||||||
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
|
"MessageConfirmMarkSeriesFinished": "Czy na pewno chcesz oznaczyć wszystkie książki w tej serii jako ukończone?",
|
||||||
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
|
"MessageConfirmMarkSeriesNotFinished": "Czy na pewno chcesz oznaczyć wszystkie książki w tej serii jako nieukończone?",
|
||||||
"MessageConfirmPurgeCache": "Purge cache will delete the entire directory at <code>/metadata/cache</code>. <br /><br />Are you sure you want to remove the cache directory?",
|
"MessageConfirmPurgeCache": "Purge cache will delete the entire directory at <code>/metadata/cache</code>. <br /><br />Are you sure you want to remove the cache directory?",
|
||||||
"MessageConfirmPurgeItemsCache": "Purge items cache will delete the entire directory at <code>/metadata/cache/items</code>.<br />Are you sure?",
|
"MessageConfirmPurgeItemsCache": "Purge items cache will delete the entire directory at <code>/metadata/cache/items</code>.<br />Are you sure?",
|
||||||
"MessageConfirmQuickEmbed": "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?",
|
"MessageConfirmQuickEmbed": "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?",
|
||||||
@ -630,7 +640,7 @@
|
|||||||
"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?",
|
||||||
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
|
"MessageConfirmRemoveListeningSessions": "Czy na pewno chcesz usunąć {0} sesji słuchania?",
|
||||||
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
|
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
|
||||||
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
|
"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?",
|
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
|
||||||
@ -656,12 +666,12 @@
|
|||||||
"MessageListeningSessionsInTheLastYear": "{0} sesje odsłuchowe w ostatnim roku",
|
"MessageListeningSessionsInTheLastYear": "{0} sesje odsłuchowe w ostatnim roku",
|
||||||
"MessageLoading": "Ładowanie...",
|
"MessageLoading": "Ładowanie...",
|
||||||
"MessageLoadingFolders": "Ładowanie folderów...",
|
"MessageLoadingFolders": "Ładowanie folderów...",
|
||||||
"MessageLogsDescription": "Logs are stored in <code>/metadata/logs</code> as JSON files. Crash logs are stored in <code>/metadata/logs/crash_logs.txt</code>.",
|
"MessageLogsDescription": "Logi zapisane są w <code>/metadata/logs</code> jako pliki JSON. Logi awaryjne są zapisane w <code>/metadata/logs/crash_logs.txt</code>.",
|
||||||
"MessageM4BFailed": "Tworzenie pliku M4B nie powiodło się",
|
"MessageM4BFailed": "Tworzenie pliku M4B nie powiodło się!",
|
||||||
"MessageM4BFinished": "Tworzenie pliku M4B zakończyło się!",
|
"MessageM4BFinished": "Tworzenie pliku M4B zakończyło się!",
|
||||||
"MessageMapChapterTitles": "Mapowanie tytułów rozdziałów do istniejących rozdziałów audiobooka bez dostosowywania znaczników czasu",
|
"MessageMapChapterTitles": "Mapowanie tytułów rozdziałów do istniejących rozdziałów audiobooka bez dostosowywania znaczników czasu",
|
||||||
"MessageMarkAllEpisodesFinished": "Mark all episodes finished",
|
"MessageMarkAllEpisodesFinished": "Oznacz wszystkie odcinki jako ukończone",
|
||||||
"MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished",
|
"MessageMarkAllEpisodesNotFinished": "Oznacz wszystkie odcinki jako nieukończone",
|
||||||
"MessageMarkAsFinished": "Oznacz jako ukończone",
|
"MessageMarkAsFinished": "Oznacz jako ukończone",
|
||||||
"MessageMarkAsNotFinished": "Oznacz jako nieukończone",
|
"MessageMarkAsNotFinished": "Oznacz jako nieukończone",
|
||||||
"MessageMatchBooksDescription": "spróbuje dopasować książki w bibliotece bez plików audio, korzystając z wybranego dostawcy wyszukiwania i wypełnić puste szczegóły i okładki. Nie nadpisuje informacji.",
|
"MessageMatchBooksDescription": "spróbuje dopasować książki w bibliotece bez plików audio, korzystając z wybranego dostawcy wyszukiwania i wypełnić puste szczegóły i okładki. Nie nadpisuje informacji.",
|
||||||
@ -681,7 +691,7 @@
|
|||||||
"MessageNoGenres": "Brak gatunków",
|
"MessageNoGenres": "Brak gatunków",
|
||||||
"MessageNoIssues": "Brak problemów",
|
"MessageNoIssues": "Brak problemów",
|
||||||
"MessageNoItems": "Brak elementów",
|
"MessageNoItems": "Brak elementów",
|
||||||
"MessageNoItemsFound": "Nie znaleziono żadnych elemntów",
|
"MessageNoItemsFound": "Nie znaleziono żadnych elementów",
|
||||||
"MessageNoListeningSessions": "Brak sesji odtwarzania",
|
"MessageNoListeningSessions": "Brak sesji odtwarzania",
|
||||||
"MessageNoLogs": "Brak logów",
|
"MessageNoLogs": "Brak logów",
|
||||||
"MessageNoMediaProgress": "Brak postępu",
|
"MessageNoMediaProgress": "Brak postępu",
|
||||||
@ -691,29 +701,31 @@
|
|||||||
"MessageNoSearchResultsFor": "Brak wyników wyszukiwania dla \"{0}\"",
|
"MessageNoSearchResultsFor": "Brak wyników wyszukiwania dla \"{0}\"",
|
||||||
"MessageNoSeries": "No Series",
|
"MessageNoSeries": "No Series",
|
||||||
"MessageNoTags": "No Tags",
|
"MessageNoTags": "No Tags",
|
||||||
"MessageNoTasksRunning": "No Tasks Running",
|
"MessageNoTasksRunning": "Brak uruchomionych zadań",
|
||||||
"MessageNoUpdateNecessary": "Brak konieczności aktualizacji",
|
"MessageNoUpdateNecessary": "Brak konieczności aktualizacji",
|
||||||
"MessageNoUpdatesWereNecessary": "Brak aktualizacji",
|
"MessageNoUpdatesWereNecessary": "Brak aktualizacji",
|
||||||
"MessageNoUserPlaylists": "You have no playlists",
|
"MessageNoUserPlaylists": "Nie masz żadnych list odtwarzania",
|
||||||
"MessageNotYetImplemented": "Jeszcze nie zaimplementowane",
|
"MessageNotYetImplemented": "Jeszcze nie zaimplementowane",
|
||||||
"MessageOr": "lub",
|
"MessageOr": "lub",
|
||||||
"MessagePauseChapter": "Zatrzymaj odtwarzanie rozdziały",
|
"MessagePauseChapter": "Zatrzymaj odtwarzanie rozdziały",
|
||||||
"MessagePlayChapter": "Rozpocznij odtwarzanie od początku rozdziału",
|
"MessagePlayChapter": "Rozpocznij odtwarzanie od początku rozdziału",
|
||||||
"MessagePlaylistCreateFromCollection": "Create playlist from collection",
|
"MessagePlaylistCreateFromCollection": "Utwórz listę odtwarznia na podstawie kolekcji",
|
||||||
"MessagePodcastHasNoRSSFeedForMatching": "Podcast nie ma adresu url kanału RSS, który mógłby zostać użyty do dopasowania",
|
"MessagePodcastHasNoRSSFeedForMatching": "Podcast nie ma adresu url kanału RSS, który mógłby zostać użyty do dopasowania",
|
||||||
"MessageQuickMatchDescription": "Wypełnij puste informacje i okładkę pierwszym wynikiem dopasowania z '{0}'. Nie nadpisuje szczegółów, chyba że włączone jest ustawienie serwera 'Preferuj dopasowane metadane'.",
|
"MessageQuickMatchDescription": "Wypełnij puste informacje i okładkę pierwszym wynikiem dopasowania z '{0}'. Nie nadpisuje szczegółów, chyba że włączone jest ustawienie serwera 'Preferuj dopasowane metadane'.",
|
||||||
"MessageRemoveChapter": "Usuń rozdział",
|
"MessageRemoveChapter": "Usuń rozdział",
|
||||||
"MessageRemoveEpisodes": "Usuń {0} odcinków",
|
"MessageRemoveEpisodes": "Usuń {0} odcinków",
|
||||||
"MessageRemoveFromPlayerQueue": "Remove from player queue",
|
"MessageRemoveFromPlayerQueue": "Usuń z kolejki odtwarzacza",
|
||||||
"MessageRemoveUserWarning": "Czy na pewno chcesz trwale usunąć użytkownika \"{0}\"?",
|
"MessageRemoveUserWarning": "Czy na pewno chcesz trwale usunąć użytkownika \"{0}\"?",
|
||||||
"MessageReportBugsAndContribute": "Zgłoś błędy, pomysły i pomóż rozwijać aplikację na",
|
"MessageReportBugsAndContribute": "Zgłoś błędy, pomysły i pomóż rozwijać aplikację na",
|
||||||
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?",
|
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?",
|
||||||
"MessageRestoreBackupConfirm": "Czy na pewno chcesz przywrócić kopię zapasową utworzoną w dniu",
|
"MessageRestoreBackupConfirm": "Czy na pewno chcesz przywrócić kopię zapasową utworzoną w dniu",
|
||||||
"MessageRestoreBackupWarning": "Przywrócenie kopii zapasowej spowoduje nadpisane bazy danych w folderze /config oraz okładke w folderze /metadata/items & /metadata/authors.<br /><br />Kopie zapasowe nie modyfikują żadnego pliku w folderach z plikami audio. Jeśli włączyłeś ustawienia serwera, aby przechowywać okładki i metadane w folderach biblioteki, to nie są one zapisywane w kopii zapasowej lub nadpisywane<br /><br />Wszyscy klienci korzystający z Twojego serwera będą automatycznie odświeżani",
|
"MessageRestoreBackupWarning": "Przywrócenie kopii zapasowej spowoduje nadpisanie bazy danych w folderze /config oraz okładek w folderze /metadata/items & /metadata/authors.<br /><br />Kopie zapasowe nie modyfikują żadnego pliku w folderach z plikami audio. Jeśli włączyłeś ustawienia serwera, aby przechowywać okładki i metadane w folderach biblioteki, to nie są one zapisywane w kopii zapasowej lub nadpisywane<br /><br />Wszyscy klienci korzystający z Twojego serwera będą automatycznie odświeżani.",
|
||||||
"MessageSearchResultsFor": "Wyniki wyszukiwania dla",
|
"MessageSearchResultsFor": "Wyniki wyszukiwania dla",
|
||||||
"MessageSelected": "{0} selected",
|
"MessageSelected": "{0} wybranych",
|
||||||
"MessageServerCouldNotBeReached": "Nie udało się uzyskać połączenia z serwerem",
|
"MessageServerCouldNotBeReached": "Nie udało się uzyskać połączenia z serwerem",
|
||||||
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
|
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
|
||||||
|
"MessageShareExpiresIn": "Wygaśnie za {0}",
|
||||||
|
"MessageShareURLWillBe": "URL udziału będzie <strong>{0}</strong>",
|
||||||
"MessageStartPlaybackAtTime": "Rozpoczęcie odtwarzania \"{0}\" od {1}?",
|
"MessageStartPlaybackAtTime": "Rozpoczęcie odtwarzania \"{0}\" od {1}?",
|
||||||
"MessageThinking": "Myślę...",
|
"MessageThinking": "Myślę...",
|
||||||
"MessageUploaderItemFailed": "Nie udało się przesłać",
|
"MessageUploaderItemFailed": "Nie udało się przesłać",
|
||||||
@ -727,7 +739,7 @@
|
|||||||
"NoteChangeRootPassword": "Tylko użytkownik root, może posiadać puste hasło",
|
"NoteChangeRootPassword": "Tylko użytkownik root, może posiadać puste hasło",
|
||||||
"NoteChapterEditorTimes": "Uwaga: Czas rozpoczęcia pierwszego rozdziału musi pozostać na poziomie 0:00, a czas rozpoczęcia ostatniego rozdziału nie może przekroczyć czasu trwania audiobooka.",
|
"NoteChapterEditorTimes": "Uwaga: Czas rozpoczęcia pierwszego rozdziału musi pozostać na poziomie 0:00, a czas rozpoczęcia ostatniego rozdziału nie może przekroczyć czasu trwania audiobooka.",
|
||||||
"NoteFolderPicker": "Uwaga: dotychczas zmapowane foldery nie zostaną wyświetlone",
|
"NoteFolderPicker": "Uwaga: dotychczas zmapowane foldery nie zostaną wyświetlone",
|
||||||
"NoteRSSFeedPodcastAppsHttps": "Ostrzeżenie: Większość aplikacji do obsługi podcastów wymaga, aby adres URL kanału RSS korzystał z protokołu HTTPS.",
|
"NoteRSSFeedPodcastAppsHttps": "Ostrzeżenie: Większość aplikacji do obsługi podcastów wymaga, aby adres URL kanału RSS korzystał z protokołu HTTPS",
|
||||||
"NoteRSSFeedPodcastAppsPubDate": "Ostrzeżenie: 1 lub więcej odcinków nie ma daty publikacji. Niektóre aplikacje do słuchania podcastów tego wymagają.",
|
"NoteRSSFeedPodcastAppsPubDate": "Ostrzeżenie: 1 lub więcej odcinków nie ma daty publikacji. Niektóre aplikacje do słuchania podcastów tego wymagają.",
|
||||||
"NoteUploaderFoldersWithMediaFiles": "Foldery z plikami multimedialnymi będą traktowane jako osobne elementy w bibliotece.",
|
"NoteUploaderFoldersWithMediaFiles": "Foldery z plikami multimedialnymi będą traktowane jako osobne elementy w bibliotece.",
|
||||||
"NoteUploaderOnlyAudioFiles": "Jeśli przesyłasz tylko pliki audio, każdy plik audio będzie traktowany jako osobny audiobook.",
|
"NoteUploaderOnlyAudioFiles": "Jeśli przesyłasz tylko pliki audio, każdy plik audio będzie traktowany jako osobny audiobook.",
|
||||||
@ -736,7 +748,7 @@
|
|||||||
"PlaceholderNewFolderPath": "Nowa ścieżka folderu",
|
"PlaceholderNewFolderPath": "Nowa ścieżka folderu",
|
||||||
"PlaceholderNewPlaylist": "New playlist name",
|
"PlaceholderNewPlaylist": "New playlist name",
|
||||||
"PlaceholderSearch": "Szukanie..",
|
"PlaceholderSearch": "Szukanie..",
|
||||||
"PlaceholderSearchEpisode": "Search episode..",
|
"PlaceholderSearchEpisode": "Szukanie odcinka..",
|
||||||
"ToastAccountUpdateFailed": "Nie udało się zaktualizować konta",
|
"ToastAccountUpdateFailed": "Nie udało się zaktualizować konta",
|
||||||
"ToastAccountUpdateSuccess": "Zaktualizowano konto",
|
"ToastAccountUpdateSuccess": "Zaktualizowano konto",
|
||||||
"ToastAuthorImageRemoveFailed": "Nie udało się usunąć obrazu",
|
"ToastAuthorImageRemoveFailed": "Nie udało się usunąć obrazu",
|
||||||
@ -778,10 +790,10 @@
|
|||||||
"ToastItemDetailsUpdateFailed": "Nie udało się zaktualizować szczegółów",
|
"ToastItemDetailsUpdateFailed": "Nie udało się zaktualizować szczegółów",
|
||||||
"ToastItemDetailsUpdateSuccess": "Zaktualizowano szczegóły",
|
"ToastItemDetailsUpdateSuccess": "Zaktualizowano szczegóły",
|
||||||
"ToastItemDetailsUpdateUnneeded": "Brak aktulizacji dla pozycji",
|
"ToastItemDetailsUpdateUnneeded": "Brak aktulizacji dla pozycji",
|
||||||
"ToastItemMarkedAsFinishedFailed": "Nie udało się oznaczyć jako zakończone",
|
"ToastItemMarkedAsFinishedFailed": "Nie udało się oznaczyć jako ukończone",
|
||||||
"ToastItemMarkedAsFinishedSuccess": "Pozycja oznaczona jako ukończona",
|
"ToastItemMarkedAsFinishedSuccess": "Pozycja oznaczona jako ukończona",
|
||||||
"ToastItemMarkedAsNotFinishedFailed": "Oznaczenie pozycji jako ukończonej nie powiodło się",
|
"ToastItemMarkedAsNotFinishedFailed": "Oznaczenie pozycji jako ukończonej nie powiodło się",
|
||||||
"ToastItemMarkedAsNotFinishedSuccess": "Pozycja oznaczona jako ukończon",
|
"ToastItemMarkedAsNotFinishedSuccess": "Pozycja oznaczona jako nieukończona",
|
||||||
"ToastLibraryCreateFailed": "Nie udało się utworzyć biblioteki",
|
"ToastLibraryCreateFailed": "Nie udało się utworzyć biblioteki",
|
||||||
"ToastLibraryCreateSuccess": "Biblioteka \"{0}\" stworzona",
|
"ToastLibraryCreateSuccess": "Biblioteka \"{0}\" stworzona",
|
||||||
"ToastLibraryDeleteFailed": "Nie udało się usunąć biblioteki",
|
"ToastLibraryDeleteFailed": "Nie udało się usunąć biblioteki",
|
||||||
|
105
docs/controllers/EmailController.yaml
Normal file
105
docs/controllers/EmailController.yaml
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
emailSettings:
|
||||||
|
type: string
|
||||||
|
description: The field to sort by from the request.
|
||||||
|
example: 'media.metadata.title'
|
||||||
|
responses:
|
||||||
|
email200:
|
||||||
|
description: Successful response - Email
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../objects/settings/EmailSettings.yaml#/components/schemas/EmailSettings'
|
||||||
|
ereader200:
|
||||||
|
description: Successful response - Ereader
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
ereaderDevices:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '../objects/settings/EmailSettings.yaml#/components/schemas/EreaderDeviceObject'
|
||||||
|
paths:
|
||||||
|
/api/emails/settings:
|
||||||
|
get:
|
||||||
|
description: Get email settings
|
||||||
|
operationId: getEmailSettings
|
||||||
|
tags:
|
||||||
|
- Email
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
$ref: '#/components/responses/email200'
|
||||||
|
patch:
|
||||||
|
summary: Update email settings
|
||||||
|
operationId: updateEmailSettings
|
||||||
|
tags:
|
||||||
|
- Email
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/EmailSettings'
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
$ref: '#/components/responses/email200'
|
||||||
|
/api/emails/test:
|
||||||
|
post:
|
||||||
|
summary: Send test email
|
||||||
|
operationId: sendTestEmail
|
||||||
|
tags:
|
||||||
|
- Email
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Successful response
|
||||||
|
/api/emails/ereader-devices:
|
||||||
|
post:
|
||||||
|
summary: Update e-reader devices
|
||||||
|
operationId: updateEReaderDevices
|
||||||
|
tags:
|
||||||
|
- Email
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
ereaderDevices:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '../objects/settings/EmailSettings.yaml#/components/schemas/EreaderDeviceObject'
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
$ref: '#/components/responses/ereader200'
|
||||||
|
400:
|
||||||
|
description: Invalid payload
|
||||||
|
/api/emails/send-ebook-to-device:
|
||||||
|
post:
|
||||||
|
summary: Send ebook to device
|
||||||
|
operationId: sendEBookToDevice
|
||||||
|
tags:
|
||||||
|
- Email
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
libraryItemId:
|
||||||
|
$ref: '../objects/LibraryItem.yaml#/components/schemas/libraryItemId'
|
||||||
|
deviceName:
|
||||||
|
$ref: '../objects/settings/EmailSettings.yaml#/components/schemas/ereaderName'
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Successful response
|
||||||
|
400:
|
||||||
|
description: Invalid request
|
||||||
|
403:
|
||||||
|
description: Forbidden
|
||||||
|
404:
|
||||||
|
description: Not found
|
78
docs/objects/settings/EmailSettings.yaml
Normal file
78
docs/objects/settings/EmailSettings.yaml
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
ereaderName:
|
||||||
|
type: string
|
||||||
|
description: The name of the e-reader device.
|
||||||
|
EreaderDeviceObject:
|
||||||
|
type: object
|
||||||
|
description: An e-reader device configured to receive EPUB through e-mail.
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
$ref: '#/components/schemas/ereaderName'
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
description: The email address associated with the e-reader device.
|
||||||
|
availabilityOption:
|
||||||
|
type: string
|
||||||
|
description: The availability option for the device.
|
||||||
|
enum: ['adminOrUp', 'userOrUp', 'guestOrUp', 'specificUsers']
|
||||||
|
users:
|
||||||
|
type: array
|
||||||
|
description: List of specific users allowed to access the device.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
- email
|
||||||
|
- availabilityOption
|
||||||
|
EmailSettings:
|
||||||
|
type: object
|
||||||
|
description: The email settings configuration for the server. This includes the credentials to send e-books and an array of e-reader devices.
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
description: The unique identifier for the email settings. Currently this is always `email-settings`
|
||||||
|
example: email-settings
|
||||||
|
host:
|
||||||
|
type: string
|
||||||
|
description: The SMTP host address.
|
||||||
|
nullable: true
|
||||||
|
port:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
description: The port number for the SMTP server.
|
||||||
|
example: 465
|
||||||
|
secure:
|
||||||
|
type: boolean
|
||||||
|
description: Indicates if the connection should use SSL/TLS.
|
||||||
|
example: true
|
||||||
|
rejectUnauthorized:
|
||||||
|
type: boolean
|
||||||
|
description: Indicates if unauthorized SSL/TLS certificates should be rejected.
|
||||||
|
example: true
|
||||||
|
user:
|
||||||
|
type: string
|
||||||
|
description: The username for SMTP authentication.
|
||||||
|
nullable: true
|
||||||
|
pass:
|
||||||
|
type: string
|
||||||
|
description: The password for SMTP authentication.
|
||||||
|
nullable: true
|
||||||
|
testAddress:
|
||||||
|
type: string
|
||||||
|
description: The test email address used for sending test emails.
|
||||||
|
nullable: true
|
||||||
|
fromAddress:
|
||||||
|
type: string
|
||||||
|
description: The default "from" email address for outgoing emails.
|
||||||
|
nullable: true
|
||||||
|
ereaderDevices:
|
||||||
|
type: array
|
||||||
|
description: List of configured e-reader devices.
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/EreaderDeviceObject'
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- port
|
||||||
|
- secure
|
||||||
|
- ereaderDevices
|
@ -29,6 +29,10 @@
|
|||||||
"name": "Series",
|
"name": "Series",
|
||||||
"description": "Series endpoints"
|
"description": "Series endpoints"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Email",
|
||||||
|
"description": "Email endpoints"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Notification",
|
"name": "Notification",
|
||||||
"description": "Notifications endpoints"
|
"description": "Notifications endpoints"
|
||||||
@ -416,6 +420,132 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/emails/settings": {
|
||||||
|
"get": {
|
||||||
|
"description": "Get email settings",
|
||||||
|
"operationId": "getEmailSettings",
|
||||||
|
"tags": [
|
||||||
|
"Email"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/email200"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"patch": {
|
||||||
|
"summary": "Update email settings",
|
||||||
|
"operationId": "updateEmailSettings",
|
||||||
|
"tags": [
|
||||||
|
"Email"
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/EmailSettings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/email200"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/emails/test": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Send test email",
|
||||||
|
"operationId": "sendTestEmail",
|
||||||
|
"tags": [
|
||||||
|
"Email"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/emails/ereader-devices": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Update e-reader devices",
|
||||||
|
"operationId": "updateEReaderDevices",
|
||||||
|
"tags": [
|
||||||
|
"Email"
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ereaderDevices": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/EreaderDeviceObject"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/ereader200"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid payload"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/emails/send-ebook-to-device": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Send ebook to device",
|
||||||
|
"operationId": "sendEBookToDevice",
|
||||||
|
"tags": [
|
||||||
|
"Email"
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"libraryItemId": {
|
||||||
|
"$ref": "#/components/schemas/libraryItemId"
|
||||||
|
},
|
||||||
|
"deviceName": {
|
||||||
|
"$ref": "#/components/schemas/ereaderName"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid request"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/libraries": {
|
"/api/libraries": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getLibraries",
|
"operationId": "getLibraries",
|
||||||
@ -1114,12 +1244,6 @@
|
|||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
|
||||||
"eventName",
|
|
||||||
"urls",
|
|
||||||
"titleTemplate",
|
|
||||||
"bodyTemplate"
|
|
||||||
],
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"libraryId": {
|
"libraryId": {
|
||||||
"$ref": "#/components/schemas/libraryIdNullable"
|
"$ref": "#/components/schemas/libraryIdNullable"
|
||||||
@ -1142,7 +1266,13 @@
|
|||||||
"type": {
|
"type": {
|
||||||
"$ref": "#/components/schemas/notificationType"
|
"$ref": "#/components/schemas/notificationType"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"required": [
|
||||||
|
"eventName",
|
||||||
|
"urls",
|
||||||
|
"titleTemplate",
|
||||||
|
"bodyTemplate"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1942,6 +2072,110 @@
|
|||||||
"example": "us",
|
"example": "us",
|
||||||
"default": "us"
|
"default": "us"
|
||||||
},
|
},
|
||||||
|
"ereaderName": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The name of the e-reader device."
|
||||||
|
},
|
||||||
|
"EreaderDeviceObject": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "An e-reader device configured to receive EPUB through e-mail.",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"$ref": "#/components/schemas/ereaderName"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The email address associated with the e-reader device."
|
||||||
|
},
|
||||||
|
"availabilityOption": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The availability option for the device.",
|
||||||
|
"enum": [
|
||||||
|
"adminOrUp",
|
||||||
|
"userOrUp",
|
||||||
|
"guestOrUp",
|
||||||
|
"specificUsers"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "List of specific users allowed to access the device.",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"email",
|
||||||
|
"availabilityOption"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"EmailSettings": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "The email settings configuration for the server. This includes the credentials to send e-books and an array of e-reader devices.",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The unique identifier for the email settings. Currently this is always `email-settings`",
|
||||||
|
"example": "email-settings"
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The SMTP host address.",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"port": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "The port number for the SMTP server.",
|
||||||
|
"example": 465
|
||||||
|
},
|
||||||
|
"secure": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Indicates if the connection should use SSL/TLS.",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"rejectUnauthorized": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Indicates if unauthorized SSL/TLS certificates should be rejected.",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The username for SMTP authentication.",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"pass": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The password for SMTP authentication.",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"testAddress": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The test email address used for sending test emails.",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"fromAddress": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The default \"from\" email address for outgoing emails.",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"ereaderDevices": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "List of configured e-reader devices.",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/EreaderDeviceObject"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"port",
|
||||||
|
"secure",
|
||||||
|
"ereaderDevices"
|
||||||
|
]
|
||||||
|
},
|
||||||
"libraryName": {
|
"libraryName": {
|
||||||
"description": "The name of the library.",
|
"description": "The name of the library.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@ -2530,6 +2764,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"email200": {
|
||||||
|
"description": "Successful response - Email",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/EmailSettings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ereader200": {
|
||||||
|
"description": "Successful response - Ereader",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ereaderDevices": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/EreaderDeviceObject"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"library200": {
|
"library200": {
|
||||||
"description": "Library found.",
|
"description": "Library found.",
|
||||||
"content": {
|
"content": {
|
||||||
|
@ -21,6 +21,14 @@ paths:
|
|||||||
$ref: './controllers/AuthorController.yaml#/paths/~1api~1authors~1{id}~1image'
|
$ref: './controllers/AuthorController.yaml#/paths/~1api~1authors~1{id}~1image'
|
||||||
/api/authors/{id}/match:
|
/api/authors/{id}/match:
|
||||||
$ref: './controllers/AuthorController.yaml#/paths/~1api~1authors~1{id}~1match'
|
$ref: './controllers/AuthorController.yaml#/paths/~1api~1authors~1{id}~1match'
|
||||||
|
/api/emails/settings:
|
||||||
|
$ref: './controllers/EmailController.yaml#/paths/~1api~1emails~1settings'
|
||||||
|
/api/emails/test:
|
||||||
|
$ref: './controllers/EmailController.yaml#/paths/~1api~1emails~1test'
|
||||||
|
/api/emails/ereader-devices:
|
||||||
|
$ref: './controllers/EmailController.yaml#/paths/~1api~1emails~1ereader-devices'
|
||||||
|
/api/emails/send-ebook-to-device:
|
||||||
|
$ref: './controllers/EmailController.yaml#/paths/~1api~1emails~1send-ebook-to-device'
|
||||||
/api/libraries:
|
/api/libraries:
|
||||||
$ref: './controllers/LibraryController.yaml#/paths/~1api~1libraries'
|
$ref: './controllers/LibraryController.yaml#/paths/~1api~1libraries'
|
||||||
/api/libraries/{id}:
|
/api/libraries/{id}:
|
||||||
@ -54,5 +62,7 @@ tags:
|
|||||||
description: Library endpoints
|
description: Library endpoints
|
||||||
- name: Series
|
- name: Series
|
||||||
description: Series endpoints
|
description: Series endpoints
|
||||||
|
- name: Email
|
||||||
|
description: Email endpoints
|
||||||
- name: Notification
|
- name: Notification
|
||||||
description: Notifications endpoints
|
description: Notifications endpoints
|
||||||
|
10
package-lock.json
generated
10
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.10.1",
|
"version": "2.11.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.10.1",
|
"version": "2.11.0",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
@ -16,7 +16,6 @@
|
|||||||
"graceful-fs": "^4.2.10",
|
"graceful-fs": "^4.2.10",
|
||||||
"htmlparser2": "^8.0.1",
|
"htmlparser2": "^8.0.1",
|
||||||
"lru-cache": "^10.0.3",
|
"lru-cache": "^10.0.3",
|
||||||
"node-tone": "^1.0.1",
|
|
||||||
"nodemailer": "^6.9.13",
|
"nodemailer": "^6.9.13",
|
||||||
"openid-client": "^5.6.1",
|
"openid-client": "^5.6.1",
|
||||||
"p-throttle": "^4.1.1",
|
"p-throttle": "^4.1.1",
|
||||||
@ -3661,11 +3660,6 @@
|
|||||||
"integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==",
|
"integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/node-tone": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-tone/-/node-tone-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-wi7L0taDZMN6tM5l85TDKHsYzdhqJTtPNgvgpk2zHeZzPt6ZIUZ9vBLTJRRDpm0xzCvbsvFHjAaudeQjLHTE4w=="
|
|
||||||
},
|
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "6.9.13",
|
"version": "6.9.13",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.13.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.13.tgz",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.10.1",
|
"version": "2.11.0",
|
||||||
"buildNumber": 1,
|
"buildNumber": 1,
|
||||||
"description": "Self-hosted audiobook and podcast server",
|
"description": "Self-hosted audiobook and podcast server",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
@ -9,7 +9,7 @@
|
|||||||
"start": "node index.js",
|
"start": "node index.js",
|
||||||
"client": "cd client && npm ci && npm run generate",
|
"client": "cd client && npm ci && npm run generate",
|
||||||
"prod": "npm run client && npm ci && node prod.js",
|
"prod": "npm run client && npm ci && node prod.js",
|
||||||
"build-win": "npm run client && pkg -t node18-win-x64 -o ./dist/win/audiobookshelf -C GZip .",
|
"build-win": "npm run client && pkg -t node20-win-x64 -o ./dist/win/audiobookshelf -C GZip .",
|
||||||
"build-linux": "build/linuxpackager",
|
"build-linux": "build/linuxpackager",
|
||||||
"docker": "docker buildx build --platform linux/amd64,linux/arm64 --push . -t advplyr/audiobookshelf",
|
"docker": "docker buildx build --platform linux/amd64,linux/arm64 --push . -t advplyr/audiobookshelf",
|
||||||
"docker-amd64-local": "docker buildx build --platform linux/amd64 --load . -t advplyr/audiobookshelf-amd64-local",
|
"docker-amd64-local": "docker buildx build --platform linux/amd64 --load . -t advplyr/audiobookshelf-amd64-local",
|
||||||
@ -42,7 +42,6 @@
|
|||||||
"graceful-fs": "^4.2.10",
|
"graceful-fs": "^4.2.10",
|
||||||
"htmlparser2": "^8.0.1",
|
"htmlparser2": "^8.0.1",
|
||||||
"lru-cache": "^10.0.3",
|
"lru-cache": "^10.0.3",
|
||||||
"node-tone": "^1.0.1",
|
|
||||||
"nodemailer": "^6.9.13",
|
"nodemailer": "^6.9.13",
|
||||||
"openid-client": "^5.6.1",
|
"openid-client": "^5.6.1",
|
||||||
"p-throttle": "^4.1.1",
|
"p-throttle": "^4.1.1",
|
||||||
|
@ -31,7 +31,7 @@ Audiobookshelf is a self-hosted audiobook and podcast server.
|
|||||||
- Fetch metadata and cover art from several sources
|
- Fetch metadata and cover art from several sources
|
||||||
- Chapter editor and chapter lookup (using [Audnexus API](https://audnex.us/))
|
- Chapter editor and chapter lookup (using [Audnexus API](https://audnex.us/))
|
||||||
- Merge your audio files into a single m4b
|
- Merge your audio files into a single m4b
|
||||||
- Embed metadata and cover image into your audio files (using [Tone](https://github.com/sandreas/tone))
|
- Embed metadata and cover image into your audio files
|
||||||
- Basic ebook support and ereader
|
- Basic ebook support and ereader
|
||||||
- Epub, pdf, cbr, cbz
|
- Epub, pdf, cbr, cbz
|
||||||
- Send ebook to device (i.e. Kindle)
|
- Send ebook to device (i.e. Kindle)
|
||||||
|
@ -104,6 +104,7 @@ class Server {
|
|||||||
*/
|
*/
|
||||||
async init() {
|
async init() {
|
||||||
Logger.info('[Server] Init v' + version)
|
Logger.info('[Server] Init v' + version)
|
||||||
|
Logger.info('[Server] Node.js Version:', process.version)
|
||||||
|
|
||||||
await this.playbackSessionManager.removeOrphanStreams()
|
await this.playbackSessionManager.removeOrphanStreams()
|
||||||
|
|
||||||
|
@ -10,7 +10,8 @@ class BackupController {
|
|||||||
getAll(req, res) {
|
getAll(req, res) {
|
||||||
res.json({
|
res.json({
|
||||||
backups: this.backupManager.backups.map((b) => b.toJSON()),
|
backups: this.backupManager.backups.map((b) => b.toJSON()),
|
||||||
backupLocation: this.backupManager.backupPath
|
backupLocation: this.backupManager.backupPath,
|
||||||
|
backupPathEnvSet: this.backupManager.backupPathEnvSet
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -43,7 +43,7 @@ class LibraryItemController {
|
|||||||
item.rssFeed = feedData?.toJSONMinified() || null
|
item.rssFeed = feedData?.toJSONMinified() || null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.mediaType === 'book' && includeEntities.includes('share')) {
|
if (item.mediaType === 'book' && req.user.isAdminOrUp && includeEntities.includes('share')) {
|
||||||
item.mediaItemShare = ShareManager.findByMediaItemId(item.media.id)
|
item.mediaItemShare = ShareManager.findByMediaItemId(item.media.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -559,9 +559,9 @@ class LibraryItemController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getToneMetadataObject(req, res) {
|
getMetadataObject(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[LibraryItemController] Non-admin user attempted to get tone metadata object`, req.user)
|
Logger.error(`[LibraryItemController] Non-admin user attempted to get metadata object`, req.user)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -570,7 +570,7 @@ class LibraryItemController {
|
|||||||
return res.sendStatus(500)
|
return res.sendStatus(500)
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(this.audioMetadataManager.getToneMetadataObjectForApi(req.libraryItem))
|
res.json(this.audioMetadataManager.getMetadataObjectForApi(req.libraryItem))
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST: api/items/:id/chapters
|
// POST: api/items/:id/chapters
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
|
|
||||||
@ -7,7 +6,7 @@ const Logger = require('../Logger')
|
|||||||
const TaskManager = require('./TaskManager')
|
const TaskManager = require('./TaskManager')
|
||||||
const Task = require('../objects/Task')
|
const Task = require('../objects/Task')
|
||||||
const { writeConcatFile } = require('../utils/ffmpegHelpers')
|
const { writeConcatFile } = require('../utils/ffmpegHelpers')
|
||||||
const toneHelpers = require('../utils/toneHelpers')
|
const ffmpegHelpers = require('../utils/ffmpegHelpers')
|
||||||
|
|
||||||
class AbMergeManager {
|
class AbMergeManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@ -17,7 +16,7 @@ class AbMergeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getPendingTaskByLibraryItemId(libraryItemId) {
|
getPendingTaskByLibraryItemId(libraryItemId) {
|
||||||
return this.pendingTasks.find(t => t.task.data.libraryItemId === libraryItemId)
|
return this.pendingTasks.find((t) => t.task.data.libraryItemId === libraryItemId)
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelEncode(task) {
|
cancelEncode(task) {
|
||||||
@ -31,23 +30,27 @@ class AbMergeManager {
|
|||||||
const targetFilename = audiobookDirname + '.m4b'
|
const targetFilename = audiobookDirname + '.m4b'
|
||||||
const itemCachePath = Path.join(this.itemsCacheDir, libraryItem.id)
|
const itemCachePath = Path.join(this.itemsCacheDir, libraryItem.id)
|
||||||
const tempFilepath = Path.join(itemCachePath, targetFilename)
|
const tempFilepath = Path.join(itemCachePath, targetFilename)
|
||||||
|
const ffmetadataPath = Path.join(itemCachePath, 'ffmetadata.txt')
|
||||||
const taskData = {
|
const taskData = {
|
||||||
libraryItemId: libraryItem.id,
|
libraryItemId: libraryItem.id,
|
||||||
libraryItemPath: libraryItem.path,
|
libraryItemPath: libraryItem.path,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
originalTrackPaths: libraryItem.media.tracks.map(t => t.metadata.path),
|
originalTrackPaths: libraryItem.media.tracks.map((t) => t.metadata.path),
|
||||||
tempFilepath,
|
tempFilepath,
|
||||||
targetFilename,
|
targetFilename,
|
||||||
targetFilepath: Path.join(libraryItem.path, targetFilename),
|
targetFilepath: Path.join(libraryItem.path, targetFilename),
|
||||||
itemCachePath,
|
itemCachePath,
|
||||||
toneJsonObject: null
|
ffmetadataObject: ffmpegHelpers.getFFMetadataObject(libraryItem, 1),
|
||||||
|
chapters: libraryItem.media.chapters?.map((c) => ({ ...c })),
|
||||||
|
coverPath: libraryItem.media.coverPath,
|
||||||
|
ffmetadataPath
|
||||||
}
|
}
|
||||||
const taskDescription = `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.`
|
const taskDescription = `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.`
|
||||||
task.setData('encode-m4b', 'Encoding M4b', taskDescription, false, taskData)
|
task.setData('encode-m4b', 'Encoding M4b', taskDescription, false, taskData)
|
||||||
TaskManager.addTask(task)
|
TaskManager.addTask(task)
|
||||||
Logger.info(`Start m4b encode for ${libraryItem.id} - TaskId: ${task.id}`)
|
Logger.info(`Start m4b encode for ${libraryItem.id} - TaskId: ${task.id}`)
|
||||||
|
|
||||||
if (!await fs.pathExists(taskData.itemCachePath)) {
|
if (!(await fs.pathExists(taskData.itemCachePath))) {
|
||||||
await fs.mkdir(taskData.itemCachePath)
|
await fs.mkdir(taskData.itemCachePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,6 +58,15 @@ class AbMergeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async runAudiobookMerge(libraryItem, task, encodingOptions) {
|
async runAudiobookMerge(libraryItem, task, encodingOptions) {
|
||||||
|
// Create ffmetadata file
|
||||||
|
const success = await ffmpegHelpers.writeFFMetadataFile(task.data.metadataObject, task.data.chapters, task.data.ffmetadataPath)
|
||||||
|
if (!success) {
|
||||||
|
Logger.error(`[AudioMetadataManager] Failed to write ffmetadata file for audiobook "${task.data.libraryItemId}"`)
|
||||||
|
task.setFailed('Failed to write metadata file.')
|
||||||
|
this.removeTask(task, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const audioBitrate = encodingOptions.bitrate || '128k'
|
const audioBitrate = encodingOptions.bitrate || '128k'
|
||||||
const audioCodec = encodingOptions.codec || 'aac'
|
const audioCodec = encodingOptions.codec || 'aac'
|
||||||
const audioChannels = encodingOptions.channels || 2
|
const audioChannels = encodingOptions.channels || 2
|
||||||
@ -90,12 +102,7 @@ class AbMergeManager {
|
|||||||
const ffmpegOutputOptions = ['-f mp4']
|
const ffmpegOutputOptions = ['-f mp4']
|
||||||
|
|
||||||
if (audioRequiresEncode) {
|
if (audioRequiresEncode) {
|
||||||
ffmpegOptions = ffmpegOptions.concat([
|
ffmpegOptions = ffmpegOptions.concat(['-map 0:a', `-acodec ${audioCodec}`, `-ac ${audioChannels}`, `-b:a ${audioBitrate}`])
|
||||||
'-map 0:a',
|
|
||||||
`-acodec ${audioCodec}`,
|
|
||||||
`-ac ${audioChannels}`,
|
|
||||||
`-b:a ${audioBitrate}`
|
|
||||||
])
|
|
||||||
} else {
|
} else {
|
||||||
ffmpegOptions.push('-max_muxing_queue_size 1000')
|
ffmpegOptions.push('-max_muxing_queue_size 1000')
|
||||||
|
|
||||||
@ -106,24 +113,6 @@ class AbMergeManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let toneJsonPath = null
|
|
||||||
try {
|
|
||||||
toneJsonPath = Path.join(task.data.itemCachePath, 'metadata.json')
|
|
||||||
await toneHelpers.writeToneMetadataJsonFile(libraryItem, libraryItem.media.chapters, toneJsonPath, 1, 'audio/mp4')
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(`[AbMergeManager] Write metadata.json failed`, error)
|
|
||||||
toneJsonPath = null
|
|
||||||
}
|
|
||||||
|
|
||||||
task.data.toneJsonObject = {
|
|
||||||
'ToneJsonFile': toneJsonPath,
|
|
||||||
'TrackNumber': 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (libraryItem.media.coverPath) {
|
|
||||||
task.data.toneJsonObject['CoverFile'] = libraryItem.media.coverPath
|
|
||||||
}
|
|
||||||
|
|
||||||
const workerData = {
|
const workerData = {
|
||||||
inputs: ffmpegInputs,
|
inputs: ffmpegInputs,
|
||||||
options: ffmpegOptions,
|
options: ffmpegOptions,
|
||||||
@ -162,7 +151,7 @@ class AbMergeManager {
|
|||||||
|
|
||||||
async sendResult(task, result) {
|
async sendResult(task, result) {
|
||||||
// Remove pending task
|
// Remove pending task
|
||||||
this.pendingTasks = this.pendingTasks.filter(d => d.id !== task.id)
|
this.pendingTasks = this.pendingTasks.filter((d) => d.id !== task.id)
|
||||||
|
|
||||||
if (result.isKilled) {
|
if (result.isKilled) {
|
||||||
task.setFailed('Ffmpeg task killed')
|
task.setFailed('Ffmpeg task killed')
|
||||||
@ -177,7 +166,7 @@ class AbMergeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Write metadata to merged file
|
// Write metadata to merged file
|
||||||
const success = await toneHelpers.tagAudioFile(task.data.tempFilepath, task.data.toneJsonObject)
|
const success = await ffmpegHelpers.addCoverAndMetadataToFile(task.data.tempFilepath, task.data.coverPath, task.data.ffmetadataPath, 1, 'audio/mp4')
|
||||||
if (!success) {
|
if (!success) {
|
||||||
Logger.error(`[AbMergeManager] Failed to write metadata to file "${task.data.tempFilepath}"`)
|
Logger.error(`[AbMergeManager] Failed to write metadata to file "${task.data.tempFilepath}"`)
|
||||||
task.setFailed('Failed to write metadata to m4b file')
|
task.setFailed('Failed to write metadata to m4b file')
|
||||||
@ -199,6 +188,9 @@ class AbMergeManager {
|
|||||||
Logger.debug(`[AbMergeManager] Moving m4b from ${task.data.tempFilepath} to ${task.data.targetFilepath}`)
|
Logger.debug(`[AbMergeManager] Moving m4b from ${task.data.tempFilepath} to ${task.data.targetFilepath}`)
|
||||||
await fs.move(task.data.tempFilepath, task.data.targetFilepath)
|
await fs.move(task.data.tempFilepath, task.data.targetFilepath)
|
||||||
|
|
||||||
|
// Remove ffmetadata file
|
||||||
|
await fs.remove(task.data.ffmetadataPath)
|
||||||
|
|
||||||
task.setFinished()
|
task.setFinished()
|
||||||
await this.removeTask(task, false)
|
await this.removeTask(task, false)
|
||||||
Logger.info(`[AbMergeManager] Ab task finished ${task.id}`)
|
Logger.info(`[AbMergeManager] Ab task finished ${task.id}`)
|
||||||
@ -207,9 +199,9 @@ class AbMergeManager {
|
|||||||
async removeTask(task, removeTempFilepath = false) {
|
async removeTask(task, removeTempFilepath = false) {
|
||||||
Logger.info('[AbMergeManager] Removing task ' + task.id)
|
Logger.info('[AbMergeManager] Removing task ' + task.id)
|
||||||
|
|
||||||
const pendingDl = this.pendingTasks.find(d => d.id === task.id)
|
const pendingDl = this.pendingTasks.find((d) => d.id === task.id)
|
||||||
if (pendingDl) {
|
if (pendingDl) {
|
||||||
this.pendingTasks = this.pendingTasks.filter(d => d.id !== task.id)
|
this.pendingTasks = this.pendingTasks.filter((d) => d.id !== task.id)
|
||||||
if (pendingDl.worker) {
|
if (pendingDl.worker) {
|
||||||
Logger.warn(`[AbMergeManager] Removing download in progress - stopping worker`)
|
Logger.warn(`[AbMergeManager] Removing download in progress - stopping worker`)
|
||||||
try {
|
try {
|
||||||
@ -223,13 +215,27 @@ class AbMergeManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (removeTempFilepath) { // On failed tasks remove the bad file if it exists
|
if (removeTempFilepath) {
|
||||||
|
// On failed tasks remove the bad file if it exists
|
||||||
if (await fs.pathExists(task.data.tempFilepath)) {
|
if (await fs.pathExists(task.data.tempFilepath)) {
|
||||||
await fs.remove(task.data.tempFilepath).then(() => {
|
await fs
|
||||||
Logger.info('[AbMergeManager] Deleted target file', task.data.tempFilepath)
|
.remove(task.data.tempFilepath)
|
||||||
}).catch((err) => {
|
.then(() => {
|
||||||
Logger.error('[AbMergeManager] Failed to delete target file', err)
|
Logger.info('[AbMergeManager] Deleted target file', task.data.tempFilepath)
|
||||||
})
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
Logger.error('[AbMergeManager] Failed to delete target file', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (await fs.pathExists(task.data.ffmetadataPath)) {
|
||||||
|
await fs
|
||||||
|
.remove(task.data.ffmetadataPath)
|
||||||
|
.then(() => {
|
||||||
|
Logger.info('[AbMergeManager] Deleted ffmetadata file', task.data.ffmetadataPath)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
Logger.error('[AbMergeManager] Failed to delete ffmetadata file', err)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,7 +5,7 @@ const Logger = require('../Logger')
|
|||||||
|
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
|
|
||||||
const toneHelpers = require('../utils/toneHelpers')
|
const ffmpegHelpers = require('../utils/ffmpegHelpers')
|
||||||
|
|
||||||
const TaskManager = require('./TaskManager')
|
const TaskManager = require('./TaskManager')
|
||||||
|
|
||||||
@ -21,22 +21,19 @@ class AudioMetadataMangaer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get queued task data
|
* Get queued task data
|
||||||
* @return {Array}
|
* @return {Array}
|
||||||
*/
|
*/
|
||||||
getQueuedTaskData() {
|
getQueuedTaskData() {
|
||||||
return this.tasksQueued.map(t => t.data)
|
return this.tasksQueued.map((t) => t.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
getIsLibraryItemQueuedOrProcessing(libraryItemId) {
|
getIsLibraryItemQueuedOrProcessing(libraryItemId) {
|
||||||
return this.tasksQueued.some(t => t.data.libraryItemId === libraryItemId) || this.tasksRunning.some(t => t.data.libraryItemId === libraryItemId)
|
return this.tasksQueued.some((t) => t.data.libraryItemId === libraryItemId) || this.tasksRunning.some((t) => t.data.libraryItemId === libraryItemId)
|
||||||
}
|
}
|
||||||
|
|
||||||
getToneMetadataObjectForApi(libraryItem) {
|
getMetadataObjectForApi(libraryItem) {
|
||||||
const audioFiles = libraryItem.media.includedAudioFiles
|
return ffmpegHelpers.getFFMetadataObject(libraryItem, libraryItem.media.includedAudioFiles.length)
|
||||||
let mimeType = audioFiles[0].mimeType
|
|
||||||
if (audioFiles.some(a => a.mimeType !== mimeType)) mimeType = null
|
|
||||||
return toneHelpers.getToneMetadataObject(libraryItem, libraryItem.media.chapters, libraryItem.media.tracks.length, mimeType)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleBatchEmbed(user, libraryItems, options = {}) {
|
handleBatchEmbed(user, libraryItems, options = {}) {
|
||||||
@ -56,29 +53,28 @@ class AudioMetadataMangaer {
|
|||||||
const itemCachePath = Path.join(this.itemsCacheDir, libraryItem.id)
|
const itemCachePath = Path.join(this.itemsCacheDir, libraryItem.id)
|
||||||
|
|
||||||
// Only writing chapters for single file audiobooks
|
// Only writing chapters for single file audiobooks
|
||||||
const chapters = (audioFiles.length == 1 || forceEmbedChapters) ? libraryItem.media.chapters.map(c => ({ ...c })) : null
|
const chapters = audioFiles.length == 1 || forceEmbedChapters ? libraryItem.media.chapters.map((c) => ({ ...c })) : null
|
||||||
|
|
||||||
let mimeType = audioFiles[0].mimeType
|
let mimeType = audioFiles[0].mimeType
|
||||||
if (audioFiles.some(a => a.mimeType !== mimeType)) mimeType = null
|
if (audioFiles.some((a) => a.mimeType !== mimeType)) mimeType = null
|
||||||
|
|
||||||
// Create task
|
// Create task
|
||||||
const taskData = {
|
const taskData = {
|
||||||
libraryItemId: libraryItem.id,
|
libraryItemId: libraryItem.id,
|
||||||
libraryItemPath: libraryItem.path,
|
libraryItemPath: libraryItem.path,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
audioFiles: audioFiles.map(af => (
|
audioFiles: audioFiles.map((af) => ({
|
||||||
{
|
index: af.index,
|
||||||
index: af.index,
|
ino: af.ino,
|
||||||
ino: af.ino,
|
filename: af.metadata.filename,
|
||||||
filename: af.metadata.filename,
|
path: af.metadata.path,
|
||||||
path: af.metadata.path,
|
cachePath: Path.join(itemCachePath, af.metadata.filename)
|
||||||
cachePath: Path.join(itemCachePath, af.metadata.filename)
|
})),
|
||||||
}
|
|
||||||
)),
|
|
||||||
coverPath: libraryItem.media.coverPath,
|
coverPath: libraryItem.media.coverPath,
|
||||||
metadataObject: toneHelpers.getToneMetadataObject(libraryItem, chapters, audioFiles.length, mimeType),
|
metadataObject: ffmpegHelpers.getFFMetadataObject(libraryItem, audioFiles.length),
|
||||||
itemCachePath,
|
itemCachePath,
|
||||||
chapters,
|
chapters,
|
||||||
|
mimeType,
|
||||||
options: {
|
options: {
|
||||||
forceEmbedChapters,
|
forceEmbedChapters,
|
||||||
backupFiles
|
backupFiles
|
||||||
@ -107,18 +103,17 @@ class AudioMetadataMangaer {
|
|||||||
|
|
||||||
// Ensure item cache dir exists
|
// Ensure item cache dir exists
|
||||||
let cacheDirCreated = false
|
let cacheDirCreated = false
|
||||||
if (!await fs.pathExists(task.data.itemCachePath)) {
|
if (!(await fs.pathExists(task.data.itemCachePath))) {
|
||||||
await fs.mkdir(task.data.itemCachePath)
|
await fs.mkdir(task.data.itemCachePath)
|
||||||
cacheDirCreated = true
|
cacheDirCreated = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create metadata json file
|
// Create ffmetadata file
|
||||||
const toneJsonPath = Path.join(task.data.itemCachePath, 'metadata.json')
|
const ffmetadataPath = Path.join(task.data.itemCachePath, 'ffmetadata.txt')
|
||||||
try {
|
const success = await ffmpegHelpers.writeFFMetadataFile(task.data.metadataObject, task.data.chapters, ffmetadataPath)
|
||||||
await fs.writeFile(toneJsonPath, JSON.stringify({ meta: task.data.metadataObject }, null, 2))
|
if (!success) {
|
||||||
} catch (error) {
|
Logger.error(`[AudioMetadataManager] Failed to write ffmetadata file for audiobook "${task.data.libraryItemId}"`)
|
||||||
Logger.error(`[AudioMetadataManager] Write metadata.json failed`, error)
|
task.setFailed('Failed to write metadata file.')
|
||||||
task.setFailed('Failed to write metadata.json')
|
|
||||||
this.handleTaskFinished(task)
|
this.handleTaskFinished(task)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -141,16 +136,7 @@ class AudioMetadataMangaer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const _toneMetadataObject = {
|
const success = await ffmpegHelpers.addCoverAndMetadataToFile(af.path, task.data.coverPath, ffmetadataPath, af.index, task.data.mimeType)
|
||||||
'ToneJsonFile': toneJsonPath,
|
|
||||||
'TrackNumber': af.index,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (task.data.coverPath) {
|
|
||||||
_toneMetadataObject['CoverFile'] = task.data.coverPath
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await toneHelpers.tagAudioFile(af.path, _toneMetadataObject)
|
|
||||||
if (success) {
|
if (success) {
|
||||||
Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${af.path}"`)
|
Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${af.path}"`)
|
||||||
}
|
}
|
||||||
@ -167,7 +153,7 @@ class AudioMetadataMangaer {
|
|||||||
if (cacheDirCreated) {
|
if (cacheDirCreated) {
|
||||||
await fs.remove(task.data.itemCachePath)
|
await fs.remove(task.data.itemCachePath)
|
||||||
} else {
|
} else {
|
||||||
await fs.remove(toneJsonPath)
|
await fs.remove(ffmetadataPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -177,7 +163,7 @@ class AudioMetadataMangaer {
|
|||||||
|
|
||||||
handleTaskFinished(task) {
|
handleTaskFinished(task) {
|
||||||
TaskManager.taskFinished(task)
|
TaskManager.taskFinished(task)
|
||||||
this.tasksRunning = this.tasksRunning.filter(t => t.id !== task.id)
|
this.tasksRunning = this.tasksRunning.filter((t) => t.id !== task.id)
|
||||||
|
|
||||||
if (this.tasksRunning.length < this.MAX_CONCURRENT_TASKS && this.tasksQueued.length) {
|
if (this.tasksRunning.length < this.MAX_CONCURRENT_TASKS && this.tasksQueued.length) {
|
||||||
Logger.info(`[AudioMetadataManager] Task finished and dequeueing next task. ${this.tasksQueued} tasks queued.`)
|
Logger.info(`[AudioMetadataManager] Task finished and dequeueing next task. ${this.tasksQueued} tasks queued.`)
|
||||||
|
@ -29,6 +29,10 @@ class BackupManager {
|
|||||||
return global.ServerSettings.backupPath
|
return global.ServerSettings.backupPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get backupPathEnvSet() {
|
||||||
|
return !!process.env.BACKUP_PATH
|
||||||
|
}
|
||||||
|
|
||||||
get backupSchedule() {
|
get backupSchedule() {
|
||||||
return global.ServerSettings.backupSchedule
|
return global.ServerSettings.backupSchedule
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef OpenMediaItemShareObject
|
* @typedef OpenMediaItemShareObject
|
||||||
@ -136,6 +137,7 @@ class ShareManager {
|
|||||||
} else {
|
} else {
|
||||||
this.openMediaItemShares.push({ id: mediaItemShare.id, mediaItemShare: mediaItemShare.toJSON() })
|
this.openMediaItemShares.push({ id: mediaItemShare.id, mediaItemShare: mediaItemShare.toJSON() })
|
||||||
}
|
}
|
||||||
|
SocketAuthority.adminEmitter('share_open', mediaItemShare.toJSONForClient())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -153,6 +155,12 @@ class ShareManager {
|
|||||||
this.openMediaItemShares = this.openMediaItemShares.filter((s) => s.id !== mediaItemShareId)
|
this.openMediaItemShares = this.openMediaItemShares.filter((s) => s.id !== mediaItemShareId)
|
||||||
this.openSharePlaybackSessions = this.openSharePlaybackSessions.filter((s) => s.mediaItemShareId !== mediaItemShareId)
|
this.openSharePlaybackSessions = this.openSharePlaybackSessions.filter((s) => s.mediaItemShareId !== mediaItemShareId)
|
||||||
await this.destroyMediaItemShare(mediaItemShareId)
|
await this.destroyMediaItemShare(mediaItemShareId)
|
||||||
|
|
||||||
|
const mediaItemShareObjectForClient = { ...mediaItemShare.mediaItemShare }
|
||||||
|
delete mediaItemShareObjectForClient.pash
|
||||||
|
delete mediaItemShareObjectForClient.userId
|
||||||
|
delete mediaItemShareObjectForClient.extraData
|
||||||
|
SocketAuthority.adminEmitter('share_closed', mediaItemShareObjectForClient)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -567,8 +567,8 @@ class LibraryItem extends Model {
|
|||||||
if (li.numEpisodesIncomplete) {
|
if (li.numEpisodesIncomplete) {
|
||||||
oldLibraryItem.numEpisodesIncomplete = li.numEpisodesIncomplete
|
oldLibraryItem.numEpisodesIncomplete = li.numEpisodesIncomplete
|
||||||
}
|
}
|
||||||
if (li.mediaType === 'book' && options.include?.includes?.('share')) {
|
if (li.mediaItemShare) {
|
||||||
oldLibraryItem.mediaItemShare = ShareManager.findByMediaItemId(li.mediaId)
|
oldLibraryItem.mediaItemShare = li.mediaItemShare
|
||||||
}
|
}
|
||||||
|
|
||||||
return oldLibraryItem
|
return oldLibraryItem
|
||||||
|
@ -61,7 +61,7 @@ class AudioMetaTags {
|
|||||||
|
|
||||||
// Track ID3 tag might be "3/10" or just "3"
|
// Track ID3 tag might be "3/10" or just "3"
|
||||||
if (this.tagTrack) {
|
if (this.tagTrack) {
|
||||||
const trackParts = this.tagTrack.split('/').map(part => Number(part))
|
const trackParts = this.tagTrack.split('/').map((part) => Number(part))
|
||||||
if (trackParts.length > 0) {
|
if (trackParts.length > 0) {
|
||||||
// Fractional track numbers not supported
|
// Fractional track numbers not supported
|
||||||
data.number = !isNaN(trackParts[0]) ? Math.trunc(trackParts[0]) : null
|
data.number = !isNaN(trackParts[0]) ? Math.trunc(trackParts[0]) : null
|
||||||
@ -81,7 +81,7 @@ class AudioMetaTags {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.tagDisc) {
|
if (this.tagDisc) {
|
||||||
const discParts = this.tagDisc.split('/').map(p => Number(p))
|
const discParts = this.tagDisc.split('/').map((p) => Number(p))
|
||||||
if (discParts.length > 0) {
|
if (discParts.length > 0) {
|
||||||
data.number = !isNaN(discParts[0]) ? Math.trunc(discParts[0]) : null
|
data.number = !isNaN(discParts[0]) ? Math.trunc(discParts[0]) : null
|
||||||
}
|
}
|
||||||
@ -93,10 +93,18 @@ class AudioMetaTags {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
get discNumber() { return this.discNumAndTotal.number }
|
get discNumber() {
|
||||||
get discTotal() { return this.discNumAndTotal.total }
|
return this.discNumAndTotal.number
|
||||||
get trackNumber() { return this.trackNumAndTotal.number }
|
}
|
||||||
get trackTotal() { return this.trackNumAndTotal.total }
|
get discTotal() {
|
||||||
|
return this.discNumAndTotal.total
|
||||||
|
}
|
||||||
|
get trackNumber() {
|
||||||
|
return this.trackNumAndTotal.number
|
||||||
|
}
|
||||||
|
get trackTotal() {
|
||||||
|
return this.trackNumAndTotal.total
|
||||||
|
}
|
||||||
|
|
||||||
construct(metadata) {
|
construct(metadata) {
|
||||||
this.tagAlbum = metadata.tagAlbum || null
|
this.tagAlbum = metadata.tagAlbum || null
|
||||||
@ -177,10 +185,6 @@ class AudioMetaTags {
|
|||||||
this.tagMusicBrainzArtistId = payload.file_tag_musicbrainz_artistid || null
|
this.tagMusicBrainzArtistId = payload.file_tag_musicbrainz_artistid || null
|
||||||
}
|
}
|
||||||
|
|
||||||
setDataFromTone(tags) {
|
|
||||||
// TODO: Implement
|
|
||||||
}
|
|
||||||
|
|
||||||
updateData(payload) {
|
updateData(payload) {
|
||||||
const dataMap = {
|
const dataMap = {
|
||||||
tagAlbum: payload.file_tag_album || null,
|
tagAlbum: payload.file_tag_album || null,
|
||||||
@ -243,4 +247,4 @@ class AudioMetaTags {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
module.exports = AudioMetaTags
|
module.exports = AudioMetaTags
|
||||||
|
@ -114,7 +114,7 @@ class ApiRouter {
|
|||||||
this.router.post('/items/:id/play/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.startEpisodePlaybackSession.bind(this))
|
this.router.post('/items/:id/play/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.startEpisodePlaybackSession.bind(this))
|
||||||
this.router.patch('/items/:id/tracks', LibraryItemController.middleware.bind(this), LibraryItemController.updateTracks.bind(this))
|
this.router.patch('/items/:id/tracks', LibraryItemController.middleware.bind(this), LibraryItemController.updateTracks.bind(this))
|
||||||
this.router.post('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this))
|
this.router.post('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this))
|
||||||
this.router.get('/items/:id/tone-object', LibraryItemController.middleware.bind(this), LibraryItemController.getToneMetadataObject.bind(this))
|
this.router.get('/items/:id/metadata-object', LibraryItemController.middleware.bind(this), LibraryItemController.getMetadataObject.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.get('/items/:id/ffprobe/:fileid', LibraryItemController.middleware.bind(this), LibraryItemController.getFFprobeData.bind(this))
|
this.router.get('/items/:id/ffprobe/:fileid', LibraryItemController.middleware.bind(this), LibraryItemController.getFFprobeData.bind(this))
|
||||||
this.router.get('/items/:id/file/:fileid', LibraryItemController.middleware.bind(this), LibraryItemController.getLibraryFile.bind(this))
|
this.router.get('/items/:id/file/:fileid', LibraryItemController.middleware.bind(this), LibraryItemController.getLibraryFile.bind(this))
|
||||||
|
@ -63,15 +63,5 @@ class MediaProbeData {
|
|||||||
this.audioMetaTags = new AudioMetaTags()
|
this.audioMetaTags = new AudioMetaTags()
|
||||||
this.audioMetaTags.setData(data.tags)
|
this.audioMetaTags.setData(data.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
setDataFromTone(data) {
|
|
||||||
// TODO: Implement
|
|
||||||
|
|
||||||
this.format = data.format
|
|
||||||
this.duration = data.duration
|
|
||||||
this.size = data.size
|
|
||||||
this.audioMetaTags = new AudioMetaTags()
|
|
||||||
this.audioMetaTags.setDataFromTone(data.tags)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
module.exports = MediaProbeData
|
module.exports = MediaProbeData
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
const axios = require('axios')
|
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 os = require('os')
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const { filePathToPOSIX } = require('./fileUtils')
|
const { filePathToPOSIX } = require('./fileUtils')
|
||||||
|
const LibraryItem = require('../objects/LibraryItem')
|
||||||
|
|
||||||
function escapeSingleQuotes(path) {
|
function escapeSingleQuotes(path) {
|
||||||
// return path.replace(/'/g, '\'\\\'\'')
|
// return path.replace(/'/g, '\'\\\'\'')
|
||||||
@ -184,3 +186,183 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
|
|||||||
ffmpeg.run()
|
ffmpeg.run()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates ffmetadata file content from the provided metadata object and chapters array.
|
||||||
|
* @param {Object} metadata - The input metadata object.
|
||||||
|
* @param {Array|null} chapters - An array of chapter objects.
|
||||||
|
* @returns {string} - The ffmetadata file content.
|
||||||
|
*/
|
||||||
|
function generateFFMetadata(metadata, chapters) {
|
||||||
|
let ffmetadataContent = ';FFMETADATA1\n'
|
||||||
|
|
||||||
|
// Add global metadata
|
||||||
|
for (const key in metadata) {
|
||||||
|
if (metadata[key]) {
|
||||||
|
ffmetadataContent += `${key}=${escapeFFMetadataValue(metadata[key])}\n`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add chapters
|
||||||
|
if (chapters) {
|
||||||
|
chapters.forEach((chapter) => {
|
||||||
|
ffmetadataContent += '\n[CHAPTER]\n'
|
||||||
|
ffmetadataContent += `TIMEBASE=1/1000\n`
|
||||||
|
ffmetadataContent += `START=${Math.floor(chapter.start * 1000)}\n`
|
||||||
|
ffmetadataContent += `END=${Math.floor(chapter.end * 1000)}\n`
|
||||||
|
if (chapter.title) {
|
||||||
|
ffmetadataContent += `title=${escapeFFMetadataValue(chapter.title)}\n`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return ffmetadataContent
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.generateFFMetadata = generateFFMetadata
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes FFmpeg metadata file with the given metadata and chapters.
|
||||||
|
*
|
||||||
|
* @param {Object} metadata - The metadata object.
|
||||||
|
* @param {Array} chapters - The array of chapter objects.
|
||||||
|
* @param {string} ffmetadataPath - The path to the FFmpeg metadata file.
|
||||||
|
* @returns {Promise<boolean>} - A promise that resolves to true if the file was written successfully, false otherwise.
|
||||||
|
*/
|
||||||
|
async function writeFFMetadataFile(metadata, chapters, ffmetadataPath) {
|
||||||
|
try {
|
||||||
|
await fs.writeFile(ffmetadataPath, generateFFMetadata(metadata, chapters))
|
||||||
|
Logger.debug(`[ffmpegHelpers] Wrote ${ffmetadataPath}`)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[ffmpegHelpers] Write ${ffmetadataPath} failed`, error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.writeFFMetadataFile = writeFFMetadataFile
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an ffmetadata and optionally a cover image to an audio file using fluent-ffmpeg.
|
||||||
|
*
|
||||||
|
* @param {string} audioFilePath - Path to the input audio file.
|
||||||
|
* @param {string|null} coverFilePath - Path to the cover image file.
|
||||||
|
* @param {string} metadataFilePath - Path to the ffmetadata file.
|
||||||
|
* @param {number} track - The track number to embed in the audio file.
|
||||||
|
* @param {string} mimeType - The MIME type of the audio file.
|
||||||
|
* @param {Ffmpeg} ffmpeg - The Ffmpeg instance to use (optional). Used for dependency injection in tests.
|
||||||
|
* @returns {Promise<boolean>} A promise that resolves to true if the operation is successful, false otherwise.
|
||||||
|
*/
|
||||||
|
async function addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpeg = Ffmpeg()) {
|
||||||
|
const isMp4 = mimeType === 'audio/mp4'
|
||||||
|
const isMp3 = mimeType === 'audio/mpeg'
|
||||||
|
|
||||||
|
const audioFileDir = Path.dirname(audioFilePath)
|
||||||
|
const audioFileExt = Path.extname(audioFilePath)
|
||||||
|
const audioFileBaseName = Path.basename(audioFilePath, audioFileExt)
|
||||||
|
const tempFilePath = filePathToPOSIX(Path.join(audioFileDir, `${audioFileBaseName}.tmp${audioFileExt}`))
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
ffmpeg.input(audioFilePath).input(metadataFilePath).outputOptions([
|
||||||
|
'-map 0:a', // map audio stream from input file
|
||||||
|
'-map_metadata 1', // map metadata tags from metadata file first
|
||||||
|
'-map_metadata 0', // add additional metadata tags from input file
|
||||||
|
'-map_chapters 1', // map chapters from metadata file
|
||||||
|
'-c copy' // copy streams
|
||||||
|
])
|
||||||
|
|
||||||
|
if (track && !isNaN(track)) {
|
||||||
|
ffmpeg.outputOptions(['-metadata track=' + track])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMp4) {
|
||||||
|
ffmpeg.outputOptions([
|
||||||
|
'-f mp4' // force output format to mp4
|
||||||
|
])
|
||||||
|
} else if (isMp3) {
|
||||||
|
ffmpeg.outputOptions([
|
||||||
|
'-id3v2_version 3' // set ID3v2 version to 3
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coverFilePath) {
|
||||||
|
ffmpeg.input(coverFilePath).outputOptions([
|
||||||
|
'-map 2:v', // map video stream from cover image file
|
||||||
|
'-disposition:v:0 attached_pic', // set cover image as attached picture
|
||||||
|
'-metadata:s:v',
|
||||||
|
'title=Cover', // add title metadata to cover image stream
|
||||||
|
'-metadata:s:v',
|
||||||
|
'comment=Cover' // add comment metadata to cover image stream
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
ffmpeg.outputOptions([
|
||||||
|
'-map 0:v?' // retain video stream from input file if exists
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
ffmpeg
|
||||||
|
.output(tempFilePath)
|
||||||
|
.on('start', function (commandLine) {
|
||||||
|
Logger.debug('[ffmpegHelpers] Spawned Ffmpeg with command: ' + commandLine)
|
||||||
|
})
|
||||||
|
.on('end', (stdout, stderr) => {
|
||||||
|
Logger.debug('[ffmpegHelpers] ffmpeg stdout:', stdout)
|
||||||
|
Logger.debug('[ffmpegHelpers] ffmpeg stderr:', stderr)
|
||||||
|
fs.copyFileSync(tempFilePath, audioFilePath)
|
||||||
|
fs.unlinkSync(tempFilePath)
|
||||||
|
resolve(true)
|
||||||
|
})
|
||||||
|
.on('error', (err, stdout, stderr) => {
|
||||||
|
Logger.error('Error adding cover image and metadata:', err)
|
||||||
|
Logger.error('ffmpeg stdout:', stdout)
|
||||||
|
Logger.error('ffmpeg stderr:', stderr)
|
||||||
|
resolve(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
ffmpeg.run()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.addCoverAndMetadataToFile = addCoverAndMetadataToFile
|
||||||
|
|
||||||
|
function escapeFFMetadataValue(value) {
|
||||||
|
return value.replace(/([;=\n\\#])/g, '\\$1')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the FFmpeg metadata object for a given library item.
|
||||||
|
*
|
||||||
|
* @param {LibraryItem} libraryItem - The library item containing the media metadata.
|
||||||
|
* @param {number} audioFilesLength - The length of the audio files.
|
||||||
|
* @returns {Object} - The FFmpeg metadata object.
|
||||||
|
*/
|
||||||
|
function getFFMetadataObject(libraryItem, audioFilesLength) {
|
||||||
|
const metadata = libraryItem.media.metadata
|
||||||
|
|
||||||
|
const ffmetadata = {
|
||||||
|
title: metadata.title,
|
||||||
|
artist: metadata.authorName,
|
||||||
|
album_artist: metadata.authorName,
|
||||||
|
album: (metadata.title || '') + (metadata.subtitle ? `: ${metadata.subtitle}` : ''),
|
||||||
|
TIT3: metadata.subtitle, // mp3 only
|
||||||
|
genre: metadata.genres?.join('; '),
|
||||||
|
date: metadata.publishedYear,
|
||||||
|
comment: metadata.description,
|
||||||
|
description: metadata.description,
|
||||||
|
composer: metadata.narratorName,
|
||||||
|
copyright: metadata.publisher,
|
||||||
|
publisher: metadata.publisher, // mp3 only
|
||||||
|
TRACKTOTAL: `${audioFilesLength}`, // mp3 only
|
||||||
|
grouping: metadata.series?.map((s) => s.name + (s.sequence ? ` #${s.sequence}` : '')).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.keys(ffmetadata).forEach((key) => {
|
||||||
|
if (!ffmetadata[key]) {
|
||||||
|
delete ffmetadata[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return ffmetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.getFFMetadataObject = getFFMetadataObject
|
||||||
|
@ -332,9 +332,9 @@ module.exports = {
|
|||||||
/**
|
/**
|
||||||
* Get library items for book media type using filter and sort
|
* Get library items for book media type using filter and sort
|
||||||
* @param {string} libraryId
|
* @param {string} libraryId
|
||||||
* @param {[oldUser]} user
|
* @param {import('../../objects/user/User')} user
|
||||||
* @param {[string]} filterGroup
|
* @param {string|null} filterGroup
|
||||||
* @param {[string]} filterValue
|
* @param {string|null} filterValue
|
||||||
* @param {string} sortBy
|
* @param {string} sortBy
|
||||||
* @param {string} sortDesc
|
* @param {string} sortDesc
|
||||||
* @param {boolean} collapseseries
|
* @param {boolean} collapseseries
|
||||||
@ -356,7 +356,7 @@ module.exports = {
|
|||||||
sortBy = 'media.metadata.title'
|
sortBy = 'media.metadata.title'
|
||||||
}
|
}
|
||||||
const includeRSSFeed = include.includes('rssfeed')
|
const includeRSSFeed = include.includes('rssfeed')
|
||||||
const includeMediaItemShare = include.includes('share')
|
const includeMediaItemShare = !!user?.isAdminOrUp && include.includes('share')
|
||||||
|
|
||||||
// For sorting by author name an additional attribute must be added
|
// For sorting by author name an additional attribute must be added
|
||||||
// with author names concatenated
|
// with author names concatenated
|
||||||
|
@ -1,113 +0,0 @@
|
|||||||
const tone = require('node-tone')
|
|
||||||
const fs = require('../libs/fsExtra')
|
|
||||||
const Logger = require('../Logger')
|
|
||||||
|
|
||||||
function getToneMetadataObject(libraryItem, chapters, trackTotal, mimeType = null) {
|
|
||||||
const bookMetadata = libraryItem.media.metadata
|
|
||||||
const coverPath = libraryItem.media.coverPath
|
|
||||||
|
|
||||||
const isMp4 = mimeType === 'audio/mp4'
|
|
||||||
const isMp3 = mimeType === 'audio/mpeg'
|
|
||||||
|
|
||||||
const metadataObject = {
|
|
||||||
'album': bookMetadata.title || '',
|
|
||||||
'title': bookMetadata.title || '',
|
|
||||||
'trackTotal': trackTotal,
|
|
||||||
'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) {
|
|
||||||
if (!isMp3) {
|
|
||||||
metadataObject.additionalFields['----:com.pilabor.tone:SERIES'] = bookMetadata.firstSeriesName
|
|
||||||
}
|
|
||||||
metadataObject['movementName'] = bookMetadata.firstSeriesName
|
|
||||||
}
|
|
||||||
if (bookMetadata.firstSeriesSequence) {
|
|
||||||
// Non-mp3
|
|
||||||
if (!isMp3) {
|
|
||||||
metadataObject.additionalFields['----:com.pilabor.tone:PART'] = bookMetadata.firstSeriesSequence
|
|
||||||
}
|
|
||||||
// MP3 Files with non-integer sequence
|
|
||||||
const isNonIntegerSequence = String(bookMetadata.firstSeriesSequence).includes('.') || isNaN(bookMetadata.firstSeriesSequence)
|
|
||||||
if (isMp3 && isNonIntegerSequence) {
|
|
||||||
metadataObject.additionalFields['PART'] = bookMetadata.firstSeriesSequence
|
|
||||||
}
|
|
||||||
if (!isNonIntegerSequence) {
|
|
||||||
metadataObject['movement'] = bookMetadata.firstSeriesSequence
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (bookMetadata.genres.length) {
|
|
||||||
metadataObject['genre'] = bookMetadata.genres.join('/')
|
|
||||||
}
|
|
||||||
if (bookMetadata.publisher) {
|
|
||||||
metadataObject['publisher'] = bookMetadata.publisher
|
|
||||||
}
|
|
||||||
if (bookMetadata.asin) {
|
|
||||||
if (!isMp3) {
|
|
||||||
metadataObject.additionalFields['----:com.pilabor.tone:AUDIBLE_ASIN'] = bookMetadata.asin
|
|
||||||
}
|
|
||||||
if (!isMp4) {
|
|
||||||
metadataObject.additionalFields['asin'] = bookMetadata.asin
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (bookMetadata.isbn) {
|
|
||||||
metadataObject.additionalFields['isbn'] = bookMetadata.isbn
|
|
||||||
}
|
|
||||||
if (coverPath) {
|
|
||||||
metadataObject['coverFile'] = coverPath
|
|
||||||
}
|
|
||||||
if (parsePublishedYear(bookMetadata.publishedYear)) {
|
|
||||||
metadataObject['publishingDate'] = parsePublishedYear(bookMetadata.publishedYear)
|
|
||||||
}
|
|
||||||
if (chapters && chapters.length > 0) {
|
|
||||||
let metadataChapters = []
|
|
||||||
for (const chapter of chapters) {
|
|
||||||
metadataChapters.push({
|
|
||||||
start: Math.round(chapter.start * 1000),
|
|
||||||
length: Math.round((chapter.end - chapter.start) * 1000),
|
|
||||||
title: chapter.title,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
metadataObject['chapters'] = metadataChapters
|
|
||||||
}
|
|
||||||
|
|
||||||
return metadataObject
|
|
||||||
}
|
|
||||||
module.exports.getToneMetadataObject = getToneMetadataObject
|
|
||||||
|
|
||||||
module.exports.writeToneMetadataJsonFile = (libraryItem, chapters, filePath, trackTotal, mimeType) => {
|
|
||||||
const metadataObject = getToneMetadataObject(libraryItem, chapters, trackTotal, mimeType)
|
|
||||||
return fs.writeFile(filePath, JSON.stringify({ meta: metadataObject }, null, 2))
|
|
||||||
}
|
|
||||||
|
|
||||||
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 true
|
|
||||||
}).catch((error) => {
|
|
||||||
Logger.error(`[toneHelpers] tagAudioFile: Failed for "${filePath}"`, error)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePublishedYear(publishedYear) {
|
|
||||||
if (isNaN(publishedYear) || !publishedYear || Number(publishedYear) <= 0) return null
|
|
||||||
return `01/01/${publishedYear}`
|
|
||||||
}
|
|
@ -1,173 +0,0 @@
|
|||||||
const tone = require('node-tone')
|
|
||||||
const MediaProbeData = require('../scanner/MediaProbeData')
|
|
||||||
const Logger = require('../Logger')
|
|
||||||
|
|
||||||
/*
|
|
||||||
Sample dump from tone
|
|
||||||
{
|
|
||||||
"audio": {
|
|
||||||
"bitrate": 17,
|
|
||||||
"format": "MPEG-4 Part 14",
|
|
||||||
"formatShort": "MPEG-4",
|
|
||||||
"sampleRate": 44100.0,
|
|
||||||
"duration": 209284.0,
|
|
||||||
"channels": {
|
|
||||||
"count": 2,
|
|
||||||
"description": "Stereo (2/0.0)"
|
|
||||||
},
|
|
||||||
"frames": {
|
|
||||||
"offset": 42168,
|
|
||||||
"length": 446932
|
|
||||||
"metaFormat": [
|
|
||||||
"mp4"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"meta": {
|
|
||||||
"album": "node-tone",
|
|
||||||
"albumArtist": "advplyr",
|
|
||||||
"artist": "advplyr",
|
|
||||||
"composer": "Composer 5",
|
|
||||||
"comment": "testing out tone metadata",
|
|
||||||
"encodingTool": "audiobookshelf",
|
|
||||||
"genre": "abs",
|
|
||||||
"itunesCompilation": "no",
|
|
||||||
"itunesMediaType": "audiobook",
|
|
||||||
"itunesPlayGap": "noGap",
|
|
||||||
"narrator": "Narrator 5",
|
|
||||||
"recordingDate": "2022-09-10T00:00:00",
|
|
||||||
"title": "Test 5",
|
|
||||||
"trackNumber": 5,
|
|
||||||
"chapters": [
|
|
||||||
{
|
|
||||||
"start": 0,
|
|
||||||
"length": 500,
|
|
||||||
"title": "chapter 1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": 500,
|
|
||||||
"length": 500,
|
|
||||||
"title": "chapter 2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": 1000,
|
|
||||||
"length": 208284,
|
|
||||||
"title": "chapter 3"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"embeddedPictures": [
|
|
||||||
{
|
|
||||||
"code": 14,
|
|
||||||
"mimetype": "image/png",
|
|
||||||
"data": "..."
|
|
||||||
},
|
|
||||||
"additionalFields": {
|
|
||||||
"test": "Test 5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"file": {
|
|
||||||
"size": 530793,
|
|
||||||
"created": "2022-09-10T13:32:51.1942586-05:00",
|
|
||||||
"modified": "2022-09-10T14:09:19.366071-05:00",
|
|
||||||
"accessed": "2022-09-11T13:00:56.5097533-05:00",
|
|
||||||
"path": "C:\\Users\\Coop\\Documents\\NodeProjects\\node-tone\\samples",
|
|
||||||
"name": "m4b.m4b"
|
|
||||||
}
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|
||||||
function bitrateKilobitToBit(bitrate) {
|
|
||||||
if (isNaN(bitrate) || !bitrate) return 0
|
|
||||||
return Number(bitrate) * 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
function msToSeconds(ms) {
|
|
||||||
if (isNaN(ms) || !ms) return 0
|
|
||||||
return Number(ms) / 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseProbeDump(dumpPayload) {
|
|
||||||
const audioMetadata = dumpPayload.audio
|
|
||||||
const audioChannels = audioMetadata.channels || {}
|
|
||||||
const audio_stream = {
|
|
||||||
bit_rate: bitrateKilobitToBit(audioMetadata.bitrate), // tone uses Kbps but ffprobe uses bps so convert to bits
|
|
||||||
codec: null,
|
|
||||||
time_base: null,
|
|
||||||
language: null,
|
|
||||||
channel_layout: audioChannels.description || null,
|
|
||||||
channels: audioChannels.count || null,
|
|
||||||
sample_rate: audioMetadata.sampleRate || null
|
|
||||||
}
|
|
||||||
|
|
||||||
let chapterIndex = 0
|
|
||||||
const chapters = (dumpPayload.meta.chapters || []).map(chap => {
|
|
||||||
return {
|
|
||||||
id: chapterIndex++,
|
|
||||||
start: msToSeconds(chap.start),
|
|
||||||
end: msToSeconds(chap.start + chap.length),
|
|
||||||
title: chap.title || ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
var video_stream = null
|
|
||||||
if (dumpPayload.meta.embeddedPictures && dumpPayload.meta.embeddedPictures.length) {
|
|
||||||
const mimetype = dumpPayload.meta.embeddedPictures[0].mimetype
|
|
||||||
video_stream = {
|
|
||||||
codec: mimetype === 'image/png' ? 'png' : 'jpeg'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const tags = { ...dumpPayload.meta }
|
|
||||||
delete tags.chapters
|
|
||||||
delete tags.embeddedPictures
|
|
||||||
|
|
||||||
const fileMetadata = dumpPayload.file
|
|
||||||
var sizeBytes = !isNaN(fileMetadata.size) ? Number(fileMetadata.size) : null
|
|
||||||
var sizeMb = sizeBytes !== null ? Number((sizeBytes / (1024 * 1024)).toFixed(2)) : null
|
|
||||||
return {
|
|
||||||
format: audioMetadata.format || 'Unknown',
|
|
||||||
duration: msToSeconds(audioMetadata.duration),
|
|
||||||
size: sizeBytes,
|
|
||||||
sizeMb,
|
|
||||||
bit_rate: audio_stream.bit_rate,
|
|
||||||
audio_stream,
|
|
||||||
video_stream,
|
|
||||||
chapters,
|
|
||||||
tags
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.probe = (filepath, verbose = false) => {
|
|
||||||
if (process.env.TONE_PATH) {
|
|
||||||
tone.TONE_PATH = process.env.TONE_PATH
|
|
||||||
}
|
|
||||||
|
|
||||||
return tone.dump(filepath).then((dumpPayload) => {
|
|
||||||
if (verbose) {
|
|
||||||
Logger.debug(`[toneProber] dump for file "${filepath}"`, dumpPayload)
|
|
||||||
}
|
|
||||||
const rawProbeData = parseProbeDump(dumpPayload)
|
|
||||||
const probeData = new MediaProbeData()
|
|
||||||
probeData.setDataFromTone(rawProbeData)
|
|
||||||
return probeData
|
|
||||||
}).catch((error) => {
|
|
||||||
Logger.error(`[toneProber] Failed to probe file at path "${filepath}"`, error)
|
|
||||||
return {
|
|
||||||
error
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.rawProbe = (filepath) => {
|
|
||||||
if (process.env.TONE_PATH) {
|
|
||||||
tone.TONE_PATH = process.env.TONE_PATH
|
|
||||||
}
|
|
||||||
|
|
||||||
return tone.dump(filepath).then((dumpPayload) => {
|
|
||||||
return dumpPayload
|
|
||||||
}).catch((error) => {
|
|
||||||
Logger.error(`[toneProber] Failed to probe file at path "${filepath}"`, error)
|
|
||||||
return {
|
|
||||||
error
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
249
test/server/utils/ffmpegHelpers.test.js
Normal file
249
test/server/utils/ffmpegHelpers.test.js
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
const { expect } = require('chai')
|
||||||
|
const sinon = require('sinon')
|
||||||
|
const { generateFFMetadata, addCoverAndMetadataToFile } = require('../../../server/utils/ffmpegHelpers')
|
||||||
|
const fs = require('../../../server/libs/fsExtra')
|
||||||
|
const EventEmitter = require('events')
|
||||||
|
|
||||||
|
global.isWin = process.platform === 'win32'
|
||||||
|
|
||||||
|
describe('generateFFMetadata', () => {
|
||||||
|
function createTestSetup() {
|
||||||
|
const metadata = {
|
||||||
|
title: 'My Audiobook',
|
||||||
|
artist: 'John Doe',
|
||||||
|
album: 'Best Audiobooks'
|
||||||
|
}
|
||||||
|
|
||||||
|
const chapters = [
|
||||||
|
{ start: 0, end: 1000, title: 'Chapter 1' },
|
||||||
|
{ start: 1000, end: 2000, title: 'Chapter 2' }
|
||||||
|
]
|
||||||
|
|
||||||
|
return { metadata, chapters }
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata = null
|
||||||
|
let chapters = null
|
||||||
|
beforeEach(() => {
|
||||||
|
const input = createTestSetup()
|
||||||
|
metadata = input.metadata
|
||||||
|
chapters = input.chapters
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should generate ffmetadata content with chapters', () => {
|
||||||
|
const result = generateFFMetadata(metadata, chapters)
|
||||||
|
|
||||||
|
expect(result).to.equal(';FFMETADATA1\ntitle=My Audiobook\nartist=John Doe\nalbum=Best Audiobooks\n\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=1000000\ntitle=Chapter 1\n\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=1000000\nEND=2000000\ntitle=Chapter 2\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should generate ffmetadata content without chapters', () => {
|
||||||
|
chapters = null
|
||||||
|
|
||||||
|
const result = generateFFMetadata(metadata, chapters)
|
||||||
|
|
||||||
|
expect(result).to.equal(';FFMETADATA1\ntitle=My Audiobook\nartist=John Doe\nalbum=Best Audiobooks\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle chapters with no title', () => {
|
||||||
|
chapters = [
|
||||||
|
{ start: 0, end: 1000 },
|
||||||
|
{ start: 1000, end: 2000 }
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = generateFFMetadata(metadata, chapters)
|
||||||
|
|
||||||
|
expect(result).to.equal(';FFMETADATA1\ntitle=My Audiobook\nartist=John Doe\nalbum=Best Audiobooks\n\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=1000000\n\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=1000000\nEND=2000000\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle metadata escaping special characters (=, ;, #, and a newline)', () => {
|
||||||
|
metadata.title = 'My Audiobook; with = special # characters\n'
|
||||||
|
chapters[0].title = 'Chapter #1'
|
||||||
|
|
||||||
|
const result = generateFFMetadata(metadata, chapters)
|
||||||
|
|
||||||
|
expect(result).to.equal(';FFMETADATA1\ntitle=My Audiobook\\; with \\= special \\# characters\\\n\nartist=John Doe\nalbum=Best Audiobooks\n\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=1000000\ntitle=Chapter \\#1\n\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=1000000\nEND=2000000\ntitle=Chapter 2\n')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('addCoverAndMetadataToFile', () => {
|
||||||
|
function createTestSetup() {
|
||||||
|
const audioFilePath = '/path/to/audio/file.mp3'
|
||||||
|
const coverFilePath = '/path/to/cover/image.jpg'
|
||||||
|
const metadataFilePath = '/path/to/metadata/file.txt'
|
||||||
|
const track = 1
|
||||||
|
const mimeType = 'audio/mpeg'
|
||||||
|
|
||||||
|
const ffmpegStub = new EventEmitter()
|
||||||
|
ffmpegStub.input = sinon.stub().returnsThis()
|
||||||
|
ffmpegStub.outputOptions = sinon.stub().returnsThis()
|
||||||
|
ffmpegStub.output = sinon.stub().returnsThis()
|
||||||
|
ffmpegStub.input = sinon.stub().returnsThis()
|
||||||
|
ffmpegStub.run = sinon.stub().callsFake(() => {
|
||||||
|
ffmpegStub.emit('end')
|
||||||
|
})
|
||||||
|
const fsCopyFileSyncStub = sinon.stub(fs, 'copyFileSync')
|
||||||
|
const fsUnlinkSyncStub = sinon.stub(fs, 'unlinkSync')
|
||||||
|
|
||||||
|
return { audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub, fsCopyFileSyncStub, fsUnlinkSyncStub }
|
||||||
|
}
|
||||||
|
|
||||||
|
let audioFilePath = null
|
||||||
|
let coverFilePath = null
|
||||||
|
let metadataFilePath = null
|
||||||
|
let track = null
|
||||||
|
let mimeType = null
|
||||||
|
let ffmpegStub = null
|
||||||
|
let fsCopyFileSyncStub = null
|
||||||
|
let fsUnlinkSyncStub = null
|
||||||
|
beforeEach(() => {
|
||||||
|
const input = createTestSetup()
|
||||||
|
audioFilePath = input.audioFilePath
|
||||||
|
coverFilePath = input.coverFilePath
|
||||||
|
metadataFilePath = input.metadataFilePath
|
||||||
|
track = input.track
|
||||||
|
mimeType = input.mimeType
|
||||||
|
ffmpegStub = input.ffmpegStub
|
||||||
|
fsCopyFileSyncStub = input.fsCopyFileSyncStub
|
||||||
|
fsUnlinkSyncStub = input.fsUnlinkSyncStub
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add cover image and metadata to audio file', async () => {
|
||||||
|
// Act
|
||||||
|
const result = await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).to.be.true
|
||||||
|
expect(ffmpegStub.input.calledThrice).to.be.true
|
||||||
|
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
|
||||||
|
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
|
||||||
|
expect(ffmpegStub.input.getCall(2).args[0]).to.equal(coverFilePath)
|
||||||
|
|
||||||
|
expect(ffmpegStub.outputOptions.callCount).to.equal(4)
|
||||||
|
expect(ffmpegStub.outputOptions.getCall(0).args[0]).to.deep.equal(['-map 0:a', '-map_metadata 1', '-map_metadata 0', '-map_chapters 1', '-c copy'])
|
||||||
|
expect(ffmpegStub.outputOptions.getCall(1).args[0]).to.deep.equal(['-metadata track=1'])
|
||||||
|
expect(ffmpegStub.outputOptions.getCall(2).args[0]).to.deep.equal(['-id3v2_version 3'])
|
||||||
|
expect(ffmpegStub.outputOptions.getCall(3).args[0]).to.deep.equal(['-map 2:v', '-disposition:v:0 attached_pic', '-metadata:s:v', 'title=Cover', '-metadata:s:v', 'comment=Cover'])
|
||||||
|
|
||||||
|
expect(ffmpegStub.output.calledOnce).to.be.true
|
||||||
|
expect(ffmpegStub.output.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
|
||||||
|
|
||||||
|
expect(ffmpegStub.run.calledOnce).to.be.true
|
||||||
|
|
||||||
|
expect(fsCopyFileSyncStub.calledOnce).to.be.true
|
||||||
|
expect(fsCopyFileSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
|
||||||
|
expect(fsCopyFileSyncStub.firstCall.args[1]).to.equal('/path/to/audio/file.mp3')
|
||||||
|
|
||||||
|
expect(fsUnlinkSyncStub.calledOnce).to.be.true
|
||||||
|
expect(fsUnlinkSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
|
||||||
|
|
||||||
|
// Restore the stub
|
||||||
|
sinon.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle missing cover image', async () => {
|
||||||
|
// Arrange
|
||||||
|
coverFilePath = null
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).to.be.true
|
||||||
|
expect(ffmpegStub.input.calledTwice).to.be.true
|
||||||
|
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
|
||||||
|
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
|
||||||
|
|
||||||
|
expect(ffmpegStub.outputOptions.callCount).to.equal(4)
|
||||||
|
expect(ffmpegStub.outputOptions.getCall(0).args[0]).to.deep.equal(['-map 0:a', '-map_metadata 1', '-map_metadata 0', '-map_chapters 1', '-c copy'])
|
||||||
|
expect(ffmpegStub.outputOptions.getCall(1).args[0]).to.deep.equal(['-metadata track=1'])
|
||||||
|
expect(ffmpegStub.outputOptions.getCall(2).args[0]).to.deep.equal(['-id3v2_version 3'])
|
||||||
|
expect(ffmpegStub.outputOptions.getCall(3).args[0]).to.deep.equal(['-map 0:v?'])
|
||||||
|
|
||||||
|
expect(ffmpegStub.output.calledOnce).to.be.true
|
||||||
|
expect(ffmpegStub.output.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
|
||||||
|
|
||||||
|
expect(ffmpegStub.run.calledOnce).to.be.true
|
||||||
|
|
||||||
|
expect(fsCopyFileSyncStub.calledOnce).to.be.true
|
||||||
|
expect(fsCopyFileSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
|
||||||
|
expect(fsCopyFileSyncStub.firstCall.args[1]).to.equal('/path/to/audio/file.mp3')
|
||||||
|
|
||||||
|
expect(fsUnlinkSyncStub.calledOnce).to.be.true
|
||||||
|
expect(fsUnlinkSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
|
||||||
|
|
||||||
|
// Restore the stub
|
||||||
|
sinon.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle error during ffmpeg execution', async () => {
|
||||||
|
// Arrange
|
||||||
|
ffmpegStub.run = sinon.stub().callsFake(() => {
|
||||||
|
ffmpegStub.emit('error', new Error('FFmpeg error'))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).to.be.false
|
||||||
|
expect(ffmpegStub.input.calledThrice).to.be.true
|
||||||
|
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
|
||||||
|
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
|
||||||
|
expect(ffmpegStub.input.getCall(2).args[0]).to.equal(coverFilePath)
|
||||||
|
|
||||||
|
expect(ffmpegStub.outputOptions.callCount).to.equal(4)
|
||||||
|
expect(ffmpegStub.outputOptions.getCall(0).args[0]).to.deep.equal(['-map 0:a', '-map_metadata 1', '-map_metadata 0', '-map_chapters 1', '-c copy'])
|
||||||
|
expect(ffmpegStub.outputOptions.getCall(1).args[0]).to.deep.equal(['-metadata track=1'])
|
||||||
|
expect(ffmpegStub.outputOptions.getCall(2).args[0]).to.deep.equal(['-id3v2_version 3'])
|
||||||
|
expect(ffmpegStub.outputOptions.getCall(3).args[0]).to.deep.equal(['-map 2:v', '-disposition:v:0 attached_pic', '-metadata:s:v', 'title=Cover', '-metadata:s:v', 'comment=Cover'])
|
||||||
|
|
||||||
|
expect(ffmpegStub.output.calledOnce).to.be.true
|
||||||
|
expect(ffmpegStub.output.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
|
||||||
|
|
||||||
|
expect(ffmpegStub.run.calledOnce).to.be.true
|
||||||
|
|
||||||
|
expect(fsCopyFileSyncStub.called).to.be.false
|
||||||
|
|
||||||
|
expect(fsUnlinkSyncStub.called).to.be.false
|
||||||
|
|
||||||
|
// Restore the stub
|
||||||
|
sinon.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle m4b embedding', async () => {
|
||||||
|
// Arrange
|
||||||
|
mimeType = 'audio/mp4'
|
||||||
|
audioFilePath = '/path/to/audio/file.m4b'
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).to.be.true
|
||||||
|
expect(ffmpegStub.input.calledThrice).to.be.true
|
||||||
|
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
|
||||||
|
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
|
||||||
|
expect(ffmpegStub.input.getCall(2).args[0]).to.equal(coverFilePath)
|
||||||
|
|
||||||
|
expect(ffmpegStub.outputOptions.callCount).to.equal(4)
|
||||||
|
expect(ffmpegStub.outputOptions.getCall(0).args[0]).to.deep.equal(['-map 0:a', '-map_metadata 1', '-map_metadata 0', '-map_chapters 1', '-c copy'])
|
||||||
|
expect(ffmpegStub.outputOptions.getCall(1).args[0]).to.deep.equal(['-metadata track=1'])
|
||||||
|
expect(ffmpegStub.outputOptions.getCall(2).args[0]).to.deep.equal(['-f mp4'])
|
||||||
|
expect(ffmpegStub.outputOptions.getCall(3).args[0]).to.deep.equal(['-map 2:v', '-disposition:v:0 attached_pic', '-metadata:s:v', 'title=Cover', '-metadata:s:v', 'comment=Cover'])
|
||||||
|
|
||||||
|
expect(ffmpegStub.output.calledOnce).to.be.true
|
||||||
|
expect(ffmpegStub.output.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.m4b')
|
||||||
|
|
||||||
|
expect(ffmpegStub.run.calledOnce).to.be.true
|
||||||
|
|
||||||
|
expect(fsCopyFileSyncStub.calledOnce).to.be.true
|
||||||
|
expect(fsCopyFileSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.m4b')
|
||||||
|
expect(fsCopyFileSyncStub.firstCall.args[1]).to.equal('/path/to/audio/file.m4b')
|
||||||
|
|
||||||
|
expect(fsUnlinkSyncStub.calledOnce).to.be.true
|
||||||
|
expect(fsUnlinkSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.m4b')
|
||||||
|
|
||||||
|
// Restore the stub
|
||||||
|
sinon.restore()
|
||||||
|
})
|
||||||
|
})
|
Loading…
x
Reference in New Issue
Block a user