mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-01-06 06:31:19 -05:00
Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b44e36e7b | ||
|
|
db1ca08c2e | ||
|
|
557d3243c3 | ||
|
|
785942b94f | ||
|
|
3df7caa838 | ||
|
|
aef2c52630 | ||
|
|
dccad3055b | ||
|
|
c629923a80 | ||
|
|
b4f1fd5b25 | ||
|
|
267897ce74 | ||
|
|
022bf9d0ef | ||
|
|
61c759e0c4 | ||
|
|
cfb3ce0c60 | ||
|
|
72396c5a98 | ||
|
|
12f231b886 | ||
|
|
6aeed24296 | ||
|
|
d8b6e09bc0 | ||
|
|
d95975cade | ||
|
|
c4208a4690 | ||
|
|
7c7a6df6e4 | ||
|
|
791c058ef8 | ||
|
|
c847aea0a4 | ||
|
|
e56164aa5a | ||
|
|
cfb5e909a9 | ||
|
|
071444a9e7 | ||
|
|
34ac972130 | ||
|
|
97b5cf04f5 | ||
|
|
0d50d730d9 | ||
|
|
3a7fd0bcc9 | ||
|
|
f0edea5d52 | ||
|
|
9c6b07df99 | ||
|
|
caacf461ab | ||
|
|
5bdbc75522 | ||
|
|
0d3e6b1d0a | ||
|
|
a122e25cba | ||
|
|
d7b287bfed | ||
|
|
ba4f585318 | ||
|
|
3f859723a6 | ||
|
|
c820d0e62b | ||
|
|
7a47032a96 | ||
|
|
2db4dd6a40 | ||
|
|
f58e2b6dce | ||
|
|
859a53e79a | ||
|
|
ad0edc6329 | ||
|
|
002fb7a35e | ||
|
|
cc62a20a5d | ||
|
|
ec7e965dfa | ||
|
|
9c3f5406a9 | ||
|
|
f4ec6948d2 | ||
|
|
9a51c3be0f | ||
|
|
b1ee54522a | ||
|
|
c14d13440f | ||
|
|
8c84640484 | ||
|
|
0d8917ced6 | ||
|
|
a006eb489d | ||
|
|
f2941e04d3 | ||
|
|
2728546660 | ||
|
|
c8c40360ad | ||
|
|
79ab656217 | ||
|
|
5c250da388 | ||
|
|
505e0eb3a2 | ||
|
|
388444e51f | ||
|
|
08d7a9aa14 | ||
|
|
956678c08c | ||
|
|
911c854365 | ||
|
|
3c5dc17e3c | ||
|
|
e709cc4cb1 | ||
|
|
da7825e3e3 | ||
|
|
4039dc7968 | ||
|
|
e345c4cc9e | ||
|
|
a08cfa436e | ||
|
|
7207efb4da | ||
|
|
481611ff33 | ||
|
|
b67cd37a38 | ||
|
|
d2512d324a |
44
.github/workflows/integration-test.yml
vendored
Normal file
44
.github/workflows/integration-test.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Integration Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'dependabot/**' # Don't run dependabot branches, as they are already covered by pull requests
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build and test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: setup nade
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- name: install pkg
|
||||
run: npm install -g pkg
|
||||
|
||||
- name: get client dependencies
|
||||
working-directory: client
|
||||
run: npm ci
|
||||
|
||||
- name: build client
|
||||
working-directory: client
|
||||
run: npm run generate
|
||||
|
||||
- name: get server dependencies
|
||||
run: npm ci --only=production
|
||||
|
||||
- name: build binary
|
||||
run: pkg -t node18-linux-x64 -o audiobookshelf .
|
||||
|
||||
- name: run audiobookshelf
|
||||
run: |
|
||||
./audiobookshelf &
|
||||
sleep 5
|
||||
|
||||
- name: test if server is available
|
||||
run: curl -sf http://127.0.0.1:3333 | grep Audiobookshelf
|
||||
@@ -64,12 +64,22 @@
|
||||
|
||||
<div class="flex-grow hidden sm:inline-block" />
|
||||
|
||||
<!-- collapse series checkbox -->
|
||||
<ui-checkbox v-if="isLibraryPage && isBookLibrary && !isBatchSelecting" v-model="settings.collapseSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
|
||||
|
||||
<!-- library filter select -->
|
||||
<controls-library-filter-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" />
|
||||
|
||||
<!-- library sort select -->
|
||||
<controls-library-sort-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateOrder" />
|
||||
|
||||
<!-- series filter select -->
|
||||
<controls-library-filter-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesFilterBy" is-series class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesFilter" />
|
||||
|
||||
<!-- series sort select -->
|
||||
<controls-sort-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesSortBy" :descending.sync="settings.seriesSortDesc" :items="seriesSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesSort" />
|
||||
|
||||
<!-- issues page remove all button -->
|
||||
<ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn>
|
||||
</template>
|
||||
<!-- search page -->
|
||||
|
||||
@@ -86,6 +86,14 @@
|
||||
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-icons text-2xl">file_download</span>
|
||||
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonDownloadQueue }}</p>
|
||||
|
||||
<div v-show="isPodcastDownloadQueuePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'">
|
||||
<span class="material-icons text-2xl">warning</span>
|
||||
|
||||
@@ -149,6 +157,9 @@ export default {
|
||||
isMusicLibrary() {
|
||||
return this.currentLibraryMediaType === 'music'
|
||||
},
|
||||
isPodcastDownloadQueuePage() {
|
||||
return this.$route.name === 'library-library-podcast-download-queue'
|
||||
},
|
||||
isPodcastSearchPage() {
|
||||
return this.$route.name === 'library-library-podcast-search'
|
||||
},
|
||||
@@ -212,4 +223,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -11,12 +11,15 @@
|
||||
</nuxt-link>
|
||||
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
|
||||
<span class="material-icons text-sm">person</span>
|
||||
<p v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</p>
|
||||
<p v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</p>
|
||||
<p v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base">
|
||||
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
</p>
|
||||
<p v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</p>
|
||||
<div class="flex items-center">
|
||||
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div>
|
||||
<div v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</div>
|
||||
<div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base">
|
||||
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
</div>
|
||||
<div v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</div>
|
||||
<widgets-explicit-indicator :explicit="isExplicit"></widgets-explicit-indicator>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-gray-400 flex items-center">
|
||||
@@ -129,6 +132,9 @@ export default {
|
||||
isMusic() {
|
||||
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'music' : false
|
||||
},
|
||||
isExplicit() {
|
||||
return this.mediaMetadata.explicit || false
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
@@ -474,4 +480,4 @@ export default {
|
||||
#streamContainer {
|
||||
box-shadow: 0px -6px 8px #1111113f;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -28,7 +28,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="px-4 flex-grow">
|
||||
<h1>{{ book.title }}</h1>
|
||||
<h1>
|
||||
<div class="flex items-center">
|
||||
{{ book.title }}<widgets-explicit-indicator :explicit="book.explicit" />
|
||||
</div>
|
||||
</h1>
|
||||
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ book.author }}</p>
|
||||
<p v-if="book.genres" class="text-xs text-gray-400 leading-5">{{ book.genres.join(', ') }}</p>
|
||||
<p class="text-xs text-gray-400 leading-5">{{ book.trackCount }} Episodes</p>
|
||||
@@ -78,4 +82,4 @@ export default {
|
||||
this.selectedCover = this.bookCovers.length ? this.bookCovers[0] : this.book.cover || null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
85
client/components/cards/ItemTaskRunningCard.vue
Normal file
85
client/components/cards/ItemTaskRunningCard.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="flex items-center h-full px-1 overflow-hidden">
|
||||
<div class="h-5 w-5 min-w-5 text-lg mr-1.5 flex items-center justify-center">
|
||||
<span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{actionIcon}}</span>
|
||||
<widgets-loading-spinner v-else />
|
||||
</div>
|
||||
<div class="flex-grow px-2 taskRunningCardContent">
|
||||
<p class="truncate text-sm">{{ title }}</p>
|
||||
|
||||
<p class="truncate text-xs text-gray-300">{{ description }}</p>
|
||||
|
||||
<p v-if="isFailed && failedMessage" class="text-xs truncate text-red-500">{{ failedMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
task: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
title() {
|
||||
return this.task.title || 'No Title'
|
||||
},
|
||||
description() {
|
||||
return this.task.description || ''
|
||||
},
|
||||
details() {
|
||||
return this.task.details || 'Unknown'
|
||||
},
|
||||
isFinished() {
|
||||
return this.task.isFinished || false
|
||||
},
|
||||
isFailed() {
|
||||
return this.task.isFailed || false
|
||||
},
|
||||
failedMessage() {
|
||||
return this.task.error || ''
|
||||
},
|
||||
action() {
|
||||
return this.task.action || ''
|
||||
},
|
||||
actionIcon() {
|
||||
switch (this.action) {
|
||||
case 'download-podcast-episode':
|
||||
return 'cloud_download'
|
||||
case 'encode-m4b':
|
||||
return 'sync'
|
||||
default:
|
||||
return 'settings'
|
||||
}
|
||||
},
|
||||
taskIconStatus() {
|
||||
if (this.isFinished && this.isFailed) {
|
||||
return 'text-red-500'
|
||||
}
|
||||
if (this.isFinished && !this.isFailed) {
|
||||
return 'text-green-500'
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.taskRunningCardContent {
|
||||
width: calc(100% - 80px);
|
||||
height: 75px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -7,9 +7,12 @@
|
||||
|
||||
<!-- Alternative bookshelf title/author/sort -->
|
||||
<div v-if="isAlternativeBookshelfView || isAuthorBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||
{{ displayTitle }}
|
||||
</p>
|
||||
<div :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||
<div class="flex items-center">
|
||||
<span class="truncate">{{ displayTitle }}</span>
|
||||
<widgets-explicit-indicator :explicit="isExplicit" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || ' ' }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
</div>
|
||||
@@ -193,6 +196,9 @@ export default {
|
||||
isMusic() {
|
||||
return this.mediaType === 'music'
|
||||
},
|
||||
isExplicit() {
|
||||
return this.mediaMetadata.explicit || false
|
||||
},
|
||||
placeholderUrl() {
|
||||
const config = this.$config || this.$nuxt.$config
|
||||
return `${config.routerBasePath}/book_placeholder.jpg`
|
||||
@@ -734,7 +740,7 @@ export default {
|
||||
episodeId: this.recentEpisode.id,
|
||||
title: this.recentEpisode.title,
|
||||
subtitle: this.mediaMetadata.title,
|
||||
caption: this.recentEpisode.publishedAt ? `Published ${this.$formatDate(this.recentEpisode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
caption: this.recentEpisode.publishedAt ? `Published ${this.$formatDate(this.recentEpisode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||
duration: this.recentEpisode.audioFile.duration || null,
|
||||
coverPath: this.media.coverPath || null
|
||||
}
|
||||
@@ -858,7 +864,7 @@ export default {
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: this.mediaMetadata.title,
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||
duration: episode.audioFile.duration || null,
|
||||
coverPath: this.media.coverPath || null
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative ml-4 sm:ml-8" v-click-outside="clickOutside">
|
||||
<div class="flex items-center justify-center text-gray-300 cursor-pointer h-full" @mousedown.prevent @mouseup.prevent @click="setShowMenu(true)">
|
||||
<span class="font-mono uppercase text-gray-200 text-sm sm:text-base">{{ playbackRate.toFixed(1) }}<span class="text-base sm:text-lg">⨯</span></span>
|
||||
<span class="font-mono uppercase text-gray-200 text-sm sm:text-base">{{ playbackRate.toFixed(1) }}<span class="text-base">x</span></span>
|
||||
</div>
|
||||
<div v-show="showMenu" class="absolute -top-20 z-20 bg-bg border-black-200 border shadow-xl rounded-lg" :style="{ left: menuLeft + 'px' }">
|
||||
<div class="absolute -bottom-1.5 right-0 w-full flex justify-center" :style="{ left: arrowLeft + 'px' }">
|
||||
@@ -11,7 +11,7 @@
|
||||
<template v-for="rate in rates">
|
||||
<div :key="rate" class="h-full border-black-300 w-11 cursor-pointer border rounded-sm" :class="value === rate ? 'bg-black-100' : 'hover:bg-black hover:bg-opacity-10'" style="min-width: 44px; max-width: 44px" @click="set(rate)">
|
||||
<div class="w-full h-full flex justify-center items-center">
|
||||
<p class="text-xs text-center font-mono">{{ rate }}<span class="text-sm">⨯</span></p>
|
||||
<p class="text-xs text-center font-mono">{{ rate }}<span class="text-sm">x</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -19,7 +19,7 @@
|
||||
<div class="w-full py-1 px-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<ui-icon-btn :disabled="!canDecrement" icon="remove" @click="decrement" />
|
||||
<p class="px-2 text-2xl sm:text-3xl">{{ playbackRate }}<span class="text-2xl">⨯</span></p>
|
||||
<p class="px-2 text-2xl sm:text-3xl">{{ playbackRate }}<span class="text-2xl">x</span></p>
|
||||
<ui-icon-btn :disabled="!canIncrement" icon="add" @click="increment" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -73,6 +73,12 @@ export default {
|
||||
},
|
||||
canCreateBookmark() {
|
||||
return !this.bookmarks.find((bm) => bm.time === this.currentTime)
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -111,7 +117,7 @@ export default {
|
||||
},
|
||||
submitCreateBookmark() {
|
||||
if (!this.newBookmarkTitle) {
|
||||
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
||||
this.newBookmarkTitle = this.$formatDatetime(Date.now(), this.dateFormat, this.timeFormat)
|
||||
}
|
||||
var bookmark = {
|
||||
title: this.newBookmarkTitle,
|
||||
@@ -134,4 +140,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -19,13 +19,13 @@
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelStartedAt }}</div>
|
||||
<div class="px-1">
|
||||
{{ $formatDate(_session.startedAt, 'MMMM do, yyyy HH:mm') }}
|
||||
{{ $formatDatetime(_session.startedAt, dateFormat, timeFormat) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelUpdatedAt }}</div>
|
||||
<div class="px-1">
|
||||
{{ $formatDate(_session.updatedAt, 'MMMM do, yyyy HH:mm') }}
|
||||
{{ $formatDatetime(_session.updatedAt, dateFormat, timeFormat) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
@@ -151,6 +151,12 @@ export default {
|
||||
else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'
|
||||
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
|
||||
return 'Unknown'
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -186,4 +192,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -164,6 +164,13 @@
|
||||
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.releaseDate || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.explicit != null" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.explicit" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-checkbox v-model="selectedMatch.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
<p v-if="mediaMetadata.explicit != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.explicit ? 'Explicit (checked)' : 'Not Explicit (unchecked)' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end py-2">
|
||||
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
@@ -327,6 +334,7 @@ export default {
|
||||
res.itunesPageUrl = res.pageUrl || null
|
||||
res.itunesId = res.id || null
|
||||
res.author = res.artistName || null
|
||||
res.explicit = res.explicit || false
|
||||
return res
|
||||
})
|
||||
}
|
||||
|
||||
@@ -59,6 +59,14 @@ export default {
|
||||
newMaxNewEpisodesToDownload: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
libraryItem: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isProcessing: {
|
||||
get() {
|
||||
@@ -176,4 +184,4 @@ export default {
|
||||
height: calc(100% - 80px);
|
||||
max-height: calc(100% - 80px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -11,8 +11,15 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-show="canGoPrev" class="absolute -left-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
|
||||
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goPrevEpisode" @mousedown.prevent>arrow_back_ios</div>
|
||||
</div>
|
||||
<div v-show="canGoNext" class="absolute -right-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
|
||||
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextEpisode" @mousedown.prevent>arrow_forward_ios</div>
|
||||
</div>
|
||||
|
||||
<div ref="wrapper" class="p-4 w-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh">
|
||||
<component v-if="libraryItem && show" :is="tabComponentName" :library-item="libraryItem" :episode="episode" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
|
||||
<component v-if="libraryItem && show" :is="tabComponentName" :library-item="libraryItem" :episode="episodeItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
@@ -21,8 +28,8 @@
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
episodeItem: null,
|
||||
processing: false,
|
||||
selectedTab: 'details',
|
||||
tabs: [
|
||||
{
|
||||
id: 'details',
|
||||
@@ -37,6 +44,29 @@ export default {
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
const availableTabIds = this.tabs.map((tab) => tab.id)
|
||||
if (!availableTabIds.length) {
|
||||
this.show = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!availableTabIds.includes(this.selectedTab)) {
|
||||
this.selectedTab = availableTabIds[0]
|
||||
}
|
||||
|
||||
this.episodeItem = null
|
||||
this.init()
|
||||
this.registerListeners()
|
||||
} else {
|
||||
this.unregisterListeners()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
@@ -46,27 +76,118 @@ export default {
|
||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', val)
|
||||
}
|
||||
},
|
||||
selectedTab: {
|
||||
get() {
|
||||
return this.$store.state.editPodcastModalTab
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('setEditPodcastModalTab', val)
|
||||
}
|
||||
},
|
||||
libraryItem() {
|
||||
return this.$store.state.selectedLibraryItem
|
||||
},
|
||||
episode() {
|
||||
return this.$store.state.globals.selectedEpisode
|
||||
},
|
||||
selectedEpisodeId() {
|
||||
return this.episode.id
|
||||
},
|
||||
title() {
|
||||
if (!this.libraryItem) return ''
|
||||
return this.libraryItem.media.metadata.title || 'Unknown'
|
||||
return this.libraryItem?.media.metadata.title || 'Unknown'
|
||||
},
|
||||
tabComponentName() {
|
||||
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||
const _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||
return _tab ? _tab.component : ''
|
||||
},
|
||||
episodeTableEpisodeIds() {
|
||||
return this.$store.state.episodeTableEpisodeIds || []
|
||||
},
|
||||
currentEpisodeIndex() {
|
||||
if (!this.episodeTableEpisodeIds.length) return 0
|
||||
return this.episodeTableEpisodeIds.findIndex((bid) => bid === this.selectedEpisodeId)
|
||||
},
|
||||
canGoPrev() {
|
||||
return this.episodeTableEpisodeIds.length && this.currentEpisodeIndex > 0
|
||||
},
|
||||
canGoNext() {
|
||||
return this.episodeTableEpisodeIds.length && this.currentEpisodeIndex < this.episodeTableEpisodeIds.length - 1
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async goPrevEpisode() {
|
||||
if (this.currentEpisodeIndex - 1 < 0) return
|
||||
const prevEpisodeId = this.episodeTableEpisodeIds[this.currentEpisodeIndex - 1]
|
||||
this.processing = true
|
||||
const prevEpisode = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${prevEpisodeId}`).catch((error) => {
|
||||
const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch episode'
|
||||
this.$toast.error(errorMsg)
|
||||
return null
|
||||
})
|
||||
this.processing = false
|
||||
if (prevEpisode) {
|
||||
this.episodeItem = prevEpisode
|
||||
this.$store.commit('globals/setSelectedEpisode', prevEpisode)
|
||||
} else {
|
||||
console.error('Episode not found', prevEpisodeId)
|
||||
}
|
||||
},
|
||||
async goNextEpisode() {
|
||||
if (this.currentEpisodeIndex >= this.episodeTableEpisodeIds.length - 1) return
|
||||
this.processing = true
|
||||
const nextEpisodeId = this.episodeTableEpisodeIds[this.currentEpisodeIndex + 1]
|
||||
const nextEpisode = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${nextEpisodeId}`).catch((error) => {
|
||||
const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
|
||||
this.$toast.error(errorMsg)
|
||||
return null
|
||||
})
|
||||
this.processing = false
|
||||
if (nextEpisode) {
|
||||
this.episodeItem = nextEpisode
|
||||
this.$store.commit('globals/setSelectedEpisode', nextEpisode)
|
||||
} else {
|
||||
console.error('Episode not found', nextEpisodeId)
|
||||
}
|
||||
},
|
||||
selectTab(tab) {
|
||||
this.selectedTab = tab
|
||||
if (this.selectedTab === tab) return
|
||||
if (this.tabs.find((t) => t.id === tab)) {
|
||||
this.selectedTab = tab
|
||||
this.processing = false
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.fetchFull()
|
||||
},
|
||||
async fetchFull() {
|
||||
try {
|
||||
this.processing = true
|
||||
this.episodeItem = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${this.selectedEpisodeId}`)
|
||||
this.processing = false
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch episode', this.selectedEpisodeId, error)
|
||||
this.processing = false
|
||||
this.show = false
|
||||
}
|
||||
},
|
||||
hotkey(action) {
|
||||
if (action === this.$hotkeys.Modal.NEXT_PAGE) {
|
||||
this.goNextEpisode()
|
||||
} else if (action === this.$hotkeys.Modal.PREV_PAGE) {
|
||||
this.goPrevEpisode()
|
||||
}
|
||||
},
|
||||
registerListeners() {
|
||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||
},
|
||||
unregisterListeners() {
|
||||
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {},
|
||||
beforeDestroy() {
|
||||
this.unregisterListeners()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -77,4 +198,4 @@ export default {
|
||||
.tab.tab-selected {
|
||||
height: 41px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -19,8 +19,15 @@
|
||||
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
|
||||
</div>
|
||||
<div class="px-8 py-2">
|
||||
<p v-if="episode.episode" class="font-semibold text-gray-200">#{{ episode.episode }}</p>
|
||||
<p class="break-words mb-1">{{ episode.title }}</p>
|
||||
<div class="flex items-center font-semibold text-gray-200">
|
||||
<div v-if="episode.season || episode.episode">#</div>
|
||||
<div v-if="episode.season">{{ episode.season }}x</div>
|
||||
<div v-if="episode.episode">{{ episode.episode }}</div>
|
||||
</div>
|
||||
<div class="flex items-center mb-1">
|
||||
<div class="break-words">{{ episode.title }}</div>
|
||||
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
||||
</div>
|
||||
<p v-if="episode.subtitle" class="break-words mb-1 text-sm text-gray-300 episode-subtitle">{{ episode.subtitle }}</p>
|
||||
<p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,17 @@
|
||||
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" :label="$strings.LabelGenres" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap">
|
||||
<div class="md:w-1/4 p-2">
|
||||
<ui-dropdown :label="$strings.LabelPodcastType" v-model="podcast.type" :items="podcastTypes" small />
|
||||
</div>
|
||||
<div class="md:w-1/4 p-2">
|
||||
<ui-text-input-with-label v-model="podcast.language" :label="$strings.LabelLanguage" />
|
||||
</div>
|
||||
<div class="md:w-1/4 px-2 pt-7">
|
||||
<ui-checkbox v-model="podcast.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 w-full">
|
||||
<ui-textarea-with-label v-model="podcast.description" :label="$strings.LabelDescription" :rows="3" />
|
||||
</div>
|
||||
@@ -82,7 +93,10 @@ export default {
|
||||
itunesPageUrl: '',
|
||||
itunesId: '',
|
||||
itunesArtistId: '',
|
||||
autoDownloadEpisodes: false
|
||||
autoDownloadEpisodes: false,
|
||||
language: '',
|
||||
explicit: false,
|
||||
type: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -140,6 +154,9 @@ export default {
|
||||
selectedFolderPath() {
|
||||
if (!this.selectedFolder) return ''
|
||||
return this.selectedFolder.fullPath
|
||||
},
|
||||
podcastTypes() {
|
||||
return this.$store.state.globals.podcastTypes || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -170,7 +187,9 @@ export default {
|
||||
itunesPageUrl: this.podcast.itunesPageUrl,
|
||||
itunesId: this.podcast.itunesId,
|
||||
itunesArtistId: this.podcast.itunesArtistId,
|
||||
language: this.podcast.language
|
||||
language: this.podcast.language,
|
||||
explicit: this.podcast.explicit,
|
||||
type: this.podcast.type
|
||||
},
|
||||
autoDownloadEpisodes: this.podcast.autoDownloadEpisodes
|
||||
}
|
||||
@@ -205,9 +224,11 @@ export default {
|
||||
this.podcast.itunesPageUrl = this._podcastData.pageUrl || ''
|
||||
this.podcast.itunesId = this._podcastData.id || ''
|
||||
this.podcast.itunesArtistId = this._podcastData.artistId || ''
|
||||
this.podcast.language = this._podcastData.language || ''
|
||||
this.podcast.language = this._podcastData.language || this.feedMetadata.language || ''
|
||||
this.podcast.autoDownloadEpisodes = false
|
||||
this.podcast.type = this._podcastData.type || this.feedMetadata.type || 'episodic'
|
||||
|
||||
this.podcast.explicit = this._podcastData.explicit || this.feedMetadata.explicit === 'yes' || this.feedMetadata.explicit == 'true'
|
||||
if (this.folderItems[0]) {
|
||||
this.selectedFolderId = this.folderItems[0].value
|
||||
this.folderUpdated()
|
||||
@@ -226,4 +247,4 @@ export default {
|
||||
#episodes-scroll {
|
||||
max-height: calc(80vh - 200px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<ui-text-input-with-label v-model="newEpisode.episode" :label="$strings.LabelEpisode" />
|
||||
</div>
|
||||
<div class="w-1/5 p-1">
|
||||
<ui-text-input-with-label v-model="newEpisode.episodeType" :label="$strings.LabelEpisodeType" />
|
||||
<ui-dropdown v-model="newEpisode.episodeType" :label="$strings.LabelEpisodeType" :items="episodeTypes" small />
|
||||
</div>
|
||||
<div class="w-2/5 p-1">
|
||||
<ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" :label="$strings.LabelPubDate" />
|
||||
@@ -24,7 +24,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end pt-4">
|
||||
<ui-btn @click="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
<!-- desktop -->
|
||||
<ui-btn @click="submit" class="mx-2 hidden md:block">{{ $strings.ButtonSave }}</ui-btn>
|
||||
<ui-btn @click="saveAndClose" class="mx-2 hidden md:block">{{ $strings.ButtonSaveAndClose }}</ui-btn>
|
||||
|
||||
<!-- mobile -->
|
||||
<ui-btn @click="saveAndClose" class="mx-2 md:hidden">{{ $strings.ButtonSave }}</ui-btn>
|
||||
</div>
|
||||
<div v-if="enclosureUrl" class="py-4">
|
||||
<p class="text-xs text-gray-300 font-semibold">Episode URL from RSS feed</p>
|
||||
@@ -89,6 +94,9 @@ export default {
|
||||
},
|
||||
enclosureUrl() {
|
||||
return this.enclosure.url
|
||||
},
|
||||
episodeTypes() {
|
||||
return this.$store.state.globals.episodeTypes || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -122,28 +130,43 @@ export default {
|
||||
}
|
||||
return updatePayload
|
||||
},
|
||||
submit() {
|
||||
const payload = this.getUpdatePayload()
|
||||
if (!Object.keys(payload).length) {
|
||||
return this.$toast.info('No updates were made')
|
||||
async saveAndClose() {
|
||||
const wasUpdated = await this.submit()
|
||||
if (wasUpdated !== null) this.$emit('close')
|
||||
},
|
||||
async submit() {
|
||||
if (this.isProcessing) {
|
||||
return null
|
||||
}
|
||||
|
||||
const updatedDetails = this.getUpdatePayload()
|
||||
if (!Object.keys(updatedDetails).length) {
|
||||
this.$toast.info('No changes were made')
|
||||
return false
|
||||
}
|
||||
return this.updateDetails(updatedDetails)
|
||||
},
|
||||
async updateDetails(updatedDetails) {
|
||||
this.isProcessing = true
|
||||
this.$axios
|
||||
.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, payload)
|
||||
.then(() => {
|
||||
this.isProcessing = false
|
||||
const updateResult = await this.$axios.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, updatedDetails).catch((error) => {
|
||||
console.error('Failed update episode', error)
|
||||
this.isProcessing = false
|
||||
this.$toast.error(error?.response?.data || 'Failed to update episode')
|
||||
return false
|
||||
})
|
||||
|
||||
this.isProcessing = false
|
||||
if (updateResult) {
|
||||
if (updateResult) {
|
||||
this.$toast.success('Podcast episode updated')
|
||||
this.$emit('close')
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to update episode'
|
||||
console.error('Failed update episode', error)
|
||||
this.isProcessing = false
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
return true
|
||||
} else {
|
||||
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -14,6 +14,21 @@
|
||||
|
||||
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(currentFeed.feedUrl)">content_copy</span>
|
||||
</div>
|
||||
|
||||
<div v-if="currentFeed.meta" class="mt-5">
|
||||
<div class="flex py-0.5">
|
||||
<div class="w-48"><span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRssFeedPreventIndexing }}</span></div>
|
||||
<div> {{ currentFeed.meta.preventIndexing ? 'Yes' : 'No' }} </div>
|
||||
</div>
|
||||
<div v-if="currentFeed.meta.ownerName" class="flex py-0.5">
|
||||
<div class="w-48"><span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRssFeedCustomOwnerName }}</span></div>
|
||||
<div> {{ currentFeed.meta.ownerName }} </div>
|
||||
</div>
|
||||
<div v-if="currentFeed.meta.ownerEmail" class="flex py-0.5">
|
||||
<div class="w-48"><span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRssFeedCustomOwnerEmail }}</span></div>
|
||||
<div> {{ currentFeed.meta.ownerEmail }} </div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full">
|
||||
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderOpenRSSFeed }}</p>
|
||||
@@ -22,6 +37,7 @@
|
||||
<ui-text-input-with-label v-model="newFeedSlug" :label="$strings.LabelRSSFeedSlug" />
|
||||
<p class="text-xs text-gray-400 py-0.5 px-1">{{ $getString('MessageFeedURLWillBe', [demoFeedUrl]) }}</p>
|
||||
</div>
|
||||
<widgets-rss-feed-metadata-builder v-model="metadataDetails" />
|
||||
|
||||
<p v-if="isHttp" class="w-full pt-2 text-warning text-xs">{{ $strings.NoteRSSFeedPodcastAppsHttps }}</p>
|
||||
<p v-if="hasEpisodesWithoutPubDate" class="w-full pt-2 text-warning text-xs">{{ $strings.NoteRSSFeedPodcastAppsPubDate }}</p>
|
||||
@@ -41,7 +57,12 @@ export default {
|
||||
return {
|
||||
processing: false,
|
||||
newFeedSlug: null,
|
||||
currentFeed: null
|
||||
currentFeed: null,
|
||||
metadataDetails: {
|
||||
preventIndexing: true,
|
||||
ownerName: '',
|
||||
ownerEmail: ''
|
||||
},
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -107,7 +128,8 @@ export default {
|
||||
|
||||
const payload = {
|
||||
serverAddress: window.origin,
|
||||
slug: this.newFeedSlug
|
||||
slug: this.newFeedSlug,
|
||||
metadataDetails: this.metadataDetails
|
||||
}
|
||||
if (this.$isDev) payload.serverAddress = `http://localhost:3333${this.$config.routerBasePath}`
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<td>
|
||||
<p class="truncate text-xs sm:text-sm md:text-base">/{{ backup.path.replace(/\\/g, '/') }}</p>
|
||||
</td>
|
||||
<td class="hidden sm:table-cell font-sans text-sm">{{ backup.datePretty }}</td>
|
||||
<td class="hidden sm:table-cell font-sans text-sm">{{ $formatDatetime(backup.createdAt, dateFormat, timeFormat) }}</td>
|
||||
<td class="hidden sm:table-cell font-mono md:text-sm text-xs">{{ $bytesPretty(backup.fileSize) }}</td>
|
||||
<td>
|
||||
<div class="w-full flex flex-row items-center justify-center">
|
||||
@@ -46,7 +46,7 @@
|
||||
<p class="text-error text-lg font-semibold">{{ $strings.MessageImportantNotice }}</p>
|
||||
<p class="text-base py-1" v-html="$strings.MessageRestoreBackupWarning" />
|
||||
|
||||
<p class="text-lg text-center my-8">{{ $strings.MessageRestoreBackupConfirm }} {{ selectedBackup.datePretty }}?</p>
|
||||
<p class="text-lg text-center my-8">{{ $strings.MessageRestoreBackupConfirm }} {{ $formatDatetime(selectedBackup.createdAt, dateFormat, timeFormat) }}?</p>
|
||||
<div class="flex px-1 items-center">
|
||||
<ui-btn color="primary" @click="showConfirmApply = false">{{ $strings.ButtonNevermind }}</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
@@ -71,6 +71,12 @@ export default {
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -90,7 +96,7 @@ export default {
|
||||
})
|
||||
},
|
||||
deleteBackupClick(backup) {
|
||||
if (confirm(this.$getString('MessageConfirmDeleteBackup', [backup.datePretty]))) {
|
||||
if (confirm(this.$getString('MessageConfirmDeleteBackup', [this.$formatDatetime(backup.createdAt, this.dateFormat, this.timeFormat)]))) {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/backups/${backup.id}`)
|
||||
@@ -208,4 +214,4 @@ export default {
|
||||
padding-bottom: 5px;
|
||||
background-color: #333;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -25,13 +25,13 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-xs font-mono hidden sm:table-cell">
|
||||
<ui-tooltip v-if="user.lastSeen" direction="top" :text="$formatDate(user.lastSeen, 'MMMM do, yyyy HH:mm')">
|
||||
<ui-tooltip v-if="user.lastSeen" direction="top" :text="$formatDatetime(user.lastSeen, dateFormat, timeFormat)">
|
||||
{{ $dateDistanceFromNow(user.lastSeen) }}
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
<td class="text-xs font-mono hidden sm:table-cell">
|
||||
<ui-tooltip direction="top" :text="$formatDate(user.createdAt, 'MMMM do, yyyy HH:mm')">
|
||||
{{ $formatDate(user.createdAt, 'MMM d, yyyy') }}
|
||||
<ui-tooltip direction="top" :text="$formatDatetime(user.createdAt, dateFormat, timeFormat)">
|
||||
{{ $formatDate(user.createdAt, dateFormat) }}
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
<td class="py-0">
|
||||
@@ -74,6 +74,12 @@ export default {
|
||||
var usermap = {}
|
||||
this.$store.state.users.usersOnline.forEach((u) => (usermap[u.id] = u))
|
||||
return usermap
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -201,4 +207,4 @@ export default {
|
||||
padding-bottom: 5px;
|
||||
background-color: #272727;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
65
client/components/tables/podcast/DownloadQueueTable.vue
Normal file
65
client/components/tables/podcast/DownloadQueueTable.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="w-full my-2">
|
||||
<div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center">
|
||||
<p class="pr-2 md:pr-4">{{ $strings.HeaderDownloadQueue }}</p>
|
||||
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||
<span class="text-sm font-mono">{{ queue.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="slide">
|
||||
<div class="w-full">
|
||||
<table class="text-sm tracksTable">
|
||||
<tr>
|
||||
<th class="text-left px-4 min-w-48">{{ $strings.LabelPodcast }}</th>
|
||||
<th class="text-left w-32 min-w-32">{{ $strings.LabelEpisode }}</th>
|
||||
<th class="text-left px-4">{{ $strings.LabelEpisodeTitle }}</th>
|
||||
<th class="text-left px-4 w-48">{{ $strings.LabelPubDate }}</th>
|
||||
</tr>
|
||||
<template v-for="downloadQueued in queue">
|
||||
<tr :key="downloadQueued.id">
|
||||
<td class="px-4">
|
||||
<div class="flex items-center">
|
||||
<nuxt-link :to="`/item/${downloadQueued.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ downloadQueued.podcastTitle }}</nuxt-link>
|
||||
<widgets-explicit-indicator :explicit="downloadQueued.podcastExplicit" />
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center">
|
||||
<div v-if="downloadQueued.season">{{ downloadQueued.season }}x</div>
|
||||
<div v-if="downloadQueued.episode">{{ downloadQueued.episode }}</div>
|
||||
<widgets-podcast-type-indicator :type="downloadQueued.episodeType" />
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4">
|
||||
{{ downloadQueued.episodeDisplayTitle }}
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
<div class="flex items-center">
|
||||
<p>{{ $dateDistanceFromNow(downloadQueued.publishedAt) }}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
queue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
libraryItemId: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -2,16 +2,17 @@
|
||||
<div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div v-if="episode" class="flex items-center cursor-pointer" :class="{ 'opacity-70': isSelected || selectionMode }" @click="clickedEpisode">
|
||||
<div class="flex-grow px-2">
|
||||
<p class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</p>
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm font-semibold">{{ title }}</span>
|
||||
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ subtitle }}</p>
|
||||
|
||||
<div class="flex justify-between pt-2 max-w-xl">
|
||||
<p v-if="episode.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p>
|
||||
<p v-if="episode.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p>
|
||||
<p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, 'MMM do, yyyy') }}</p>
|
||||
<p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, dateFormat) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center pt-2">
|
||||
@@ -128,6 +129,9 @@ export default {
|
||||
},
|
||||
publishedAt() {
|
||||
return this.episode.publishedAt
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -205,4 +209,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -143,6 +143,12 @@ export default {
|
||||
var itemProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
|
||||
return !itemProgress || !itemProgress.isFinished
|
||||
})
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -195,7 +201,7 @@ export default {
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: this.mediaMetadata.title,
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||
duration: episode.audioFile.duration || null,
|
||||
coverPath: this.media.coverPath || null
|
||||
}
|
||||
@@ -263,7 +269,7 @@ export default {
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: this.mediaMetadata.title,
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||
duration: episode.audioFile.duration || null,
|
||||
coverPath: this.media.coverPath || null
|
||||
})
|
||||
@@ -281,6 +287,8 @@ export default {
|
||||
this.showPodcastRemoveModal = true
|
||||
},
|
||||
editEpisode(episode) {
|
||||
const episodeIds = this.episodesSorted.map((e) => e.id)
|
||||
this.$store.commit('setEpisodeTableEpisodeIds', episodeIds)
|
||||
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||
@@ -314,4 +322,4 @@ export default {
|
||||
.episode-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -68,8 +68,6 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {
|
||||
console.log('Before destroy')
|
||||
}
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
19
client/components/widgets/AlreadyInLibraryIndicator.vue
Normal file
19
client/components/widgets/AlreadyInLibraryIndicator.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<ui-tooltip v-if="alreadyInLibrary" :text="$strings.LabelAlreadyInYourLibrary" direction="top">
|
||||
<span class="material-icons ml-1 text-success" style="font-size: 0.8rem">check_circle</span>
|
||||
</ui-tooltip>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
alreadyInLibrary: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -36,6 +36,10 @@
|
||||
<p v-else class="text-success text-base md:text-lg text-center">{{ $strings.MessageValidCronExpression }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="cronExpression && isValid" class="flex items-center justify-center text-yellow-400 mt-2">
|
||||
<span class="material-icons-outlined mr-2 text-xl">event</span>
|
||||
<p>{{ $strings.LabelNextScheduledRun }}: {{ nextRun }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -63,6 +67,14 @@ export default {
|
||||
isValid: true
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
minuteIsValid() {
|
||||
return !(isNaN(this.selectedMinute) || this.selectedMinute === '' || this.selectedMinute < 0 || this.selectedMinute > 59)
|
||||
@@ -70,6 +82,11 @@ export default {
|
||||
hourIsValid() {
|
||||
return !(isNaN(this.selectedHour) || this.selectedHour === '' || this.selectedHour < 0 || this.selectedHour > 23)
|
||||
},
|
||||
nextRun() {
|
||||
if (!this.cronExpression) return ''
|
||||
const parsed = this.$getNextScheduledDate(this.cronExpression)
|
||||
return this.$formatJsDatetime(parsed, this.$store.state.serverSettings.dateFormat, this.$store.state.serverSettings.timeFormat) || ''
|
||||
},
|
||||
description() {
|
||||
if ((this.selectedInterval !== 'custom' || !this.selectedWeekdays.length) && this.selectedInterval !== 'daily') return ''
|
||||
|
||||
@@ -271,6 +288,11 @@ export default {
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.selectedInterval = 'custom'
|
||||
this.selectedHour = 0
|
||||
this.selectedMinute = 0
|
||||
this.selectedWeekdays = []
|
||||
|
||||
if (!this.value) return
|
||||
const pieces = this.value.split(' ')
|
||||
if (pieces.length !== 5) {
|
||||
@@ -309,4 +331,4 @@ export default {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
19
client/components/widgets/ExplicitIndicator.vue
Normal file
19
client/components/widgets/ExplicitIndicator.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<ui-tooltip v-if="explicit" :text="$strings.LabelExplicit" direction="top">
|
||||
<span class="material-icons ml-1" style="font-size: 0.8rem">explicit</span>
|
||||
</ui-tooltip>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
explicit: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,15 +1,51 @@
|
||||
<template>
|
||||
<div v-if="tasksRunning" class="w-4 h-4 mx-3 relative">
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
<div v-if="tasksRunning" class="w-4 h-4 mx-3 relative" v-click-outside="clickOutsideObj">
|
||||
<button type="button" :disabled="disabled" class="w-10 sm:w-full relative h-full cursor-pointer" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<ui-tooltip text="Tasks running" direction="bottom" class="flex items-center">
|
||||
<widgets-loading-spinner />
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</button>
|
||||
<transition name="menu">
|
||||
<div class="sm:w-80 w-full relative">
|
||||
<div v-show="showMenu" class="absolute z-40 -mt-px w-40 sm:w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalTaskRunningMenu">
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-if="tasksRunningOrFailed.length">
|
||||
<p class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">{{ $strings.LabelTasks }}</p>
|
||||
<template v-for="task in tasksRunningOrFailed">
|
||||
<nuxt-link :key="task.id" v-if="actionLink(task)" :to="actionLink(task)">
|
||||
<li class="text-gray-50 select-none relative hover:bg-black-400 py-1 cursor-pointer">
|
||||
<cards-item-task-running-card :task="task" />
|
||||
</li>
|
||||
</nuxt-link>
|
||||
<li v-else :key="task.id" class="text-gray-50 select-none relative hover:bg-black-400 py-1">
|
||||
<cards-item-task-running-card :task="task" />
|
||||
</li>
|
||||
</template>
|
||||
</template>
|
||||
<li v-else class="py-2 px-2">
|
||||
<p>{{ $strings.MessageNoTasksRunning }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
clickOutsideObj: {
|
||||
handler: this.clickedOutside,
|
||||
events: ['mousedown'],
|
||||
isActive: true
|
||||
},
|
||||
showMenu: false,
|
||||
disabled: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
tasks() {
|
||||
@@ -17,9 +53,37 @@ export default {
|
||||
},
|
||||
tasksRunning() {
|
||||
return this.tasks.some((t) => !t.isFinished)
|
||||
},
|
||||
tasksRunningOrFailed() {
|
||||
// return just the tasks that are running or failed in the last 1 minute
|
||||
return this.tasks.filter((t) => !t.isFinished || (t.isFailed && t.finishedAt > new Date().getTime() - 1000 * 60)) || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickShowMenu() {
|
||||
if (this.disabled) return
|
||||
this.showMenu = !this.showMenu
|
||||
},
|
||||
clickedOutside() {
|
||||
this.showMenu = false
|
||||
},
|
||||
actionLink(task) {
|
||||
switch (task.action) {
|
||||
case 'download-podcast-episode':
|
||||
return `/library/${task.data.libraryId}/podcast/download-queue`
|
||||
case 'encode-m4b':
|
||||
return `/audiobook/${task.data.libraryItemId}/manage?tool=m4b`
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.globalTaskRunningMenu {
|
||||
max-height: 80vh;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -39,6 +39,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-dropdown :label="$strings.LabelPodcastType" v-model="details.type" :items="podcastTypes" small class="max-w-52" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -65,7 +70,8 @@ export default {
|
||||
itunesId: null,
|
||||
itunesArtistId: null,
|
||||
explicit: false,
|
||||
language: null
|
||||
language: null,
|
||||
type: null
|
||||
},
|
||||
newTags: []
|
||||
}
|
||||
@@ -93,6 +99,9 @@ export default {
|
||||
},
|
||||
filterData() {
|
||||
return this.$store.state.libraries.filterData || {}
|
||||
},
|
||||
podcastTypes() {
|
||||
return this.$store.state.globals.podcastTypes || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -219,6 +228,7 @@ export default {
|
||||
this.details.itunesArtistId = this.mediaMetadata.itunesArtistId || ''
|
||||
this.details.language = this.mediaMetadata.language || ''
|
||||
this.details.explicit = !!this.mediaMetadata.explicit
|
||||
this.details.type = this.mediaMetadata.type || 'episodic'
|
||||
|
||||
this.newTags = [...(this.media.tags || [])]
|
||||
},
|
||||
@@ -228,4 +238,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
31
client/components/widgets/PodcastTypeIndicator.vue
Normal file
31
client/components/widgets/PodcastTypeIndicator.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div>
|
||||
<template v-if="type == 'bonus'">
|
||||
<ui-tooltip text="Bonus" direction="top">
|
||||
<span class="material-icons ml-1" style="font-size: 0.8rem">local_play</span>
|
||||
</ui-tooltip>
|
||||
</template>
|
||||
<template v-if="type == 'trailer'">
|
||||
<ui-tooltip text="Trailer" direction="top">
|
||||
<span class="material-icons ml-1" style="font-size: 0.8rem">local_movies</span>
|
||||
</ui-tooltip>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
default: 'full'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
92
client/components/widgets/RssFeedMetadataBuilder.vue
Normal file
92
client/components/widgets/RssFeedMetadataBuilder.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="w-full py-2">
|
||||
<div class="flex -mb-px">
|
||||
<div class="w-1/2 h-6 rounded-tl-md relative border border-black-200 flex items-center justify-center cursor-pointer" :class="!showAdvancedView ? 'text-white bg-bg hover:bg-opacity-60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary bg-opacity-70 hover:bg-opacity-60'" @click="showAdvancedView = false">
|
||||
<p class="text-sm">{{ $strings.HeaderRSSFeedGeneral }}</p>
|
||||
</div>
|
||||
<div class="w-1/2 h-6 rounded-tr-md relative border border-black-200 flex items-center justify-center -ml-px cursor-pointer" :class="showAdvancedView ? 'text-white bg-bg hover:bg-opacity-60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary bg-opacity-70 hover:bg-opacity-60'" @click="showAdvancedView = true">
|
||||
<p class="text-sm">{{ $strings.HeaderAdvanced }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2 py-4 md:p-4 border border-black-200 rounded-b-md mr-px" style="min-height: 200px">
|
||||
<template v-if="!showAdvancedView">
|
||||
<div class="flex-grow pt-2 mb-2">
|
||||
<ui-checkbox v-model="preventIndexing" :label="$strings.LabelPreventIndexing" checkbox-bg="primary" border-color="gray-600" label-class="pl-2" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex-grow pt-2 mb-2">
|
||||
<ui-checkbox v-model="preventIndexing" :label="$strings.LabelPreventIndexing" checkbox-bg="primary" border-color="gray-600" label-class="pl-2" />
|
||||
</div>
|
||||
<div class="w-full relative mb-1">
|
||||
<ui-text-input-with-label v-model="ownerName" :label="$strings.LabelRssFeedCustomOwnerName" />
|
||||
</div>
|
||||
<div class="w-full relative mb-1">
|
||||
<ui-text-input-with-label v-model="ownerEmail" :label="$strings.LabelRssFeedCustomOwnerEmail" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {
|
||||
preventIndexing: true,
|
||||
ownerName: '',
|
||||
ownerEmail: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showAdvancedView: false
|
||||
}
|
||||
},
|
||||
watch: {},
|
||||
computed: {
|
||||
preventIndexing: {
|
||||
get() {
|
||||
return this.value.preventIndexing
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('input', {
|
||||
...this.value,
|
||||
preventIndexing: value
|
||||
})
|
||||
}
|
||||
},
|
||||
ownerName: {
|
||||
get() {
|
||||
return this.value.ownerName
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('input', {
|
||||
...this.value,
|
||||
ownerName: value
|
||||
})
|
||||
}
|
||||
},
|
||||
ownerEmail: {
|
||||
get() {
|
||||
return this.value.ownerEmail
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('input', {
|
||||
...this.value,
|
||||
ownerEmail: value
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
},
|
||||
mounted() {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
37
client/package-lock.json
generated
37
client/package-lock.json
generated
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.2.15",
|
||||
"version": "2.2.16",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.2.15",
|
||||
"version": "2.2.16",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
"@nuxtjs/proxy": "^2.1.0",
|
||||
"core-js": "^3.16.0",
|
||||
"cron-parser": "^4.7.1",
|
||||
"date-fns": "^2.25.0",
|
||||
"epubjs": "^0.3.88",
|
||||
"hls.js": "^1.0.7",
|
||||
@@ -5464,6 +5465,17 @@
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="
|
||||
},
|
||||
"node_modules/cron-parser": {
|
||||
"version": "4.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.7.1.tgz",
|
||||
"integrity": "sha512-WguFaoQ0hQ61SgsCZLHUcNbAvlK0lypKXu62ARguefYmjzaOXIVRNrAmyXzabTwUn4sQvQLkk6bjH+ipGfw8bA==",
|
||||
"dependencies": {
|
||||
"luxon": "^3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
@@ -9134,6 +9146,14 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.2.1.tgz",
|
||||
"integrity": "sha512-QrwPArQCNLAKGO/C+ZIilgIuDnEnKx5QYODdDtbFaxzsbZcc/a7WFq7MhsVYgRlwawLtvOUESTlfJ+hc/USqPg==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
|
||||
@@ -21582,6 +21602,14 @@
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="
|
||||
},
|
||||
"cron-parser": {
|
||||
"version": "4.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.7.1.tgz",
|
||||
"integrity": "sha512-WguFaoQ0hQ61SgsCZLHUcNbAvlK0lypKXu62ARguefYmjzaOXIVRNrAmyXzabTwUn4sQvQLkk6bjH+ipGfw8bA==",
|
||||
"requires": {
|
||||
"luxon": "^3.2.1"
|
||||
}
|
||||
},
|
||||
"cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
@@ -24397,6 +24425,11 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"luxon": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.2.1.tgz",
|
||||
"integrity": "sha512-QrwPArQCNLAKGO/C+ZIilgIuDnEnKx5QYODdDtbFaxzsbZcc/a7WFq7MhsVYgRlwawLtvOUESTlfJ+hc/USqPg=="
|
||||
},
|
||||
"make-dir": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.2.15",
|
||||
"version": "2.2.16",
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -16,6 +16,7 @@
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
"@nuxtjs/proxy": "^2.1.0",
|
||||
"core-js": "^3.16.0",
|
||||
"cron-parser": "^4.7.1",
|
||||
"date-fns": "^2.25.0",
|
||||
"epubjs": "^0.3.88",
|
||||
"hls.js": "^1.0.7",
|
||||
|
||||
@@ -9,10 +9,17 @@
|
||||
</div>
|
||||
|
||||
<div v-if="enableBackups" class="mb-6">
|
||||
<div class="flex items-center pl-6">
|
||||
<span class="material-icons-outlined text-2xl text-black-50">schedule</span>
|
||||
<p class="text-gray-100 px-2">{{ scheduleDescription }}</p>
|
||||
<span class="material-icons text-lg text-black-50 hover:text-yellow-500 cursor-pointer" @click="showCronBuilder = !showCronBuilder">edit</span>
|
||||
<div class="flex items-center pl-6 mb-2">
|
||||
<span class="material-icons-outlined text-2xl text-black-50 mr-2">schedule</span>
|
||||
<div class="w-48"><span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.HeaderSchedule }}:</span></div>
|
||||
<div class="text-gray-100">{{ scheduleDescription }}</div>
|
||||
<span class="material-icons text-lg text-black-50 hover:text-yellow-500 cursor-pointer ml-2" @click="showCronBuilder = !showCronBuilder">edit</span>
|
||||
</div>
|
||||
|
||||
<div v-if="nextBackupDate" class="flex items-center pl-6 py-0.5 px-2">
|
||||
<span class="material-icons-outlined text-2xl text-black-50 mr-2">event</span>
|
||||
<div class="w-48"><span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNextBackupDate }}:</span></div>
|
||||
<div class="text-gray-100">{{ nextBackupDate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,10 +71,21 @@ export default {
|
||||
serverSettings() {
|
||||
return this.$store.state.serverSettings
|
||||
},
|
||||
dateFormat() {
|
||||
return this.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.serverSettings.timeFormat
|
||||
},
|
||||
scheduleDescription() {
|
||||
if (!this.cronExpression) return ''
|
||||
const parsed = this.$parseCronExpression(this.cronExpression)
|
||||
return parsed ? parsed.description : 'Custom cron expression ' + this.cronExpression
|
||||
return parsed ? parsed.description : `${this.$strings.LabelCustomCronExpression} ${this.cronExpression}`
|
||||
},
|
||||
nextBackupDate() {
|
||||
if (!this.cronExpression) return ''
|
||||
const parsed = this.$getNextScheduledDate(this.cronExpression)
|
||||
return this.$formatJsDatetime(parsed, this.dateFormat, this.timeFormat) || ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -90,15 +108,15 @@ export default {
|
||||
updateServerSettings(payload) {
|
||||
this.updatingServerSettings = true
|
||||
this.$store
|
||||
.dispatch('updateServerSettings', payload)
|
||||
.then((success) => {
|
||||
console.log('Updated Server Settings', success)
|
||||
this.updatingServerSettings = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
this.updatingServerSettings = false
|
||||
})
|
||||
.dispatch('updateServerSettings', payload)
|
||||
.then((success) => {
|
||||
console.log('Updated Server Settings', success)
|
||||
this.updatingServerSettings = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
this.updatingServerSettings = false
|
||||
})
|
||||
},
|
||||
initServerSettings() {
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
@@ -113,4 +131,4 @@ export default {
|
||||
this.initServerSettings()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -68,8 +68,14 @@
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="flex-grow py-2">
|
||||
<ui-dropdown :label="$strings.LabelSettingsDateFormat" v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-52" @input="(val) => updateSettingsKey('dateFormat', val)" />
|
||||
<p class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelExample }}: {{ dateExample }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow py-2">
|
||||
<ui-dropdown :label="$strings.LabelSettingsTimeFormat" v-model="newServerSettings.timeFormat" :items="timeFormats" small class="max-w-52" @input="(val) => updateSettingsKey('timeFormat', val)" />
|
||||
<p class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelExample }}: {{ timeExample }}</p>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
@@ -293,6 +299,17 @@ export default {
|
||||
},
|
||||
dateFormats() {
|
||||
return this.$store.state.globals.dateFormats
|
||||
},
|
||||
timeFormats() {
|
||||
return this.$store.state.globals.timeFormats
|
||||
},
|
||||
dateExample() {
|
||||
const date = new Date(2014, 2, 25)
|
||||
return this.$formatJsDate(date, this.newServerSettings.dateFormat)
|
||||
},
|
||||
timeExample() {
|
||||
const date = new Date(2014, 2, 25, 17, 30, 0)
|
||||
return this.$formatJsTime(date, this.newServerSettings.timeFormat)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -420,4 +437,4 @@ export default {
|
||||
this.initServerSettings()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -60,6 +60,25 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="w-80 my-6 mx-auto">
|
||||
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsLargestItems }}</h1>
|
||||
<p v-if="!top10LargestItems.length">{{ $strings.MessageNoItems }}</p>
|
||||
<template v-for="(ab, index) in top10LargestItems">
|
||||
<div :key="index" class="w-full py-2">
|
||||
<div class="flex items-center mb-1">
|
||||
<p class="text-sm text-white text-opacity-70 w-44 pr-2 truncate">
|
||||
{{ index + 1 }}. <nuxt-link :to="`/item/${ab.id}`" class="hover:underline">{{ ab.title }}</nuxt-link>
|
||||
</p>
|
||||
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
|
||||
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * ab.size) / largestItemSize) + '%' }" />
|
||||
</div>
|
||||
<div class="w-4 ml-3">
|
||||
<p class="text-sm font-bold">{{ $bytesPretty(ab.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</app-settings-content>
|
||||
</div>
|
||||
@@ -105,6 +124,13 @@ export default {
|
||||
if (!this.top10LongestItems.length) return 0
|
||||
return this.top10LongestItems[0].duration
|
||||
},
|
||||
top10LargestItems() {
|
||||
return this.libraryStats ? this.libraryStats.largestItems || [] : []
|
||||
},
|
||||
largestItemSize() {
|
||||
if (!this.top10LargestItems.length) return 0
|
||||
return this.top10LargestItems[0].size
|
||||
},
|
||||
authorsWithCount() {
|
||||
return this.libraryStats ? this.libraryStats.authorsWithCount : []
|
||||
},
|
||||
@@ -135,4 +161,4 @@ export default {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
|
||||
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
@@ -105,6 +105,12 @@ export default {
|
||||
if (!this.userFilter) return null
|
||||
var user = this.users.find((u) => u.id === this.userFilter)
|
||||
return user ? user.username : null
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -149,7 +155,7 @@ export default {
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: libraryItem.media.metadata.title,
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||
duration: episode.audioFile.duration || null,
|
||||
coverPath: libraryItem.media.coverPath || null
|
||||
}
|
||||
@@ -266,4 +272,4 @@ export default {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -79,12 +79,12 @@
|
||||
<p class="text-sm">{{ Math.floor(item.progress * 100) }}%</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="item.startedAt" direction="top" :text="$formatDate(item.startedAt, 'MMMM do, yyyy HH:mm')">
|
||||
<ui-tooltip v-if="item.startedAt" direction="top" :text="$formatDatetime(item.startedAt, dateFormat, timeFormat)">
|
||||
<p class="text-sm">{{ $dateDistanceFromNow(item.startedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="item.lastUpdate" direction="top" :text="$formatDate(item.lastUpdate, 'MMMM do, yyyy HH:mm')">
|
||||
<ui-tooltip v-if="item.lastUpdate" direction="top" :text="$formatDatetime(item.lastUpdate, dateFormat, timeFormat)">
|
||||
<p class="text-sm">{{ $dateDistanceFromNow(item.lastUpdate) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
@@ -149,6 +149,12 @@ export default {
|
||||
latestSession() {
|
||||
if (!this.listeningSessions.sessions || !this.listeningSessions.sessions.length) return null
|
||||
return this.listeningSessions.sessions[0]
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
|
||||
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
@@ -96,6 +96,12 @@ export default {
|
||||
},
|
||||
userOnline() {
|
||||
return this.$store.getters['users/getIsUserOnline'](this.user.id)
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -140,7 +146,7 @@ export default {
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: libraryItem.media.metadata.title,
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||
duration: episode.audioFile.duration || null,
|
||||
coverPath: libraryItem.media.coverPath || null
|
||||
}
|
||||
@@ -252,4 +258,4 @@ export default {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -25,7 +25,10 @@
|
||||
<div class="flex justify-center">
|
||||
<div class="mb-4">
|
||||
<h1 class="text-2xl md:text-3xl font-semibold">
|
||||
{{ title }}
|
||||
<div class="flex items-center">
|
||||
{{ title }}
|
||||
<widgets-explicit-indicator :explicit="isExplicit" />
|
||||
</div>
|
||||
</h1>
|
||||
|
||||
<p v-if="bookSubtitle" class="text-gray-200 text-xl md:text-2xl">{{ bookSubtitle }}</p>
|
||||
@@ -315,6 +318,9 @@ export default {
|
||||
isInvalid() {
|
||||
return this.libraryItem.isInvalid
|
||||
},
|
||||
isExplicit() {
|
||||
return this.mediaMetadata.explicit || false
|
||||
},
|
||||
invalidAudioFiles() {
|
||||
if (!this.isBook) return []
|
||||
return this.libraryItem.media.audioFiles.filter((af) => af.invalid)
|
||||
@@ -632,7 +638,7 @@ export default {
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: this.title,
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||
duration: episode.audioFile.duration || null,
|
||||
coverPath: this.libraryItem.media.coverPath || null
|
||||
})
|
||||
@@ -753,9 +759,8 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.libraryItem.episodesDownloading) {
|
||||
this.episodeDownloadsQueued = this.libraryItem.episodesDownloading || []
|
||||
}
|
||||
this.episodeDownloadsQueued = this.libraryItem.episodeDownloadsQueued || []
|
||||
this.episodesDownloading = this.libraryItem.episodesDownloading || []
|
||||
|
||||
// use this items library id as the current
|
||||
if (this.libraryId) {
|
||||
|
||||
140
client/pages/library/_library/podcast/download-queue.vue
Normal file
140
client/pages/library/_library/podcast/download-queue.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<app-book-shelf-toolbar page="podcast-search" />
|
||||
|
||||
<div id="bookshelf" class="w-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative">
|
||||
<div class="w-full max-w-5xl mx-auto py-4">
|
||||
<p class="text-xl mb-2 font-semibold px-4 md:px-0">{{ $strings.HeaderCurrentDownloads }}</p>
|
||||
<p v-if="!episodesDownloading.length" class="text-lg py-4">{{ $strings.MessageNoDownloadsInProgress }}</p>
|
||||
<template v-for="episode in episodesDownloading">
|
||||
<div :key="episode.id" class="flex py-5 relative">
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId)" :width="96" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="hidden md:block" />
|
||||
<div class="flex-grow pl-4 max-w-2xl">
|
||||
<!-- mobile -->
|
||||
<div class="flex md:hidden mb-2">
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId)" :width="48" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="md:hidden" />
|
||||
<div class="flex-grow px-2">
|
||||
<div class="flex items-center">
|
||||
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcastTitle }}</nuxt-link>
|
||||
<widgets-explicit-indicator :explicit="episode.podcastExplicit" />
|
||||
</div>
|
||||
<p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- desktop -->
|
||||
<div class="hidden md:block">
|
||||
<div class="flex items-center">
|
||||
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcastTitle }}</nuxt-link>
|
||||
<widgets-explicit-indicator :explicit="episode.podcastExplicit" />
|
||||
</div>
|
||||
<p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center font-semibold text-gray-200">
|
||||
<div v-if="episode.season || episode.episode">#</div>
|
||||
<div v-if="episode.season">{{ episode.season }}x</div>
|
||||
<div v-if="episode.episode">{{ episode.episode }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-2">
|
||||
<span class="font-semibold text-sm md:text-base">{{ episode.episodeDisplayTitle }}</span>
|
||||
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-200 mb-4">{{ episode.subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<tables-podcast-download-queue-table v-if="episodeDownloadsQueued.length" :queue="episodeDownloadsQueued"></tables-podcast-download-queue-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ params, redirect }) {
|
||||
if (!params.library) {
|
||||
console.error('No library...', params.library)
|
||||
return redirect('/')
|
||||
}
|
||||
return {
|
||||
libraryId: params.library
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
episodesDownloading: [],
|
||||
episodeDownloadsQueued: [],
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
episodeDownloadQueued(episodeDownload) {
|
||||
if (episodeDownload.libraryId === this.libraryId) {
|
||||
this.episodeDownloadsQueued.push(episodeDownload)
|
||||
}
|
||||
},
|
||||
episodeDownloadStarted(episodeDownload) {
|
||||
if (episodeDownload.libraryId === this.libraryId) {
|
||||
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
|
||||
this.episodesDownloading.push(episodeDownload)
|
||||
}
|
||||
},
|
||||
episodeDownloadFinished(episodeDownload) {
|
||||
if (episodeDownload.libraryId === this.libraryId) {
|
||||
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
|
||||
this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id)
|
||||
}
|
||||
},
|
||||
episodeDownloadQueueUpdated(downloadQueueDetails) {
|
||||
this.episodeDownloadsQueued = downloadQueueDetails.queue.filter((q) => q.libraryId == this.libraryId)
|
||||
},
|
||||
async loadInitialDownloadQueue() {
|
||||
this.processing = true
|
||||
const queuePayload = await this.$axios.$get(`/api/libraries/${this.libraryId}/episode-downloads`).catch((error) => {
|
||||
console.error('Failed to get download queue', error)
|
||||
this.$toast.error('Failed to get download queue')
|
||||
return null
|
||||
})
|
||||
this.processing = false
|
||||
this.episodeDownloadsQueued = queuePayload?.queue || []
|
||||
|
||||
if (queuePayload?.currentDownload) {
|
||||
this.episodesDownloading.push(queuePayload.currentDownload)
|
||||
}
|
||||
|
||||
// Initialize listeners after load to prevent event race conditions
|
||||
this.initListeners()
|
||||
},
|
||||
initListeners() {
|
||||
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)
|
||||
this.$root.socket.on('episode_download_queue_updated', this.episodeDownloadQueueUpdated)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.libraryId) {
|
||||
this.$store.commit('libraries/setCurrentLibrary', this.libraryId)
|
||||
}
|
||||
|
||||
this.loadInitialDownloadQueue()
|
||||
},
|
||||
beforeDestroy() {
|
||||
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)
|
||||
this.$root.socket.off('episode_download_queue_updated', this.episodeDownloadQueueUpdated)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -14,19 +14,36 @@
|
||||
<div class="flex md:hidden mb-2">
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId)" :width="48" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="md:hidden" />
|
||||
<div class="flex-grow px-2">
|
||||
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcast.metadata.title }}</nuxt-link>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex" @click.stop>
|
||||
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcast.metadata.title }}</nuxt-link>
|
||||
</div>
|
||||
<widgets-explicit-indicator :explicit="episode.podcast.metadata.explicit" />
|
||||
</div>
|
||||
<p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- desktop -->
|
||||
<div class="hidden md:block">
|
||||
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcast.metadata.title }}</nuxt-link>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex" @click.stop>
|
||||
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcast.metadata.title }}</nuxt-link>
|
||||
</div>
|
||||
<widgets-explicit-indicator :explicit="episode.podcast.metadata.explicit" />
|
||||
</div>
|
||||
<p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>
|
||||
</div>
|
||||
|
||||
<p class="font-semibold mb-2 text-sm md:text-base">{{ episode.title }}</p>
|
||||
<div class="flex items-center font-semibold text-gray-200">
|
||||
<div v-if="episode.season || episode.episode">#</div>
|
||||
<div v-if="episode.season">{{ episode.season }}x</div>
|
||||
<div v-if="episode.episode">{{ episode.episode }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-2">
|
||||
<div class="font-semibold text-sm md:text-base">{{ episode.title }}</div>
|
||||
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-200 mb-4">{{ episode.subtitle }}</p>
|
||||
|
||||
@@ -113,6 +130,9 @@ export default {
|
||||
if (i.episodeId) episodeIds[i.episodeId] = true
|
||||
})
|
||||
return episodeIds
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -156,7 +176,7 @@ export default {
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: episode.podcast.metadata.title,
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||
duration: episode.duration || null,
|
||||
coverPath: episode.podcast.coverPath || null
|
||||
})
|
||||
@@ -194,7 +214,7 @@ export default {
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: episode.podcast.metadata.title,
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||
duration: episode.duration || null,
|
||||
coverPath: episode.podcast.coverPath || null
|
||||
}
|
||||
@@ -206,4 +226,4 @@ export default {
|
||||
this.loadRecentEpisodes()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -5,13 +5,12 @@
|
||||
<div id="bookshelf" class="w-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative">
|
||||
<div class="w-full max-w-4xl mx-auto flex">
|
||||
<form @submit.prevent="submit" class="flex flex-grow">
|
||||
<ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2 text-sm md:text-base" />
|
||||
<ui-text-input v-model="searchInput" type="search" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2 text-sm md:text-base" />
|
||||
<ui-btn type="submit" :disabled="processing" class="hidden md:block">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
<ui-btn type="submit" :disabled="processing" class="block md:hidden" small>{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
</form>
|
||||
<ui-file-input ref="fileInput" :accept="'.opml, .txt'" class="ml-2" @change="opmlFileUpload">{{ $strings.ButtonUploadOPMLFile }}</ui-file-input>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-3xl mx-auto py-4">
|
||||
<p v-if="termSearched && !results.length && !processing" class="text-center text-xl">{{ $strings.MessageNoPodcastsFound }}</p>
|
||||
<template v-for="podcast in results">
|
||||
@@ -20,7 +19,11 @@
|
||||
<img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" />
|
||||
</div>
|
||||
<div class="flex-grow pl-4 max-w-2xl">
|
||||
<a :href="podcast.pageUrl" class="text-base md:text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
|
||||
<div class="flex items-center">
|
||||
<a :href="podcast.pageUrl" class="text-base md:text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
|
||||
<widgets-explicit-indicator :explicit="podcast.explicit" />
|
||||
<widgets-already-in-library-indicator :already-in-library="podcast.alreadyInLibrary"/>
|
||||
</div>
|
||||
<p class="text-sm md:text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
|
||||
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
|
||||
<p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} {{ $strings.HeaderEpisodes }}</p>
|
||||
@@ -68,10 +71,14 @@ export default {
|
||||
selectedPodcast: null,
|
||||
selectedPodcastFeed: null,
|
||||
showOPMLFeedsModal: false,
|
||||
opmlFeeds: []
|
||||
opmlFeeds: [],
|
||||
existentPodcasts: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
}
|
||||
@@ -144,18 +151,29 @@ export default {
|
||||
return []
|
||||
})
|
||||
console.log('Got results', results)
|
||||
for (let result of results) {
|
||||
let podcast = this.existentPodcasts.find((p) => p.itunesId === result.id || p.title === result.title.toLowerCase())
|
||||
if (podcast) {
|
||||
result.alreadyInLibrary = true
|
||||
result.existentId = podcast.id
|
||||
}
|
||||
}
|
||||
this.results = results
|
||||
this.termSearched = term
|
||||
this.processing = false
|
||||
},
|
||||
async selectPodcast(podcast) {
|
||||
console.log('Selected podcast', podcast)
|
||||
if(podcast.existentId){
|
||||
this.$router.push(`/item/${podcast.existentId}`)
|
||||
return
|
||||
}
|
||||
if (!podcast.feedUrl) {
|
||||
this.$toast.error('Invalid podcast - no feed')
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
var payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: podcast.feedUrl }).catch((error) => {
|
||||
var payload = await this.$axios.$post(`/api/podcasts/feed`, {rssFeed: podcast.feedUrl}).catch((error) => {
|
||||
console.error('Failed to get feed', error)
|
||||
this.$toast.error('Failed to get podcast feed')
|
||||
return null
|
||||
@@ -167,8 +185,26 @@ export default {
|
||||
this.selectedPodcast = podcast
|
||||
this.showNewPodcastModal = true
|
||||
console.log('Got podcast feed', payload.podcast)
|
||||
},
|
||||
async fetchExistentPodcastsInYourLibrary() {
|
||||
this.processing = true
|
||||
|
||||
const podcasts = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/items?page=0&minified=1`).catch((error) => {
|
||||
console.error('Failed to fetch podcasts', error)
|
||||
return []
|
||||
})
|
||||
this.existentPodcasts = podcasts.results.map((p) => {
|
||||
return {
|
||||
title: p.media.metadata.title.toLowerCase(),
|
||||
itunesId: p.media.metadata.itunesId,
|
||||
id: p.id
|
||||
}
|
||||
})
|
||||
this.processing = false
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
this.fetchExistentPodcastsInYourLibrary()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -23,6 +23,22 @@ Vue.prototype.$formatJsDate = (jsdate, fnsFormat = 'MM/dd/yyyy HH:mm') => {
|
||||
if (!jsdate || !isDate(jsdate)) return ''
|
||||
return format(jsdate, fnsFormat)
|
||||
}
|
||||
Vue.prototype.$formatTime = (unixms, fnsFormat = 'HH:mm') => {
|
||||
if (!unixms) return ''
|
||||
return format(unixms, fnsFormat)
|
||||
}
|
||||
Vue.prototype.$formatJsTime = (jsdate, fnsFormat = 'HH:mm') => {
|
||||
if (!jsdate || !isDate(jsdate)) return ''
|
||||
return format(jsdate, fnsFormat)
|
||||
}
|
||||
Vue.prototype.$formatDatetime = (unixms, fnsDateFormart = 'MM/dd/yyyy', fnsTimeFormat = 'HH:mm') => {
|
||||
if (!unixms) return ''
|
||||
return format(unixms, `${fnsDateFormart} ${fnsTimeFormat}`)
|
||||
}
|
||||
Vue.prototype.$formatJsDatetime = (jsdate, fnsDateFormart = 'MM/dd/yyyy', fnsTimeFormat = 'HH:mm') => {
|
||||
if (!jsdate || !isDate(jsdate)) return ''
|
||||
return format(jsdate, `${fnsDateFormart} ${fnsTimeFormat}`)
|
||||
}
|
||||
Vue.prototype.$addDaysToToday = (daysToAdd) => {
|
||||
var date = addDays(new Date(), daysToAdd)
|
||||
if (!date || !isDate(date)) return null
|
||||
@@ -167,4 +183,4 @@ export default ({ app, store }, inject) => {
|
||||
inject('isDev', process.env.NODE_ENV !== 'production')
|
||||
|
||||
store.commit('setRouterBasePath', app.$config.routerBasePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Vue from 'vue'
|
||||
import cronParser from 'cron-parser'
|
||||
|
||||
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
|
||||
if (isNaN(bytes) || bytes == 0) {
|
||||
@@ -136,6 +137,11 @@ Vue.prototype.$parseCronExpression = (expression) => {
|
||||
}
|
||||
}
|
||||
|
||||
Vue.prototype.$getNextScheduledDate = (expression) => {
|
||||
const interval = cronParser.parseExpression(expression);
|
||||
return interval.next().toDate()
|
||||
}
|
||||
|
||||
export function supplant(str, subs) {
|
||||
// source: http://crockford.com/javascript/remedial.html
|
||||
return str.replace(/{([^{}]*)}/g,
|
||||
@@ -144,4 +150,4 @@ export function supplant(str, subs) {
|
||||
return typeof r === 'string' || typeof r === 'number' ? r : a
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,11 +32,50 @@ export const state = () => ({
|
||||
text: 'DD/MM/YYYY',
|
||||
value: 'dd/MM/yyyy'
|
||||
},
|
||||
{
|
||||
text: 'DD.MM.YYYY',
|
||||
value: 'dd.MM.yyyy'
|
||||
},
|
||||
{
|
||||
text: 'YYYY-MM-DD',
|
||||
value: 'yyyy-MM-dd'
|
||||
},
|
||||
{
|
||||
text: 'MMM do, yyyy',
|
||||
value: 'MMM do, yyyy'
|
||||
},
|
||||
{
|
||||
text: 'MMMM do, yyyy',
|
||||
value: 'MMMM do, yyyy'
|
||||
},
|
||||
{
|
||||
text: 'dd MMM yyyy',
|
||||
value: 'dd MMM yyyy'
|
||||
},
|
||||
{
|
||||
text: 'dd MMMM yyyy',
|
||||
value: 'dd MMMM yyyy'
|
||||
}
|
||||
],
|
||||
timeFormats: [
|
||||
{
|
||||
text: 'h:mma (am/pm)',
|
||||
value: 'h:mma'
|
||||
},
|
||||
{
|
||||
text: 'HH:mm (24-hour)',
|
||||
value: 'HH:mm'
|
||||
}
|
||||
],
|
||||
podcastTypes: [
|
||||
{ text: 'Episodic', value: 'episodic' },
|
||||
{ text: 'Serial', value: 'serial' }
|
||||
],
|
||||
episodeTypes: [
|
||||
{ text: 'Full', value: 'full' },
|
||||
{ text: 'Trailer', value: 'trailer' },
|
||||
{ text: 'Bonus', value: 'bonus' }
|
||||
],
|
||||
libraryIcons: ['database', 'audiobookshelf', 'books-1', 'books-2', 'book-1', 'microphone-1', 'microphone-3', 'radio', 'podcast', 'rss', 'headphones', 'music', 'file-picture', 'rocket', 'power', 'star', 'heart']
|
||||
})
|
||||
|
||||
@@ -169,4 +208,4 @@ export const mutations = {
|
||||
state.selectedMediaItems.push(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export const state = () => ({
|
||||
playerQueueAutoPlay: true,
|
||||
playerIsFullscreen: false,
|
||||
editModalTab: 'details',
|
||||
editPodcastModalTab: 'details',
|
||||
showEditModal: false,
|
||||
showEReader: false,
|
||||
selectedLibraryItem: null,
|
||||
@@ -21,6 +22,7 @@ export const state = () => ({
|
||||
previousPath: '/',
|
||||
showExperimentalFeatures: false,
|
||||
bookshelfBookIds: [],
|
||||
episodeTableEpisodeIds: [],
|
||||
openModal: null,
|
||||
innerModalOpen: false,
|
||||
lastBookshelfScrollData: {},
|
||||
@@ -135,6 +137,9 @@ export const mutations = {
|
||||
setBookshelfBookIds(state, val) {
|
||||
state.bookshelfBookIds = val || []
|
||||
},
|
||||
setEpisodeTableEpisodeIds(state, val) {
|
||||
state.episodeTableEpisodeIds = val || []
|
||||
},
|
||||
setPreviousPath(state, val) {
|
||||
state.previousPath = val
|
||||
},
|
||||
@@ -198,6 +203,9 @@ export const mutations = {
|
||||
setShowEditModal(state, val) {
|
||||
state.showEditModal = val
|
||||
},
|
||||
setEditPodcastModalTab(state, tab) {
|
||||
state.editPodcastModalTab = tab
|
||||
},
|
||||
showEReader(state, libraryItem) {
|
||||
state.selectedLibraryItem = libraryItem
|
||||
|
||||
@@ -225,4 +233,4 @@ export const mutations = {
|
||||
setInnerModalOpen(state, val) {
|
||||
state.innerModalOpen = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,10 @@
|
||||
"ButtonCloseFeed": "Feed schließen",
|
||||
"ButtonCollections": "Sammlungen",
|
||||
"ButtonConfigureScanner": "Scannereinstellungen",
|
||||
"ButtonCreate": "Ertsellen",
|
||||
"ButtonCreate": "Erstellen",
|
||||
"ButtonCreateBackup": "Sicherung erstellen",
|
||||
"ButtonDelete": "Löschen",
|
||||
"ButtonDownloadQueue": "Queue",
|
||||
"ButtonEdit": "Bearbeiten",
|
||||
"ButtonEditChapters": "Kapitel bearbeiten",
|
||||
"ButtonEditPodcast": "Podcast bearbeiten",
|
||||
@@ -92,7 +93,9 @@
|
||||
"HeaderCollection": "Sammlungen",
|
||||
"HeaderCollectionItems": "Sammlungseinträge",
|
||||
"HeaderCover": "Titelbild",
|
||||
"HeaderCurrentDownloads": "Current Downloads",
|
||||
"HeaderDetails": "Details",
|
||||
"HeaderDownloadQueue": "Download Queue",
|
||||
"HeaderEpisodes": "Episoden",
|
||||
"HeaderFiles": "Dateien",
|
||||
"HeaderFindChapters": "Kapitel suchen",
|
||||
@@ -127,6 +130,7 @@
|
||||
"HeaderRemoveEpisode": "Episode löschen",
|
||||
"HeaderRemoveEpisodes": "Lösche {0} Episoden",
|
||||
"HeaderRSSFeedIsOpen": "RSS-Feed ist geöffnet",
|
||||
"HeaderRSSFeedGeneral": "RSS Details",
|
||||
"HeaderSavedMediaProgress": "Gespeicherte Hörfortschritte",
|
||||
"HeaderSchedule": "Zeitplan",
|
||||
"HeaderScheduleLibraryScans": "Automatische Bibliotheksscans",
|
||||
@@ -138,6 +142,7 @@
|
||||
"HeaderSettingsGeneral": "Allgemein",
|
||||
"HeaderSettingsScanner": "Scanner",
|
||||
"HeaderSleepTimer": "Einschlaf-Timer",
|
||||
"HeaderStatsLargestItems": "Largest Items",
|
||||
"HeaderStatsLongestItems": "Längste Einträge (h)",
|
||||
"HeaderStatsMinutesListeningChart": "Hörminuten (letzte 7 Tage)",
|
||||
"HeaderStatsRecentSessions": "Neueste Ereignisse",
|
||||
@@ -162,6 +167,7 @@
|
||||
"LabelAddToPlaylistBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu",
|
||||
"LabelAll": "Alle",
|
||||
"LabelAllUsers": "Alle Benutzer",
|
||||
"LabelAlreadyInYourLibrary": "Already in your library",
|
||||
"LabelAppend": "Anhängen",
|
||||
"LabelAuthor": "Autor",
|
||||
"LabelAuthorFirstLast": "Autor (Vorname Nachname)",
|
||||
@@ -192,6 +198,7 @@
|
||||
"LabelCronExpression": "Cron Ausdruck",
|
||||
"LabelCurrent": "Aktuell",
|
||||
"LabelCurrently": "Aktuell:",
|
||||
"LabelCustomCronExpression": "Custom Cron Expression:",
|
||||
"LabelDatetime": "Datum & Uhrzeit",
|
||||
"LabelDescription": "Beschreibung",
|
||||
"LabelDeselectAll": "Alles abwählen",
|
||||
@@ -209,6 +216,7 @@
|
||||
"LabelEpisode": "Episode",
|
||||
"LabelEpisodeTitle": "Episodentitel",
|
||||
"LabelEpisodeType": "Episodentyp",
|
||||
"LabelExample": "Example",
|
||||
"LabelExplicit": "Explizit (Altersbeschränkung)",
|
||||
"LabelFeedURL": "Feed URL",
|
||||
"LabelFile": "Datei",
|
||||
@@ -270,6 +278,8 @@
|
||||
"LabelNewestAuthors": "Neuste Autoren",
|
||||
"LabelNewestEpisodes": "Neueste Episoden",
|
||||
"LabelNewPassword": "Neues Passwort",
|
||||
"LabelNextBackupDate": "Next backup date",
|
||||
"LabelNextScheduledRun": "Next scheduled run",
|
||||
"LabelNotes": "Hinweise",
|
||||
"LabelNotFinished": "nicht beendet",
|
||||
"LabelNotificationAppriseURL": "Apprise URL(s)",
|
||||
@@ -300,7 +310,9 @@
|
||||
"LabelPlayMethod": "Abspielmethode",
|
||||
"LabelPodcast": "Podcast",
|
||||
"LabelPodcasts": "Podcasts",
|
||||
"LabelPodcastType": "Podcast Type",
|
||||
"LabelPrefixesToIgnore": "Zu ignorierende(s) Vorwort(e) (Groß- und Kleinschreibung wird nicht berücksichtigt)",
|
||||
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
|
||||
"LabelProgress": "Fortschritt",
|
||||
"LabelProvider": "Anbieter",
|
||||
"LabelPubDate": "Veröffentlichungsdatum",
|
||||
@@ -315,6 +327,9 @@
|
||||
"LabelRSSFeedOpen": "RSS Feed Offen",
|
||||
"LabelRSSFeedSlug": "RSS Feed Schlagwort",
|
||||
"LabelRSSFeedURL": "RSS Feed URL",
|
||||
"LabelRssFeedCustomOwnerName": "Custom owner Name",
|
||||
"LabelRssFeedCustomOwnerEmail": "Custom owner Email",
|
||||
"LabelRssFeedPreventIndexing": "Prevent Indexing",
|
||||
"LabelSearchTerm": "Begriff suchen",
|
||||
"LabelSearchTitle": "Titel",
|
||||
"LabelSearchTitleOrASIN": "Titel oder ASIN",
|
||||
@@ -357,6 +372,7 @@
|
||||
"LabelSettingsStoreCoversWithItemHelp": "Standardmäßig werden die Titelbilder in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Titelbilder als jpg Datei in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet. Es wird immer nur eine Datei mit dem Namen \"cover.jpg\" gespeichert.",
|
||||
"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. Es wird immer nur eine Datei mit dem Namen \"matadata.abs\" gespeichert.",
|
||||
"LabelSettingsTimeFormat": "Time Format",
|
||||
"LabelShowAll": "Alles anzeigen",
|
||||
"LabelSize": "Größe",
|
||||
"LabelSleepTimer": "Einschlaf-Timer",
|
||||
@@ -381,9 +397,10 @@
|
||||
"LabelStatsWeekListening": "Gehörte Wochen",
|
||||
"LabelSubtitle": "Untertitel",
|
||||
"LabelSupportedFileTypes": "Unterstützte Dateitypen",
|
||||
"LabelTag": "Tag",
|
||||
"LabelTag": "Schlagwort",
|
||||
"LabelTags": "Schlagwörter",
|
||||
"LabelTagsAccessibleToUser": "Für Benutzer zugängliche Schlagwörter",
|
||||
"LabelTasks": "Tasks Running",
|
||||
"LabelTimeListened": "Gehörte Zeit",
|
||||
"LabelTimeListenedToday": "Heute gehörte Zeit",
|
||||
"LabelTimeRemaining": "{0} verbleibend",
|
||||
@@ -485,6 +502,8 @@
|
||||
"MessageNoCollections": "Keine Sammlungen",
|
||||
"MessageNoCoversFound": "Keine Titelbilder gefunden",
|
||||
"MessageNoDescription": "Keine Beschreibung",
|
||||
"MessageNoDownloadsQueued": "No downloads queued",
|
||||
"MessageNoDownloadsInProgress": "No downloads currently in progress",
|
||||
"MessageNoEpisodeMatchesFound": "Keine Episodenübereinstimmungen gefunden",
|
||||
"MessageNoEpisodes": "Keine Episoden",
|
||||
"MessageNoFoldersAvailable": "Keine Ordner verfügbar",
|
||||
@@ -501,6 +520,7 @@
|
||||
"MessageNoSearchResultsFor": "Keine Suchergebnisse für \"{0}\"",
|
||||
"MessageNoSeries": "Keine Serien",
|
||||
"MessageNoTags": "Keine Tags",
|
||||
"MessageNoTasksRunning": "No Tasks Running",
|
||||
"MessageNotYetImplemented": "Noch nicht implementiert",
|
||||
"MessageNoUpdateNecessary": "Keine Aktualisierung erforderlich",
|
||||
"MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig",
|
||||
@@ -615,4 +635,4 @@
|
||||
"ToastSocketFailedToConnect": "Verbindung zum WebSocket fehlgeschlagen",
|
||||
"ToastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden",
|
||||
"ToastUserDeleteSuccess": "Benutzer gelöscht"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"ButtonCreate": "Create",
|
||||
"ButtonCreateBackup": "Create Backup",
|
||||
"ButtonDelete": "Delete",
|
||||
"ButtonDownloadQueue": "Queue",
|
||||
"ButtonEdit": "Edit",
|
||||
"ButtonEditChapters": "Edit Chapters",
|
||||
"ButtonEditPodcast": "Edit Podcast",
|
||||
@@ -92,7 +93,9 @@
|
||||
"HeaderCollection": "Collection",
|
||||
"HeaderCollectionItems": "Collection Items",
|
||||
"HeaderCover": "Cover",
|
||||
"HeaderCurrentDownloads": "Current Downloads",
|
||||
"HeaderDetails": "Details",
|
||||
"HeaderDownloadQueue": "Download Queue",
|
||||
"HeaderEpisodes": "Episodes",
|
||||
"HeaderFiles": "Files",
|
||||
"HeaderFindChapters": "Find Chapters",
|
||||
@@ -127,6 +130,7 @@
|
||||
"HeaderRemoveEpisode": "Remove Episode",
|
||||
"HeaderRemoveEpisodes": "Remove {0} Episodes",
|
||||
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
|
||||
"HeaderRSSFeedGeneral": "RSS Details",
|
||||
"HeaderSavedMediaProgress": "Saved Media Progress",
|
||||
"HeaderSchedule": "Schedule",
|
||||
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
|
||||
@@ -138,6 +142,7 @@
|
||||
"HeaderSettingsGeneral": "General",
|
||||
"HeaderSettingsScanner": "Scanner",
|
||||
"HeaderSleepTimer": "Sleep Timer",
|
||||
"HeaderStatsLargestItems": "Largest Items",
|
||||
"HeaderStatsLongestItems": "Longest Items (hrs)",
|
||||
"HeaderStatsMinutesListeningChart": "Minutes Listening (last 7 days)",
|
||||
"HeaderStatsRecentSessions": "Recent Sessions",
|
||||
@@ -162,6 +167,7 @@
|
||||
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
|
||||
"LabelAll": "All",
|
||||
"LabelAllUsers": "All Users",
|
||||
"LabelAlreadyInYourLibrary": "Already in your library",
|
||||
"LabelAppend": "Append",
|
||||
"LabelAuthor": "Author",
|
||||
"LabelAuthorFirstLast": "Author (First Last)",
|
||||
@@ -192,6 +198,7 @@
|
||||
"LabelCronExpression": "Cron Expression",
|
||||
"LabelCurrent": "Current",
|
||||
"LabelCurrently": "Currently:",
|
||||
"LabelCustomCronExpression": "Custom Cron Expression:",
|
||||
"LabelDatetime": "Datetime",
|
||||
"LabelDescription": "Description",
|
||||
"LabelDeselectAll": "Deselect All",
|
||||
@@ -209,6 +216,7 @@
|
||||
"LabelEpisode": "Episode",
|
||||
"LabelEpisodeTitle": "Episode Title",
|
||||
"LabelEpisodeType": "Episode Type",
|
||||
"LabelExample": "Example",
|
||||
"LabelExplicit": "Explicit",
|
||||
"LabelFeedURL": "Feed URL",
|
||||
"LabelFile": "File",
|
||||
@@ -270,6 +278,8 @@
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNewPassword": "New Password",
|
||||
"LabelNextBackupDate": "Next backup date",
|
||||
"LabelNextScheduledRun": "Next scheduled run",
|
||||
"LabelNotes": "Notes",
|
||||
"LabelNotFinished": "Not Finished",
|
||||
"LabelNotificationAppriseURL": "Apprise URL(s)",
|
||||
@@ -300,7 +310,9 @@
|
||||
"LabelPlayMethod": "Play Method",
|
||||
"LabelPodcast": "Podcast",
|
||||
"LabelPodcasts": "Podcasts",
|
||||
"LabelPodcastType": "Podcast Type",
|
||||
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
|
||||
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
|
||||
"LabelProgress": "Progress",
|
||||
"LabelProvider": "Provider",
|
||||
"LabelPubDate": "Pub Date",
|
||||
@@ -315,6 +327,9 @@
|
||||
"LabelRSSFeedOpen": "RSS Feed Open",
|
||||
"LabelRSSFeedSlug": "RSS Feed Slug",
|
||||
"LabelRSSFeedURL": "RSS Feed URL",
|
||||
"LabelRssFeedCustomOwnerName": "Custom owner Name",
|
||||
"LabelRssFeedCustomOwnerEmail": "Custom owner Email",
|
||||
"LabelRssFeedPreventIndexing": "Prevent Indexing",
|
||||
"LabelSearchTerm": "Search Term",
|
||||
"LabelSearchTitle": "Search Title",
|
||||
"LabelSearchTitleOrASIN": "Search Title or ASIN",
|
||||
@@ -357,6 +372,7 @@
|
||||
"LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept",
|
||||
"LabelSettingsStoreMetadataWithItem": "Store metadata with item",
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension",
|
||||
"LabelSettingsTimeFormat": "Time Format",
|
||||
"LabelShowAll": "Show All",
|
||||
"LabelSize": "Size",
|
||||
"LabelSleepTimer": "Sleep timer",
|
||||
@@ -384,6 +400,7 @@
|
||||
"LabelTag": "Tag",
|
||||
"LabelTags": "Tags",
|
||||
"LabelTagsAccessibleToUser": "Tags Accessible to User",
|
||||
"LabelTasks": "Tasks Running",
|
||||
"LabelTimeListened": "Time Listened",
|
||||
"LabelTimeListenedToday": "Time Listened Today",
|
||||
"LabelTimeRemaining": "{0} remaining",
|
||||
@@ -485,6 +502,8 @@
|
||||
"MessageNoCollections": "No Collections",
|
||||
"MessageNoCoversFound": "No Covers Found",
|
||||
"MessageNoDescription": "No description",
|
||||
"MessageNoDownloadsQueued": "No downloads queued",
|
||||
"MessageNoDownloadsInProgress": "No downloads currently in progress",
|
||||
"MessageNoEpisodeMatchesFound": "No episode matches found",
|
||||
"MessageNoEpisodes": "No Episodes",
|
||||
"MessageNoFoldersAvailable": "No Folders Available",
|
||||
@@ -501,6 +520,7 @@
|
||||
"MessageNoSearchResultsFor": "No search results for \"{0}\"",
|
||||
"MessageNoSeries": "No Series",
|
||||
"MessageNoTags": "No Tags",
|
||||
"MessageNoTasksRunning": "No Tasks Running",
|
||||
"MessageNotYetImplemented": "Not yet implemented",
|
||||
"MessageNoUpdateNecessary": "No update necessary",
|
||||
"MessageNoUpdatesWereNecessary": "No updates were necessary",
|
||||
@@ -615,4 +635,4 @@
|
||||
"ToastSocketFailedToConnect": "Socket failed to connect",
|
||||
"ToastUserDeleteFailed": "Failed to delete user",
|
||||
"ToastUserDeleteSuccess": "User deleted"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"ButtonCreate": "Create",
|
||||
"ButtonCreateBackup": "Create Backup",
|
||||
"ButtonDelete": "Delete",
|
||||
"ButtonDownloadQueue": "Queue",
|
||||
"ButtonEdit": "Edit",
|
||||
"ButtonEditChapters": "Edit Chapters",
|
||||
"ButtonEditPodcast": "Edit Podcast",
|
||||
@@ -92,7 +93,9 @@
|
||||
"HeaderCollection": "Collection",
|
||||
"HeaderCollectionItems": "Collection Items",
|
||||
"HeaderCover": "Cover",
|
||||
"HeaderCurrentDownloads": "Current Downloads",
|
||||
"HeaderDetails": "Details",
|
||||
"HeaderDownloadQueue": "Download Queue",
|
||||
"HeaderEpisodes": "Episodes",
|
||||
"HeaderFiles": "Files",
|
||||
"HeaderFindChapters": "Find Chapters",
|
||||
@@ -127,6 +130,7 @@
|
||||
"HeaderRemoveEpisode": "Remove Episode",
|
||||
"HeaderRemoveEpisodes": "Remove {0} Episodes",
|
||||
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
|
||||
"HeaderRSSFeedGeneral": "RSS Details",
|
||||
"HeaderSavedMediaProgress": "Saved Media Progress",
|
||||
"HeaderSchedule": "Schedule",
|
||||
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
|
||||
@@ -138,6 +142,7 @@
|
||||
"HeaderSettingsGeneral": "General",
|
||||
"HeaderSettingsScanner": "Scanner",
|
||||
"HeaderSleepTimer": "Sleep Timer",
|
||||
"HeaderStatsLargestItems": "Largest Items",
|
||||
"HeaderStatsLongestItems": "Longest Items (hrs)",
|
||||
"HeaderStatsMinutesListeningChart": "Minutes Listening (last 7 days)",
|
||||
"HeaderStatsRecentSessions": "Recent Sessions",
|
||||
@@ -162,6 +167,7 @@
|
||||
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
|
||||
"LabelAll": "All",
|
||||
"LabelAllUsers": "All Users",
|
||||
"LabelAlreadyInYourLibrary": "Already in your library",
|
||||
"LabelAppend": "Append",
|
||||
"LabelAuthor": "Author",
|
||||
"LabelAuthorFirstLast": "Author (First Last)",
|
||||
@@ -192,6 +198,7 @@
|
||||
"LabelCronExpression": "Cron Expression",
|
||||
"LabelCurrent": "Current",
|
||||
"LabelCurrently": "Currently:",
|
||||
"LabelCustomCronExpression": "Custom Cron Expression:",
|
||||
"LabelDatetime": "Datetime",
|
||||
"LabelDescription": "Description",
|
||||
"LabelDeselectAll": "Deselect All",
|
||||
@@ -209,6 +216,7 @@
|
||||
"LabelEpisode": "Episode",
|
||||
"LabelEpisodeTitle": "Episode Title",
|
||||
"LabelEpisodeType": "Episode Type",
|
||||
"LabelExample": "Example",
|
||||
"LabelExplicit": "Explicit",
|
||||
"LabelFeedURL": "Feed URL",
|
||||
"LabelFile": "File",
|
||||
@@ -270,6 +278,8 @@
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNewPassword": "New Password",
|
||||
"LabelNextBackupDate": "Next backup date",
|
||||
"LabelNextScheduledRun": "Next scheduled run",
|
||||
"LabelNotes": "Notes",
|
||||
"LabelNotFinished": "Not Finished",
|
||||
"LabelNotificationAppriseURL": "Apprise URL(s)",
|
||||
@@ -300,7 +310,9 @@
|
||||
"LabelPlayMethod": "Play Method",
|
||||
"LabelPodcast": "Podcast",
|
||||
"LabelPodcasts": "Podcasts",
|
||||
"LabelPodcastType": "Podcast Type",
|
||||
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
|
||||
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
|
||||
"LabelProgress": "Progress",
|
||||
"LabelProvider": "Provider",
|
||||
"LabelPubDate": "Pub Date",
|
||||
@@ -315,6 +327,9 @@
|
||||
"LabelRSSFeedOpen": "RSS Feed Open",
|
||||
"LabelRSSFeedSlug": "RSS Feed Slug",
|
||||
"LabelRSSFeedURL": "RSS Feed URL",
|
||||
"LabelRssFeedCustomOwnerName": "Custom owner Name",
|
||||
"LabelRssFeedCustomOwnerEmail": "Custom owner Email",
|
||||
"LabelRssFeedPreventIndexing": "Prevent Indexing",
|
||||
"LabelSearchTerm": "Search Term",
|
||||
"LabelSearchTitle": "Search Title",
|
||||
"LabelSearchTitleOrASIN": "Search Title or ASIN",
|
||||
@@ -357,6 +372,7 @@
|
||||
"LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept",
|
||||
"LabelSettingsStoreMetadataWithItem": "Store metadata with item",
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension",
|
||||
"LabelSettingsTimeFormat": "Time Format",
|
||||
"LabelShowAll": "Show All",
|
||||
"LabelSize": "Size",
|
||||
"LabelSleepTimer": "Sleep timer",
|
||||
@@ -384,6 +400,7 @@
|
||||
"LabelTag": "Tag",
|
||||
"LabelTags": "Tags",
|
||||
"LabelTagsAccessibleToUser": "Tags Accessible to User",
|
||||
"LabelTasks": "Tasks Running",
|
||||
"LabelTimeListened": "Time Listened",
|
||||
"LabelTimeListenedToday": "Time Listened Today",
|
||||
"LabelTimeRemaining": "{0} remaining",
|
||||
@@ -485,6 +502,8 @@
|
||||
"MessageNoCollections": "No Collections",
|
||||
"MessageNoCoversFound": "No Covers Found",
|
||||
"MessageNoDescription": "No description",
|
||||
"MessageNoDownloadsQueued": "No downloads queued",
|
||||
"MessageNoDownloadsInProgress": "No downloads currently in progress",
|
||||
"MessageNoEpisodeMatchesFound": "No episode matches found",
|
||||
"MessageNoEpisodes": "No Episodes",
|
||||
"MessageNoFoldersAvailable": "No Folders Available",
|
||||
@@ -501,6 +520,7 @@
|
||||
"MessageNoSearchResultsFor": "No search results for \"{0}\"",
|
||||
"MessageNoSeries": "No Series",
|
||||
"MessageNoTags": "No Tags",
|
||||
"MessageNoTasksRunning": "No Tasks Running",
|
||||
"MessageNotYetImplemented": "Not yet implemented",
|
||||
"MessageNoUpdateNecessary": "No update necessary",
|
||||
"MessageNoUpdatesWereNecessary": "No updates were necessary",
|
||||
@@ -615,4 +635,4 @@
|
||||
"ToastSocketFailedToConnect": "Socket failed to connect",
|
||||
"ToastUserDeleteFailed": "Failed to delete user",
|
||||
"ToastUserDeleteSuccess": "User deleted"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"ButtonCreate": "Créer",
|
||||
"ButtonCreateBackup": "Créer une sauvegarde",
|
||||
"ButtonDelete": "Effacer",
|
||||
"ButtonDownloadQueue": "Queue",
|
||||
"ButtonEdit": "Modifier",
|
||||
"ButtonEditChapters": "Modifier les chapitres",
|
||||
"ButtonEditPodcast": "Modifier les podcasts",
|
||||
@@ -64,18 +65,18 @@
|
||||
"ButtonScan": "Analyser",
|
||||
"ButtonScanLibrary": "Analyser la bibliothèque",
|
||||
"ButtonSearch": "Rechercher",
|
||||
"ButtonSelectFolderPath": "Sélectionner le Chemin du dossier",
|
||||
"ButtonSelectFolderPath": "Sélectionner le chemin du dossier",
|
||||
"ButtonSeries": "Séries",
|
||||
"ButtonSetChaptersFromTracks": "Positionner les Chapitre par rapports aux Pistes",
|
||||
"ButtonShiftTimes": "Décaler le Temps",
|
||||
"ButtonSetChaptersFromTracks": "Positionner les chapitres par rapports aux pistes",
|
||||
"ButtonShiftTimes": "Décaler le temps du livre",
|
||||
"ButtonShow": "Afficher",
|
||||
"ButtonStartM4BEncode": "Démarrer l'encodage M4B",
|
||||
"ButtonStartMetadataEmbed": "Démarrer les Métadonnées Intégrées",
|
||||
"ButtonStartMetadataEmbed": "Démarrer les Métadonnées intégrées",
|
||||
"ButtonSubmit": "Soumettre",
|
||||
"ButtonUpload": "Téléverser",
|
||||
"ButtonUploadBackup": "Téléverser une Sauvegarde",
|
||||
"ButtonUploadCover": "Téléverser une Couverture",
|
||||
"ButtonUploadOPMLFile": "Téléverser un Fichier OPML",
|
||||
"ButtonUploadBackup": "Téléverser une sauvegarde",
|
||||
"ButtonUploadCover": "Téléverser une couverture",
|
||||
"ButtonUploadOPMLFile": "Téléverser un fichier OPML",
|
||||
"ButtonUserDelete": "Effacer l'utilisateur {0}",
|
||||
"ButtonUserEdit": "Modifier l'utilisateur {0}",
|
||||
"ButtonViewAll": "Afficher tout",
|
||||
@@ -92,7 +93,9 @@
|
||||
"HeaderCollection": "Collection",
|
||||
"HeaderCollectionItems": "Entrées de la Collection",
|
||||
"HeaderCover": "Couverture",
|
||||
"HeaderCurrentDownloads": "Current Downloads",
|
||||
"HeaderDetails": "Détails",
|
||||
"HeaderDownloadQueue": "Download Queue",
|
||||
"HeaderEpisodes": "Épisodes",
|
||||
"HeaderFiles": "Fichiers",
|
||||
"HeaderFindChapters": "Trouver les chapitres",
|
||||
@@ -107,14 +110,14 @@
|
||||
"HeaderListeningSessions": "Sessions d'écoute",
|
||||
"HeaderListeningStats": "Statistiques d'écoute",
|
||||
"HeaderLogin": "Connexion",
|
||||
"HeaderLogs": "Fichiers Journaux",
|
||||
"HeaderLogs": "Journaux",
|
||||
"HeaderManageGenres": "Gérer les genres",
|
||||
"HeaderManageTags": "Gérer les étiquettes",
|
||||
"HeaderMapDetails": "Édition en Masse",
|
||||
"HeaderMapDetails": "Édition en masse",
|
||||
"HeaderMatch": "Rechercher",
|
||||
"HeaderMetadataToEmbed": "Métadonnée à Intégrer",
|
||||
"HeaderNewAccount": "Nouveau Compte",
|
||||
"HeaderNewLibrary": "Nouvelle Bibliothèque",
|
||||
"HeaderMetadataToEmbed": "Métadonnée à intégrer",
|
||||
"HeaderNewAccount": "Nouveau compte",
|
||||
"HeaderNewLibrary": "Nouvelle bibliothèque",
|
||||
"HeaderNotifications": "Notifications",
|
||||
"HeaderOpenRSSFeed": "Ouvrir Flux RSS",
|
||||
"HeaderOtherFiles": "Autres fichiers",
|
||||
@@ -126,7 +129,8 @@
|
||||
"HeaderPreviewCover": "Prévisualiser la couverture",
|
||||
"HeaderRemoveEpisode": "Supprimer l'épisode",
|
||||
"HeaderRemoveEpisodes": "Suppression de {0} épisodes",
|
||||
"HeaderRSSFeedIsOpen": "Le Flux RSS et Ouvert",
|
||||
"HeaderRSSFeedIsOpen": "Le Flux RSS est actif",
|
||||
"HeaderRSSFeedGeneral": "RSS Details",
|
||||
"HeaderSavedMediaProgress": "Progression de la sauvegarde des médias",
|
||||
"HeaderSchedule": "Programmation",
|
||||
"HeaderScheduleLibraryScans": "Analyse automatique de la bibliothèque",
|
||||
@@ -138,6 +142,7 @@
|
||||
"HeaderSettingsGeneral": "Général",
|
||||
"HeaderSettingsScanner": "Scanneur",
|
||||
"HeaderSleepTimer": "Minuterie",
|
||||
"HeaderStatsLargestItems": "Articles les plus lourd",
|
||||
"HeaderStatsLongestItems": "Articles les plus long (heures)",
|
||||
"HeaderStatsMinutesListeningChart": "Minutes d'écoute (7 derniers jours)",
|
||||
"HeaderStatsRecentSessions": "Sessions récentes",
|
||||
@@ -161,7 +166,8 @@
|
||||
"LabelAddToPlaylist": "Ajouter à la liste de lecture",
|
||||
"LabelAddToPlaylistBatch": "{0} éléments ajoutés à la liste de lecture",
|
||||
"LabelAll": "Tout",
|
||||
"LabelAllUsers": "Tous les Utilisateurs",
|
||||
"LabelAllUsers": "Tous les utilisateurs",
|
||||
"LabelAlreadyInYourLibrary": "Already in your library",
|
||||
"LabelAppend": "Ajouter",
|
||||
"LabelAuthor": "Auteur",
|
||||
"LabelAuthorFirstLast": "Auteur (Prénom Nom)",
|
||||
@@ -169,11 +175,11 @@
|
||||
"LabelAuthors": "Auteurs",
|
||||
"LabelAutoDownloadEpisodes": "Téléchargement automatique d'épisode",
|
||||
"LabelBackToUser": "Revenir à l'Utilisateur",
|
||||
"LabelBackupsEnableAutomaticBackups": "Activer les Sauvegardes Automatiques",
|
||||
"LabelBackupsEnableAutomaticBackups": "Activer les sauvegardes automatiques",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "Sauvegardes Enregistrées dans /metadata/backups",
|
||||
"LabelBackupsMaxBackupSize": "Taille de Sauvegarde Maximale (en GB)",
|
||||
"LabelBackupsMaxBackupSize": "Taille maximale de la sauvegarde (en Go)",
|
||||
"LabelBackupsMaxBackupSizeHelp": "Afin de prévenir les mauvaises configuration, la sauvegarde échouera si elle excède la taille limite.",
|
||||
"LabelBackupsNumberToKeep": "Nombre de Sauvegardes à maintenir",
|
||||
"LabelBackupsNumberToKeep": "Nombre de sauvegardes à maintenir",
|
||||
"LabelBackupsNumberToKeepHelp": "Une seule sauvegarde sera effacée à la fois. Si vous avez plus de sauvegardes à effacer, vous devrez le faire manuellement.",
|
||||
"LabelBooks": "Livres",
|
||||
"LabelChangePassword": "Changer le mot de passe",
|
||||
@@ -192,9 +198,10 @@
|
||||
"LabelCronExpression": "Expression Cron",
|
||||
"LabelCurrent": "Courrant",
|
||||
"LabelCurrently": "En ce moment :",
|
||||
"LabelCustomCronExpression": "Custom Cron Expression:",
|
||||
"LabelDatetime": "Datetime",
|
||||
"LabelDescription": "Description",
|
||||
"LabelDeselectAll": "Tout Déselectionner",
|
||||
"LabelDeselectAll": "Tout déselectionner",
|
||||
"LabelDevice": "Appareil",
|
||||
"LabelDeviceInfo": "Détail de l'appareil",
|
||||
"LabelDirectory": "Répertoire",
|
||||
@@ -209,12 +216,13 @@
|
||||
"LabelEpisode": "Épisode",
|
||||
"LabelEpisodeTitle": "Titre de l'épisode",
|
||||
"LabelEpisodeType": "Type de l'épisode",
|
||||
"LabelExample": "Example",
|
||||
"LabelExplicit": "Restriction",
|
||||
"LabelFeedURL": "URL deu flux",
|
||||
"LabelFile": "Fichier",
|
||||
"LabelFileBirthtime": "Creation du fichier",
|
||||
"LabelFileModified": "Modification du fichier",
|
||||
"LabelFilename": "Nom de Fichier",
|
||||
"LabelFilename": "Nom de fichier",
|
||||
"LabelFilterByUser": "Filtrer par l'utilisateur",
|
||||
"LabelFindEpisodes": "Trouver des épisodes",
|
||||
"LabelFinished": "Fini(e)",
|
||||
@@ -248,7 +256,7 @@
|
||||
"LabelLibrariesAccessibleToUser": "Bibliothèque accessible à l'utilisateur",
|
||||
"LabelLibrary": "Bibliothèque",
|
||||
"LabelLibraryItem": "Article de bibliothèque",
|
||||
"LabelLibraryName": "Nom de bibliothèque",
|
||||
"LabelLibraryName": "Nom de la bibliothèque",
|
||||
"LabelLimit": "Limite",
|
||||
"LabelListenAgain": "Écouter à nouveau",
|
||||
"LabelLogLevelDebug": "Debug",
|
||||
@@ -270,6 +278,8 @@
|
||||
"LabelNewestAuthors": "Nouveaux auteurs",
|
||||
"LabelNewestEpisodes": "Derniers épisodes",
|
||||
"LabelNewPassword": "Nouveau mot de passe",
|
||||
"LabelNextBackupDate": "Next backup date",
|
||||
"LabelNextScheduledRun": "Next scheduled run",
|
||||
"LabelNotes": "Notes",
|
||||
"LabelNotFinished": "Non terminé(e)",
|
||||
"LabelNotificationAppriseURL": "URL(s) d'apprise",
|
||||
@@ -300,7 +310,9 @@
|
||||
"LabelPlayMethod": "Méthode d'écoute",
|
||||
"LabelPodcast": "Podcast",
|
||||
"LabelPodcasts": "Podcasts",
|
||||
"LabelPodcastType": "Podcast Type",
|
||||
"LabelPrefixesToIgnore": "Préfixes à Ignorer (Insensible à la Casse)",
|
||||
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
|
||||
"LabelProgress": "Progression",
|
||||
"LabelProvider": "Fournisseur",
|
||||
"LabelPubDate": "Date de publication",
|
||||
@@ -315,6 +327,9 @@
|
||||
"LabelRSSFeedOpen": "Flux RSS ouvert",
|
||||
"LabelRSSFeedSlug": "Identificateur d'adresse du Flux RSS ",
|
||||
"LabelRSSFeedURL": "Adresse du flux RSS",
|
||||
"LabelRssFeedCustomOwnerName": "Custom owner Name",
|
||||
"LabelRssFeedCustomOwnerEmail": "Custom owner Email",
|
||||
"LabelRssFeedPreventIndexing": "Prevent Indexing",
|
||||
"LabelSearchTerm": "Terme de recherche",
|
||||
"LabelSearchTitle": "Titre de recherche",
|
||||
"LabelSearchTitleOrASIN": "Recherche du titre ou ASIN",
|
||||
@@ -324,16 +339,16 @@
|
||||
"LabelSeriesName": "Nom de la série",
|
||||
"LabelSeriesProgress": "Progression de séries",
|
||||
"LabelSettingsBookshelfViewHelp": "Design Skeuomorphic avec une étagère en bois",
|
||||
"LabelSettingsChromecastSupport": "Support Chromecast",
|
||||
"LabelSettingsChromecastSupport": "Support du Chromecast",
|
||||
"LabelSettingsDateFormat": "Format de date",
|
||||
"LabelSettingsDisableWatcher": "Désactiver la surveillance",
|
||||
"LabelSettingsDisableWatcherForLibrary": "Désactiver la surveillance du dossier pour la bibliothèque",
|
||||
"LabelSettingsDisableWatcherForLibrary": "Désactiver la surveillance des dossiers pour la bibliothèque",
|
||||
"LabelSettingsDisableWatcherHelp": "Désactive la mise à jour automatique lorsque les fichiers changent. *Nécessite un redémarrage*",
|
||||
"LabelSettingsEnableEReader": "Active E-reader pour tous les utilisateurs",
|
||||
"LabelSettingsEnableEReaderHelp": "E-reader est toujours en cours de développement, mais ce paramètre l'active pour tous les utilisateurs (ou utiliser l'interrupteur \"Fonctionnalités Expérimentales\" pour l'activer seulement pour vous)",
|
||||
"LabelSettingsExperimentalFeatures": "Fonctionnalités Expérimentales",
|
||||
"LabelSettingsEnableEReaderHelp": "E-reader est toujours en cours de développement, mais ce paramètre l'active pour tous les utilisateurs (ou utiliser l'interrupteur \"Fonctionnalités expérimentales\" pour l'activer seulement pour vous)",
|
||||
"LabelSettingsExperimentalFeatures": "Fonctionnalités expérimentales",
|
||||
"LabelSettingsExperimentalFeaturesHelp": "Fonctionnalités en cours de développement sur lesquels nous attendons votre retour et expérience. Cliquer pour ouvrir la discussion Github.",
|
||||
"LabelSettingsFindCovers": "Rechercher des Couvertures",
|
||||
"LabelSettingsFindCovers": "Chercher des couvertures de livre",
|
||||
"LabelSettingsFindCoversHelp": "Si votre livre audio ne possède pas de couverture intégrée ou une image de couverture dans le dossier, l'analyser tentera de récupérer une couverture.<br>Attention, cela peut augmenter le temps d'analyse.",
|
||||
"LabelSettingsHomePageBookshelfView": "La page d'accueil utilise la vue étagère",
|
||||
"LabelSettingsLibraryBookshelfView": "La bibliothèque utilise la vue étagère",
|
||||
@@ -341,7 +356,7 @@
|
||||
"LabelSettingsOverdriveMediaMarkersHelp": "Les fichiers MP3 d'Overdrive viennent avec les minutages des chapitres intégrés en métadonnées. Activer ce paramètre utilisera ces minutages pour les chapitres automatiquement.",
|
||||
"LabelSettingsParseSubtitles": "Analyse des sous-titres",
|
||||
"LabelSettingsParseSubtitlesHelp": "Extrait les sous-titres depuis le dossier du Livre Audio.<br>Les sous-titres doivent être séparés par \" - \"<br>i.e. \"Titre du Livre - Ceci est un sous-titre\" aura le sous-titre \"Ceci est un sous-titre\"",
|
||||
"LabelSettingsPreferAudioMetadata": "Préférer les Métadonnées Audio",
|
||||
"LabelSettingsPreferAudioMetadata": "Préférer les Métadonnées audio",
|
||||
"LabelSettingsPreferAudioMetadataHelp": "Les méta étiquettes ID3 des fichiers audios seront utilisés à la place des noms de dossier pour les détails du livre audio",
|
||||
"LabelSettingsPreferMatchedMetadata": "Préférer les Métadonnées par correspondance",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Les métadonnées par correspondance écrase les détails de l'article lors d'une Recherche par Correspondance Rapide. Par défaut, la recherche par correspondance rapide ne comblera que les éléments manquant.",
|
||||
@@ -357,6 +372,7 @@
|
||||
"LabelSettingsStoreCoversWithItemHelp": "Par défaut, les couvertures sont enregistrées dans /metadata/items. Activer ce paramètre enregistrera les couvertures dans le dossier avec les fichiersde l'article. Seul un fichier nommé \"cover\" sera gardé.",
|
||||
"LabelSettingsStoreMetadataWithItem": "Enregistrer les Métadonnées avec les articles",
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les métadonnées sont enregistrées dans /metadata/items. Activer ce paramètre enregistrera les métadonnées dans le dossier de l'article avec une extension \".abs\".",
|
||||
"LabelSettingsTimeFormat": "Time Format",
|
||||
"LabelShowAll": "Afficher Tout",
|
||||
"LabelSize": "Taille",
|
||||
"LabelSleepTimer": "Minuterie",
|
||||
@@ -372,7 +388,7 @@
|
||||
"LabelStatsDaysListened": "Jours d'écoute",
|
||||
"LabelStatsHours": "Heures",
|
||||
"LabelStatsInARow": "d'affilé(s)",
|
||||
"LabelStatsItemsFinished": "Articles Terminés",
|
||||
"LabelStatsItemsFinished": "Articles terminés",
|
||||
"LabelStatsItemsInLibrary": "Articles dans la Bibliothèque",
|
||||
"LabelStatsMinutes": "minutes",
|
||||
"LabelStatsMinutesListening": "Minutes d'écoute",
|
||||
@@ -380,10 +396,11 @@
|
||||
"LabelStatsOverallHours": "Heures au total",
|
||||
"LabelStatsWeekListening": "Écoute de la semaine",
|
||||
"LabelSubtitle": "Sous-Titre",
|
||||
"LabelSupportedFileTypes": "Types de fichiers Supportés",
|
||||
"LabelSupportedFileTypes": "Types de fichiers supportés",
|
||||
"LabelTag": "Étiquette",
|
||||
"LabelTags": "Étiquettes",
|
||||
"LabelTagsAccessibleToUser": "Étiquettes accessibles à l'utilisateur",
|
||||
"LabelTasks": "Tasks Running",
|
||||
"LabelTimeListened": "Temps d'écoute",
|
||||
"LabelTimeListenedToday": "Nombres d'écoutes Aujourd'hui",
|
||||
"LabelTimeRemaining": "{0} restantes",
|
||||
@@ -428,7 +445,7 @@
|
||||
"LabelYourProgress": "Votre progression",
|
||||
"MessageAddToPlayerQueue": "Ajouter en file d'attente",
|
||||
"MessageAppriseDescription": "Nécessite une instance d'<a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API Apprise</a> pour utiliser cette fonctionnalité ou une api qui prend en charge les mêmes requêtes. <br />L'URL de l'API Apprise doit comprendre le chemin complet pour envoyer la notification. Par exemple, si votre instance écoute sur <code>http://192.168.1.1:8337</code> alors vous devez mettre <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageBackupsDescription": "Les Sauvegardes incluent les utilisateurs, la progression de lecture par utilisateur, les détails des articles des bibliothèques, les paramètres du serveur et les images sauvegardées. Les Sauvegardes n'incluent pas les fichiers de votre bibliothèque.",
|
||||
"MessageBackupsDescription": "Les sauvegardes incluent les utilisateurs, la progression de lecture par utilisateur, les détails des articles des bibliothèques, les paramètres du serveur et les images sauvegardées. Les Sauvegardes n'incluent pas les fichiers de votre bibliothèque.",
|
||||
"MessageBatchQuickMatchDescription": "La Recherche par Correspondance Rapide tentera d'ajouter les couvertures et les métadonnées manquantes pour les articles sélectionnés. Activer l'option suivante pour autoriser la Recherche par Correspondance à écraser les données existantes.",
|
||||
"MessageBookshelfNoCollections": "Vous n'avez pas encore de collections",
|
||||
"MessageBookshelfNoResultsForFilter": "Aucun résultat pour le filtre \"{0}: {1}\"",
|
||||
@@ -485,6 +502,8 @@
|
||||
"MessageNoCollections": "Pas de collections",
|
||||
"MessageNoCoversFound": "Aucune couverture trouvée",
|
||||
"MessageNoDescription": "Pas de description",
|
||||
"MessageNoDownloadsQueued": "No downloads queued",
|
||||
"MessageNoDownloadsInProgress": "No downloads currently in progress",
|
||||
"MessageNoEpisodeMatchesFound": "Pas de correspondance d'épisode trouvée",
|
||||
"MessageNoEpisodes": "Aucun épisode",
|
||||
"MessageNoFoldersAvailable": "Aucun dossier disponible",
|
||||
@@ -501,6 +520,7 @@
|
||||
"MessageNoSearchResultsFor": "Pas de résultats de recherche pour \"{0}\"",
|
||||
"MessageNoSeries": "Pas de séries",
|
||||
"MessageNoTags": "Pas d'étiquettes",
|
||||
"MessageNoTasksRunning": "No Tasks Running",
|
||||
"MessageNotYetImplemented": "Non implémenté",
|
||||
"MessageNoUpdateNecessary": "Pas de mise à jour nécessaire",
|
||||
"MessageNoUpdatesWereNecessary": "Aucune mise à jour n'était nécessaire",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"ButtonCreate": "Napravi",
|
||||
"ButtonCreateBackup": "Napravi backup",
|
||||
"ButtonDelete": "Obriši",
|
||||
"ButtonDownloadQueue": "Queue",
|
||||
"ButtonEdit": "Edit",
|
||||
"ButtonEditChapters": "Uredi poglavlja",
|
||||
"ButtonEditPodcast": "Uredi podcast",
|
||||
@@ -92,7 +93,9 @@
|
||||
"HeaderCollection": "Kolekcija",
|
||||
"HeaderCollectionItems": "Stvari u kolekciji",
|
||||
"HeaderCover": "Cover",
|
||||
"HeaderCurrentDownloads": "Current Downloads",
|
||||
"HeaderDetails": "Detalji",
|
||||
"HeaderDownloadQueue": "Download Queue",
|
||||
"HeaderEpisodes": "Epizode",
|
||||
"HeaderFiles": "Datoteke",
|
||||
"HeaderFindChapters": "Pronađi poglavlja",
|
||||
@@ -127,6 +130,7 @@
|
||||
"HeaderRemoveEpisode": "Ukloni epizodu",
|
||||
"HeaderRemoveEpisodes": "Ukloni {0} epizoda/-e",
|
||||
"HeaderRSSFeedIsOpen": "RSS Feed je otvoren",
|
||||
"HeaderRSSFeedGeneral": "RSS Details",
|
||||
"HeaderSavedMediaProgress": "Spremljen Media Progress",
|
||||
"HeaderSchedule": "Schedule",
|
||||
"HeaderScheduleLibraryScans": "Zakaži automatsko skeniranje biblioteke",
|
||||
@@ -138,6 +142,7 @@
|
||||
"HeaderSettingsGeneral": "Opčenito",
|
||||
"HeaderSettingsScanner": "Scanner",
|
||||
"HeaderSleepTimer": "Sleep Timer",
|
||||
"HeaderStatsLargestItems": "Largest Items",
|
||||
"HeaderStatsLongestItems": "Najduže stavke (sati)",
|
||||
"HeaderStatsMinutesListeningChart": "Minuta odslušanih (posljednjih 7 dana)",
|
||||
"HeaderStatsRecentSessions": "Nedavne sesije",
|
||||
@@ -162,6 +167,7 @@
|
||||
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
|
||||
"LabelAll": "All",
|
||||
"LabelAllUsers": "Svi korisnici",
|
||||
"LabelAlreadyInYourLibrary": "Already in your library",
|
||||
"LabelAppend": "Append",
|
||||
"LabelAuthor": "Autor",
|
||||
"LabelAuthorFirstLast": "Author (First Last)",
|
||||
@@ -192,6 +198,7 @@
|
||||
"LabelCronExpression": "Cron Expression",
|
||||
"LabelCurrent": "Trenutan",
|
||||
"LabelCurrently": "Trenutno:",
|
||||
"LabelCustomCronExpression": "Custom Cron Expression:",
|
||||
"LabelDatetime": "Datetime",
|
||||
"LabelDescription": "Opis",
|
||||
"LabelDeselectAll": "Odznači sve",
|
||||
@@ -209,6 +216,7 @@
|
||||
"LabelEpisode": "Epizoda",
|
||||
"LabelEpisodeTitle": "Naslov epizode",
|
||||
"LabelEpisodeType": "Vrsta epizode",
|
||||
"LabelExample": "Example",
|
||||
"LabelExplicit": "Explicit",
|
||||
"LabelFeedURL": "Feed URL",
|
||||
"LabelFile": "Datoteka",
|
||||
@@ -270,6 +278,8 @@
|
||||
"LabelNewestAuthors": "Najnoviji autori",
|
||||
"LabelNewestEpisodes": "Najnovije epizode",
|
||||
"LabelNewPassword": "Nova lozinka",
|
||||
"LabelNextBackupDate": "Next backup date",
|
||||
"LabelNextScheduledRun": "Next scheduled run",
|
||||
"LabelNotes": "Bilješke",
|
||||
"LabelNotFinished": "Nedovršeno",
|
||||
"LabelNotificationAppriseURL": "Apprise URL(s)",
|
||||
@@ -300,7 +310,9 @@
|
||||
"LabelPlayMethod": "Vrsta reprodukcije",
|
||||
"LabelPodcast": "Podcast",
|
||||
"LabelPodcasts": "Podcasts",
|
||||
"LabelPodcastType": "Podcast Type",
|
||||
"LabelPrefixesToIgnore": "Prefiksi za ignorirati (mala i velika slova nisu bitna)",
|
||||
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
|
||||
"LabelProgress": "Napredak",
|
||||
"LabelProvider": "Dobavljač",
|
||||
"LabelPubDate": "Datam izdavanja",
|
||||
@@ -315,6 +327,9 @@
|
||||
"LabelRSSFeedOpen": "RSS Feed Open",
|
||||
"LabelRSSFeedSlug": "RSS Feed Slug",
|
||||
"LabelRSSFeedURL": "RSS Feed URL",
|
||||
"LabelRssFeedCustomOwnerName": "Custom owner Name",
|
||||
"LabelRssFeedCustomOwnerEmail": "Custom owner Email",
|
||||
"LabelRssFeedPreventIndexing": "Prevent Indexing",
|
||||
"LabelSearchTerm": "Traži pojam",
|
||||
"LabelSearchTitle": "Traži naslov",
|
||||
"LabelSearchTitleOrASIN": "Traži naslov ili ASIN",
|
||||
@@ -357,6 +372,7 @@
|
||||
"LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept",
|
||||
"LabelSettingsStoreMetadataWithItem": "Spremi metapodatke uz stavku",
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "Po defaultu metapodatci su spremljeni u /metadata/items, uključujućite li ovu postavku, metapodatci će biti spremljeni u folderima od biblioteke. Koristi .abs ekstenziju.",
|
||||
"LabelSettingsTimeFormat": "Time Format",
|
||||
"LabelShowAll": "Prikaži sve",
|
||||
"LabelSize": "Veličina",
|
||||
"LabelSleepTimer": "Sleep timer",
|
||||
@@ -384,6 +400,7 @@
|
||||
"LabelTag": "Tag",
|
||||
"LabelTags": "Tags",
|
||||
"LabelTagsAccessibleToUser": "Tags dostupni korisniku",
|
||||
"LabelTasks": "Tasks Running",
|
||||
"LabelTimeListened": "Vremena odslušano",
|
||||
"LabelTimeListenedToday": "Vremena odslušano danas",
|
||||
"LabelTimeRemaining": "{0} preostalo",
|
||||
@@ -485,6 +502,8 @@
|
||||
"MessageNoCollections": "Nema kolekcija",
|
||||
"MessageNoCoversFound": "Covers nisu pronađeni",
|
||||
"MessageNoDescription": "Nema opisa",
|
||||
"MessageNoDownloadsQueued": "No downloads queued",
|
||||
"MessageNoDownloadsInProgress": "No downloads currently in progress",
|
||||
"MessageNoEpisodeMatchesFound": "Nijedna epizoda pronađena",
|
||||
"MessageNoEpisodes": "Nema epizoda",
|
||||
"MessageNoFoldersAvailable": "Nema dostupnih foldera",
|
||||
@@ -501,6 +520,7 @@
|
||||
"MessageNoSearchResultsFor": "Nema rezultata pretragee za \"{0}\"",
|
||||
"MessageNoSeries": "No Series",
|
||||
"MessageNoTags": "No Tags",
|
||||
"MessageNoTasksRunning": "No Tasks Running",
|
||||
"MessageNotYetImplemented": "Not yet implemented",
|
||||
"MessageNoUpdateNecessary": "Aktualiziranje nije potrebno",
|
||||
"MessageNoUpdatesWereNecessary": "Aktualiziranje nije bilo potrebno",
|
||||
@@ -615,4 +635,4 @@
|
||||
"ToastSocketFailedToConnect": "Socket failed to connect",
|
||||
"ToastUserDeleteFailed": "Neuspješno brisanje korisnika",
|
||||
"ToastUserDeleteSuccess": "Korisnik obrisan"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"ButtonCreate": "Crea",
|
||||
"ButtonCreateBackup": "Crea un Backup",
|
||||
"ButtonDelete": "Elimina",
|
||||
"ButtonDownloadQueue": "Queue",
|
||||
"ButtonEdit": "Edit",
|
||||
"ButtonEditChapters": "Modifica Capitoli",
|
||||
"ButtonEditPodcast": "Modifica Podcast",
|
||||
@@ -92,7 +93,9 @@
|
||||
"HeaderCollection": "Raccolta",
|
||||
"HeaderCollectionItems": "Elementi della Raccolta",
|
||||
"HeaderCover": "Cover",
|
||||
"HeaderCurrentDownloads": "Current Downloads",
|
||||
"HeaderDetails": "Dettagli",
|
||||
"HeaderDownloadQueue": "Download Queue",
|
||||
"HeaderEpisodes": "Episodi",
|
||||
"HeaderFiles": "File",
|
||||
"HeaderFindChapters": "Trova Capitoli",
|
||||
@@ -127,6 +130,7 @@
|
||||
"HeaderRemoveEpisode": "Rimuovi Episodi",
|
||||
"HeaderRemoveEpisodes": "Rimuovi {0} Episodi",
|
||||
"HeaderRSSFeedIsOpen": "RSS Feed è aperto",
|
||||
"HeaderRSSFeedGeneral": "RSS Details",
|
||||
"HeaderSavedMediaProgress": "Progressi salvati",
|
||||
"HeaderSchedule": "Schedula",
|
||||
"HeaderScheduleLibraryScans": "Schedula la scansione della libreria",
|
||||
@@ -138,6 +142,7 @@
|
||||
"HeaderSettingsGeneral": "Generale",
|
||||
"HeaderSettingsScanner": "Scanner",
|
||||
"HeaderSleepTimer": "Sveglia",
|
||||
"HeaderStatsLargestItems": "Largest Items",
|
||||
"HeaderStatsLongestItems": "libri più lunghi (ore)",
|
||||
"HeaderStatsMinutesListeningChart": "Minuti ascoltati (Ultimi 7 Giorni)",
|
||||
"HeaderStatsRecentSessions": "Sessioni Recenti",
|
||||
@@ -162,6 +167,7 @@
|
||||
"LabelAddToPlaylistBatch": "Aggiungi {0} file alla Playlist",
|
||||
"LabelAll": "Tutti",
|
||||
"LabelAllUsers": "Tutti gli Utenti",
|
||||
"LabelAlreadyInYourLibrary": "Already in your library",
|
||||
"LabelAppend": "Appese",
|
||||
"LabelAuthor": "Autore",
|
||||
"LabelAuthorFirstLast": "Autore (Per Nome)",
|
||||
@@ -192,6 +198,7 @@
|
||||
"LabelCronExpression": "Espressione Cron",
|
||||
"LabelCurrent": "Attuale",
|
||||
"LabelCurrently": "Attualmente:",
|
||||
"LabelCustomCronExpression": "Custom Cron Expression:",
|
||||
"LabelDatetime": "Data & Ora",
|
||||
"LabelDescription": "Descrizione",
|
||||
"LabelDeselectAll": "Deseleziona Tutto",
|
||||
@@ -209,6 +216,7 @@
|
||||
"LabelEpisode": "Episodio",
|
||||
"LabelEpisodeTitle": "Titolo Episodio",
|
||||
"LabelEpisodeType": "Tipo Episodio",
|
||||
"LabelExample": "Example",
|
||||
"LabelExplicit": "Esplicito",
|
||||
"LabelFeedURL": "Feed URL",
|
||||
"LabelFile": "File",
|
||||
@@ -270,6 +278,8 @@
|
||||
"LabelNewestAuthors": "Autori Recenti",
|
||||
"LabelNewestEpisodes": "Episodi Recenti",
|
||||
"LabelNewPassword": "Nuova Password",
|
||||
"LabelNextBackupDate": "Next backup date",
|
||||
"LabelNextScheduledRun": "Next scheduled run",
|
||||
"LabelNotes": "Note",
|
||||
"LabelNotFinished": "Da Completare",
|
||||
"LabelNotificationAppriseURL": "Apprendi URL(s)",
|
||||
@@ -300,7 +310,9 @@
|
||||
"LabelPlayMethod": "Metodo di riproduzione",
|
||||
"LabelPodcast": "Podcast",
|
||||
"LabelPodcasts": "Podcasts",
|
||||
"LabelPodcastType": "Podcast Type",
|
||||
"LabelPrefixesToIgnore": "Suffissi da ignorare (specificando maiuscole e minuscole)",
|
||||
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
|
||||
"LabelProgress": "Cominciati",
|
||||
"LabelProvider": "Provider",
|
||||
"LabelPubDate": "Data Pubblicazione",
|
||||
@@ -315,6 +327,9 @@
|
||||
"LabelRSSFeedOpen": "RSS Feed Aperto",
|
||||
"LabelRSSFeedSlug": "RSS Feed Slug",
|
||||
"LabelRSSFeedURL": "RSS Feed URL",
|
||||
"LabelRssFeedCustomOwnerName": "Custom owner Name",
|
||||
"LabelRssFeedCustomOwnerEmail": "Custom owner Email",
|
||||
"LabelRssFeedPreventIndexing": "Prevent Indexing",
|
||||
"LabelSearchTerm": "Ricerca",
|
||||
"LabelSearchTitle": "Cerca Titolo",
|
||||
"LabelSearchTitleOrASIN": "Cerca titolo o ASIN",
|
||||
@@ -357,6 +372,7 @@
|
||||
"LabelSettingsStoreCoversWithItemHelp": "Di default, le immagini di copertina sono salvate dentro /metadata/items, abilitando questa opzione le copertine saranno archiviate nella cartella della libreria corrispondente. Verrà conservato solo un file denominato \"cover\"",
|
||||
"LabelSettingsStoreMetadataWithItem": "Archivia i metadata con il file",
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "Di default, i metadati sono salvati dentro /metadata/items, abilitando questa opzione si memorizzeranno i metadata nella cartella della libreria. I file avranno estensione .abs",
|
||||
"LabelSettingsTimeFormat": "Time Format",
|
||||
"LabelShowAll": "Mostra Tutto",
|
||||
"LabelSize": "Dimensione",
|
||||
"LabelSleepTimer": "Sleep timer",
|
||||
@@ -384,6 +400,7 @@
|
||||
"LabelTag": "Tag",
|
||||
"LabelTags": "Tags",
|
||||
"LabelTagsAccessibleToUser": "Tags permessi agli Utenti",
|
||||
"LabelTasks": "Tasks Running",
|
||||
"LabelTimeListened": "Tempo di Ascolto",
|
||||
"LabelTimeListenedToday": "Tempo di Ascolto Oggi",
|
||||
"LabelTimeRemaining": "{0} rimanente",
|
||||
@@ -485,6 +502,8 @@
|
||||
"MessageNoCollections": "Nessuna Raccolta",
|
||||
"MessageNoCoversFound": "Nessuna Cover Trovata",
|
||||
"MessageNoDescription": "Nessuna descrizione",
|
||||
"MessageNoDownloadsQueued": "No downloads queued",
|
||||
"MessageNoDownloadsInProgress": "No downloads currently in progress",
|
||||
"MessageNoEpisodeMatchesFound": "Nessun episodio corrispondente trovato",
|
||||
"MessageNoEpisodes": "Nessun Episodio",
|
||||
"MessageNoFoldersAvailable": "Nessuna Cartella disponibile",
|
||||
@@ -501,6 +520,7 @@
|
||||
"MessageNoSearchResultsFor": "Nessun risultato per \"{0}\"",
|
||||
"MessageNoSeries": "Nessuna Serie",
|
||||
"MessageNoTags": "No Tags",
|
||||
"MessageNoTasksRunning": "No Tasks Running",
|
||||
"MessageNotYetImplemented": "Non Ancora Implementato",
|
||||
"MessageNoUpdateNecessary": "Nessun aggiornamento necessario",
|
||||
"MessageNoUpdatesWereNecessary": "Nessun aggiornamento necessario",
|
||||
@@ -615,4 +635,4 @@
|
||||
"ToastSocketFailedToConnect": "Socket non riesce a connettersi",
|
||||
"ToastUserDeleteFailed": "Errore eliminazione utente",
|
||||
"ToastUserDeleteSuccess": "Utente eliminato"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"ButtonCreate": "Utwórz",
|
||||
"ButtonCreateBackup": "Utwórz kopię zapasową",
|
||||
"ButtonDelete": "Usuń",
|
||||
"ButtonDownloadQueue": "Queue",
|
||||
"ButtonEdit": "Edit",
|
||||
"ButtonEditChapters": "Edytuj rozdziały",
|
||||
"ButtonEditPodcast": "Edytuj podcast",
|
||||
@@ -92,7 +93,9 @@
|
||||
"HeaderCollection": "Kolekcja",
|
||||
"HeaderCollectionItems": "Elementy kolekcji",
|
||||
"HeaderCover": "Okładka",
|
||||
"HeaderCurrentDownloads": "Current Downloads",
|
||||
"HeaderDetails": "Szczegóły",
|
||||
"HeaderDownloadQueue": "Download Queue",
|
||||
"HeaderEpisodes": "Rozdziały",
|
||||
"HeaderFiles": "Pliki",
|
||||
"HeaderFindChapters": "Wyszukaj rozdziały",
|
||||
@@ -127,6 +130,7 @@
|
||||
"HeaderRemoveEpisode": "Usuń odcinek",
|
||||
"HeaderRemoveEpisodes": "Usuń {0} odcinków",
|
||||
"HeaderRSSFeedIsOpen": "Kanał RSS jest otwarty",
|
||||
"HeaderRSSFeedGeneral": "RSS Details",
|
||||
"HeaderSavedMediaProgress": "Zapisany postęp",
|
||||
"HeaderSchedule": "Harmonogram",
|
||||
"HeaderScheduleLibraryScans": "Zaplanuj automatyczne skanowanie biblioteki",
|
||||
@@ -138,6 +142,7 @@
|
||||
"HeaderSettingsGeneral": "Ogólne",
|
||||
"HeaderSettingsScanner": "Skanowanie",
|
||||
"HeaderSleepTimer": "Wyłącznik czasowy",
|
||||
"HeaderStatsLargestItems": "Largest Items",
|
||||
"HeaderStatsLongestItems": "Najdłuższe pozycje (hrs)",
|
||||
"HeaderStatsMinutesListeningChart": "Czas słuchania w minutach (ostatnie 7 dni)",
|
||||
"HeaderStatsRecentSessions": "Ostatnie sesje",
|
||||
@@ -162,6 +167,7 @@
|
||||
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
|
||||
"LabelAll": "All",
|
||||
"LabelAllUsers": "Wszyscy użytkownicy",
|
||||
"LabelAlreadyInYourLibrary": "Already in your library",
|
||||
"LabelAppend": "Append",
|
||||
"LabelAuthor": "Autor",
|
||||
"LabelAuthorFirstLast": "Autor (Rosnąco)",
|
||||
@@ -192,6 +198,7 @@
|
||||
"LabelCronExpression": "Wyrażenie CRON",
|
||||
"LabelCurrent": "Aktualny",
|
||||
"LabelCurrently": "Obecnie:",
|
||||
"LabelCustomCronExpression": "Custom Cron Expression:",
|
||||
"LabelDatetime": "Data i godzina",
|
||||
"LabelDescription": "Opis",
|
||||
"LabelDeselectAll": "Odznacz wszystko",
|
||||
@@ -209,6 +216,7 @@
|
||||
"LabelEpisode": "Odcinek",
|
||||
"LabelEpisodeTitle": "Tytuł odcinka",
|
||||
"LabelEpisodeType": "Typ odcinka",
|
||||
"LabelExample": "Example",
|
||||
"LabelExplicit": "Nieprzyzwoite",
|
||||
"LabelFeedURL": "URL kanału",
|
||||
"LabelFile": "Plik",
|
||||
@@ -270,6 +278,8 @@
|
||||
"LabelNewestAuthors": "Najnowsi autorzy",
|
||||
"LabelNewestEpisodes": "Najnowsze odcinki",
|
||||
"LabelNewPassword": "Nowe hasło",
|
||||
"LabelNextBackupDate": "Next backup date",
|
||||
"LabelNextScheduledRun": "Next scheduled run",
|
||||
"LabelNotes": "Uwagi",
|
||||
"LabelNotFinished": "Nieukończone",
|
||||
"LabelNotificationAppriseURL": "URLe Apprise",
|
||||
@@ -300,7 +310,9 @@
|
||||
"LabelPlayMethod": "Metoda odtwarzania",
|
||||
"LabelPodcast": "Podcast",
|
||||
"LabelPodcasts": "Podcasty",
|
||||
"LabelPodcastType": "Podcast Type",
|
||||
"LabelPrefixesToIgnore": "Ignorowane prefiksy (wielkość liter nie ma znaczenia)",
|
||||
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
|
||||
"LabelProgress": "Postęp",
|
||||
"LabelProvider": "Dostawca",
|
||||
"LabelPubDate": "Data publikacji",
|
||||
@@ -315,6 +327,9 @@
|
||||
"LabelRSSFeedOpen": "RSS Feed otwarty",
|
||||
"LabelRSSFeedSlug": "RSS Feed Slug",
|
||||
"LabelRSSFeedURL": "URL kanały RSS",
|
||||
"LabelRssFeedCustomOwnerName": "Custom owner Name",
|
||||
"LabelRssFeedCustomOwnerEmail": "Custom owner Email",
|
||||
"LabelRssFeedPreventIndexing": "Prevent Indexing",
|
||||
"LabelSearchTerm": "Wyszukiwanie frazy",
|
||||
"LabelSearchTitle": "Wyszukaj tytuł",
|
||||
"LabelSearchTitleOrASIN": "Szukaj tytuł lub ASIN",
|
||||
@@ -357,6 +372,7 @@
|
||||
"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. Rozszerzenie pliku metadanych: .abs",
|
||||
"LabelSettingsTimeFormat": "Time Format",
|
||||
"LabelShowAll": "Pokaż wszystko",
|
||||
"LabelSize": "Rozmiar",
|
||||
"LabelSleepTimer": "Wyłącznik czasowy",
|
||||
@@ -384,6 +400,7 @@
|
||||
"LabelTag": "Tag",
|
||||
"LabelTags": "Tagi",
|
||||
"LabelTagsAccessibleToUser": "Tagi dostępne dla użytkownika",
|
||||
"LabelTasks": "Tasks Running",
|
||||
"LabelTimeListened": "Czas odtwarzania",
|
||||
"LabelTimeListenedToday": "Czas odtwarzania dzisiaj",
|
||||
"LabelTimeRemaining": "Pozostało {0}",
|
||||
@@ -485,6 +502,8 @@
|
||||
"MessageNoCollections": "Brak kolekcji",
|
||||
"MessageNoCoversFound": "Okładki nieznalezione",
|
||||
"MessageNoDescription": "Brak opisu",
|
||||
"MessageNoDownloadsQueued": "No downloads queued",
|
||||
"MessageNoDownloadsInProgress": "No downloads currently in progress",
|
||||
"MessageNoEpisodeMatchesFound": "Nie znaleziono pasujących odcinków",
|
||||
"MessageNoEpisodes": "Brak odcinków",
|
||||
"MessageNoFoldersAvailable": "Brak dostępnych folderów",
|
||||
@@ -501,6 +520,7 @@
|
||||
"MessageNoSearchResultsFor": "Brak wyników wyszukiwania dla \"{0}\"",
|
||||
"MessageNoSeries": "No Series",
|
||||
"MessageNoTags": "No Tags",
|
||||
"MessageNoTasksRunning": "No Tasks Running",
|
||||
"MessageNotYetImplemented": "Jeszcze nie zaimplementowane",
|
||||
"MessageNoUpdateNecessary": "Brak konieczności aktualizacji",
|
||||
"MessageNoUpdatesWereNecessary": "Brak aktualizacji",
|
||||
@@ -615,4 +635,4 @@
|
||||
"ToastSocketFailedToConnect": "Poączenie z serwerem nie powiodło się",
|
||||
"ToastUserDeleteFailed": "Nie udało się usunąć użytkownika",
|
||||
"ToastUserDeleteSuccess": "Użytkownik usunięty"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"ButtonCreate": "Создать",
|
||||
"ButtonCreateBackup": "Создать бэкап",
|
||||
"ButtonDelete": "Удалить",
|
||||
"ButtonDownloadQueue": "Queue",
|
||||
"ButtonEdit": "Редактировать",
|
||||
"ButtonEditChapters": "Редактировать Главы",
|
||||
"ButtonEditPodcast": "Редактировать Подкаст",
|
||||
@@ -92,7 +93,9 @@
|
||||
"HeaderCollection": "Коллекция",
|
||||
"HeaderCollectionItems": "Элементы Коллекции",
|
||||
"HeaderCover": "Обложка",
|
||||
"HeaderCurrentDownloads": "Current Downloads",
|
||||
"HeaderDetails": "Подробности",
|
||||
"HeaderDownloadQueue": "Download Queue",
|
||||
"HeaderEpisodes": "Эпизоды",
|
||||
"HeaderFiles": "Файлы",
|
||||
"HeaderFindChapters": "Найти Главы",
|
||||
@@ -127,6 +130,7 @@
|
||||
"HeaderRemoveEpisode": "Удалить Эпизод",
|
||||
"HeaderRemoveEpisodes": "Удалить {0} Эпизодов",
|
||||
"HeaderRSSFeedIsOpen": "RSS-канал Открыт",
|
||||
"HeaderRSSFeedGeneral": "RSS Details",
|
||||
"HeaderSavedMediaProgress": "Прогресс Медиа Сохранен",
|
||||
"HeaderSchedule": "Планировщик",
|
||||
"HeaderScheduleLibraryScans": "Планировщик Автоматического Сканирования Библиотеки",
|
||||
@@ -138,6 +142,7 @@
|
||||
"HeaderSettingsGeneral": "Основные",
|
||||
"HeaderSettingsScanner": "Сканер",
|
||||
"HeaderSleepTimer": "Таймер Сна",
|
||||
"HeaderStatsLargestItems": "Largest Items",
|
||||
"HeaderStatsLongestItems": "Самые Длинные Книги (часов)",
|
||||
"HeaderStatsMinutesListeningChart": "Минут прослушивания (последние 7 дней)",
|
||||
"HeaderStatsRecentSessions": "Последние Сеансы",
|
||||
@@ -162,6 +167,7 @@
|
||||
"LabelAddToPlaylistBatch": "Добавить {0} Элементов в Плейлист",
|
||||
"LabelAll": "Все",
|
||||
"LabelAllUsers": "Все пользователи",
|
||||
"LabelAlreadyInYourLibrary": "Already in your library",
|
||||
"LabelAppend": "Добавить",
|
||||
"LabelAuthor": "Автор",
|
||||
"LabelAuthorFirstLast": "Автор (Имя Фамилия)",
|
||||
@@ -192,6 +198,7 @@
|
||||
"LabelCronExpression": "Выражение Cron",
|
||||
"LabelCurrent": "Текущий",
|
||||
"LabelCurrently": "Текущее:",
|
||||
"LabelCustomCronExpression": "Custom Cron Expression:",
|
||||
"LabelDatetime": "Дата и время",
|
||||
"LabelDescription": "Описание",
|
||||
"LabelDeselectAll": "Снять Выделение",
|
||||
@@ -209,6 +216,7 @@
|
||||
"LabelEpisode": "Эпизод",
|
||||
"LabelEpisodeTitle": "Имя Эпизода",
|
||||
"LabelEpisodeType": "Тип Эпизода",
|
||||
"LabelExample": "Example",
|
||||
"LabelExplicit": "Явный",
|
||||
"LabelFeedURL": "URL Канала",
|
||||
"LabelFile": "Файл",
|
||||
@@ -270,6 +278,8 @@
|
||||
"LabelNewestAuthors": "Новые Авторы",
|
||||
"LabelNewestEpisodes": "Новые Эпизоды",
|
||||
"LabelNewPassword": "Новый Пароль",
|
||||
"LabelNextBackupDate": "Next backup date",
|
||||
"LabelNextScheduledRun": "Next scheduled run",
|
||||
"LabelNotes": "Заметки",
|
||||
"LabelNotFinished": "Не Завершено",
|
||||
"LabelNotificationAppriseURL": "URL(ы) для извещений",
|
||||
@@ -300,7 +310,9 @@
|
||||
"LabelPlayMethod": "Метод Воспроизведения",
|
||||
"LabelPodcast": "Подкаст",
|
||||
"LabelPodcasts": "Подкасты",
|
||||
"LabelPodcastType": "Podcast Type",
|
||||
"LabelPrefixesToIgnore": "Игнорируемые Префиксы (без учета регистра)",
|
||||
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
|
||||
"LabelProgress": "Прогресс",
|
||||
"LabelProvider": "Провайдер",
|
||||
"LabelPubDate": "Дата Публикации",
|
||||
@@ -315,6 +327,9 @@
|
||||
"LabelRSSFeedOpen": "Открыть RSS-канал",
|
||||
"LabelRSSFeedSlug": "Встроить RSS-канал",
|
||||
"LabelRSSFeedURL": "URL RSS-канала",
|
||||
"LabelRssFeedCustomOwnerName": "Custom owner Name",
|
||||
"LabelRssFeedCustomOwnerEmail": "Custom owner Email",
|
||||
"LabelRssFeedPreventIndexing": "Prevent Indexing",
|
||||
"LabelSearchTerm": "Поисковый Запрос",
|
||||
"LabelSearchTitle": "Поиск по Названию",
|
||||
"LabelSearchTitleOrASIN": "Поиск по Названию или ASIN",
|
||||
@@ -357,6 +372,7 @@
|
||||
"LabelSettingsStoreCoversWithItemHelp": "По умолчанию обложки сохраняются в папке /metadata/items, при включении этой настройки обложка будет храниться в папке элемента. Будет сохраняться только один файл с именем \"cover\"",
|
||||
"LabelSettingsStoreMetadataWithItem": "Хранить метаинформацию с элементом",
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "По умолчанию метаинформация сохраняется в папке /metadata/items, при включении этой настройки метаинформация будет храниться в папке элемента. Используется расширение файла .abs",
|
||||
"LabelSettingsTimeFormat": "Time Format",
|
||||
"LabelShowAll": "Показать Все",
|
||||
"LabelSize": "Размер",
|
||||
"LabelSleepTimer": "Таймер сна",
|
||||
@@ -384,6 +400,7 @@
|
||||
"LabelTag": "Тег",
|
||||
"LabelTags": "Теги",
|
||||
"LabelTagsAccessibleToUser": "Теги Доступные для Пользователя",
|
||||
"LabelTasks": "Tasks Running",
|
||||
"LabelTimeListened": "Время Прослушивания",
|
||||
"LabelTimeListenedToday": "Время Прослушивания Сегодня",
|
||||
"LabelTimeRemaining": "{0} осталось",
|
||||
@@ -485,6 +502,8 @@
|
||||
"MessageNoCollections": "Нет Коллекций",
|
||||
"MessageNoCoversFound": "Обложек не найдено",
|
||||
"MessageNoDescription": "Нет описания",
|
||||
"MessageNoDownloadsQueued": "No downloads queued",
|
||||
"MessageNoDownloadsInProgress": "No downloads currently in progress",
|
||||
"MessageNoEpisodeMatchesFound": "Совпадения эпизодов не найдены",
|
||||
"MessageNoEpisodes": "Нет Эпизодов",
|
||||
"MessageNoFoldersAvailable": "Нет доступных папок",
|
||||
@@ -501,6 +520,7 @@
|
||||
"MessageNoSearchResultsFor": "Нет результатов поиска для \"{0}\"",
|
||||
"MessageNoSeries": "Нет Серий",
|
||||
"MessageNoTags": "Нет Тегов",
|
||||
"MessageNoTasksRunning": "No Tasks Running",
|
||||
"MessageNotYetImplemented": "Пока не реализовано",
|
||||
"MessageNoUpdateNecessary": "Обновление не требуется",
|
||||
"MessageNoUpdatesWereNecessary": "Обновления не требовались",
|
||||
@@ -615,4 +635,4 @@
|
||||
"ToastSocketFailedToConnect": "Не удалось подключить сокет",
|
||||
"ToastUserDeleteFailed": "Не удалось удалить пользователя",
|
||||
"ToastUserDeleteSuccess": "Пользователь удален"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"ButtonCreate": "创建",
|
||||
"ButtonCreateBackup": "创建备份",
|
||||
"ButtonDelete": "删除",
|
||||
"ButtonDownloadQueue": "Queue",
|
||||
"ButtonEdit": "编辑",
|
||||
"ButtonEditChapters": "编辑章节",
|
||||
"ButtonEditPodcast": "编辑播客",
|
||||
@@ -92,7 +93,9 @@
|
||||
"HeaderCollection": "收藏",
|
||||
"HeaderCollectionItems": "收藏项目",
|
||||
"HeaderCover": "封面",
|
||||
"HeaderCurrentDownloads": "Current Downloads",
|
||||
"HeaderDetails": "详情",
|
||||
"HeaderDownloadQueue": "Download Queue",
|
||||
"HeaderEpisodes": "剧集",
|
||||
"HeaderFiles": "文件",
|
||||
"HeaderFindChapters": "查找章节",
|
||||
@@ -127,6 +130,7 @@
|
||||
"HeaderRemoveEpisode": "移除剧集",
|
||||
"HeaderRemoveEpisodes": "移除 {0} 剧集",
|
||||
"HeaderRSSFeedIsOpen": "RSS 源已打开",
|
||||
"HeaderRSSFeedGeneral": "RSS Details",
|
||||
"HeaderSavedMediaProgress": "保存媒体进度",
|
||||
"HeaderSchedule": "计划任务",
|
||||
"HeaderScheduleLibraryScans": "自动扫描媒体库",
|
||||
@@ -138,6 +142,7 @@
|
||||
"HeaderSettingsGeneral": "通用",
|
||||
"HeaderSettingsScanner": "扫描",
|
||||
"HeaderSleepTimer": "睡眠计时",
|
||||
"HeaderStatsLargestItems": "Largest Items",
|
||||
"HeaderStatsLongestItems": "项目时长(小时)",
|
||||
"HeaderStatsMinutesListeningChart": "收听分钟数(最近7天)",
|
||||
"HeaderStatsRecentSessions": "历史会话",
|
||||
@@ -162,6 +167,7 @@
|
||||
"LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表",
|
||||
"LabelAll": "全部",
|
||||
"LabelAllUsers": "所有用户",
|
||||
"LabelAlreadyInYourLibrary": "Already in your library",
|
||||
"LabelAppend": "附加",
|
||||
"LabelAuthor": "作者",
|
||||
"LabelAuthorFirstLast": "作者 (姓 名)",
|
||||
@@ -192,6 +198,7 @@
|
||||
"LabelCronExpression": "计划任务表达式",
|
||||
"LabelCurrent": "当前",
|
||||
"LabelCurrently": "当前:",
|
||||
"LabelCustomCronExpression": "Custom Cron Expression:",
|
||||
"LabelDatetime": "日期时间",
|
||||
"LabelDescription": "描述",
|
||||
"LabelDeselectAll": "全部取消选择",
|
||||
@@ -209,6 +216,7 @@
|
||||
"LabelEpisode": "剧集",
|
||||
"LabelEpisodeTitle": "剧集标题",
|
||||
"LabelEpisodeType": "剧集类型",
|
||||
"LabelExample": "Example",
|
||||
"LabelExplicit": "信息准确",
|
||||
"LabelFeedURL": "源 URL",
|
||||
"LabelFile": "文件",
|
||||
@@ -270,6 +278,8 @@
|
||||
"LabelNewestAuthors": "最新作者",
|
||||
"LabelNewestEpisodes": "最新剧集",
|
||||
"LabelNewPassword": "新密码",
|
||||
"LabelNextBackupDate": "Next backup date",
|
||||
"LabelNextScheduledRun": "Next scheduled run",
|
||||
"LabelNotes": "注释",
|
||||
"LabelNotFinished": "未听完",
|
||||
"LabelNotificationAppriseURL": "通知 URL(s)",
|
||||
@@ -300,7 +310,9 @@
|
||||
"LabelPlayMethod": "播放方法",
|
||||
"LabelPodcast": "播客",
|
||||
"LabelPodcasts": "播客",
|
||||
"LabelPodcastType": "Podcast Type",
|
||||
"LabelPrefixesToIgnore": "忽略的前缀 (不区分大小写)",
|
||||
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
|
||||
"LabelProgress": "进度",
|
||||
"LabelProvider": "供应商",
|
||||
"LabelPubDate": "出版日期",
|
||||
@@ -315,6 +327,9 @@
|
||||
"LabelRSSFeedOpen": "打开 RSS 源",
|
||||
"LabelRSSFeedSlug": "RSS 源段",
|
||||
"LabelRSSFeedURL": "RSS 源 URL",
|
||||
"LabelRssFeedCustomOwnerName": "Custom owner Name",
|
||||
"LabelRssFeedCustomOwnerEmail": "Custom owner Email",
|
||||
"LabelRssFeedPreventIndexing": "Prevent Indexing",
|
||||
"LabelSearchTerm": "搜索项",
|
||||
"LabelSearchTitle": "搜索标题",
|
||||
"LabelSearchTitleOrASIN": "搜索标题或 ASIN",
|
||||
@@ -357,6 +372,7 @@
|
||||
"LabelSettingsStoreCoversWithItemHelp": "默认情况下封面存储在/metadata/items文件夹中, 启用此设置将存储封面在你媒体项目文件夹中. 只保留一个名为 \"cover\" 的文件",
|
||||
"LabelSettingsStoreMetadataWithItem": "存储项目元数据",
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "默认情况下元数据文件存储在/metadata/items文件夹中, 启用此设置将存储元数据在你媒体项目文件夹中. 使 .abs 文件护展名",
|
||||
"LabelSettingsTimeFormat": "Time Format",
|
||||
"LabelShowAll": "全部显示",
|
||||
"LabelSize": "文件大小",
|
||||
"LabelSleepTimer": "睡眠定时",
|
||||
@@ -384,6 +400,7 @@
|
||||
"LabelTag": "标签",
|
||||
"LabelTags": "标签",
|
||||
"LabelTagsAccessibleToUser": "用户可访问的标签",
|
||||
"LabelTasks": "Tasks Running",
|
||||
"LabelTimeListened": "收听时间",
|
||||
"LabelTimeListenedToday": "今日收听的时间",
|
||||
"LabelTimeRemaining": "剩余 {0}",
|
||||
@@ -485,6 +502,8 @@
|
||||
"MessageNoCollections": "没有收藏",
|
||||
"MessageNoCoversFound": "没有找到封面",
|
||||
"MessageNoDescription": "没有描述",
|
||||
"MessageNoDownloadsQueued": "No downloads queued",
|
||||
"MessageNoDownloadsInProgress": "No downloads currently in progress",
|
||||
"MessageNoEpisodeMatchesFound": "没有找到任何剧集匹配项",
|
||||
"MessageNoEpisodes": "没有剧集",
|
||||
"MessageNoFoldersAvailable": "没有可用文件夹",
|
||||
@@ -501,6 +520,7 @@
|
||||
"MessageNoSearchResultsFor": "没有搜索到结果 \"{0}\"",
|
||||
"MessageNoSeries": "无系列",
|
||||
"MessageNoTags": "无标签",
|
||||
"MessageNoTasksRunning": "No Tasks Running",
|
||||
"MessageNotYetImplemented": "尚未实施",
|
||||
"MessageNoUpdateNecessary": "无需更新",
|
||||
"MessageNoUpdatesWereNecessary": "无需更新",
|
||||
@@ -615,4 +635,4 @@
|
||||
"ToastSocketFailedToConnect": "网络连接失败",
|
||||
"ToastUserDeleteFailed": "删除用户失败",
|
||||
"ToastUserDeleteSuccess": "用户已删除"
|
||||
}
|
||||
}
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.2.15",
|
||||
"version": "2.2.16",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.2.15",
|
||||
"version": "2.2.16",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.2.15",
|
||||
"version": "2.2.16",
|
||||
"description": "Self-hosted audiobook and podcast server",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -72,7 +72,7 @@ class Server {
|
||||
this.abMergeManager = new AbMergeManager(this.db, this.taskManager)
|
||||
this.playbackSessionManager = new PlaybackSessionManager(this.db)
|
||||
this.coverManager = new CoverManager(this.db, this.cacheManager)
|
||||
this.podcastManager = new PodcastManager(this.db, this.watcher, this.notificationManager)
|
||||
this.podcastManager = new PodcastManager(this.db, this.watcher, this.notificationManager, this.taskManager)
|
||||
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager)
|
||||
this.rssFeedManager = new RssFeedManager(this.db)
|
||||
this.eBookManager = new EBookManager(this.db)
|
||||
|
||||
@@ -82,6 +82,11 @@ class LibraryController {
|
||||
return res.json(req.library)
|
||||
}
|
||||
|
||||
async getEpisodeDownloadQueue(req, res) {
|
||||
const libraryDownloadQueueDetails = this.podcastManager.getDownloadQueueDetails(req.library.id)
|
||||
return res.json(libraryDownloadQueueDetails)
|
||||
}
|
||||
|
||||
async update(req, res) {
|
||||
const library = req.library
|
||||
|
||||
@@ -229,6 +234,16 @@ class LibraryController {
|
||||
if (payload.sortBy === 'book.volumeNumber') payload.sortBy = null // TODO: Remove temp fix after mobile release 0.9.60
|
||||
if (filterSeries && !payload.sortBy) {
|
||||
sortArray.push({ asc: (li) => li.media.metadata.getSeries(filterSeries).sequence })
|
||||
// If no series sequence then fallback to sorting by title (or collapsed series name for sub-series)
|
||||
sortArray.push({
|
||||
asc: (li) => {
|
||||
if (this.db.serverSettings.sortingIgnorePrefix) {
|
||||
return li.collapsedSeries?.nameIgnorePrefix || li.media.metadata.titleIgnorePrefix
|
||||
} else {
|
||||
return li.collapsedSeries?.name || li.media.metadata.title
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (payload.sortBy) {
|
||||
@@ -637,6 +652,7 @@ class LibraryController {
|
||||
var authorsWithCount = libraryHelpers.getAuthorsWithCount(libraryItems)
|
||||
var genresWithCount = libraryHelpers.getGenresWithCount(libraryItems)
|
||||
var durationStats = libraryHelpers.getItemDurationStats(libraryItems)
|
||||
var sizeStats = libraryHelpers.getItemSizeStats(libraryItems)
|
||||
var stats = {
|
||||
totalItems: libraryItems.length,
|
||||
totalAuthors: Object.keys(authorsWithCount).length,
|
||||
@@ -645,6 +661,7 @@ class LibraryController {
|
||||
longestItems: durationStats.longestItems,
|
||||
numAudioTracks: durationStats.numAudioTracks,
|
||||
totalSize: libraryHelpers.getLibraryItemsTotalSize(libraryItems),
|
||||
largestItems: sizeStats.largestItems,
|
||||
authorsWithCount,
|
||||
genresWithCount
|
||||
}
|
||||
@@ -755,4 +772,4 @@ class LibraryController {
|
||||
next()
|
||||
}
|
||||
}
|
||||
module.exports = new LibraryController()
|
||||
module.exports = new LibraryController()
|
||||
|
||||
@@ -36,8 +36,11 @@ class LibraryItemController {
|
||||
}).filter(au => au)
|
||||
}
|
||||
} else if (includeEntities.includes('downloads')) {
|
||||
var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
|
||||
item.episodesDownloading = downloadsInQueue.map(d => d.toJSONForClient())
|
||||
const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
|
||||
item.episodeDownloadsQueued = downloadsInQueue.map(d => d.toJSONForClient())
|
||||
if (this.podcastManager.currentDownload?.libraryItemId === req.libraryItem.id) {
|
||||
item.episodesDownloading = [this.podcastManager.currentDownload.toJSONForClient()]
|
||||
}
|
||||
}
|
||||
|
||||
return res.json(item)
|
||||
|
||||
@@ -225,6 +225,20 @@ class PodcastController {
|
||||
res.json(libraryItem.toJSONExpanded())
|
||||
}
|
||||
|
||||
// GET: api/podcasts/:id/episode/:episodeId
|
||||
async getEpisode(req, res) {
|
||||
const episodeId = req.params.episodeId
|
||||
const libraryItem = req.libraryItem
|
||||
|
||||
const episode = libraryItem.media.episodes.find(ep => ep.id === episodeId)
|
||||
if (!episode) {
|
||||
Logger.error(`[PodcastController] getEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
res.json(episode)
|
||||
}
|
||||
|
||||
// DELETE: api/podcasts/:id/episode/:episodeId
|
||||
async removeEpisode(req, res) {
|
||||
var episodeId = req.params.episodeId
|
||||
@@ -283,4 +297,4 @@ class PodcastController {
|
||||
next()
|
||||
}
|
||||
}
|
||||
module.exports = new PodcastController()
|
||||
module.exports = new PodcastController()
|
||||
|
||||
@@ -134,4 +134,4 @@ class RSSFeedController {
|
||||
next()
|
||||
}
|
||||
}
|
||||
module.exports = new RSSFeedController()
|
||||
module.exports = new RSSFeedController()
|
||||
|
||||
@@ -24,9 +24,15 @@ class NotificationManager {
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryId: libraryItem.libraryId,
|
||||
libraryName: library ? library.name : 'Unknown',
|
||||
mediaTags: (libraryItem.media.tags || []).join(', '),
|
||||
podcastTitle: libraryItem.media.metadata.title,
|
||||
podcastAuthor: libraryItem.media.metadata.author || '',
|
||||
podcastDescription: libraryItem.media.metadata.description || '',
|
||||
podcastGenres: (libraryItem.media.metadata.genres || []).join(', '),
|
||||
episodeId: episode.id,
|
||||
episodeTitle: episode.title
|
||||
episodeTitle: episode.title,
|
||||
episodeSubtitle: episode.subtitle || '',
|
||||
episodeDescription: episode.description || ''
|
||||
}
|
||||
this.triggerNotification('onPodcastEpisodeDownloaded', eventData)
|
||||
}
|
||||
@@ -110,4 +116,4 @@ class NotificationManager {
|
||||
})
|
||||
}
|
||||
}
|
||||
module.exports = NotificationManager
|
||||
module.exports = NotificationManager
|
||||
|
||||
@@ -14,12 +14,14 @@ const LibraryFile = require('../objects/files/LibraryFile')
|
||||
const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload')
|
||||
const PodcastEpisode = require('../objects/entities/PodcastEpisode')
|
||||
const AudioFile = require('../objects/files/AudioFile')
|
||||
const Task = require("../objects/Task")
|
||||
|
||||
class PodcastManager {
|
||||
constructor(db, watcher, notificationManager) {
|
||||
constructor(db, watcher, notificationManager, taskManager) {
|
||||
this.db = db
|
||||
this.watcher = watcher
|
||||
this.notificationManager = notificationManager
|
||||
this.taskManager = taskManager
|
||||
|
||||
this.downloadQueue = []
|
||||
this.currentDownload = null
|
||||
@@ -56,18 +58,28 @@ class PodcastManager {
|
||||
newPe.setData(ep, index++)
|
||||
newPe.libraryItemId = libraryItem.id
|
||||
var newPeDl = new PodcastEpisodeDownload()
|
||||
newPeDl.setData(newPe, libraryItem, isAutoDownload)
|
||||
newPeDl.setData(newPe, libraryItem, isAutoDownload, libraryItem.libraryId)
|
||||
this.startPodcastEpisodeDownload(newPeDl)
|
||||
})
|
||||
}
|
||||
|
||||
async startPodcastEpisodeDownload(podcastEpisodeDownload) {
|
||||
SocketAuthority.emitter('episode_download_queue_updated', this.getDownloadQueueDetails())
|
||||
if (this.currentDownload) {
|
||||
this.downloadQueue.push(podcastEpisodeDownload)
|
||||
SocketAuthority.emitter('episode_download_queued', podcastEpisodeDownload.toJSONForClient())
|
||||
return
|
||||
}
|
||||
|
||||
const task = new Task()
|
||||
const taskDescription = `Downloading episode "${podcastEpisodeDownload.podcastEpisode.title}".`
|
||||
const taskData = {
|
||||
libraryId: podcastEpisodeDownload.libraryId,
|
||||
libraryItemId: podcastEpisodeDownload.libraryItemId,
|
||||
}
|
||||
task.setData('download-podcast-episode', 'Downloading Episode', taskDescription, taskData)
|
||||
this.taskManager.addTask(task)
|
||||
|
||||
SocketAuthority.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient())
|
||||
this.currentDownload = podcastEpisodeDownload
|
||||
|
||||
@@ -81,7 +93,7 @@ class PodcastManager {
|
||||
await filePerms.setDefault(this.currentDownload.libraryItem.path)
|
||||
}
|
||||
|
||||
var success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath).then(() => true).catch((error) => {
|
||||
let success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath).then(() => true).catch((error) => {
|
||||
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
|
||||
return false
|
||||
})
|
||||
@@ -90,15 +102,21 @@ class PodcastManager {
|
||||
if (!success) {
|
||||
await fs.remove(this.currentDownload.targetPath)
|
||||
this.currentDownload.setFinished(false)
|
||||
task.setFailed('Failed to download episode')
|
||||
} else {
|
||||
Logger.info(`[PodcastManager] Successfully downloaded podcast episode "${this.currentDownload.podcastEpisode.title}"`)
|
||||
this.currentDownload.setFinished(true)
|
||||
task.setFinished()
|
||||
}
|
||||
} else {
|
||||
task.setFailed('Failed to download episode')
|
||||
this.currentDownload.setFinished(false)
|
||||
}
|
||||
|
||||
this.taskManager.taskFinished(task)
|
||||
|
||||
SocketAuthority.emitter('episode_download_finished', this.currentDownload.toJSONForClient())
|
||||
SocketAuthority.emitter('episode_download_queue_updated', this.getDownloadQueueDetails())
|
||||
|
||||
this.watcher.removeIgnoreDir(this.currentDownload.libraryItem.path)
|
||||
this.currentDownload = null
|
||||
@@ -329,5 +347,15 @@ class PodcastManager {
|
||||
feeds: rssFeedData
|
||||
}
|
||||
}
|
||||
|
||||
getDownloadQueueDetails(libraryId = null) {
|
||||
let _currentDownload = this.currentDownload
|
||||
if (libraryId && _currentDownload?.libraryId !== libraryId) _currentDownload = null
|
||||
|
||||
return {
|
||||
currentDownload: _currentDownload?.toJSONForClient(),
|
||||
queue: this.downloadQueue.filter(item => !libraryId || item.libraryId === libraryId).map(item => item.toJSONForClient())
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = PodcastManager
|
||||
module.exports = PodcastManager
|
||||
|
||||
@@ -188,9 +188,12 @@ class RssFeedManager {
|
||||
async openFeedForItem(user, libraryItem, options) {
|
||||
const serverAddress = options.serverAddress
|
||||
const slug = options.slug
|
||||
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
|
||||
const ownerName = options.metadataDetails?.ownerName
|
||||
const ownerEmail = options.metadataDetails?.ownerEmail
|
||||
|
||||
const feed = new Feed()
|
||||
feed.setFromItem(user.id, slug, libraryItem, serverAddress)
|
||||
feed.setFromItem(user.id, slug, libraryItem, serverAddress, preventIndexing, ownerName, ownerEmail)
|
||||
this.feeds[feed.id] = feed
|
||||
|
||||
Logger.debug(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
|
||||
@@ -202,9 +205,12 @@ class RssFeedManager {
|
||||
async openFeedForCollection(user, collectionExpanded, options) {
|
||||
const serverAddress = options.serverAddress
|
||||
const slug = options.slug
|
||||
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
|
||||
const ownerName = options.metadataDetails?.ownerName
|
||||
const ownerEmail = options.metadataDetails?.ownerEmail
|
||||
|
||||
const feed = new Feed()
|
||||
feed.setFromCollection(user.id, slug, collectionExpanded, serverAddress)
|
||||
feed.setFromCollection(user.id, slug, collectionExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
|
||||
this.feeds[feed.id] = feed
|
||||
|
||||
Logger.debug(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
|
||||
@@ -216,9 +222,12 @@ class RssFeedManager {
|
||||
async openFeedForSeries(user, seriesExpanded, options) {
|
||||
const serverAddress = options.serverAddress
|
||||
const slug = options.slug
|
||||
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
|
||||
const ownerName = options.metadataDetails?.ownerName
|
||||
const ownerEmail = options.metadataDetails?.ownerEmail
|
||||
|
||||
const feed = new Feed()
|
||||
feed.setFromSeries(user.id, slug, seriesExpanded, serverAddress)
|
||||
feed.setFromSeries(user.id, slug, seriesExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
|
||||
this.feeds[feed.id] = feed
|
||||
|
||||
Logger.debug(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
|
||||
@@ -246,4 +255,4 @@ class RssFeedManager {
|
||||
return this.handleCloseFeed(feed)
|
||||
}
|
||||
}
|
||||
module.exports = RssFeedManager
|
||||
module.exports = RssFeedManager
|
||||
|
||||
@@ -70,17 +70,19 @@ class Feed {
|
||||
id: this.id,
|
||||
entityType: this.entityType,
|
||||
entityId: this.entityId,
|
||||
feedUrl: this.feedUrl
|
||||
feedUrl: this.feedUrl,
|
||||
meta: this.meta.toJSONMinified(),
|
||||
}
|
||||
}
|
||||
|
||||
getEpisodePath(id) {
|
||||
var episode = this.episodes.find(ep => ep.id === id)
|
||||
console.log('getEpisodePath=', id, episode)
|
||||
if (!episode) return null
|
||||
return episode.fullPath
|
||||
}
|
||||
|
||||
setFromItem(userId, slug, libraryItem, serverAddress) {
|
||||
setFromItem(userId, slug, libraryItem, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) {
|
||||
const media = libraryItem.media
|
||||
const mediaMetadata = media.metadata
|
||||
const isPodcast = libraryItem.mediaType === 'podcast'
|
||||
@@ -106,6 +108,11 @@ class Feed {
|
||||
this.meta.feedUrl = feedUrl
|
||||
this.meta.link = `${serverAddress}/item/${libraryItem.id}`
|
||||
this.meta.explicit = !!mediaMetadata.explicit
|
||||
this.meta.type = mediaMetadata.type
|
||||
this.meta.language = mediaMetadata.language
|
||||
this.meta.preventIndexing = preventIndexing
|
||||
this.meta.ownerName = ownerName
|
||||
this.meta.ownerEmail = ownerEmail
|
||||
|
||||
this.episodes = []
|
||||
if (isPodcast) { // PODCAST EPISODES
|
||||
@@ -142,6 +149,8 @@ class Feed {
|
||||
this.meta.author = author
|
||||
this.meta.imageUrl = media.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover` : `${this.serverAddress}/Logo.png`
|
||||
this.meta.explicit = !!mediaMetadata.explicit
|
||||
this.meta.type = mediaMetadata.type
|
||||
this.meta.language = mediaMetadata.language
|
||||
|
||||
this.episodes = []
|
||||
if (isPodcast) { // PODCAST EPISODES
|
||||
@@ -333,4 +342,4 @@ class Feed {
|
||||
return author
|
||||
}
|
||||
}
|
||||
module.exports = Feed
|
||||
module.exports = Feed
|
||||
|
||||
@@ -14,6 +14,9 @@ class FeedEpisode {
|
||||
this.author = null
|
||||
this.explicit = null
|
||||
this.duration = null
|
||||
this.season = null
|
||||
this.episode = null
|
||||
this.episodeType = null
|
||||
|
||||
this.libraryItemId = null
|
||||
this.episodeId = null
|
||||
@@ -35,6 +38,9 @@ class FeedEpisode {
|
||||
this.author = episode.author
|
||||
this.explicit = episode.explicit
|
||||
this.duration = episode.duration
|
||||
this.season = episode.season
|
||||
this.episode = episode.episode
|
||||
this.episodeType = episode.episodeType
|
||||
this.libraryItemId = episode.libraryItemId
|
||||
this.episodeId = episode.episodeId || null
|
||||
this.trackIndex = episode.trackIndex || 0
|
||||
@@ -52,6 +58,9 @@ class FeedEpisode {
|
||||
author: this.author,
|
||||
explicit: this.explicit,
|
||||
duration: this.duration,
|
||||
season: this.season,
|
||||
episode: this.episode,
|
||||
episodeType: this.episodeType,
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: this.episodeId,
|
||||
trackIndex: this.trackIndex,
|
||||
@@ -77,25 +86,31 @@ class FeedEpisode {
|
||||
this.author = meta.author
|
||||
this.explicit = mediaMetadata.explicit
|
||||
this.duration = episode.duration
|
||||
this.season = episode.season
|
||||
this.episode = episode.episode
|
||||
this.episodeType = episode.episodeType
|
||||
this.libraryItemId = libraryItem.id
|
||||
this.episodeId = episode.id
|
||||
this.trackIndex = 0
|
||||
this.fullPath = episode.audioFile.metadata.path
|
||||
}
|
||||
|
||||
setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, meta, additionalOffset = 0) {
|
||||
setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, meta, additionalOffset = null) {
|
||||
// Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate>
|
||||
let timeOffset = isNaN(audioTrack.index) ? 0 : (Number(audioTrack.index) * 1000) // Offset pubdate to ensure correct order
|
||||
let episodeId = String(audioTrack.index)
|
||||
|
||||
// Additional offset can be used for collections/series
|
||||
if (additionalOffset && !isNaN(additionalOffset)) {
|
||||
if (additionalOffset !== null && !isNaN(additionalOffset)) {
|
||||
timeOffset += Number(additionalOffset) * 1000
|
||||
|
||||
episodeId = String(additionalOffset) + '-' + episodeId
|
||||
}
|
||||
|
||||
// e.g. Track 1 will have a pub date before Track 2
|
||||
const audiobookPubDate = date.format(new Date(libraryItem.addedAt + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
|
||||
|
||||
const contentUrl = `/feed/${slug}/item/${audioTrack.index}/${audioTrack.metadata.filename}`
|
||||
const contentUrl = `/feed/${slug}/item/${episodeId}/${audioTrack.metadata.filename}`
|
||||
const media = libraryItem.media
|
||||
const mediaMetadata = media.metadata
|
||||
|
||||
@@ -110,7 +125,7 @@ class FeedEpisode {
|
||||
}
|
||||
}
|
||||
|
||||
this.id = String(audioTrack.index)
|
||||
this.id = episodeId
|
||||
this.title = title
|
||||
this.description = mediaMetadata.description || ''
|
||||
this.enclosure = {
|
||||
@@ -144,9 +159,12 @@ class FeedEpisode {
|
||||
{ 'itunes:summary': this.description || '' },
|
||||
{
|
||||
"itunes:explicit": !!this.explicit
|
||||
}
|
||||
},
|
||||
{ "itunes:episodeType": this.episodeType },
|
||||
{ "itunes:season": this.season },
|
||||
{ "itunes:episode": this.episode }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = FeedEpisode
|
||||
module.exports = FeedEpisode
|
||||
|
||||
@@ -7,6 +7,11 @@ class FeedMeta {
|
||||
this.feedUrl = null
|
||||
this.link = null
|
||||
this.explicit = null
|
||||
this.type = null
|
||||
this.language = null
|
||||
this.preventIndexing = null
|
||||
this.ownerName = null
|
||||
this.ownerEmail = null
|
||||
|
||||
if (meta) {
|
||||
this.construct(meta)
|
||||
@@ -21,6 +26,11 @@ class FeedMeta {
|
||||
this.feedUrl = meta.feedUrl
|
||||
this.link = meta.link
|
||||
this.explicit = meta.explicit
|
||||
this.type = meta.type
|
||||
this.language = meta.language
|
||||
this.preventIndexing = meta.preventIndexing
|
||||
this.ownerName = meta.ownerName
|
||||
this.ownerEmail = meta.ownerEmail
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
@@ -31,7 +41,22 @@ class FeedMeta {
|
||||
imageUrl: this.imageUrl,
|
||||
feedUrl: this.feedUrl,
|
||||
link: this.link,
|
||||
explicit: this.explicit
|
||||
explicit: this.explicit,
|
||||
type: this.type,
|
||||
language: this.language,
|
||||
preventIndexing: this.preventIndexing,
|
||||
ownerName: this.ownerName,
|
||||
ownerEmail: this.ownerEmail
|
||||
}
|
||||
}
|
||||
|
||||
toJSONMinified() {
|
||||
return {
|
||||
title: this.title,
|
||||
description: this.description,
|
||||
preventIndexing: this.preventIndexing,
|
||||
ownerName: this.ownerName,
|
||||
ownerEmail: this.ownerEmail
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,16 +68,18 @@ class FeedMeta {
|
||||
feed_url: this.feedUrl,
|
||||
site_url: this.link,
|
||||
image_url: this.imageUrl,
|
||||
language: 'en',
|
||||
custom_namespaces: {
|
||||
'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd',
|
||||
'psc': 'http://podlove.org/simple-chapters',
|
||||
'podcast': 'https://podcastindex.org/namespace/1.0'
|
||||
'podcast': 'https://podcastindex.org/namespace/1.0',
|
||||
'googleplay': 'http://www.google.com/schemas/play-podcasts/1.0'
|
||||
},
|
||||
custom_elements: [
|
||||
{ 'language': this.language || 'en' },
|
||||
{ 'author': this.author || 'advplyr' },
|
||||
{ 'itunes:author': this.author || 'advplyr' },
|
||||
{ 'itunes:summary': this.description || '' },
|
||||
{ 'itunes:type': this.type },
|
||||
{
|
||||
'itunes:image': {
|
||||
_attr: {
|
||||
@@ -62,15 +89,15 @@ class FeedMeta {
|
||||
},
|
||||
{
|
||||
'itunes:owner': [
|
||||
{ 'itunes:name': this.author || '' },
|
||||
{ 'itunes:email': '' }
|
||||
{ 'itunes:name': this.ownerName || this.author || '' },
|
||||
{ 'itunes:email': this.ownerEmail || '' }
|
||||
]
|
||||
},
|
||||
{
|
||||
"itunes:explicit": !!this.explicit
|
||||
}
|
||||
{ 'itunes:explicit': !!this.explicit },
|
||||
{ 'itunes:block': this.preventIndexing?"Yes":"No" },
|
||||
{ 'googleplay:block': this.preventIndexing?"yes":"no" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = FeedMeta
|
||||
module.exports = FeedMeta
|
||||
|
||||
@@ -197,9 +197,15 @@ class LibraryItem {
|
||||
if (key === 'libraryFiles') {
|
||||
this.libraryFiles = payload.libraryFiles.map(lf => lf.clone())
|
||||
|
||||
// Use first image library file as cover
|
||||
const firstImageFile = this.libraryFiles.find(lf => lf.fileType === 'image')
|
||||
if (firstImageFile) this.media.coverPath = firstImageFile.metadata.path
|
||||
// Set cover image
|
||||
const imageFiles = this.libraryFiles.filter(lf => lf.fileType === 'image')
|
||||
const coverMatch = imageFiles.find(iFile => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
|
||||
if (coverMatch) {
|
||||
this.media.coverPath = coverMatch.metadata.path
|
||||
} else if (imageFiles.length) {
|
||||
this.media.coverPath = imageFiles[0].metadata.path
|
||||
}
|
||||
|
||||
} else if (this[key] !== undefined && key !== 'media') {
|
||||
this[key] = payload[key]
|
||||
}
|
||||
@@ -330,6 +336,7 @@ class LibraryItem {
|
||||
}
|
||||
|
||||
if (dataFound.ino !== this.ino) {
|
||||
Logger.warn(`[LibraryItem] Check scan item changed inode "${this.ino}" -> "${dataFound.ino}"`)
|
||||
this.ino = dataFound.ino
|
||||
hasUpdated = true
|
||||
}
|
||||
@@ -341,7 +348,7 @@ class LibraryItem {
|
||||
}
|
||||
|
||||
if (dataFound.path !== this.path) {
|
||||
Logger.warn(`[LibraryItem] Check scan item changed path "${this.path}" -> "${dataFound.path}"`)
|
||||
Logger.warn(`[LibraryItem] Check scan item changed path "${this.path}" -> "${dataFound.path}" (inode ${this.ino})`)
|
||||
this.path = dataFound.path
|
||||
this.relPath = dataFound.relPath
|
||||
hasUpdated = true
|
||||
@@ -444,8 +451,14 @@ class LibraryItem {
|
||||
// Set cover image if not set
|
||||
const imageFiles = this.libraryFiles.filter(lf => lf.fileType === 'image')
|
||||
if (imageFiles.length && !this.media.coverPath) {
|
||||
this.media.coverPath = imageFiles[0].metadata.path
|
||||
Logger.debug('[LibraryItem] Set media cover path', this.media.coverPath)
|
||||
// attempt to find a file called cover.<ext> otherwise just fall back to the first image found
|
||||
const coverMatch = imageFiles.find(iFile => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
|
||||
if (coverMatch) {
|
||||
this.media.coverPath = coverMatch.metadata.path
|
||||
} else {
|
||||
this.media.coverPath = imageFiles[0].metadata.path
|
||||
}
|
||||
Logger.info('[LibraryItem] Set media cover path', this.media.coverPath)
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ class PodcastEpisodeDownload {
|
||||
this.podcastEpisode = null
|
||||
this.url = null
|
||||
this.libraryItem = null
|
||||
this.libraryId = null
|
||||
|
||||
this.isAutoDownload = false
|
||||
this.isDownloading = false
|
||||
this.isFinished = false
|
||||
this.failed = false
|
||||
|
||||
@@ -22,15 +22,21 @@ class PodcastEpisodeDownload {
|
||||
toJSONForClient() {
|
||||
return {
|
||||
id: this.id,
|
||||
episodeDisplayTitle: this.podcastEpisode ? this.podcastEpisode.title : null,
|
||||
episodeDisplayTitle: this.podcastEpisode?.title ?? null,
|
||||
url: this.url,
|
||||
libraryItemId: this.libraryItem ? this.libraryItem.id : null,
|
||||
isDownloading: this.isDownloading,
|
||||
libraryItemId: this.libraryItem?.id || null,
|
||||
libraryId: this.libraryId || null,
|
||||
isFinished: this.isFinished,
|
||||
failed: this.failed,
|
||||
startedAt: this.startedAt,
|
||||
createdAt: this.createdAt,
|
||||
finishedAt: this.finishedAt
|
||||
finishedAt: this.finishedAt,
|
||||
podcastTitle: this.libraryItem?.media.metadata.title ?? null,
|
||||
podcastExplicit: !!this.libraryItem?.media.metadata.explicit,
|
||||
season: this.podcastEpisode?.season ?? null,
|
||||
episode: this.podcastEpisode?.episode ?? null,
|
||||
episodeType: this.podcastEpisode?.episodeType ?? 'full',
|
||||
publishedAt: this.podcastEpisode?.publishedAt ?? null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,13 +53,14 @@ class PodcastEpisodeDownload {
|
||||
return this.libraryItem ? this.libraryItem.id : null
|
||||
}
|
||||
|
||||
setData(podcastEpisode, libraryItem, isAutoDownload) {
|
||||
setData(podcastEpisode, libraryItem, isAutoDownload, libraryId) {
|
||||
this.id = getId('epdl')
|
||||
this.podcastEpisode = podcastEpisode
|
||||
this.url = podcastEpisode.enclosure.url
|
||||
this.url = encodeURI(podcastEpisode.enclosure.url)
|
||||
this.libraryItem = libraryItem
|
||||
this.isAutoDownload = isAutoDownload
|
||||
this.createdAt = Date.now()
|
||||
this.libraryId = libraryId
|
||||
}
|
||||
|
||||
setFinished(success) {
|
||||
@@ -62,4 +69,4 @@ class PodcastEpisodeDownload {
|
||||
this.failed = !success
|
||||
}
|
||||
}
|
||||
module.exports = PodcastEpisodeDownload
|
||||
module.exports = PodcastEpisodeDownload
|
||||
|
||||
@@ -117,7 +117,7 @@ class PodcastEpisode {
|
||||
this.enclosure = data.enclosure ? { ...data.enclosure } : null
|
||||
this.season = data.season || ''
|
||||
this.episode = data.episode || ''
|
||||
this.episodeType = data.episodeType || ''
|
||||
this.episodeType = data.episodeType || 'full'
|
||||
this.publishedAt = data.publishedAt || 0
|
||||
this.addedAt = Date.now()
|
||||
this.updatedAt = Date.now()
|
||||
@@ -165,4 +165,4 @@ class PodcastEpisode {
|
||||
return cleanStringForSearch(this.title).includes(query)
|
||||
}
|
||||
}
|
||||
module.exports = PodcastEpisode
|
||||
module.exports = PodcastEpisode
|
||||
|
||||
@@ -296,7 +296,7 @@ class Book {
|
||||
})
|
||||
}
|
||||
} else if (key === 'narrators') {
|
||||
if (opfMetadata.narrators && opfMetadata.narrators.length && (!this.metadata.narrators.length || opfMetadataOverrideDetails)) {
|
||||
if (opfMetadata.narrators?.length && (!this.metadata.narrators.length || opfMetadataOverrideDetails)) {
|
||||
metadataUpdatePayload.narrators = opfMetadata.narrators
|
||||
}
|
||||
} else if (key === 'series') {
|
||||
|
||||
@@ -15,6 +15,7 @@ class PodcastMetadata {
|
||||
this.itunesArtistId = null
|
||||
this.explicit = false
|
||||
this.language = null
|
||||
this.type = null
|
||||
|
||||
if (metadata) {
|
||||
this.construct(metadata)
|
||||
@@ -34,6 +35,7 @@ class PodcastMetadata {
|
||||
this.itunesArtistId = metadata.itunesArtistId
|
||||
this.explicit = metadata.explicit
|
||||
this.language = metadata.language || null
|
||||
this.type = metadata.type || 'episodic'
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
@@ -49,7 +51,8 @@ class PodcastMetadata {
|
||||
itunesId: this.itunesId,
|
||||
itunesArtistId: this.itunesArtistId,
|
||||
explicit: this.explicit,
|
||||
language: this.language
|
||||
language: this.language,
|
||||
type: this.type
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +70,8 @@ class PodcastMetadata {
|
||||
itunesId: this.itunesId,
|
||||
itunesArtistId: this.itunesArtistId,
|
||||
explicit: this.explicit,
|
||||
language: this.language
|
||||
language: this.language,
|
||||
type: this.type
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +116,7 @@ class PodcastMetadata {
|
||||
this.itunesArtistId = mediaMetadata.itunesArtistId || null
|
||||
this.explicit = !!mediaMetadata.explicit
|
||||
this.language = mediaMetadata.language || null
|
||||
this.type = mediaMetadata.type || null
|
||||
if (mediaMetadata.genres && mediaMetadata.genres.length) {
|
||||
this.genres = [...mediaMetadata.genres]
|
||||
}
|
||||
@@ -132,4 +137,4 @@ class PodcastMetadata {
|
||||
return hasUpdates
|
||||
}
|
||||
}
|
||||
module.exports = PodcastMetadata
|
||||
module.exports = PodcastMetadata
|
||||
|
||||
@@ -51,6 +51,7 @@ class ServerSettings {
|
||||
this.chromecastEnabled = false
|
||||
this.enableEReader = false
|
||||
this.dateFormat = 'MM/dd/yyyy'
|
||||
this.timeFormat = 'HH:mm'
|
||||
this.language = 'en-us'
|
||||
|
||||
this.logLevel = Logger.logLevel
|
||||
@@ -96,6 +97,7 @@ class ServerSettings {
|
||||
this.chromecastEnabled = !!settings.chromecastEnabled
|
||||
this.enableEReader = !!settings.enableEReader
|
||||
this.dateFormat = settings.dateFormat || 'MM/dd/yyyy'
|
||||
this.timeFormat = settings.timeFormat || 'HH:mm'
|
||||
this.language = settings.language || 'en-us'
|
||||
this.logLevel = settings.logLevel || Logger.logLevel
|
||||
this.version = settings.version || null
|
||||
@@ -146,6 +148,7 @@ class ServerSettings {
|
||||
chromecastEnabled: this.chromecastEnabled,
|
||||
enableEReader: this.enableEReader,
|
||||
dateFormat: this.dateFormat,
|
||||
timeFormat: this.timeFormat,
|
||||
language: this.language,
|
||||
logLevel: this.logLevel,
|
||||
version: this.version
|
||||
@@ -178,4 +181,4 @@ class ServerSettings {
|
||||
return hasUpdates
|
||||
}
|
||||
}
|
||||
module.exports = ServerSettings
|
||||
module.exports = ServerSettings
|
||||
|
||||
@@ -95,7 +95,8 @@ class iTunes {
|
||||
cover: this.getCoverArtwork(data),
|
||||
trackCount: data.trackCount,
|
||||
feedUrl: data.feedUrl,
|
||||
pageUrl: data.collectionViewUrl
|
||||
pageUrl: data.collectionViewUrl,
|
||||
explicit: data.trackExplicitness === 'explicit'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,4 +106,4 @@ class iTunes {
|
||||
})
|
||||
}
|
||||
}
|
||||
module.exports = iTunes
|
||||
module.exports = iTunes
|
||||
|
||||
@@ -76,6 +76,7 @@ class ApiRouter {
|
||||
|
||||
this.router.get('/libraries/:id/items', LibraryController.middleware.bind(this), LibraryController.getLibraryItems.bind(this))
|
||||
this.router.delete('/libraries/:id/issues', LibraryController.middleware.bind(this), LibraryController.removeLibraryItemsWithIssues.bind(this))
|
||||
this.router.get('/libraries/:id/episode-downloads', LibraryController.middleware.bind(this), LibraryController.getEpisodeDownloadQueue.bind(this))
|
||||
this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this))
|
||||
this.router.get('/libraries/:id/collections', LibraryController.middleware.bind(this), LibraryController.getCollectionsForLibrary.bind(this))
|
||||
this.router.get('/libraries/:id/playlists', LibraryController.middleware.bind(this), LibraryController.getUserPlaylistsForLibrary.bind(this))
|
||||
@@ -235,6 +236,7 @@ class ApiRouter {
|
||||
this.router.get('/podcasts/:id/search-episode', PodcastController.middleware.bind(this), PodcastController.findEpisode.bind(this))
|
||||
this.router.post('/podcasts/:id/download-episodes', PodcastController.middleware.bind(this), PodcastController.downloadEpisodes.bind(this))
|
||||
this.router.post('/podcasts/:id/match-episodes', PodcastController.middleware.bind(this), PodcastController.quickMatchEpisodes.bind(this))
|
||||
this.router.get('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.getEpisode.bind(this))
|
||||
this.router.patch('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.updateEpisode.bind(this))
|
||||
this.router.delete('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.removeEpisode.bind(this))
|
||||
|
||||
@@ -553,4 +555,4 @@ class ApiRouter {
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = ApiRouter
|
||||
module.exports = ApiRouter
|
||||
|
||||
@@ -201,6 +201,7 @@ class Scanner {
|
||||
const dataFound = libraryItemDataFound.find(lid => lid.ino === libraryItem.ino || comparePaths(lid.relPath, libraryItem.relPath))
|
||||
if (!dataFound) {
|
||||
libraryScan.addLog(LogLevel.WARN, `Library Item "${libraryItem.media.metadata.title}" is missing`)
|
||||
Logger.warn(`[Scanner] Library item "${libraryItem.media.metadata.title}" is missing (inode "${libraryItem.ino}")`)
|
||||
libraryScan.resultsMissing++
|
||||
libraryItem.setMissing()
|
||||
itemsToUpdate.push(libraryItem)
|
||||
@@ -899,7 +900,7 @@ class Scanner {
|
||||
description: episodeToMatch.description || '',
|
||||
enclosure: episodeToMatch.enclosure || null,
|
||||
episode: episodeToMatch.episode || '',
|
||||
episodeType: episodeToMatch.episodeType || '',
|
||||
episodeType: episodeToMatch.episodeType || 'full',
|
||||
season: episodeToMatch.season || '',
|
||||
pubDate: episodeToMatch.pubDate || '',
|
||||
publishedAt: episodeToMatch.publishedAt
|
||||
@@ -993,4 +994,4 @@ class Scanner {
|
||||
return MediaFileScanner.probeAudioFileWithTone(audioFile)
|
||||
}
|
||||
}
|
||||
module.exports = Scanner
|
||||
module.exports = Scanner
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const sanitizeHtml = require('../libs/sanitizeHtml')
|
||||
const {entities} = require("./htmlEntities");
|
||||
const { entities } = require("./htmlEntities");
|
||||
|
||||
function sanitize(html) {
|
||||
const sanitizerOptions = {
|
||||
allowedTags: [
|
||||
'p', 'ol', 'ul', 'li', 'a', 'strong', 'em', 'del'
|
||||
'p', 'ol', 'ul', 'li', 'a', 'strong', 'em', 'del', 'br'
|
||||
],
|
||||
disallowedTagsMode: 'discard',
|
||||
allowedAttributes: {
|
||||
|
||||
@@ -95,17 +95,20 @@ module.exports = {
|
||||
checkSeriesProgressFilter(series, filterBy, user) {
|
||||
const filter = this.decode(filterBy.split('.')[1])
|
||||
|
||||
var numBooksStartedOrFinished = 0
|
||||
let someBookHasProgress = false
|
||||
let someBookIsUnfinished = false
|
||||
for (const libraryItem of series.books) {
|
||||
const itemProgress = user.getMediaProgress(libraryItem.id)
|
||||
if (filter === 'Finished' && (!itemProgress || !itemProgress.isFinished)) return false
|
||||
if (filter === 'Not Started' && itemProgress) return false
|
||||
if (itemProgress) numBooksStartedOrFinished++
|
||||
if (!itemProgress || !itemProgress.isFinished) someBookIsUnfinished = true
|
||||
if (itemProgress && itemProgress.progress > 0) someBookHasProgress = true
|
||||
|
||||
if (filter === 'finished' && (!itemProgress || !itemProgress.isFinished)) return false
|
||||
if (filter === 'not-started' && itemProgress) return false
|
||||
}
|
||||
|
||||
if (numBooksStartedOrFinished === series.books.length) { // Completely finished series
|
||||
if (filter === 'Not Finished') return false
|
||||
} else if (numBooksStartedOrFinished === 0 && filter === 'In Progress') { // Series not started
|
||||
if (!someBookIsUnfinished && filter === 'not-finished') { // Completely finished series
|
||||
return false
|
||||
} else if (!someBookHasProgress && filter === 'in-progress') { // Series not started
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -280,6 +283,19 @@ module.exports = {
|
||||
}
|
||||
},
|
||||
|
||||
getItemSizeStats(libraryItems) {
|
||||
var sorted = sort(libraryItems).desc(li => li.media.size)
|
||||
var top10 = sorted.slice(0, 10).map(li => ({ id: li.id, title: li.media.metadata.title, size: li.media.size })).filter(i => i.size > 0)
|
||||
var totalSize = 0
|
||||
libraryItems.forEach((li) => {
|
||||
totalSize += li.media.size
|
||||
})
|
||||
return {
|
||||
totalSize,
|
||||
largestItems: top10
|
||||
}
|
||||
},
|
||||
|
||||
getLibraryItemsTotalSize(libraryItems) {
|
||||
var totalSize = 0
|
||||
libraryItems.forEach((li) => {
|
||||
@@ -843,4 +859,4 @@ module.exports = {
|
||||
|
||||
return Object.values(albums)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ module.exports.notificationData = {
|
||||
requiresLibrary: true,
|
||||
libraryMediaType: 'podcast',
|
||||
description: 'Triggered when a podcast episode is auto-downloaded',
|
||||
variables: ['libraryItemId', 'libraryId', 'podcastTitle', 'episodeTitle', 'libraryName', 'episodeId'],
|
||||
variables: ['libraryItemId', 'libraryId', 'podcastTitle', 'podcastAuthor', 'podcastDescription', 'podcastGenres', 'episodeTitle', 'episodeSubtitle', 'episodeDescription', 'libraryName', 'episodeId', 'mediaTags'],
|
||||
defaults: {
|
||||
title: 'New {{podcastTitle}} Episode!',
|
||||
body: '{{episodeTitle}} has been added to {{libraryName}} library.'
|
||||
@@ -16,9 +16,15 @@ module.exports.notificationData = {
|
||||
libraryItemId: 'li_notification_test',
|
||||
libraryId: 'lib_test',
|
||||
libraryName: 'Podcasts',
|
||||
mediaTags: 'TestTag1, TestTag2',
|
||||
podcastTitle: 'Abs Test Podcast',
|
||||
podcastAuthor: 'Audiobookshelf',
|
||||
podcastDescription: 'Description of the Abs Test Podcast belongs here.',
|
||||
podcastGenres: 'TestGenre1, TestGenre2',
|
||||
episodeId: 'ep_notification_test',
|
||||
episodeTitle: 'Successful Test'
|
||||
episodeTitle: 'Successful Test Episode',
|
||||
episodeSubtitle: 'Episode Subtitle',
|
||||
episodeDescription: 'Some description of the podcast episode.'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -35,4 +41,4 @@ module.exports.notificationData = {
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ function fetchVolumeNumber(metadataMeta) {
|
||||
|
||||
function fetchNarrators(creators, metadata) {
|
||||
const narrators = fetchCreators(creators, 'nrt')
|
||||
if (typeof metadata.meta == "undefined" || narrators.length) return narrators
|
||||
if (narrators?.length) return narrators
|
||||
try {
|
||||
const narratorsJSON = JSON.parse(fetchTagString(metadata.meta, "calibre:user_metadata:#narrators").replace(/"/g, '"'))
|
||||
return narratorsJSON["#value#"]
|
||||
@@ -150,7 +150,7 @@ module.exports.parseOpfMetadataXML = async (xml) => {
|
||||
const metadataMeta = prefix ? metadata[`${prefix}:meta`] || metadata.meta : metadata.meta
|
||||
|
||||
metadata.meta = {}
|
||||
if (metadataMeta && metadataMeta.length) {
|
||||
if (metadataMeta?.length) {
|
||||
metadataMeta.forEach((meta) => {
|
||||
if (meta && meta['$'] && meta['$'].name) {
|
||||
metadata.meta[meta['$'].name] = [meta['$'].content || '']
|
||||
|
||||
@@ -46,7 +46,8 @@ function extractPodcastMetadata(channel) {
|
||||
categories: extractCategories(channel),
|
||||
feedUrl: null,
|
||||
description: null,
|
||||
descriptionPlain: null
|
||||
descriptionPlain: null,
|
||||
type: null
|
||||
}
|
||||
|
||||
if (channel['itunes:new-feed-url']) {
|
||||
@@ -61,7 +62,7 @@ function extractPodcastMetadata(channel) {
|
||||
metadata.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription)
|
||||
}
|
||||
|
||||
var arrayFields = ['title', 'language', 'itunes:explicit', 'itunes:author', 'pubDate', 'link']
|
||||
var arrayFields = ['title', 'language', 'itunes:explicit', 'itunes:author', 'pubDate', 'link', 'itunes:type']
|
||||
arrayFields.forEach((key) => {
|
||||
var cleanKey = key.split(':').pop()
|
||||
metadata[cleanKey] = extractFirstArrayItem(channel, key)
|
||||
@@ -258,4 +259,4 @@ module.exports.findMatchingEpisodesInFeed = (feed, searchTitle) => {
|
||||
}
|
||||
})
|
||||
return matches.sort((a, b) => a.levenshtein - b.levenshtein)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user