Merge branch 'master' into show-subtitles

This commit is contained in:
advplyr 2024-07-09 15:58:34 -05:00
commit eb5af47bbf
49 changed files with 1387 additions and 682 deletions

View File

@ -10,6 +10,3 @@ RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends \
curl tzdata ffmpeg && \
rm -rf /var/lib/apt/lists/*
# Move tone executable to appropriate directory
COPY --from=sandreas/tone:v0.1.5 /usr/local/bin/tone /usr/local/bin/

View File

@ -4,7 +4,7 @@ on:
pull_request:
push:
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:
build:
@ -18,8 +18,8 @@ jobs:
with:
node-version: 20
- name: install pkg
run: npm install -g pkg
- name: install pkg (using yao-pkg fork for targetting node20)
run: npm install -g @yao-pkg/pkg
- name: get client dependencies
working-directory: client
@ -33,7 +33,7 @@ jobs:
run: npm ci --only=production
- name: build binary
run: pkg -t node18-linux-x64 -o audiobookshelf .
run: pkg -t node20-linux-x64 -o audiobookshelf .
- name: run audiobookshelf
run: |

View File

@ -6,7 +6,6 @@ RUN npm ci && npm cache clean --force
RUN npm run generate
### STAGE 1: Build server ###
FROM sandreas/tone:v0.1.5 AS tone
FROM node:20-alpine
ENV NODE_ENV=production
@ -21,7 +20,6 @@ RUN apk update && \
g++ \
tini
COPY --from=tone /usr/local/bin/tone /usr/local/bin/
COPY --from=build /client/dist /client/dist
COPY index.js package* /
COPY server server

View File

@ -50,7 +50,6 @@ install_ffmpeg() {
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_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
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
rm ffmpeg-git-amd64-static.tar.xz
# Temp downloading tone library to the ffmpeg dir
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"
echo "Good to go on Ffmpeg... hopefully"
}
setup_config() {
@ -77,12 +70,6 @@ setup_config() {
echo "Existing config found."
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
if [ ! -d "$DEFAULT_DATA_DIR" ]; then
@ -98,7 +85,6 @@ setup_config() {
CONFIG_PATH=$DEFAULT_DATA_DIR/config
FFMPEG_PATH=$FFMPEG_INSTALL_DIR/ffmpeg
FFPROBE_PATH=$FFMPEG_INSTALL_DIR/ffprobe
TONE_PATH=$FFMPEG_INSTALL_DIR/tone
PORT=$DEFAULT_PORT
HOST=$DEFAULT_HOST"

View File

@ -48,7 +48,7 @@ Description: $DESCRIPTION"
echo "$controlfile" > dist/debian/DEBIAN/control;
# 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

View File

@ -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() {
if (this.$root.socket) {
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_added', this.libraryItemsAdded)
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 {
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_added', this.libraryItemsAdded)
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 {
console.error('Error socket not initialized')
}

View File

@ -601,6 +601,30 @@ export default {
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() {
let numPages = Math.ceil(this.totalEntities / this.booksPerFetch)
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_updated', this.playlistUpdated)
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 {
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_updated', this.playlistUpdated)
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 {
console.error('Bookshelf - Socket not initialized')
}

View File

@ -121,7 +121,7 @@
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
</div>
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version" />
<modals-changelog-view-modal v-model="showChangelogModal" :versionData="versionData" />
</div>
</template>
@ -219,9 +219,6 @@ export default {
githubTagUrl() {
return this.versionData.githubTagUrl
},
currentVersionChangelog() {
return this.versionData.currentVersionChangelog || 'No Changelog Available'
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
@ -245,4 +242,4 @@ export default {
#siderail-buttons-container.player-open {
max-height: calc(100vh - 64px - 48px - 160px);
}
</style>
</style>

View File

@ -539,6 +539,12 @@ export default {
func: 'openPlaylists',
text: this.$strings.LabelAddToPlaylist
})
if (this.userIsAdminOrUp) {
items.push({
func: 'openShare',
text: this.$strings.LabelShare
})
}
}
if (this.ebookFormat && this.store.state.libraries.ereaderDevices?.length) {
items.push({
@ -897,6 +903,10 @@ export default {
this.store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.recentEpisode }])
this.store.commit('globals/setShowPlaylistsModal', true)
},
openShare() {
this.store.commit('setSelectedLibraryItem', this.libraryItem)
this.store.commit('globals/setShareModal', this.mediaItemShare)
},
deleteLibraryItem() {
const payload = {
message: this.$strings.MessageConfirmDeleteLibraryItem,

View File

@ -89,6 +89,9 @@ export default {
this.$emit('input', val)
}
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
libraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
@ -148,7 +151,7 @@ export default {
]
},
bookItems() {
return [
const items = [
{
text: this.$strings.LabelAll,
value: 'all'
@ -229,13 +232,16 @@ export default {
text: this.$strings.LabelRSSFeedOpen,
value: 'feed-open',
sublist: false
},
{
}
]
if (this.userIsAdminOrUp) {
items.push({
text: this.$strings.LabelShareOpen,
value: 'share-open',
sublist: false
}
]
})
}
return items
},
podcastItems() {
return [

View File

@ -6,6 +6,13 @@
</div>
</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="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">
<div class="w-full py-2">
<label class="px-1 text-sm font-semibold block">{{ $strings.LabelShareURL }}</label>
@ -53,17 +60,7 @@
<script>
export default {
props: {
value: Boolean,
libraryItem: {
type: Object,
default: () => null
},
mediaItemShare: {
type: Object,
default: () => null
}
},
props: {},
data() {
return {
processing: false,
@ -99,12 +96,18 @@ export default {
computed: {
show: {
get() {
return this.value
return this.$store.state.globals.showShareModal
},
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() {
return this.$store.state.user.user
},

View File

@ -6,7 +6,9 @@
</div>
</template>
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">
<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>
</modals-modal>
@ -18,17 +20,9 @@ import { marked } from '@/static/libs/marked/index.js'
export default {
props: {
value: Boolean,
changelog: String,
currentVersion: String
},
watch: {
show: {
immediate: true,
handler(newVal) {
if (newVal) {
this.init()
}
}
versionData: {
type: Object,
default: () => {}
}
},
computed: {
@ -40,16 +34,27 @@ export default {
this.$emit('input', val)
}
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
},
changelog() {
return this.versionData?.currentVersionChangelog || 'No Changelog Available'
},
compiledMarkedown() {
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() {
return this.currentVersion
return this.$config.version
}
},
methods: {
init() {}
},
methods: {},
mounted() {}
}
</script>
@ -57,7 +62,7 @@ export default {
<style scoped>
/*
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
*/
@ -70,4 +75,4 @@ since we don't have access to the actual elements in this component
.custom-text ::v-deep > ul {
@apply list-disc list-inside pb-4;
}
</style>
</style>

View File

@ -2,11 +2,8 @@
<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>
<!-- 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 -->
<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>
<p class="text-lg">{{ $strings.LabelToolsMakeM4b }}</p>
@ -23,7 +20,7 @@
</div>
<!-- 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>
<p class="text-lg">{{ $strings.LabelToolsEmbedMetadata }}</p>
@ -111,12 +108,6 @@ export default {
},
isEncodeTaskRunning() {
return this.encodeTask && !this.encodeTask?.isFinished
},
isWindowsInstall() {
return this.Source == 'windows'
},
Source() {
return this.$store.state.Source
}
},
methods: {
@ -141,4 +132,4 @@ export default {
}
}
}
</script>
</script>

View File

@ -171,7 +171,7 @@ export default {
this.$axios
.$get('/api/backups')
.then((data) => {
this.$emit('loaded', data.backupLocation)
this.$emit('loaded', data)
this.setBackups(data.backups || [])
})
.catch((error) => {

View File

@ -20,6 +20,7 @@
<modals-batch-quick-match-model />
<modals-rssfeed-open-close-modal />
<modals-raw-cover-preview-modal />
<modals-share-modal />
<prompt-confirm />
<readers-reader />
</div>
@ -598,4 +599,4 @@ export default {
margin-left: 0px;
}
}
</style>
</style>

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.10.1",
"version": "2.11.0",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",

View File

@ -11,10 +11,9 @@
</div>
</div>
<div class="flex justify-center">
<div class="flex justify-center mb-2">
<div class="w-full max-w-2xl">
<p class="text-xl mb-1">{{ $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>
<p class="text-xl">{{ $strings.HeaderMetadataToEmbed }}</p>
</div>
<div class="w-full max-w-2xl"></div>
</div>
@ -26,7 +25,7 @@
<div class="w-2/3 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelValue }}</div>
</div>
<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 class="w-1/3 font-semibold">{{ key }}</div>
<div class="w-2/3">
@ -208,7 +207,7 @@ export default {
processing: false,
audiofilesEncoding: {},
audiofilesFinished: {},
toneObject: null,
metadataObject: null,
selectedTool: 'embed',
isCancelingEncode: false,
showEncodeOptions: false,
@ -387,7 +386,7 @@ export default {
window.history.replaceState({ path: newurl }, '', newurl)
},
init() {
this.fetchToneObject()
this.fetchMetadataEmbedObject()
if (this.$route.query.tool === 'm4b') {
if (this.availableTools.some((t) => t.value === 'm4b')) {
this.selectedTool = 'm4b'
@ -401,15 +400,14 @@ export default {
const shouldBackupAudioFiles = localStorage.getItem('embedMetadataShouldBackup')
this.shouldBackupAudioFiles = shouldBackupAudioFiles != 0
},
fetchToneObject() {
fetchMetadataEmbedObject() {
this.$axios
.$get(`/api/items/${this.libraryItemId}/tone-object`)
.then((toneObject) => {
delete toneObject.CoverFile
this.toneObject = toneObject
.$get(`/api/items/${this.libraryItemId}/metadata-object`)
.then((metadataObject) => {
this.metadataObject = metadataObject
})
.catch((error) => {
console.error('Failed to fetch tone object', error)
console.error('Failed to fetch metadata object', error)
})
},
taskUpdated(task) {
@ -426,4 +424,4 @@ export default {
this.$root.socket.off('audiofile_metadata_finished', this.audiofileMetadataFinished)
}
}
</script>
</script>

View File

@ -16,11 +16,11 @@
</div>
<div v-else>
<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-btn small :loading="savingBackupPath" color="success" type="submit" class="h-8">{{ $strings.ButtonSave }}</ui-btn>
<ui-text-input v-model="newBackupLocation" :disabled="savingBackupPath || !canEditBackup" class="w-full max-w-[calc(100%-50px)] text-sm h-8" />
<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>
</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>
@ -92,6 +92,7 @@ export default {
newServerSettings: {},
showCronBuilder: false,
showEditBackupPath: false,
backupPathEnvSet: false,
backupLocation: '',
newBackupLocation: '',
savingBackupPath: false
@ -115,6 +116,10 @@ export default {
timeFormat() {
return this.serverSettings.timeFormat
},
canEditBackup() {
// Prevent editing of backup path if an environment variable is set
return !this.backupPathEnvSet
},
scheduleDescription() {
if (!this.cronExpression) return ''
const parsed = this.$parseCronExpression(this.cronExpression)
@ -127,9 +132,10 @@ export default {
}
},
methods: {
backupsLoaded(backupLocation) {
this.backupLocation = backupLocation
this.newBackupLocation = backupLocation
backupsLoaded(data) {
this.backupLocation = data.backupLocation
this.newBackupLocation = data.backupLocation
this.backupPathEnvSet = data.backupPathEnvSet
},
cancelEditBackupPath() {
this.newBackupLocation = this.backupLocation

View File

@ -147,7 +147,6 @@
</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-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :library-item-id="libraryItemId" hide-create @select="selectBookmark" />
</div>
@ -186,8 +185,7 @@ export default {
episodeDownloadsQueued: [],
showBookmarksModal: false,
isDescriptionClamped: false,
showFullDescription: false,
showShareModal: false
showFullDescription: false
}
},
computed: {
@ -440,7 +438,7 @@ export default {
})
}
if (this.userIsAdminOrUp && !this.isPodcast) {
if (this.userIsAdminOrUp && !this.isPodcast && this.tracks.length) {
items.push({
text: this.$strings.LabelShare,
action: 'share'
@ -458,12 +456,6 @@ export default {
}
},
methods: {
openedShare(mediaItemShare) {
this.mediaItemShare = mediaItemShare
},
removedShare() {
this.mediaItemShare = null
},
selectBookmark(bookmark) {
if (!bookmark) return
if (this.isStreaming) {
@ -682,6 +674,16 @@ export default {
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() {
if (this.isQueued) {
// Remove from queue
@ -778,7 +780,8 @@ export default {
} else if (action === 'sendToDevice') {
this.sendToDevice(data)
} 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('rss_feed_open', this.rssFeedOpen)
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_started', this.episodeDownloadStarted)
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('rss_feed_open', this.rssFeedOpen)
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_started', this.episodeDownloadStarted)
this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)

View File

@ -49,11 +49,11 @@ export async function checkForUpdate() {
}
if (verObj.version == currVerObj.version) {
currVerObj.pubdate = new Date(release.published_at)
currVerObj.changelog = release.body
}
})
}
})
if (!largestVer) {
console.error('No valid version tags to compare with')
@ -65,6 +65,8 @@ export async function checkForUpdate() {
latestVersion: largestVer.version,
githubTagUrl: `https://github.com/advplyr/audiobookshelf/releases/tag/v${largestVer.version}`,
currentVersion: currVerObj.version,
currentTagUrl: `https://github.com/advplyr/audiobookshelf/releases/tag/v${currVerObj.version}`,
currentVersionPubDate: currVerObj.pubdate,
currentVersionChangelog: currVerObj.changelog
}
}
}

View File

@ -10,6 +10,7 @@ export const state = () => ({
showEditPodcastEpisode: false,
showViewPodcastEpisodeModal: false,
showRSSFeedOpenCloseModal: false,
showShareModal: false,
showConfirmPrompt: false,
showRawCoverPreviewModal: false,
confirmPromptOptions: null,
@ -22,6 +23,7 @@ export const state = () => ({
selectedAuthor: null,
selectedMediaItems: [],
selectedRawCoverUrl: null,
selectedMediaItemShare: null,
isCasting: false, // Actively casting
isChromecastInitialized: false, // Script loadeds
showBatchQuickMatchModal: false,
@ -157,6 +159,13 @@ export const mutations = {
state.rssFeedEntity = entity
state.showRSSFeedOpenCloseModal = true
},
setShowShareModal(state, val) {
state.showShareModal = val
},
setShareModal(state, mediaItemShare) {
state.selectedMediaItemShare = mediaItemShare
state.showShareModal = true
},
setShowConfirmPrompt(state, val) {
state.showConfirmPrompt = val
},

View File

@ -258,6 +258,7 @@
"LabelCurrently": "Aktuell:",
"LabelCustomCronExpression": "Benutzerdefinierter Cron-Ausdruck:",
"LabelDatetime": "Datum & Uhrzeit",
"LabelDays": "Tage",
"LabelDeleteFromFileSystemCheckbox": "Löschen von der Festplatte + Datenbank (deaktivieren um nur aus der Datenbank zu löschen)",
"LabelDescription": "Beschreibung",
"LabelDeselectAll": "Alles abwählen",
@ -321,6 +322,7 @@
"LabelHighestPriority": "Höchste Priorität",
"LabelHost": "Host",
"LabelHour": "Stunde",
"LabelHours": "Stunden",
"LabelIcon": "Symbol",
"LabelImageURLFromTheWeb": "Bild-URL vom Internet",
"LabelInProgress": "In Bearbeitung",
@ -371,6 +373,7 @@
"LabelMetadataOrderOfPrecedenceDescription": "Höher priorisierte Quellen für Metadaten überschreiben Metadaten aus Quellen mit niedrigerer Priorität",
"LabelMetadataProvider": "Metadatenanbieter",
"LabelMinute": "Minute",
"LabelMinutes": "Minuten",
"LabelMissing": "Fehlend",
"LabelMissingEbook": "E-Book fehlt",
"LabelMissingSupplementaryEbook": "Ergänzendes E-Book fehlt",
@ -410,6 +413,7 @@
"LabelOverwrite": "Überschreiben",
"LabelPassword": "Passwort",
"LabelPath": "Pfad",
"LabelPermanent": "Dauerhaft",
"LabelPermissionsAccessAllLibraries": "Zugriff auf alle Bibliotheken",
"LabelPermissionsAccessAllTags": "Zugriff auf alle Schlagwörter",
"LabelPermissionsAccessExplicitContent": "Zugriff auf explizite (alterbeschränkte) Inhalte",
@ -507,6 +511,9 @@
"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",
"LabelSettingsTimeFormat": "Zeitformat",
"LabelShare": "Teilen",
"LabelShareOpen": "Teilen Offen",
"LabelShareURL": "URL teilen",
"LabelShowAll": "Alles anzeigen",
"LabelShowSeconds": "Zeige Sekunden",
"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.",
"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",
"MessageBackupsLocationNoEditNote": "Hinweis: Der Sicherungsspeicherort wird über eine Umgebungsvariable festgelegt und kann hier nicht geändert werden.",
"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.",
"MessageBookshelfNoCollections": "Es wurden noch keine Sammlungen erstellt",
@ -716,6 +724,9 @@
"MessageSelected": "{0} ausgewählt",
"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",
"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}?",
"MessageThinking": "Nachdenken...",
"MessageUploaderItemFailed": "Hochladen fehlgeschlagen",

View File

@ -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>.",
"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",
"MessageBackupsLocationNoEditNote": "Note: The backup location is set through an environment variable and cannot be changed here.",
"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.",
"MessageBookshelfNoCollections": "You haven't made any collections yet",

View File

@ -258,6 +258,7 @@
"LabelCurrently": "En este momento:",
"LabelCustomCronExpression": "Expresión de Cron Personalizada:",
"LabelDatetime": "Hora y Fecha",
"LabelDays": "Días",
"LabelDeleteFromFileSystemCheckbox": "Eliminar archivos del sistema (desmarcar para eliminar sólo de la base de datos)",
"LabelDescription": "Descripción",
"LabelDeselectAll": "Deseleccionar Todos",
@ -321,6 +322,7 @@
"LabelHighestPriority": "Mayor prioridad",
"LabelHost": "Host",
"LabelHour": "Hora",
"LabelHours": "Horas",
"LabelIcon": "Icono",
"LabelImageURLFromTheWeb": "URL de la imagen",
"LabelInProgress": "En proceso",
@ -371,6 +373,7 @@
"LabelMetadataOrderOfPrecedenceDescription": "Las fuentes de metadatos de mayor prioridad prevalecerán sobre las de menor prioridad",
"LabelMetadataProvider": "Proveedor de Metadatos",
"LabelMinute": "Minuto",
"LabelMinutes": "Minutos",
"LabelMissing": "Ausente",
"LabelMissingEbook": "No tiene ebook",
"LabelMissingSupplementaryEbook": "No tiene ebook suplementario",
@ -410,6 +413,7 @@
"LabelOverwrite": "Sobrescribir",
"LabelPassword": "Contraseña",
"LabelPath": "Ruta de carpeta",
"LabelPermanent": "Permanente",
"LabelPermissionsAccessAllLibraries": "Puede Accesar a Todas las bibliotecas",
"LabelPermissionsAccessAllTags": "Pueda Accesar a Todas las Etiquetas",
"LabelPermissionsAccessExplicitContent": "Puede Accesar a Contenido Explicito",
@ -507,6 +511,8 @@
"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",
"LabelSettingsTimeFormat": "Formato de Tiempo",
"LabelShare": "Compartir",
"LabelShareURL": "Compartir la URL",
"LabelShowAll": "Mostrar Todos",
"LabelShowSeconds": "Mostrar segundos",
"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>.",
"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",
"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",
"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.",
@ -716,6 +723,9 @@
"MessageSelected": "{0} seleccionado(s)",
"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",
"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}?",
"MessageThinking": "Pensando...",
"MessageUploaderItemFailed": "Error al Subir",

View File

@ -9,7 +9,7 @@
"ButtonApply": "Zatwierdź",
"ButtonApplyChapters": "Zatwierdź rozdziały",
"ButtonAuthors": "Autorzy",
"ButtonBack": "Back",
"ButtonBack": "Wstecz",
"ButtonBrowseForFolder": "Wyszukaj folder",
"ButtonCancel": "Anuluj",
"ButtonCancelEncode": "Anuluj enkodowanie",
@ -25,7 +25,7 @@
"ButtonCreateBackup": "Utwórz kopię zapasową",
"ButtonDelete": "Usuń",
"ButtonDownloadQueue": "Kolejka",
"ButtonEdit": "Edit",
"ButtonEdit": "Edycja",
"ButtonEditChapters": "Edytuj rozdziały",
"ButtonEditPodcast": "Edytuj podcast",
"ButtonForceReScan": "Wymuś ponowne skanowanie",
@ -35,7 +35,7 @@
"ButtonIssues": "Błędy",
"ButtonJumpBackward": "Skocz do tyłu",
"ButtonJumpForward": "Skocz do przodu",
"ButtonLatest": "Aktualna wersja:",
"ButtonLatest": "Aktualna wersja",
"ButtonLibrary": "Biblioteka",
"ButtonLogout": "Wyloguj",
"ButtonLookup": "Importuj",
@ -83,7 +83,7 @@
"ButtonSelectFolderPath": "Wybierz ścieżkę folderu",
"ButtonSeries": "Seria",
"ButtonSetChaptersFromTracks": "Ustawiaj rozdziały na podstawie utworów",
"ButtonShare": "Share",
"ButtonShare": "Udostępnij",
"ButtonShiftTimes": "Przesunięcie czasowe",
"ButtonShow": "Pokaż",
"ButtonStartM4BEncode": "Eksportuj jako plik M4B",
@ -114,17 +114,17 @@
"HeaderCollection": "Kolekcja",
"HeaderCollectionItems": "Elementy kolekcji",
"HeaderCover": "Okładka",
"HeaderCurrentDownloads": "Current Downloads",
"HeaderCustomMessageOnLogin": "Custom Message on Login",
"HeaderCustomMetadataProviders": "Custom Metadata Providers",
"HeaderCurrentDownloads": "Obecnie ściągane",
"HeaderCustomMessageOnLogin": "Własny tekst podczas logowania",
"HeaderCustomMetadataProviders": "Niestandardowi dostawcy metadanych",
"HeaderDetails": "Szczegóły",
"HeaderDownloadQueue": "Download Queue",
"HeaderEbookFiles": "Ebook Files",
"HeaderDownloadQueue": "Kolejka do ściągania",
"HeaderEbookFiles": "Pliki Ebook",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings",
"HeaderEmailSettings": "Ustawienia e-mail",
"HeaderEpisodes": "Rozdziały",
"HeaderEreaderDevices": "Ereader Devices",
"HeaderEreaderSettings": "Ereader Settings",
"HeaderEreaderDevices": "Czytniki",
"HeaderEreaderSettings": "Ustawienia czytnika",
"HeaderFiles": "Pliki",
"HeaderFindChapters": "Wyszukaj rozdziały",
"HeaderIgnoredFiles": "Zignoruj pliki",
@ -141,9 +141,9 @@
"HeaderLogs": "Logi",
"HeaderManageGenres": "Zarządzaj gatunkami",
"HeaderManageTags": "Zarządzaj tagami",
"HeaderMapDetails": "Map details",
"HeaderMapDetails": "Szczegóły mapowania",
"HeaderMatch": "Dopasuj",
"HeaderMetadataOrderOfPrecedence": "Metadata order of precedence",
"HeaderMetadataOrderOfPrecedence": "Kolejność metadanych",
"HeaderMetadataToEmbed": "Osadź metadane",
"HeaderNewAccount": "Nowe konto",
"HeaderNewLibrary": "Nowa biblioteka",
@ -153,9 +153,9 @@
"HeaderOtherFiles": "Inne pliki",
"HeaderPasswordAuthentication": "Uwierzytelnianie hasłem",
"HeaderPermissions": "Uprawnienia",
"HeaderPlayerQueue": "Player Queue",
"HeaderPlaylist": "Playlist",
"HeaderPlaylistItems": "Playlist Items",
"HeaderPlayerQueue": "Kolejka odtwarzania",
"HeaderPlaylist": "Playlista",
"HeaderPlaylistItems": "Pozycje listy odtwarzania",
"HeaderPodcastsToAdd": "Podcasty do dodania",
"HeaderPreviewCover": "Podgląd okładki",
"HeaderRSSFeedGeneral": "RSS Details",
@ -174,25 +174,25 @@
"HeaderSettingsGeneral": "Ogólne",
"HeaderSettingsScanner": "Skanowanie",
"HeaderSleepTimer": "Wyłącznik czasowy",
"HeaderStatsLargestItems": "Largest Items",
"HeaderStatsLargestItems": "Największe pozycje",
"HeaderStatsLongestItems": "Najdłuższe pozycje (hrs)",
"HeaderStatsMinutesListeningChart": "Czas słuchania w minutach (ostatnie 7 dni)",
"HeaderStatsRecentSessions": "Ostatnie sesje",
"HeaderStatsTop10Authors": "Top 10 Autorów",
"HeaderStatsTop5Genres": "Top 5 Gatunków",
"HeaderTableOfContents": "Table of Contents",
"HeaderTableOfContents": "Spis treści",
"HeaderTools": "Narzędzia",
"HeaderUpdateAccount": "Zaktualizuj konto",
"HeaderUpdateAuthor": "Zaktualizuj autorów",
"HeaderUpdateDetails": "Zaktualizuj szczegóły",
"HeaderUpdateLibrary": "Zaktualizuj bibliotekę",
"HeaderUsers": "Użytkownicy",
"HeaderYearReview": "Year {0} in Review",
"HeaderYearReview": "Podsumowanie roku {0}",
"HeaderYourStats": "Twoje statystyki",
"LabelAbridged": "Abridged",
"LabelAbridgedChecked": "Abridged (checked)",
"LabelAbridgedUnchecked": "Unabridged (unchecked)",
"LabelAccessibleBy": "Accessible by",
"LabelAbridged": "Skrócony",
"LabelAbridgedChecked": "Skrócony (zaznaczono)",
"LabelAbridgedUnchecked": "Nieskrócony (nie zaznaczone)",
"LabelAccessibleBy": "Dostęp przez",
"LabelAccountType": "Typ konta",
"LabelAccountTypeAdmin": "Administrator",
"LabelAccountTypeGuest": "Gość",
@ -202,28 +202,28 @@
"LabelAddToCollectionBatch": "Dodaj {0} książki do kolekcji",
"LabelAddToPlaylist": "Add to Playlist",
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
"LabelAdded": "Added",
"LabelAdded": "Dodane",
"LabelAddedAt": "Dodano",
"LabelAdminUsersOnly": "Tylko użytkownicy administracyjni",
"LabelAll": "All",
"LabelAll": "Wszystkie",
"LabelAllUsers": "Wszyscy użytkownicy",
"LabelAllUsersExcludingGuests": "Wszyscy użytkownicy z wyłączeniem gości",
"LabelAllUsersIncludingGuests": "Wszyscy użytkownicy, łącznie z gośćmi",
"LabelAlreadyInYourLibrary": "Already in your library",
"LabelAppend": "Append",
"LabelAlreadyInYourLibrary": "Już istnieje w twojej bibliotece",
"LabelAppend": "Dołącz",
"LabelAuthor": "Autor",
"LabelAuthorFirstLast": "Autor (Rosnąco)",
"LabelAuthorLastFirst": "Author (Malejąco)",
"LabelAuthors": "Autorzy",
"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.",
"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>)",
"LabelAutoRegister": "Auto Register",
"LabelAutoRegisterDescription": "Automatically create new users after logging in",
"LabelBackToUser": "Powrót",
"LabelBackupLocation": "Backup Location",
"LabelBackupLocation": "Lokalizacja kopii zapasowej",
"LabelBackupsEnableAutomaticBackups": "Włącz automatyczne kopie zapasowe",
"LabelBackupsEnableAutomaticBackupsHelp": "Kopie zapasowe są zapisywane w folderze /metadata/backups",
"LabelBackupsMaxBackupSize": "Maksymalny łączny rozmiar backupów (w GB)",
@ -235,20 +235,20 @@
"LabelButtonText": "Button Text",
"LabelByAuthor": "by {0}",
"LabelChangePassword": "Zmień hasło",
"LabelChannels": "Channels",
"LabelChannels": "Kanały",
"LabelChapterTitle": "Tytuł rozdziału",
"LabelChapters": "Chapters",
"LabelChapters": "Rozdziały",
"LabelChaptersFound": "Znalezione rozdziały",
"LabelClickForMoreInfo": "Click for more info",
"LabelClickForMoreInfo": "Kliknij po więcej szczegółów",
"LabelClosePlayer": "Zamknij odtwarzacz",
"LabelCodec": "Codec",
"LabelCollapseSeries": "Podsumuj serię",
"LabelCollection": "Collection",
"LabelCollection": "Kolekcja",
"LabelCollections": "Kolekcje",
"LabelComplete": "Ukończone",
"LabelConfirmPassword": "Potwierdź hasło",
"LabelContinueListening": "Kontynuuj odtwarzanie",
"LabelContinueReading": "Continue Reading",
"LabelContinueReading": "Kontynuuj czytanie",
"LabelContinueSeries": "Kontynuuj serię",
"LabelCover": "Okładka",
"LabelCoverImageURL": "URL okładki",
@ -258,6 +258,7 @@
"LabelCurrently": "Obecnie:",
"LabelCustomCronExpression": "Custom Cron Expression:",
"LabelDatetime": "Data i godzina",
"LabelDays": "Dni",
"LabelDeleteFromFileSystemCheckbox": "Usuń z systemu plików (odznacz, aby usunąć tylko z bazy danych)",
"LabelDescription": "Opis",
"LabelDeselectAll": "Odznacz wszystko",
@ -269,34 +270,34 @@
"LabelDiscFromMetadata": "Oznaczenie dysku z metadanych",
"LabelDiscover": "Odkrywaj",
"LabelDownload": "Pobierz",
"LabelDownloadNEpisodes": "Download {0} episodes",
"LabelDownloadNEpisodes": "Ściąganie {0} odcinków",
"LabelDuration": "Czas trwania",
"LabelDurationComparisonExactMatch": "(exact match)",
"LabelDurationComparisonLonger": "({0} longer)",
"LabelDurationComparisonShorter": "({0} shorter)",
"LabelDurationComparisonLonger": "({0} dłużej)",
"LabelDurationComparisonShorter": "({0} krócej)",
"LabelDurationFound": "Znaleziona długość:",
"LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEbooks": "Ebooki",
"LabelEdit": "Edytuj",
"LabelEmail": "Email",
"LabelEmailSettingsFromAddress": "From Address",
"LabelEmailSettingsRejectUnauthorized": "Reject unauthorized certificates",
"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.",
"LabelEmailSettingsSecure": "Secure",
"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)",
"LabelEmailSettingsFromAddress": "Z adresu",
"LabelEmailSettingsRejectUnauthorized": "Odrzuć nieautoryzowane certyfikaty",
"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": "Bezpieczeństwo",
"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",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEmbeddedCover": "Wbudowana okładka",
"LabelEnable": "Włącz",
"LabelEnd": "Zakończ",
"LabelEpisode": "Odcinek",
"LabelEpisodeTitle": "Tytuł odcinka",
"LabelEpisodeType": "Typ odcinka",
"LabelExample": "Example",
"LabelExample": "Przykład",
"LabelExplicit": "Nieprzyzwoite",
"LabelExplicitChecked": "Explicit (checked)",
"LabelExplicitUnchecked": "Not Explicit (unchecked)",
"LabelFeedURL": "URL kanału",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFetchingMetadata": "Pobieranie metadanych",
"LabelFile": "Plik",
"LabelFileBirthtime": "Data utworzenia pliku",
"LabelFileModified": "Data modyfikacji pliku",
@ -304,25 +305,26 @@
"LabelFilterByUser": "Filtruj według danego użytkownika",
"LabelFindEpisodes": "Znajdź odcinki",
"LabelFinished": "Zakończone",
"LabelFolder": "Folder",
"LabelFolder": "Katalog",
"LabelFolders": "Foldery",
"LabelFontBold": "Bold",
"LabelFontBoldness": "Font Boldness",
"LabelFontBold": "Pogrubiony",
"LabelFontBoldness": "Grubość czcionki",
"LabelFontFamily": "Rodzina czcionek",
"LabelFontItalic": "Italic",
"LabelFontScale": "Font scale",
"LabelFontStrikethrough": "Strikethrough",
"LabelFontScale": "Rozmiar czcionki",
"LabelFontStrikethrough": "Przekreślony",
"LabelFormat": "Format",
"LabelGenre": "Gatunek",
"LabelGenres": "Gatunki",
"LabelHardDeleteFile": "Usuń trwale plik",
"LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHighestPriority": "Highest priority",
"LabelHasEbook": "Ma ebooka",
"LabelHasSupplementaryEbook": "Posiada dodatkowy ebook",
"LabelHighestPriority": "Najwyższy priorytet",
"LabelHost": "Host",
"LabelHour": "Godzina",
"LabelHours": "Godziny",
"LabelIcon": "Ikona",
"LabelImageURLFromTheWeb": "Image URL from the web",
"LabelImageURLFromTheWeb": "Link do obrazu w sieci",
"LabelInProgress": "W trakcie",
"LabelIncludeInTracklist": "Dołącz do listy odtwarzania",
"LabelIncomplete": "Nieukończone",
@ -335,19 +337,19 @@
"LabelIntervalEvery6Hours": "Co 6 godzin",
"LabelIntervalEveryDay": "Każdego dnia",
"LabelIntervalEveryHour": "Każdej godziny",
"LabelInvert": "Invert",
"LabelInvert": "Inversja",
"LabelItem": "Pozycja",
"LabelLanguage": "Język",
"LabelLanguageDefaultServer": "Domyślny język serwera",
"LabelLanguages": "Languages",
"LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated",
"LabelLanguages": "Języki",
"LabelLastBookAdded": "Ostatnio dodana książka",
"LabelLastBookUpdated": "Ostatnio modyfikowana książka",
"LabelLastSeen": "Ostatnio widziany",
"LabelLastTime": "Ostatni czas",
"LabelLastUpdate": "Ostatnia aktualizacja",
"LabelLayout": "Layout",
"LabelLayoutSinglePage": "Single page",
"LabelLayoutSplitPage": "Split page",
"LabelLayout": "Układ",
"LabelLayoutSinglePage": "Pojedyncza strona",
"LabelLayoutSplitPage": "Podział strony",
"LabelLess": "Mniej",
"LabelLibrariesAccessibleToUser": "Biblioteki dostępne dla użytkownika",
"LabelLibrary": "Biblioteka",
@ -355,42 +357,43 @@
"LabelLibraryItem": "Element biblioteki",
"LabelLibraryName": "Nazwa biblioteki",
"LabelLimit": "Limit",
"LabelLineSpacing": "Line spacing",
"LabelLineSpacing": "Odstęp między wierszami",
"LabelListenAgain": "Słuchaj ponownie",
"LabelLogLevelDebug": "Debug",
"LabelLogLevelInfo": "Informacja",
"LabelLogLevelWarn": "Ostrzeżenie",
"LabelLookForNewEpisodesAfterDate": "Szukaj nowych odcinków po dacie",
"LabelLowestPriority": "Lowest Priority",
"LabelMatchExistingUsersBy": "Match existing users by",
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
"LabelLowestPriority": "Najniższy priorytet",
"LabelMatchExistingUsersBy": "Dopasuje istniejących użytkowników poprzez",
"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",
"LabelMediaType": "Typ mediów",
"LabelMetaTag": "Tag",
"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",
"LabelMinute": "Minuta",
"LabelMinutes": "Minuty",
"LabelMissing": "Brakujący",
"LabelMissingEbook": "Has no ebook",
"LabelMissingSupplementaryEbook": "Has no supplementary ebook",
"LabelMissingEbook": "Nie posiada ebooka",
"LabelMissingSupplementaryEbook": "Nie posiada dodatkowego ebooka",
"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.",
"LabelMore": "Więcej",
"LabelMoreInfo": "More Info",
"LabelMoreInfo": "Więcej informacji",
"LabelName": "Nazwa",
"LabelNarrator": "Narrator",
"LabelNarrator": "Lektor",
"LabelNarrators": "Lektorzy",
"LabelNew": "Nowy",
"LabelNewPassword": "Nowe hasło",
"LabelNewestAuthors": "Najnowsi autorzy",
"LabelNewestEpisodes": "Najnowsze odcinki",
"LabelNextBackupDate": "Next backup date",
"LabelNextScheduledRun": "Next scheduled run",
"LabelNoCustomMetadataProviders": "No custom metadata providers",
"LabelNoEpisodesSelected": "No episodes selected",
"LabelNextBackupDate": "Data kolejnej kopii zapasowej",
"LabelNextScheduledRun": "Następne uruchomienie",
"LabelNoCustomMetadataProviders": "Brak niestandardowych dostawców metadanych",
"LabelNoEpisodesSelected": "Nie wybrano żadnych odcinków",
"LabelNotFinished": "Nieukończone",
"LabelNotStarted": "Nie rozpoęczto",
"LabelNotStarted": "Nie rozpoczęto",
"LabelNotes": "Uwagi",
"LabelNotificationAppriseURL": "URLe Apprise",
"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.",
"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",
"LabelOverwrite": "Overwrite",
"LabelOverwrite": "Nadpisz",
"LabelPassword": "Hasło",
"LabelPath": "Ścieżka",
"LabelPermanent": "Trwały",
"LabelPermissionsAccessAllLibraries": "Ma dostęp do wszystkich bibliotek",
"LabelPermissionsAccessAllTags": "Ma dostęp do wszystkich tagów",
"LabelPermissionsAccessExplicitContent": "Ma dostęp do treści oznacznych jako nieprzyzwoite",
@ -417,77 +421,77 @@
"LabelPermissionsDownload": "Ma możliwość pobierania",
"LabelPermissionsUpdate": "Ma możliwość aktualizowania",
"LabelPermissionsUpload": "Ma możliwość dodawania",
"LabelPersonalYearReview": "Your Year in Review ({0})",
"LabelPersonalYearReview": "Podsumowanie twojego roku ({0})",
"LabelPhotoPathURL": "Scieżka/URL do zdjęcia",
"LabelPlayMethod": "Metoda odtwarzania",
"LabelPlayerChapterNumberMarker": "{0} of {1}",
"LabelPlaylists": "Playlists",
"LabelPlaylists": "Listy odtwarzania",
"LabelPodcast": "Podcast",
"LabelPodcastSearchRegion": "Obszar wyszukiwania podcastów",
"LabelPodcastType": "Podcast Type",
"LabelPodcasts": "Podcasty",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Ignorowane prefiksy (wielkość liter nie ma znaczenia)",
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
"LabelPrimaryEbook": "Primary ebook",
"LabelPreventIndexing": "Zapobiega indeksowaniu przez iTunes i Google",
"LabelPrimaryEbook": "Główny ebook",
"LabelProgress": "Postęp",
"LabelProvider": "Dostawca",
"LabelPubDate": "Data publikacji",
"LabelPublishYear": "Rok publikacji",
"LabelPublisher": "Wydawca",
"LabelPublishers": "Publishers",
"LabelPublishers": "Wydawcy",
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
"LabelRSSFeedOpen": "RSS Feed otwarty",
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
"LabelRSSFeedPreventIndexing": "Zapobiegaj indeksowaniu",
"LabelRSSFeedSlug": "RSS Feed Slug",
"LabelRSSFeedURL": "URL kanały RSS",
"LabelRead": "Read",
"LabelReadAgain": "Read Again",
"LabelReadEbookWithoutProgress": "Read ebook without keeping progress",
"LabelRead": "Czytaj",
"LabelReadAgain": "Czytaj ponownie",
"LabelReadEbookWithoutProgress": "Czytaj książkę bez zapamiętywania postępu",
"LabelRecentSeries": "Ostatnie serie",
"LabelRecentlyAdded": "Niedawno dodany",
"LabelRecommended": "Recommended",
"LabelRedo": "Redo",
"LabelRecommended": "Polecane",
"LabelRedo": "Wycofaj",
"LabelRegion": "Region",
"LabelReleaseDate": "Data wydania",
"LabelRemoveCover": "Remove cover",
"LabelRowsPerPage": "Rows per page",
"LabelRemoveCover": "Usuń okładkę",
"LabelRowsPerPage": "Wierszy na stronę",
"LabelSearchTerm": "Wyszukiwanie frazy",
"LabelSearchTitle": "Wyszukaj tytuł",
"LabelSearchTitleOrASIN": "Szukaj tytuł lub ASIN",
"LabelSeason": "Sezon",
"LabelSelectAll": "Select all",
"LabelSelectAllEpisodes": "Select all episodes",
"LabelSelectAll": "Wybierz wszystko",
"LabelSelectAllEpisodes": "Wybierz wszystkie odcinki",
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
"LabelSelectUsers": "Select users",
"LabelSendEbookToDevice": "Send Ebook to...",
"LabelSelectUsers": "Wybór użytkowników",
"LabelSendEbookToDevice": "Wyślij ebook do...",
"LabelSequence": "Kolejność",
"LabelSeries": "Serie",
"LabelSeriesName": "Nazwy serii",
"LabelSeriesProgress": "Postęp w serii",
"LabelServerYearReview": "Server Year in Review ({0})",
"LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAudiobooksOnly": "Audiobooks only",
"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",
"LabelSettingsBookshelfViewHelp": "Widok półki z ksiązkami",
"LabelServerYearReview": "Podsumowanie serwera w roku ({0})",
"LabelSetEbookAsPrimary": "Ustaw jako pierwszy",
"LabelSetEbookAsSupplementary": "Ustaw jako dodatkowy",
"LabelSettingsAudiobooksOnly": "Wyłącznie audiobooki",
"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ążkami",
"LabelSettingsChromecastSupport": "Wsparcie Chromecast",
"LabelSettingsDateFormat": "Format daty",
"LabelSettingsDisableWatcher": "Wyłącz monitorowanie",
"LabelSettingsDisableWatcherForLibrary": "Wyłącz monitorowanie folderów dla biblioteki",
"LabelSettingsDisableWatcherHelp": "Wyłącz automatyczne dodawanie/aktualizowanie elementów po wykryciu zmian w plikach. *Wymaga restartu serwera",
"LabelSettingsEnableWatcher": "Enable Watcher",
"LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
"LabelSettingsEnableWatcher": "Włącz monitorowanie",
"LabelSettingsEnableWatcherForLibrary": "Włącz monitorowanie folderów dla biblioteki",
"LabelSettingsEnableWatcherHelp": "Włącza automatyczne dodawanie/aktualizację pozycji gdy wykryte zostaną zmiany w plikach. Wymaga restartu serwera",
"LabelSettingsEpubsAllowScriptedContent": "Zezwalanie na skrypty w plikach epub",
"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",
"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",
"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",
"LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.",
"LabelSettingsHideSingleBookSeries": "Ukryj serie z jedną książką",
"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",
"LabelSettingsLibraryBookshelfView": "Widok półki z książkami na stronie biblioteki",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Skip earlier books in Continue Series",
@ -503,12 +507,15 @@
"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",
"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",
"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",
"LabelShowSeconds": "Show seconds",
"LabelShowSeconds": "Pokaż sekundy",
"LabelSize": "Rozmiar",
"LabelSleepTimer": "Wyłącznik czasowy",
"LabelSlug": "Slug",
@ -543,8 +550,8 @@
"LabelTextEditorNumberedList": "Numbered list",
"LabelTextEditorUnlink": "Unlink",
"LabelTheme": "Theme",
"LabelThemeDark": "Dark",
"LabelThemeLight": "Light",
"LabelThemeDark": "Ciemny",
"LabelThemeLight": "Jasny",
"LabelTimeBase": "Time Base",
"LabelTimeListened": "Czas odtwarzania",
"LabelTimeListenedToday": "Czas odtwarzania dzisiaj",
@ -552,7 +559,7 @@
"LabelTimeToShift": "Czas do przesunięcia w sekundach",
"LabelTitle": "Tytuł",
"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",
"LabelToolsMakeM4bDescription": "Tworzy plik w formacie .M4B, który zawiera metadane, okładkę oraz rozdziały.",
"LabelToolsSplitM4b": "Podziel plik .M4B na pliki .MP3",
@ -561,13 +568,13 @@
"LabelTotalTimeListened": "Całkowity czas odtwarzania",
"LabelTrackFromFilename": "Ścieżka z nazwy pliku",
"LabelTrackFromMetadata": "Ścieżka z metadanych",
"LabelTracks": "Tracks",
"LabelTracks": "Ścieżki",
"LabelTracksMultiTrack": "Multi-track",
"LabelTracksNone": "No tracks",
"LabelTracksSingleTrack": "Single-track",
"LabelType": "Typ",
"LabelUnabridged": "Unabridged",
"LabelUndo": "Undo",
"LabelUndo": "Wycofaj",
"LabelUnknown": "Nieznany",
"LabelUpdateCover": "Zaktalizuj odkładkę",
"LabelUpdateCoverHelp": "Umożliwienie nadpisania istniejących okładek dla wybranych książek w przypadku znalezienia dopasowania",
@ -576,7 +583,7 @@
"LabelUpdatedAt": "Zaktualizowano",
"LabelUploaderDragAndDrop": "Przeciągnij i puść foldery lub 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",
"LabelUseFullTrack": "Użycie ścieżki rozdziału",
"LabelUser": "Użytkownik",
@ -588,39 +595,42 @@
"LabelViewQueue": "Wyświetlaj kolejkę odtwarzania",
"LabelVolume": "Głośność",
"LabelWeekdaysToRun": "Dni tygodnia",
"LabelYearReviewHide": "Hide Year in Review",
"LabelYearReviewShow": "See Year in Review",
"LabelYearReviewHide": "Ukryj Podsumowanie Roku",
"LabelYearReviewShow": "Pokaż Podsumowanie Roku",
"LabelYourAudiobookDuration": "Czas trwania audiobooka",
"LabelYourBookmarks": "Twoje zakładki",
"LabelYourPlaylists": "Your Playlists",
"LabelYourPlaylists": "Twoje playlisty",
"LabelYourProgress": "Twój postęp",
"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>.",
"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.",
"MessageBookshelfNoCollections": "Nie posiadasz jeszcze żadnych kolekcji",
"MessageBookshelfNoRSSFeeds": "Nie posiadasz żadnych otwartych feedów RSS",
"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",
"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",
"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",
"MessageCheckingCron": "Sprawdzanie cron...",
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
"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}\"?",
"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?",
"MessageConfirmDeleteSession": "Czy na pewno chcesz usunąć tę sesję?",
"MessageConfirmForceReScan": "Czy na pewno chcesz wymusić ponowne skanowanie?",
"MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?",
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
"MessageConfirmMarkAllEpisodesFinished": "Czy na pewno chcesz oznaczyć wszystkie odcinki jako ukończone?",
"MessageConfirmMarkAllEpisodesNotFinished": "Czy na pewno chcesz oznaczyć wszystkie odcinki jako nieukończone?",
"MessageConfirmMarkSeriesFinished": "Czy na pewno chcesz oznaczyć wszystkie książki w tej serii jako ukończone?",
"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?",
"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?",
@ -630,7 +640,7 @@
"MessageConfirmRemoveCollection": "Czy na pewno chcesz usunąć kolekcję \"{0}\"?",
"MessageConfirmRemoveEpisode": "Czy na pewno chcesz usunąć odcinek \"{0}\"?",
"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}\"?",
"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?",
@ -656,12 +666,12 @@
"MessageListeningSessionsInTheLastYear": "{0} sesje odsłuchowe w ostatnim roku",
"MessageLoading": "Ładowanie...",
"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>.",
"MessageM4BFailed": "Tworzenie pliku M4B nie powiodło się",
"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ę!",
"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",
"MessageMarkAllEpisodesFinished": "Mark all episodes finished",
"MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished",
"MessageMarkAllEpisodesFinished": "Oznacz wszystkie odcinki jako ukończone",
"MessageMarkAllEpisodesNotFinished": "Oznacz wszystkie odcinki jako nieukończone",
"MessageMarkAsFinished": "Oznacz jako ukoń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.",
@ -681,7 +691,7 @@
"MessageNoGenres": "Brak gatunków",
"MessageNoIssues": "Brak problemów",
"MessageNoItems": "Brak elementów",
"MessageNoItemsFound": "Nie znaleziono żadnych elemntów",
"MessageNoItemsFound": "Nie znaleziono żadnych elementów",
"MessageNoListeningSessions": "Brak sesji odtwarzania",
"MessageNoLogs": "Brak logów",
"MessageNoMediaProgress": "Brak postępu",
@ -691,29 +701,31 @@
"MessageNoSearchResultsFor": "Brak wyników wyszukiwania dla \"{0}\"",
"MessageNoSeries": "No Series",
"MessageNoTags": "No Tags",
"MessageNoTasksRunning": "No Tasks Running",
"MessageNoTasksRunning": "Brak uruchomionych zadań",
"MessageNoUpdateNecessary": "Brak konieczności aktualizacji",
"MessageNoUpdatesWereNecessary": "Brak aktualizacji",
"MessageNoUserPlaylists": "You have no playlists",
"MessageNoUserPlaylists": "Nie masz żadnych list odtwarzania",
"MessageNotYetImplemented": "Jeszcze nie zaimplementowane",
"MessageOr": "lub",
"MessagePauseChapter": "Zatrzymaj odtwarzanie rozdziały",
"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",
"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ł",
"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}\"?",
"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?",
"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",
"MessageSelected": "{0} selected",
"MessageSelected": "{0} wybranych",
"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",
"MessageShareExpiresIn": "Wygaśnie za {0}",
"MessageShareURLWillBe": "URL udziału będzie <strong>{0}</strong>",
"MessageStartPlaybackAtTime": "Rozpoczęcie odtwarzania \"{0}\" od {1}?",
"MessageThinking": "Myślę...",
"MessageUploaderItemFailed": "Nie udało się przesłać",
@ -727,7 +739,7 @@
"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.",
"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ą.",
"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.",
@ -736,7 +748,7 @@
"PlaceholderNewFolderPath": "Nowa ścieżka folderu",
"PlaceholderNewPlaylist": "New playlist name",
"PlaceholderSearch": "Szukanie..",
"PlaceholderSearchEpisode": "Search episode..",
"PlaceholderSearchEpisode": "Szukanie odcinka..",
"ToastAccountUpdateFailed": "Nie udało się zaktualizować konta",
"ToastAccountUpdateSuccess": "Zaktualizowano konto",
"ToastAuthorImageRemoveFailed": "Nie udało się usunąć obrazu",
@ -778,10 +790,10 @@
"ToastItemDetailsUpdateFailed": "Nie udało się zaktualizować szczegółów",
"ToastItemDetailsUpdateSuccess": "Zaktualizowano szczegóły",
"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",
"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",
"ToastLibraryCreateSuccess": "Biblioteka \"{0}\" stworzona",
"ToastLibraryDeleteFailed": "Nie udało się usunąć biblioteki",

View 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

View 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

View File

@ -29,6 +29,10 @@
"name": "Series",
"description": "Series endpoints"
},
{
"name": "Email",
"description": "Email endpoints"
},
{
"name": "Notification",
"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": {
"get": {
"operationId": "getLibraries",
@ -1114,12 +1244,6 @@
"application/json": {
"schema": {
"type": "object",
"required": [
"eventName",
"urls",
"titleTemplate",
"bodyTemplate"
],
"properties": {
"libraryId": {
"$ref": "#/components/schemas/libraryIdNullable"
@ -1142,7 +1266,13 @@
"type": {
"$ref": "#/components/schemas/notificationType"
}
}
},
"required": [
"eventName",
"urls",
"titleTemplate",
"bodyTemplate"
]
}
}
}
@ -1942,6 +2072,110 @@
"example": "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": {
"description": "The name of the library.",
"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": {
"description": "Library found.",
"content": {

View File

@ -21,6 +21,14 @@ paths:
$ref: './controllers/AuthorController.yaml#/paths/~1api~1authors~1{id}~1image'
/api/authors/{id}/match:
$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:
$ref: './controllers/LibraryController.yaml#/paths/~1api~1libraries'
/api/libraries/{id}:
@ -54,5 +62,7 @@ tags:
description: Library endpoints
- name: Series
description: Series endpoints
- name: Email
description: Email endpoints
- name: Notification
description: Notifications endpoints

10
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
"version": "2.10.1",
"version": "2.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
"version": "2.10.1",
"version": "2.11.0",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",
@ -16,7 +16,6 @@
"graceful-fs": "^4.2.10",
"htmlparser2": "^8.0.1",
"lru-cache": "^10.0.3",
"node-tone": "^1.0.1",
"nodemailer": "^6.9.13",
"openid-client": "^5.6.1",
"p-throttle": "^4.1.1",
@ -3661,11 +3660,6 @@
"integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==",
"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": {
"version": "6.9.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.13.tgz",

View File

@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.10.1",
"version": "2.11.0",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
@ -9,7 +9,7 @@
"start": "node index.js",
"client": "cd client && npm ci && npm run generate",
"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",
"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",
@ -42,7 +42,6 @@
"graceful-fs": "^4.2.10",
"htmlparser2": "^8.0.1",
"lru-cache": "^10.0.3",
"node-tone": "^1.0.1",
"nodemailer": "^6.9.13",
"openid-client": "^5.6.1",
"p-throttle": "^4.1.1",

View File

@ -31,7 +31,7 @@ Audiobookshelf is a self-hosted audiobook and podcast server.
- Fetch metadata and cover art from several sources
- Chapter editor and chapter lookup (using [Audnexus API](https://audnex.us/))
- 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
- Epub, pdf, cbr, cbz
- Send ebook to device (i.e. Kindle)

View File

@ -104,6 +104,7 @@ class Server {
*/
async init() {
Logger.info('[Server] Init v' + version)
Logger.info('[Server] Node.js Version:', process.version)
await this.playbackSessionManager.removeOrphanStreams()

View File

@ -10,7 +10,8 @@ class BackupController {
getAll(req, res) {
res.json({
backups: this.backupManager.backups.map((b) => b.toJSON()),
backupLocation: this.backupManager.backupPath
backupLocation: this.backupManager.backupPath,
backupPathEnvSet: this.backupManager.backupPathEnvSet
})
}

View File

@ -43,7 +43,7 @@ class LibraryItemController {
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)
}
@ -559,9 +559,9 @@ class LibraryItemController {
})
}
getToneMetadataObject(req, res) {
getMetadataObject(req, res) {
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)
}
@ -570,7 +570,7 @@ class LibraryItemController {
return res.sendStatus(500)
}
res.json(this.audioMetadataManager.getToneMetadataObjectForApi(req.libraryItem))
res.json(this.audioMetadataManager.getMetadataObjectForApi(req.libraryItem))
}
// POST: api/items/:id/chapters

View File

@ -1,4 +1,3 @@
const Path = require('path')
const fs = require('../libs/fsExtra')
@ -7,7 +6,7 @@ const Logger = require('../Logger')
const TaskManager = require('./TaskManager')
const Task = require('../objects/Task')
const { writeConcatFile } = require('../utils/ffmpegHelpers')
const toneHelpers = require('../utils/toneHelpers')
const ffmpegHelpers = require('../utils/ffmpegHelpers')
class AbMergeManager {
constructor() {
@ -17,7 +16,7 @@ class AbMergeManager {
}
getPendingTaskByLibraryItemId(libraryItemId) {
return this.pendingTasks.find(t => t.task.data.libraryItemId === libraryItemId)
return this.pendingTasks.find((t) => t.task.data.libraryItemId === libraryItemId)
}
cancelEncode(task) {
@ -31,23 +30,27 @@ class AbMergeManager {
const targetFilename = audiobookDirname + '.m4b'
const itemCachePath = Path.join(this.itemsCacheDir, libraryItem.id)
const tempFilepath = Path.join(itemCachePath, targetFilename)
const ffmetadataPath = Path.join(itemCachePath, 'ffmetadata.txt')
const taskData = {
libraryItemId: libraryItem.id,
libraryItemPath: libraryItem.path,
userId: user.id,
originalTrackPaths: libraryItem.media.tracks.map(t => t.metadata.path),
originalTrackPaths: libraryItem.media.tracks.map((t) => t.metadata.path),
tempFilepath,
targetFilename,
targetFilepath: Path.join(libraryItem.path, targetFilename),
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.`
task.setData('encode-m4b', 'Encoding M4b', taskDescription, false, taskData)
TaskManager.addTask(task)
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)
}
@ -55,6 +58,15 @@ class AbMergeManager {
}
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 audioCodec = encodingOptions.codec || 'aac'
const audioChannels = encodingOptions.channels || 2
@ -90,12 +102,7 @@ class AbMergeManager {
const ffmpegOutputOptions = ['-f mp4']
if (audioRequiresEncode) {
ffmpegOptions = ffmpegOptions.concat([
'-map 0:a',
`-acodec ${audioCodec}`,
`-ac ${audioChannels}`,
`-b:a ${audioBitrate}`
])
ffmpegOptions = ffmpegOptions.concat(['-map 0:a', `-acodec ${audioCodec}`, `-ac ${audioChannels}`, `-b:a ${audioBitrate}`])
} else {
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 = {
inputs: ffmpegInputs,
options: ffmpegOptions,
@ -162,7 +151,7 @@ class AbMergeManager {
async sendResult(task, result) {
// 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) {
task.setFailed('Ffmpeg task killed')
@ -177,7 +166,7 @@ class AbMergeManager {
}
// 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) {
Logger.error(`[AbMergeManager] Failed to write metadata to file "${task.data.tempFilepath}"`)
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}`)
await fs.move(task.data.tempFilepath, task.data.targetFilepath)
// Remove ffmetadata file
await fs.remove(task.data.ffmetadataPath)
task.setFinished()
await this.removeTask(task, false)
Logger.info(`[AbMergeManager] Ab task finished ${task.id}`)
@ -207,9 +199,9 @@ class AbMergeManager {
async removeTask(task, removeTempFilepath = false) {
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) {
this.pendingTasks = this.pendingTasks.filter(d => d.id !== task.id)
this.pendingTasks = this.pendingTasks.filter((d) => d.id !== task.id)
if (pendingDl.worker) {
Logger.warn(`[AbMergeManager] Removing download in progress - stopping worker`)
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)) {
await fs.remove(task.data.tempFilepath).then(() => {
Logger.info('[AbMergeManager] Deleted target file', task.data.tempFilepath)
}).catch((err) => {
Logger.error('[AbMergeManager] Failed to delete target file', err)
})
await fs
.remove(task.data.tempFilepath)
.then(() => {
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)
})
}
}

View File

@ -5,7 +5,7 @@ const Logger = require('../Logger')
const fs = require('../libs/fsExtra')
const toneHelpers = require('../utils/toneHelpers')
const ffmpegHelpers = require('../utils/ffmpegHelpers')
const TaskManager = require('./TaskManager')
@ -21,22 +21,19 @@ class AudioMetadataMangaer {
}
/**
* Get queued task data
* @return {Array}
*/
* Get queued task data
* @return {Array}
*/
getQueuedTaskData() {
return this.tasksQueued.map(t => t.data)
return this.tasksQueued.map((t) => t.data)
}
getIsLibraryItemQueuedOrProcessing(libraryItemId) {
return this.tasksQueued.some(t => t.data.libraryItemId === libraryItemId) || this.tasksRunning.some(t => t.data.libraryItemId === libraryItemId)
return this.tasksQueued.some((t) => t.data.libraryItemId === libraryItemId) || this.tasksRunning.some((t) => t.data.libraryItemId === libraryItemId)
}
getToneMetadataObjectForApi(libraryItem) {
const audioFiles = libraryItem.media.includedAudioFiles
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)
getMetadataObjectForApi(libraryItem) {
return ffmpegHelpers.getFFMetadataObject(libraryItem, libraryItem.media.includedAudioFiles.length)
}
handleBatchEmbed(user, libraryItems, options = {}) {
@ -56,29 +53,28 @@ class AudioMetadataMangaer {
const itemCachePath = Path.join(this.itemsCacheDir, libraryItem.id)
// Only writing chapters for single file audiobooks
const chapters = (audioFiles.length == 1 || forceEmbedChapters) ? libraryItem.media.chapters.map(c => ({ ...c })) : null
const chapters = audioFiles.length == 1 || forceEmbedChapters ? libraryItem.media.chapters.map((c) => ({ ...c })) : null
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
const taskData = {
libraryItemId: libraryItem.id,
libraryItemPath: libraryItem.path,
userId: user.id,
audioFiles: audioFiles.map(af => (
{
index: af.index,
ino: af.ino,
filename: af.metadata.filename,
path: af.metadata.path,
cachePath: Path.join(itemCachePath, af.metadata.filename)
}
)),
audioFiles: audioFiles.map((af) => ({
index: af.index,
ino: af.ino,
filename: af.metadata.filename,
path: af.metadata.path,
cachePath: Path.join(itemCachePath, af.metadata.filename)
})),
coverPath: libraryItem.media.coverPath,
metadataObject: toneHelpers.getToneMetadataObject(libraryItem, chapters, audioFiles.length, mimeType),
metadataObject: ffmpegHelpers.getFFMetadataObject(libraryItem, audioFiles.length),
itemCachePath,
chapters,
mimeType,
options: {
forceEmbedChapters,
backupFiles
@ -107,18 +103,17 @@ class AudioMetadataMangaer {
// Ensure item cache dir exists
let cacheDirCreated = false
if (!await fs.pathExists(task.data.itemCachePath)) {
if (!(await fs.pathExists(task.data.itemCachePath))) {
await fs.mkdir(task.data.itemCachePath)
cacheDirCreated = true
}
// Create metadata json file
const toneJsonPath = Path.join(task.data.itemCachePath, 'metadata.json')
try {
await fs.writeFile(toneJsonPath, JSON.stringify({ meta: task.data.metadataObject }, null, 2))
} catch (error) {
Logger.error(`[AudioMetadataManager] Write metadata.json failed`, error)
task.setFailed('Failed to write metadata.json')
// Create ffmetadata file
const ffmetadataPath = Path.join(task.data.itemCachePath, 'ffmetadata.txt')
const success = await ffmpegHelpers.writeFFMetadataFile(task.data.metadataObject, task.data.chapters, ffmetadataPath)
if (!success) {
Logger.error(`[AudioMetadataManager] Failed to write ffmetadata file for audiobook "${task.data.libraryItemId}"`)
task.setFailed('Failed to write metadata file.')
this.handleTaskFinished(task)
return
}
@ -141,16 +136,7 @@ class AudioMetadataMangaer {
}
}
const _toneMetadataObject = {
'ToneJsonFile': toneJsonPath,
'TrackNumber': af.index,
}
if (task.data.coverPath) {
_toneMetadataObject['CoverFile'] = task.data.coverPath
}
const success = await toneHelpers.tagAudioFile(af.path, _toneMetadataObject)
const success = await ffmpegHelpers.addCoverAndMetadataToFile(af.path, task.data.coverPath, ffmetadataPath, af.index, task.data.mimeType)
if (success) {
Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${af.path}"`)
}
@ -167,7 +153,7 @@ class AudioMetadataMangaer {
if (cacheDirCreated) {
await fs.remove(task.data.itemCachePath)
} else {
await fs.remove(toneJsonPath)
await fs.remove(ffmetadataPath)
}
}
@ -177,7 +163,7 @@ class AudioMetadataMangaer {
handleTaskFinished(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) {
Logger.info(`[AudioMetadataManager] Task finished and dequeueing next task. ${this.tasksQueued} tasks queued.`)

View File

@ -29,6 +29,10 @@ class BackupManager {
return global.ServerSettings.backupPath
}
get backupPathEnvSet() {
return !!process.env.BACKUP_PATH
}
get backupSchedule() {
return global.ServerSettings.backupSchedule
}

View File

@ -1,5 +1,6 @@
const Database = require('../Database')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
/**
* @typedef OpenMediaItemShareObject
@ -136,6 +137,7 @@ class ShareManager {
} else {
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.openSharePlaybackSessions = this.openSharePlaybackSessions.filter((s) => s.mediaItemShareId !== mediaItemShareId)
await this.destroyMediaItemShare(mediaItemShareId)
const mediaItemShareObjectForClient = { ...mediaItemShare.mediaItemShare }
delete mediaItemShareObjectForClient.pash
delete mediaItemShareObjectForClient.userId
delete mediaItemShareObjectForClient.extraData
SocketAuthority.adminEmitter('share_closed', mediaItemShareObjectForClient)
}
/**

View File

@ -567,8 +567,8 @@ class LibraryItem extends Model {
if (li.numEpisodesIncomplete) {
oldLibraryItem.numEpisodesIncomplete = li.numEpisodesIncomplete
}
if (li.mediaType === 'book' && options.include?.includes?.('share')) {
oldLibraryItem.mediaItemShare = ShareManager.findByMediaItemId(li.mediaId)
if (li.mediaItemShare) {
oldLibraryItem.mediaItemShare = li.mediaItemShare
}
return oldLibraryItem

View File

@ -61,7 +61,7 @@ class AudioMetaTags {
// Track ID3 tag might be "3/10" or just "3"
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) {
// Fractional track numbers not supported
data.number = !isNaN(trackParts[0]) ? Math.trunc(trackParts[0]) : null
@ -81,7 +81,7 @@ class AudioMetaTags {
}
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) {
data.number = !isNaN(discParts[0]) ? Math.trunc(discParts[0]) : null
}
@ -93,10 +93,18 @@ class AudioMetaTags {
return data
}
get discNumber() { return this.discNumAndTotal.number }
get discTotal() { return this.discNumAndTotal.total }
get trackNumber() { return this.trackNumAndTotal.number }
get trackTotal() { return this.trackNumAndTotal.total }
get discNumber() {
return this.discNumAndTotal.number
}
get discTotal() {
return this.discNumAndTotal.total
}
get trackNumber() {
return this.trackNumAndTotal.number
}
get trackTotal() {
return this.trackNumAndTotal.total
}
construct(metadata) {
this.tagAlbum = metadata.tagAlbum || null
@ -177,10 +185,6 @@ class AudioMetaTags {
this.tagMusicBrainzArtistId = payload.file_tag_musicbrainz_artistid || null
}
setDataFromTone(tags) {
// TODO: Implement
}
updateData(payload) {
const dataMap = {
tagAlbum: payload.file_tag_album || null,
@ -243,4 +247,4 @@ class AudioMetaTags {
return true
}
}
module.exports = AudioMetaTags
module.exports = AudioMetaTags

View File

@ -114,7 +114,7 @@ class ApiRouter {
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.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.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))

View File

@ -63,15 +63,5 @@ class MediaProbeData {
this.audioMetaTags = new AudioMetaTags()
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

View File

@ -1,9 +1,11 @@
const axios = require('axios')
const Ffmpeg = require('../libs/fluentFfmpeg')
const fs = require('../libs/fsExtra')
const os = require('os')
const Path = require('path')
const Logger = require('../Logger')
const { filePathToPOSIX } = require('./fileUtils')
const LibraryItem = require('../objects/LibraryItem')
function escapeSingleQuotes(path) {
// return path.replace(/'/g, '\'\\\'\'')
@ -184,3 +186,183 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
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

View File

@ -332,9 +332,9 @@ module.exports = {
/**
* Get library items for book media type using filter and sort
* @param {string} libraryId
* @param {[oldUser]} user
* @param {[string]} filterGroup
* @param {[string]} filterValue
* @param {import('../../objects/user/User')} user
* @param {string|null} filterGroup
* @param {string|null} filterValue
* @param {string} sortBy
* @param {string} sortDesc
* @param {boolean} collapseseries
@ -356,7 +356,7 @@ module.exports = {
sortBy = 'media.metadata.title'
}
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
// with author names concatenated

View File

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

View File

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

View 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()
})
})