mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-31 19:49:22 -05:00
Compare commits
62 Commits
v2.19.4
...
validate_m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c29935e57b | ||
|
|
d41b48c89a | ||
|
|
b17e6010fd | ||
|
|
a296ac6132 | ||
|
|
5746e848b0 | ||
|
|
c6b5d4aa26 | ||
|
|
43a507faa8 | ||
|
|
828d5d2afc | ||
|
|
6075f2686f | ||
|
|
ae3517bcde | ||
|
|
0a00ebcde1 | ||
|
|
68ef0f83e1 | ||
|
|
e4a34b0145 | ||
|
|
0ca65d1f79 | ||
|
|
bd3d396f37 | ||
|
|
fd1c8ee513 | ||
|
|
b0045b5b8b | ||
|
|
6674189acd | ||
|
|
c7d8021a16 | ||
|
|
9e83ad25b9 | ||
|
|
2eccb9465c | ||
|
|
599b6bd6ad | ||
|
|
e01ac489fb | ||
|
|
271dbc4764 | ||
|
|
84c2931434 | ||
|
|
38483c9269 | ||
|
|
b2e97d70df | ||
|
|
78aafe038d | ||
|
|
34f7ddfdd7 | ||
|
|
0e9777feec | ||
|
|
6351fd8d7b | ||
|
|
b7591abd06 | ||
|
|
2b36caf096 | ||
|
|
f87a0bfc2f | ||
|
|
b109b2edee | ||
|
|
7795bf25d0 | ||
|
|
3d5c02ae7c | ||
|
|
373d14a49e | ||
|
|
a17127f078 | ||
|
|
20f812403f | ||
|
|
a864c6bcc6 | ||
|
|
6c0e42db49 | ||
|
|
364ccd85fe | ||
|
|
d6b58c2f10 | ||
|
|
72169990ac | ||
|
|
5f105dc6cc | ||
|
|
706b2d7d72 | ||
|
|
64185b7519 | ||
|
|
e1b3b657c4 | ||
|
|
4662fc5244 | ||
|
|
13c20e0cdd | ||
|
|
007691ffe5 | ||
|
|
19a65dba98 | ||
|
|
799879d67d | ||
|
|
452d354b52 | ||
|
|
9d7f44f73a | ||
|
|
e8b60defb6 | ||
|
|
0cc2e39367 | ||
|
|
a34b01fcb4 | ||
|
|
7919a8b581 | ||
|
|
2fdab39e27 | ||
|
|
9b01d11b27 |
@@ -568,6 +568,18 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
routeToBookshelfIfLastIssueRemoved() {
|
||||
if (this.totalEntities === 0) {
|
||||
const currentRouteQuery = this.$route.query
|
||||
if (currentRouteQuery?.filter && currentRouteQuery.filter === 'issues') {
|
||||
this.$nextTick(() => {
|
||||
console.log('Last issue removed. Redirecting to library bookshelf')
|
||||
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
|
||||
this.$store.dispatch('libraries/fetch', this.currentLibraryId)
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
libraryItemRemoved(libraryItem) {
|
||||
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
||||
@@ -578,6 +590,7 @@ export default {
|
||||
this.executeRebuild()
|
||||
}
|
||||
}
|
||||
this.routeToBookshelfIfLastIssueRemoved()
|
||||
},
|
||||
libraryItemsAdded(libraryItems) {
|
||||
console.log('items added', libraryItems)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
<div class="text-gray-400 flex items-center w-1/2 sm:w-4/5 lg:w-2/5">
|
||||
<span class="material-symbols text-sm">person</span>
|
||||
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div>
|
||||
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base truncate">{{ podcastAuthor }}</div>
|
||||
<div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base truncate">
|
||||
<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>
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
<div class="w-full p-8">
|
||||
<div class="flex mb-2">
|
||||
<div class="w-3/4 p-1">
|
||||
<ui-text-input-with-label v-model="newName" :label="$strings.LabelName" />
|
||||
<ui-text-input-with-label v-model="newName" :label="$strings.LabelName" trim-whitespace />
|
||||
</div>
|
||||
<div class="w-1/4 p-1">
|
||||
<ui-text-input-with-label value="Book" readonly :label="$strings.LabelMediaType" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full mb-2 p-1">
|
||||
<ui-text-input-with-label v-model="newUrl" label="URL" />
|
||||
<ui-text-input-with-label v-model="newUrl" label="URL" trim-whitespace />
|
||||
</div>
|
||||
<div class="w-full mb-2 p-1">
|
||||
<ui-text-input-with-label v-model="newAuthHeaderValue" :label="$strings.LabelProviderAuthorizationValue" type="password" />
|
||||
@@ -65,7 +65,11 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submitForm() {
|
||||
async submitForm() {
|
||||
// Remove focus from active input
|
||||
document.activeElement?.blur?.()
|
||||
await this.$nextTick()
|
||||
|
||||
if (!this.newName || !this.newUrl) {
|
||||
this.$toast.error(this.$strings.ToastProviderNameAndUrlRequired)
|
||||
return
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<ui-textarea-with-label v-model="newCollectionDescription" :label="$strings.LabelDescription" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 right-0 w-full py-2 px-4 flex">
|
||||
<div class="absolute bottom-0 left-0 right-0 w-full py-4 px-4 flex">
|
||||
<ui-btn v-if="userCanDelete" small color="error" type="button" @click.stop="removeClick">{{ $strings.ButtonRemove }}</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" type="submit">{{ $strings.ButtonSave }}</ui-btn>
|
||||
@@ -94,21 +94,32 @@ export default {
|
||||
this.newCollectionDescription = this.collection.description || ''
|
||||
},
|
||||
removeClick() {
|
||||
if (confirm(this.$getString('MessageConfirmRemoveCollection', [this.collectionName]))) {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/collections/${this.collection.id}`)
|
||||
.then(() => {
|
||||
this.processing = false
|
||||
this.show = false
|
||||
this.$toast.success(this.$strings.ToastCollectionRemoveSuccess)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove collection', error)
|
||||
this.processing = false
|
||||
this.$toast.error(this.$strings.ToastRemoveFailed)
|
||||
})
|
||||
const payload = {
|
||||
message: this.$getString('MessageConfirmRemoveCollection', [this.collectionName]),
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.deleteCollection()
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
deleteCollection() {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/collections/${this.collection.id}`)
|
||||
.then(() => {
|
||||
this.show = false
|
||||
this.$toast.success(this.$strings.ToastCollectionRemoveSuccess)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove collection', error)
|
||||
this.$toast.error(this.$strings.ToastRemoveFailed)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
submitForm() {
|
||||
if (this.newCollectionName === this.collectionName && this.newCollectionDescription === this.collection.description) {
|
||||
|
||||
@@ -16,11 +16,12 @@
|
||||
v-for="(episode, index) in episodesList"
|
||||
:key="index"
|
||||
class="relative"
|
||||
:class="getIsEpisodeDownloaded(episode) ? 'bg-primary bg-opacity-40' : selectedEpisodes[episode.cleanUrl] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
|
||||
:class="episode.isDownloaded || episode.isDownloading ? 'bg-primary bg-opacity-40' : selectedEpisodes[episode.cleanUrl] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
|
||||
@click="toggleSelectEpisode(episode)"
|
||||
>
|
||||
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||
<span v-if="getIsEpisodeDownloaded(episode)" class="material-symbols text-success text-xl">download_done</span>
|
||||
<span v-if="episode.isDownloaded" class="material-symbols text-success text-xl">download_done</span>
|
||||
<span v-else-if="episode.isDownloading" class="material-symbols text-warning text-xl">download</span>
|
||||
<ui-checkbox v-else v-model="selectedEpisodes[episode.cleanUrl]" small checkbox-bg="primary" border-color="gray-600" />
|
||||
</div>
|
||||
<div class="px-8 py-2">
|
||||
@@ -58,6 +59,14 @@ export default {
|
||||
episodes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
downloadQueue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
episodesDownloading: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -79,6 +88,21 @@ export default {
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
},
|
||||
episodes: {
|
||||
handler(newVal) {
|
||||
if (newVal) this.updateEpisodeDownloadStatuses()
|
||||
}
|
||||
},
|
||||
episodesDownloading: {
|
||||
handler(newVal) {
|
||||
if (newVal) this.updateEpisodeDownloadStatuses()
|
||||
}
|
||||
},
|
||||
downloadQueue: {
|
||||
handler(newVal) {
|
||||
if (newVal) this.updateEpisodeDownloadStatuses()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -132,6 +156,13 @@ export default {
|
||||
}
|
||||
return false
|
||||
},
|
||||
getIsEpisodeDownloadingOrQueued(episode) {
|
||||
const episodesToCheck = [...this.episodesDownloading, ...this.downloadQueue]
|
||||
if (episode.guid) {
|
||||
return episodesToCheck.some((download) => download.guid === episode.guid)
|
||||
}
|
||||
return episodesToCheck.some((download) => this.getCleanEpisodeUrl(download.url) === episode.cleanUrl)
|
||||
},
|
||||
/**
|
||||
* UPDATE: As of v2.4.5 guid is used for matching existing downloaded episodes if it is found on the RSS feed.
|
||||
* Fallback to checking the clean url
|
||||
@@ -173,13 +204,13 @@ export default {
|
||||
},
|
||||
toggleSelectAll(val) {
|
||||
for (const episode of this.episodesList) {
|
||||
if (this.getIsEpisodeDownloaded(episode)) this.selectedEpisodes[episode.cleanUrl] = false
|
||||
if (episode.isDownloaded || episode.isDownloading) this.selectedEpisodes[episode.cleanUrl] = false
|
||||
else this.$set(this.selectedEpisodes, episode.cleanUrl, val)
|
||||
}
|
||||
},
|
||||
checkSetIsSelectedAll() {
|
||||
for (const episode of this.episodesList) {
|
||||
if (!this.getIsEpisodeDownloaded(episode) && !this.selectedEpisodes[episode.cleanUrl]) {
|
||||
if (!episode.isDownloaded && !episode.isDownloading && !this.selectedEpisodes[episode.cleanUrl]) {
|
||||
this.selectAll = false
|
||||
return
|
||||
}
|
||||
@@ -187,7 +218,7 @@ export default {
|
||||
this.selectAll = true
|
||||
},
|
||||
toggleSelectEpisode(episode) {
|
||||
if (this.getIsEpisodeDownloaded(episode)) return
|
||||
if (episode.isDownloaded || episode.isDownloading) return
|
||||
this.$set(this.selectedEpisodes, episode.cleanUrl, !this.selectedEpisodes[episode.cleanUrl])
|
||||
this.checkSetIsSelectedAll()
|
||||
},
|
||||
@@ -223,6 +254,23 @@ export default {
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.updateDownloadedEpisodeMaps()
|
||||
|
||||
this.episodesCleaned = this.episodes
|
||||
.filter((ep) => ep.enclosure?.url)
|
||||
.map((_ep) => {
|
||||
return {
|
||||
..._ep,
|
||||
cleanUrl: this.getCleanEpisodeUrl(_ep.enclosure.url),
|
||||
isDownloading: this.getIsEpisodeDownloadingOrQueued(_ep),
|
||||
isDownloaded: this.getIsEpisodeDownloaded(_ep)
|
||||
}
|
||||
})
|
||||
this.episodesCleaned.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
|
||||
this.selectAll = false
|
||||
this.selectedEpisodes = {}
|
||||
},
|
||||
updateDownloadedEpisodeMaps() {
|
||||
this.downloadedEpisodeGuidMap = {}
|
||||
this.downloadedEpisodeUrlMap = {}
|
||||
|
||||
@@ -230,18 +278,16 @@ export default {
|
||||
if (episode.guid) this.downloadedEpisodeGuidMap[episode.guid] = episode.id
|
||||
if (episode.enclosure?.url) this.downloadedEpisodeUrlMap[this.getCleanEpisodeUrl(episode.enclosure.url)] = episode.id
|
||||
})
|
||||
|
||||
this.episodesCleaned = this.episodes
|
||||
.filter((ep) => ep.enclosure?.url)
|
||||
.map((_ep) => {
|
||||
return {
|
||||
..._ep,
|
||||
cleanUrl: this.getCleanEpisodeUrl(_ep.enclosure.url)
|
||||
}
|
||||
})
|
||||
this.episodesCleaned.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
|
||||
this.selectAll = false
|
||||
this.selectedEpisodes = {}
|
||||
},
|
||||
updateEpisodeDownloadStatuses() {
|
||||
this.updateDownloadedEpisodeMaps()
|
||||
this.episodesCleaned = this.episodesCleaned.map((ep) => {
|
||||
return {
|
||||
...ep,
|
||||
isDownloading: this.getIsEpisodeDownloadingOrQueued(ep),
|
||||
isDownloaded: this.getIsEpisodeDownloaded(ep)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
<button aria-label="Download Backup" class="inline-flex material-symbols text-xl mx-1 mt-1 text-white/70 hover:text-white/100" @click.stop="downloadBackup(backup)">download</button>
|
||||
|
||||
<button aria-label="Delete Backup" class="inline-flex material-symbols text-xl mx-1 text-white/70 hover:text-error" @click="deleteBackupClick(backup)">delete</button>
|
||||
<button aria-label="Delete Backup" class="inline-flex material-symbols text-xl mx-1 text-white/70 hover:text-error" @click.stop="deleteBackupClick(backup)">delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -107,21 +107,32 @@ export default {
|
||||
})
|
||||
},
|
||||
deleteBackupClick(backup) {
|
||||
if (confirm(this.$getString('MessageConfirmDeleteBackup', [this.$formatDatetime(backup.createdAt, this.dateFormat, this.timeFormat)]))) {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/backups/${backup.id}`)
|
||||
.then((data) => {
|
||||
this.setBackups(data.backups || [])
|
||||
this.$toast.success(this.$strings.ToastBackupDeleteSuccess)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
this.$toast.error(this.$strings.ToastBackupDeleteFailed)
|
||||
this.processing = false
|
||||
})
|
||||
const payload = {
|
||||
message: this.$getString('MessageConfirmDeleteBackup', [this.$formatDatetime(backup.createdAt, this.dateFormat, this.timeFormat)]),
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.deleteBackup(backup)
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
deleteBackup(backup) {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/backups/${backup.id}`)
|
||||
.then((data) => {
|
||||
this.setBackups(data.backups || [])
|
||||
this.$toast.success(this.$strings.ToastBackupDeleteSuccess)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
this.$toast.error(this.$strings.ToastBackupDeleteFailed)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
applyBackup(backup) {
|
||||
this.selectedBackup = backup
|
||||
|
||||
@@ -91,24 +91,36 @@ export default {
|
||||
},
|
||||
deleteUserClick(user) {
|
||||
if (this.isDeletingUser) return
|
||||
if (confirm(this.$getString('MessageRemoveUserWarning', [user.username]))) {
|
||||
this.isDeletingUser = true
|
||||
this.$axios
|
||||
.$delete(`/api/users/${user.id}`)
|
||||
.then((data) => {
|
||||
this.isDeletingUser = false
|
||||
if (data.error) {
|
||||
this.$toast.error(data.error)
|
||||
} else {
|
||||
this.$toast.success(this.$strings.ToastUserDeleteSuccess)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete user', error)
|
||||
this.$toast.error(this.$strings.ToastUserDeleteFailed)
|
||||
this.isDeletingUser = false
|
||||
})
|
||||
|
||||
const payload = {
|
||||
message: this.$getString('MessageRemoveUserWarning', [user.username]),
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.deleteUser(user)
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
deleteUser(user) {
|
||||
this.isDeletingUser = true
|
||||
this.$axios
|
||||
.$delete(`/api/users/${user.id}`)
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
this.$toast.error(data.error)
|
||||
} else {
|
||||
this.$toast.success(this.$strings.ToastUserDeleteSuccess)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete user', error)
|
||||
this.$toast.error(this.$strings.ToastUserDeleteFailed)
|
||||
})
|
||||
.finally(() => {
|
||||
this.isDeletingUser = false
|
||||
})
|
||||
},
|
||||
editUser(user) {
|
||||
this.$emit('edit', user)
|
||||
|
||||
@@ -10,8 +10,13 @@
|
||||
<div class="h-10 flex items-center mt-1.5 mb-0.5 overflow-hidden">
|
||||
<p class="text-sm text-gray-200 line-clamp-2" v-html="episodeSubtitle"></p>
|
||||
</div>
|
||||
|
||||
<div class="h-8 flex items-center">
|
||||
<div class="w-full inline-flex justify-between max-w-xl">
|
||||
<p v-if="sortKey === 'audioFile.metadata.filename'" class="text-sm text-gray-300 truncate font-light">
|
||||
<strong className="font-bold">{{ $strings.LabelFilename }}</strong
|
||||
>: {{ episode.audioFile.metadata.filename }}
|
||||
</p>
|
||||
<div v-else class="w-full inline-flex justify-between max-w-xl">
|
||||
<p v-if="episode?.season" class="text-sm text-gray-300">{{ $getString('LabelSeasonNumber', [episode.season]) }}</p>
|
||||
<p v-if="episode?.episode" class="text-sm text-gray-300">{{ $getString('LabelEpisodeNumber', [episode.episode]) }}</p>
|
||||
<p v-if="episode?.chapters?.length" class="text-sm text-gray-300">{{ $getString('LabelChapterCount', [episode.chapters.length]) }}</p>
|
||||
@@ -65,7 +70,8 @@ export default {
|
||||
episode: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
sortKey: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
<template>
|
||||
<div id="lazy-episodes-table" class="w-full py-6">
|
||||
<div class="flex flex-wrap flex-col md:flex-row md:items-center mb-4">
|
||||
@@ -123,6 +124,10 @@ export default {
|
||||
{
|
||||
text: this.$strings.LabelEpisode,
|
||||
value: 'episode'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelFilename,
|
||||
value: 'audioFile.metadata.filename'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -171,8 +176,17 @@ export default {
|
||||
return episodeProgress && !episodeProgress.isFinished
|
||||
})
|
||||
.sort((a, b) => {
|
||||
let aValue = a[this.sortKey]
|
||||
let bValue = b[this.sortKey]
|
||||
let aValue
|
||||
let bValue
|
||||
|
||||
if (this.sortKey.includes('.')) {
|
||||
const getNestedValue = (ob, s) => s.split('.').reduce((o, k) => o?.[k], ob)
|
||||
aValue = getNestedValue(a, this.sortKey)
|
||||
bValue = getNestedValue(b, this.sortKey)
|
||||
} else {
|
||||
aValue = a[this.sortKey]
|
||||
bValue = b[this.sortKey]
|
||||
}
|
||||
|
||||
// Sort episodes with no pub date as the oldest
|
||||
if (this.sortKey === 'publishedAt') {
|
||||
@@ -361,20 +375,20 @@ export default {
|
||||
playEpisode(episode) {
|
||||
const queueItems = []
|
||||
|
||||
const episodesInListeningOrder = this.episodesCopy.map((ep) => ({ ...ep })).sort((a, b) => String(a.publishedAt).localeCompare(String(b.publishedAt), undefined, { numeric: true, sensitivity: 'base' }))
|
||||
const episodesInListeningOrder = this.episodesList
|
||||
const episodeIndex = episodesInListeningOrder.findIndex((e) => e.id === episode.id)
|
||||
for (let i = episodeIndex; i < episodesInListeningOrder.length; i++) {
|
||||
const episode = episodesInListeningOrder[i]
|
||||
const podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
|
||||
if (!podcastProgress || !podcastProgress.isFinished) {
|
||||
const _episode = episodesInListeningOrder[i]
|
||||
const podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, _episode.id)
|
||||
if (!podcastProgress?.isFinished || episode.id === _episode.id) {
|
||||
queueItems.push({
|
||||
libraryItemId: this.libraryItem.id,
|
||||
libraryId: this.libraryItem.libraryId,
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
episodeId: _episode.id,
|
||||
title: _episode.title,
|
||||
subtitle: this.mediaMetadata.title,
|
||||
caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
|
||||
duration: episode.audioFile.duration || null,
|
||||
caption: _episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(_episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
|
||||
duration: _episode.audioFile.duration || null,
|
||||
coverPath: this.media.coverPath || null
|
||||
})
|
||||
}
|
||||
@@ -440,7 +454,8 @@ export default {
|
||||
propsData: {
|
||||
index,
|
||||
libraryItemId: this.libraryItem.id,
|
||||
episode: this.episodesList[index]
|
||||
episode: this.episodesList[index],
|
||||
sortKey: this.sortKey
|
||||
},
|
||||
created() {
|
||||
this.$on('selected', (payload) => {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<ui-tooltip v-if="tasksRunning" :text="$strings.LabelTasks" direction="bottom" class="flex items-center">
|
||||
<widgets-loading-spinner />
|
||||
</ui-tooltip>
|
||||
<ui-tooltip v-else text="Activities" direction="bottom" class="flex items-center">
|
||||
<span class="material-symbols text-1.5xl" aria-label="Activities" role="button">notifications</span>
|
||||
<ui-tooltip v-else :text="$strings.LabelActivities" direction="bottom" class="flex items-center">
|
||||
<span class="material-symbols text-1.5xl" :aria-label="$strings.LabelActivities" role="button">notifications</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div v-if="showUnseenSuccessIndicator" class="w-2 h-2 rounded-full bg-success pointer-events-none absolute -top-1 -right-0.5" />
|
||||
|
||||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.19.4",
|
||||
"version": "2.19.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.19.4",
|
||||
"version": "2.19.5",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.19.4",
|
||||
"version": "2.19.5",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -176,21 +176,31 @@ export default {
|
||||
this.$store.commit('globals/setEditCollection', this.collection)
|
||||
},
|
||||
removeClick() {
|
||||
if (confirm(this.$getString('MessageConfirmRemoveCollection', [this.collectionName]))) {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/collections/${this.collection.id}`)
|
||||
.then(() => {
|
||||
this.$toast.success(this.$strings.ToastCollectionRemoveSuccess)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove collection', error)
|
||||
this.$toast.error(this.$strings.ToastCollectionRemoveFailed)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
const payload = {
|
||||
message: this.$getString('MessageConfirmRemoveCollection', [this.collectionName]),
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.deleteCollection()
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
deleteCollection() {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/collections/${this.collection.id}`)
|
||||
.then(() => {
|
||||
this.$toast.success(this.$strings.ToastCollectionRemoveSuccess)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove collection', error)
|
||||
this.$toast.error(this.$strings.ToastCollectionRemoveFailed)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
clickPlay() {
|
||||
const queueItems = []
|
||||
|
||||
@@ -122,7 +122,7 @@ export default {
|
||||
},
|
||||
scheduleDescription() {
|
||||
if (!this.cronExpression) return ''
|
||||
const parsed = this.$parseCronExpression(this.cronExpression)
|
||||
const parsed = this.$parseCronExpression(this.cronExpression, this)
|
||||
return parsed ? parsed.description : `${this.$strings.LabelCustomCronExpression} ${this.cronExpression}`
|
||||
},
|
||||
nextBackupDate() {
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
<div v-if="newServerSettings.scannerFindCovers" class="w-44 ml-14 mb-2">
|
||||
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
|
||||
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" :label="$strings.LabelCoverProvider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
|
||||
</div>
|
||||
|
||||
<div role="article" :aria-label="$strings.LabelSettingsPreferMatchedMetadataHelp" class="flex items-center py-2">
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">{{ $getString('LabelByAuthor', [podcastAuthor]) }}</p>
|
||||
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden overflow-ellipsis">
|
||||
by <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>
|
||||
{{ $getString('LabelByAuthor', ['']) }}<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="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
|
||||
<tables-tracks-table v-if="tracks.length" :title="$strings.LabelStatsAudioTracks" :tracks="tracksWithAudioFile" :is-file="isFile" :library-item-id="libraryItemId" class="mt-6" />
|
||||
|
||||
<tables-podcast-lazy-episodes-table v-if="isPodcast" :library-item="libraryItem" />
|
||||
<tables-podcast-lazy-episodes-table ref="episodesTable" v-if="isPodcast" :library-item="libraryItem" />
|
||||
|
||||
<tables-ebook-files-table v-if="ebookFiles.length" :library-item="libraryItem" class="mt-6" />
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
|
||||
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" :download-queue="episodeDownloadsQueued" :episodes-downloading="episodesDownloading" />
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :playback-rate="1" :library-item-id="libraryItemId" hide-create @select="selectBookmark" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -534,13 +534,15 @@ export default {
|
||||
let episodeId = null
|
||||
const queueItems = []
|
||||
if (this.isPodcast) {
|
||||
const episodesInListeningOrder = this.podcastEpisodes.map((ep) => ({ ...ep })).sort((a, b) => String(a.publishedAt).localeCompare(String(b.publishedAt), undefined, { numeric: true, sensitivity: 'base' }))
|
||||
// Uses the sorting and filtering from the episode table component
|
||||
const episodesInListeningOrder = this.$refs.episodesTable?.episodesList || []
|
||||
|
||||
// Find most recent episode unplayed
|
||||
let episodeIndex = episodesInListeningOrder.findLastIndex((ep) => {
|
||||
// Find the first unplayed episode from the table
|
||||
let episodeIndex = episodesInListeningOrder.findIndex((ep) => {
|
||||
const podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, ep.id)
|
||||
return !podcastProgress || !podcastProgress.isFinished
|
||||
})
|
||||
// If all episodes are played, use the first episode
|
||||
if (episodeIndex < 0) episodeIndex = 0
|
||||
|
||||
episodeId = episodesInListeningOrder[episodeIndex].id
|
||||
@@ -599,19 +601,31 @@ export default {
|
||||
},
|
||||
clearProgressClick() {
|
||||
if (!this.userMediaProgress) return
|
||||
if (confirm(this.$strings.MessageConfirmResetProgress)) {
|
||||
this.resettingProgress = true
|
||||
this.$axios
|
||||
.$delete(`/api/me/progress/${this.userMediaProgress.id}`)
|
||||
.then(() => {
|
||||
console.log('Progress reset complete')
|
||||
this.resettingProgress = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Progress reset failed', error)
|
||||
this.resettingProgress = false
|
||||
})
|
||||
|
||||
const payload = {
|
||||
message: this.$strings.MessageConfirmResetProgress,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.clearProgress()
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
clearProgress() {
|
||||
this.resettingProgress = true
|
||||
this.$axios
|
||||
.$delete(`/api/me/progress/${this.userMediaProgress.id}`)
|
||||
.then(() => {
|
||||
console.log('Progress reset complete')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Progress reset failed', error)
|
||||
})
|
||||
.finally(() => {
|
||||
this.resettingProgress = false
|
||||
})
|
||||
},
|
||||
clickRSSFeed() {
|
||||
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
|
||||
@@ -646,13 +660,11 @@ export default {
|
||||
},
|
||||
rssFeedOpen(data) {
|
||||
if (data.entityId === this.libraryItemId) {
|
||||
console.log('RSS Feed Opened', data)
|
||||
this.rssFeed = data
|
||||
}
|
||||
},
|
||||
rssFeedClosed(data) {
|
||||
if (data.entityId === this.libraryItemId) {
|
||||
console.log('RSS Feed Closed', data)
|
||||
this.rssFeed = null
|
||||
}
|
||||
},
|
||||
|
||||
@@ -107,6 +107,19 @@ Vue.prototype.$formatNumber = (num) => {
|
||||
return Intl.NumberFormat(Vue.prototype.$languageCodes.current).format(num)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the days of the week for the current language
|
||||
* Starts with Sunday
|
||||
* @returns {string[]}
|
||||
*/
|
||||
Vue.prototype.$getDaysOfWeek = () => {
|
||||
const days = []
|
||||
for (let i = 0; i < 7; i++) {
|
||||
days.push(new Date(2025, 0, 5 + i).toLocaleString(Vue.prototype.$languageCodes.current, { weekday: 'long' }))
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
const translations = {
|
||||
[defaultCode]: enUsStrings
|
||||
}
|
||||
@@ -148,6 +161,7 @@ async function loadi18n(code) {
|
||||
Vue.prototype.$setDateFnsLocale(languageCodeMap[code].dateFnsLocale)
|
||||
|
||||
this?.$eventBus?.$emit('change-lang', code)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true, showSeconds = t
|
||||
return strs.join(' ')
|
||||
}
|
||||
|
||||
Vue.prototype.$parseCronExpression = (expression) => {
|
||||
Vue.prototype.$parseCronExpression = (expression, context) => {
|
||||
if (!expression) return null
|
||||
const pieces = expression.split(' ')
|
||||
if (pieces.length !== 5) {
|
||||
@@ -102,31 +102,31 @@ Vue.prototype.$parseCronExpression = (expression) => {
|
||||
|
||||
const commonPatterns = [
|
||||
{
|
||||
text: 'Every 12 hours',
|
||||
text: context.$strings.LabelIntervalEvery12Hours,
|
||||
value: '0 */12 * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every 6 hours',
|
||||
text: context.$strings.LabelIntervalEvery6Hours,
|
||||
value: '0 */6 * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every 2 hours',
|
||||
text: context.$strings.LabelIntervalEvery2Hours,
|
||||
value: '0 */2 * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every hour',
|
||||
text: context.$strings.LabelIntervalEveryHour,
|
||||
value: '0 * * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every 30 minutes',
|
||||
text: context.$strings.LabelIntervalEvery30Minutes,
|
||||
value: '*/30 * * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every 15 minutes',
|
||||
text: context.$strings.LabelIntervalEvery15Minutes,
|
||||
value: '*/15 * * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every minute',
|
||||
text: context.$strings.LabelIntervalEveryMinute,
|
||||
value: '* * * * *'
|
||||
}
|
||||
]
|
||||
@@ -147,7 +147,7 @@ Vue.prototype.$parseCronExpression = (expression) => {
|
||||
return null
|
||||
}
|
||||
|
||||
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
const weekdays = context.$getDaysOfWeek()
|
||||
var weekdayText = 'day'
|
||||
if (pieces[4] !== '*')
|
||||
weekdayText = pieces[4]
|
||||
@@ -156,7 +156,7 @@ Vue.prototype.$parseCronExpression = (expression) => {
|
||||
.join(', ')
|
||||
|
||||
return {
|
||||
description: `Run every ${weekdayText} at ${pieces[1]}:${pieces[0].padStart(2, '0')}`
|
||||
description: context.$getString('MessageScheduleRunEveryWeekdayAtTime', [weekdayText, `${pieces[1]}:${pieces[0].padStart(2, '0')}`])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"ButtonLatest": "Апошняе",
|
||||
"ButtonLibrary": "Бібліятэка",
|
||||
"ButtonLogout": "Выйсці",
|
||||
"ButtonLookup": "",
|
||||
"ButtonLookup": "Пошук",
|
||||
"ButtonManageTracks": "Кіраванне дарожкамі",
|
||||
"ButtonMapChapterTitles": "Супаставіць назвы раздзелаў",
|
||||
"ButtonMatchAllAuthors": "Супадзенне ўсіх аўтараў",
|
||||
@@ -159,6 +159,15 @@
|
||||
"HeaderNotificationUpdate": "Абнавіць апавяшчэнне",
|
||||
"HeaderNotifications": "Апавяшчэнні",
|
||||
"HeaderOpenListeningSessions": "Адкрыць сеансы праслухоўвання",
|
||||
"HeaderOpenRSSFeed": "Адкрыць RSS-стужку",
|
||||
"HeaderPlaylist": "Плэйліст",
|
||||
"HeaderPlaylistItems": "Элементы плэйліста",
|
||||
"HeaderRSSFeedGeneral": "Падрабязнасці RSS",
|
||||
"HeaderRSSFeedIsOpen": "RSS-стужка адкрыта",
|
||||
"HeaderRSSFeeds": "RSS-стужкі",
|
||||
"HeaderRemoveEpisode": "Выдаліць эпізод",
|
||||
"HeaderRemoveEpisodes": "Выдаліць {0} эпізодаў",
|
||||
"HeaderSavedMediaProgress": "Захаваны прагрэс медыя",
|
||||
"HeaderScheduleEpisodeDownloads": "Расклад аўтаматычных спамповак эпізодаў",
|
||||
"HeaderSettings": "Налады",
|
||||
"HeaderSettingsDisplay": "Дысплей",
|
||||
@@ -166,50 +175,167 @@
|
||||
"HeaderSettingsGeneral": "Агульныя",
|
||||
"HeaderSettingsScanner": "Сканер",
|
||||
"HeaderSettingsWebClient": "Вэб-кліент",
|
||||
"HeaderSleepTimer": "Таймер сну",
|
||||
"HeaderStatsMinutesListeningChart": "Хвіліны праслухоўвання (апошнія 7 дзён)",
|
||||
"HeaderStatsTop10Authors": "10 лепшых аўтараў",
|
||||
"HeaderStatsTop5Genres": "5 лепшых жанраў",
|
||||
"HeaderTableOfContents": "Змест",
|
||||
"HeaderTools": "Інструменты",
|
||||
"HeaderUpdateAccount": "Абнавіць уліковы запіс",
|
||||
"HeaderYourStats": "Ваша статыстыка",
|
||||
"LabelAccountType": "Тып уліковага запіса",
|
||||
"LabelAccountTypeAdmin": "Адміністратар",
|
||||
"LabelAccountTypeGuest": "Госць",
|
||||
"LabelAccountTypeUser": "Карыстальнік",
|
||||
"LabelAddToPlaylist": "Дадаць у плэйліст",
|
||||
"LabelAddedDate": "Дададзена {0}",
|
||||
"LabelAll": "Усе",
|
||||
"LabelAudioBitrate": "Бітрэйт аўдыё (напрыклад, 128к)",
|
||||
"LabelAudioChannels": "Аўдыёканалы (1 або 2)",
|
||||
"LabelAudioCodec": "Аўдыёкодэк",
|
||||
"LabelAuthor": "Аўтар",
|
||||
"LabelAuthorFirstLast": "Аўтар (Імя Прозвішча)",
|
||||
"LabelAuthorLastFirst": "Аўтар (Прозвішча, Імя)",
|
||||
"LabelAuthors": "Аўтары",
|
||||
"LabelAutoDownloadEpisodes": "Аўтаматычнае спампаванне эпізодаў",
|
||||
"LabelBackupAudioFiles": "Рэзервовае капіраванне аўдыёфайлаў",
|
||||
"LabelBooks": "Кнігі",
|
||||
"LabelChapters": "Раздзелы",
|
||||
"LabelClosePlayer": "Зачыніць прайгравальнік",
|
||||
"LabelCollapseSeries": "Згарнуць серыі",
|
||||
"LabelComplete": "Завершана",
|
||||
"LabelContinueListening": "Працягваць слухаць",
|
||||
"LabelContinueReading": "Працягнуць чытанне",
|
||||
"LabelContinueSeries": "Працягнуць серыі",
|
||||
"LabelDescription": "Апісанне",
|
||||
"LabelDiscover": "Знайсці",
|
||||
"LabelDownload": "Спампаваць",
|
||||
"LabelDownloadNEpisodes": "Спампована {0} эпізодаў",
|
||||
"LabelDownloadable": "Спампоўваецца",
|
||||
"LabelDuration": "Працягласць",
|
||||
"LabelEbook": "Электронная кніга",
|
||||
"LabelEbooks": "Электронныя кнігі",
|
||||
"LabelEnable": "Уключыць",
|
||||
"LabelEncodingBackupLocation": "Рэзервовая копія вашых арыгінальных аўдыёфайлаў будзе захавана ў:",
|
||||
"LabelEncodingChaptersNotEmbedded": "Раздзелы не ўбудаваны ў шматдарожкавыя аўдыякнігі.",
|
||||
"LabelEncodingFinishedM4B": "Гатовы файл M4B будзе змешчаны ў вашу тэчку з аўдыякнігамі па адрасе:",
|
||||
"LabelEncodingInfoEmbedded": "Метаданыя будуць убудаваны ў аўдыядарожкі ўнутры вашай тэчкі з аўдыякнігамі.",
|
||||
"LabelEnd": "Канец",
|
||||
"LabelEndOfChapter": "Канец раздзела",
|
||||
"LabelEpisode": "Эпізод",
|
||||
"LabelEpisodeNotLinkedToRssFeed": "Эпізод не звязаны з RSS-стужкай",
|
||||
"LabelEpisodeUrlFromRssFeed": "URL эпізоду з RSS-стужкі",
|
||||
"LabelFeedURL": "URL стужкі",
|
||||
"LabelFile": "Файл",
|
||||
"LabelFileBirthtime": "Час стварэння файла",
|
||||
"LabelFileModified": "Час змянення файла",
|
||||
"LabelFilename": "Імя файла",
|
||||
"LabelFinished": "Скончана",
|
||||
"LabelFolder": "Тэчка",
|
||||
"LabelFontBoldness": "Таўшчыня шрыфта",
|
||||
"LabelFontScale": "Памер шрыфту",
|
||||
"LabelGenre": "Жанр",
|
||||
"LabelGenres": "Жанры",
|
||||
"LabelHasEbook": "Мае электронную кнігу",
|
||||
"LabelHasSupplementaryEbook": "Мае дадатковую электронную кнігу",
|
||||
"LabelHost": "Хост",
|
||||
"LabelInProgress": "У працэсе",
|
||||
"LabelIncomplete": "Незавершана",
|
||||
"LabelLanguage": "Мова",
|
||||
"LabelLayoutSinglePage": "Аднабаковы",
|
||||
"LabelLineSpacing": "Міжрадковы інтэрвал",
|
||||
"LabelListenAgain": "Паслухаць зноў",
|
||||
"LabelMaxEpisodesToDownload": "Максімальная колькасць эпізодаў для спампоўкі. Выкарыстоўвайце 0 для неабмежаванай колькасці.",
|
||||
"LabelMaxEpisodesToDownloadPerCheck": "Максімальная колькасць новых эпізодаў для спампоўкі за праверку",
|
||||
"LabelMaxEpisodesToKeepHelp": "Значэнне 0 не ўстанаўлівае максімальнага абмежавання. Пасля аўтаматычнай спампоўкі новага эпізоду будзе выдалены самы стары эпізод, калі ў вас больш за X эпізодаў. Пры кожнай новай спампоўцы будзе выдаляцца толькі 1 эпізод.",
|
||||
"LabelMediaPlayer": "Медыяплэер",
|
||||
"LabelMediaType": "Тып медыя",
|
||||
"LabelMissing": "Адсутнічае",
|
||||
"LabelMore": "Больш",
|
||||
"LabelMoreInfo": "Больш інфармацыі",
|
||||
"LabelName": "Імя",
|
||||
"LabelNarrator": "Чытальнік",
|
||||
"LabelNarrators": "Чытальнікі",
|
||||
"LabelOpenRSSFeed": "Адкрыць RSS-стужку",
|
||||
"LabelPermissionsDownload": "Можна спампаваць",
|
||||
"LabelPreventIndexing": "Прадухіліць індэксацыю вашай стужкі каталогамі падкастаў iTunes і Google",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Карыстальніцкая электронная пошта ўладальніка",
|
||||
"LabelRSSFeedCustomOwnerName": "Карыстальніцкае імя ўладальніка",
|
||||
"LabelRSSFeedOpen": "RSS-стужка адкрытая",
|
||||
"LabelRSSFeedPreventIndexing": "Прадухіліць індэксацыю",
|
||||
"LabelRSSFeedURL": "URL RSS-стужкі",
|
||||
"LabelReAddSeriesToContinueListening": "Дадаць серыю зноў у Працягваць слухаць",
|
||||
"LabelRecentSeries": "Апошнія серыі",
|
||||
"LabelSeries": "Серыі",
|
||||
"LabelSetEbookAsPrimary": "Зрабіць асноўным",
|
||||
"LabelSetEbookAsSupplementary": "Зрабіць дадатковым",
|
||||
"LabelSettingsExperimentalFeaturesHelp": "Функцыі ў распрацоўцы, для якіх вашы водгукі і дапамога ў тэставанні будуць карыснымі. Націсніце, каб адкрыць абмеркаванне на GitHub.",
|
||||
"LabelSettingsLibraryMarkAsFinishedWhen": "Пазначыць элемент медыя як скончаны, калі",
|
||||
"LabelShareDownloadableHelp": "Дазваляе карыстальнікам, якія маюць спасылку на доступ, спампаваць ZIP-файл элемента бібліятэкі.",
|
||||
"LabelShowAll": "Паказаць усё",
|
||||
"LabelSize": "Памер",
|
||||
"LabelStatsAudioTracks": "Аўдыядарожкі",
|
||||
"LabelTracks": "Дарожкі",
|
||||
"MessageBookshelfNoRSSFeeds": "Няма адкрытых RSS-стужак",
|
||||
"MessageConfirmCloseFeed": "Вы ўпэўнены, што жадаеце закрыць гэтую стужку?",
|
||||
"MessageConfirmRemoveListeningSessions": "Вы ўпэўнены, што жадаеце выдаліць {0} сеансаў праслухоўвання?",
|
||||
"MessageDownloadingEpisode": "Спампоўка эпізоду",
|
||||
"MessageEpisodesQueuedForDownload": "{0} эпізод(аў) у чарзе для спампоўкі",
|
||||
"MessageFeedURLWillBe": "URL стужкі будзе {0}",
|
||||
"MessageNoChapters": "Няма раздзелаў",
|
||||
"MessageNoDownloadsInProgress": "Зараз няма актыўных спамповак",
|
||||
"MessageNoDownloadsQueued": "Няма спамповак у чарзе",
|
||||
"MessageNoListeningSessions": "Няма сеансаў праслухоўвання",
|
||||
"MessageNoMediaProgress": "Няма прагрэсу медыя",
|
||||
"MessageNoPodcastFeed": "Няправільны падкаст: Няма стужкі",
|
||||
"MessageOpmlPreviewNote": "Заўвага: гэта папярэдні прагляд разабранага OPML-файла. Фактычная назва падкаста будзе ўзятая з RSS-стужкі.",
|
||||
"MessagePodcastHasNoRSSFeedForMatching": "У падкаста няма URL RSS-стужкі для супадзення",
|
||||
"MessagePodcastSearchField": "Увядзіце пошукавы запыт або URL RSS-стужкі",
|
||||
"MessageTaskDownloadingEpisodeDescription": "Спампоўка эпізоду \"{0}\"",
|
||||
"MessageTaskOpmlImportDescription": "Стварэнне падкастаў з {0} RSS-стужак",
|
||||
"MessageTaskOpmlImportFeed": "Імпарт стужкі з OPML",
|
||||
"MessageTaskOpmlImportFeedDescription": "Імпартаванне RSS-стужкі \"{0}\"",
|
||||
"MessageTaskOpmlImportFeedFailed": "Не ўдалося атрымаць стужку падкаста",
|
||||
"MessageTaskOpmlImportFeedPodcastDescription": "Стварэнне падкаста \"{0}\"",
|
||||
"MessageTaskOpmlImportFeedPodcastExists": "Падкаст ужо існуе па гэтым шляху",
|
||||
"MessageTaskOpmlImportFeedPodcastFailed": "Не ўдалося стварыць падкаст",
|
||||
"MessageTaskOpmlParseNoneFound": "У OPML-файле не знойдзена стужак",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Папярэджанне: большасць праграм для падкастаў патрабуюць, каб URL RSS-стужкі выкарыстоўваў HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Папярэджанне: адзін ці больш вашых эпізодаў не маюць даты публікацыі. Некаторыя праграмы для падкастаў патрабуюць гэтага.",
|
||||
"NoteUploaderFoldersWithMediaFiles": "Тэчкі з медыяфайламі будуць апрацоўвацца як асобныя элементы бібліятэкі.",
|
||||
"NotificationOnEpisodeDownloadedDescription": "Выклікаецца, калі эпізод падкаста аўтаматычна спампоўваецца",
|
||||
"ToastAccountUpdateSuccess": "Уліковы запіс абноўлены",
|
||||
"ToastEpisodeDownloadQueueClearFailed": "Не ўдалося ачысціць чаргу",
|
||||
"ToastEpisodeDownloadQueueClearSuccess": "Чарга спампоўкі эпізодаў ачышчана",
|
||||
"ToastInvalidMaxEpisodesToDownload": "Няправільная максімальная колькасць эпізодаў для спампоўкі",
|
||||
"ToastItemMarkedAsFinishedFailed": "Не ўдалося пазначыць як Скончана",
|
||||
"ToastItemMarkedAsFinishedSuccess": "Элемент пазначаны як Завершаны",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "Не ўдалося пазначыць як Незавершанае",
|
||||
"ToastItemMarkedAsNotFinishedSuccess": "Элемент пазначаны як Незавершаны",
|
||||
"ToastItemUpdateSuccess": "Элемент абноўлены",
|
||||
"ToastLibraryCreateFailed": "Не ўдалося стварыць бібліятэку",
|
||||
"ToastLibraryCreateSuccess": "Бібліятэка \"{0}\" створана",
|
||||
"ToastLibraryDeleteFailed": "Не ўдалося выдаліць бібліятэку",
|
||||
"ToastLibraryDeleteSuccess": "Бібліятэка выдалена",
|
||||
"ToastLibraryScanFailedToStart": "Не ўдалося запусціць сканаванне",
|
||||
"ToastLibraryScanStarted": "Сканаванне бібліятэкі запушчана",
|
||||
"ToastLibraryUpdateSuccess": "Бібліятэка \"{0}\" абноўлена",
|
||||
"ToastMatchAllAuthorsFailed": "Не ўдалося знайсці адпаведнасць для ўсіх аўтараў",
|
||||
"ToastMetadataFilesRemovedError": "Памылка пры выдаленні metadata.{0} файлаў",
|
||||
"ToastMetadataFilesRemovedNoneFound": "У бібліятэцы не знойдзены metadata.{0} файлаў",
|
||||
"ToastMetadataFilesRemovedNoneRemoved": "Не выдалена metadata.{0} файлаў",
|
||||
"ToastMetadataFilesRemovedSuccess": "{0} metadata.{1} файлаў выдалена",
|
||||
"ToastMustHaveAtLeastOnePath": "Павінен быць хаця б адзін шлях",
|
||||
"ToastNameEmailRequired": "Імя і электронная пошта абавязковыя",
|
||||
"ToastNameRequired": "Імя абавязковае",
|
||||
"ToastNewUserCreatedFailed": "Не ўдалося стварыць уліковы запіс: \"{0}\"",
|
||||
"ToastNewUserCreatedSuccess": "Новы ўліковы запіс створаны",
|
||||
"ToastNoRSSFeed": "У падкаста няма RSS-стужкі",
|
||||
"ToastPodcastGetFeedFailed": "Не ўдалося атрымаць стужку падкаста",
|
||||
"ToastPodcastNoEpisodesInFeed": "У RSS-стужцы не знойдзена эпізодаў",
|
||||
"ToastPodcastNoRssFeed": "У падкаста няма RSS-стужкі",
|
||||
"ToastRSSFeedCloseFailed": "Не ўдалося закрыць RSS-стужку",
|
||||
"ToastRSSFeedCloseSuccess": "RSS-стужка закрыта",
|
||||
"ToastUserPasswordMustChange": "Новы пароль не можа супадаць са старым",
|
||||
"ToastUserRootRequireName": "Неабходна ўвесці імя карыстальніка адміністратара"
|
||||
}
|
||||
|
||||
@@ -217,6 +217,7 @@
|
||||
"LabelAccountTypeAdmin": "Správce",
|
||||
"LabelAccountTypeGuest": "Host",
|
||||
"LabelAccountTypeUser": "Uživatel",
|
||||
"LabelActivities": "Aktivity",
|
||||
"LabelActivity": "Aktivita",
|
||||
"LabelAddToCollection": "Přidat do kolekce",
|
||||
"LabelAddToCollectionBatch": "Přidat {0} knihy do kolekce",
|
||||
@@ -389,6 +390,7 @@
|
||||
"LabelIntervalEvery6Hours": "Každých 6 hodin",
|
||||
"LabelIntervalEveryDay": "Každý den",
|
||||
"LabelIntervalEveryHour": "Každou hodinu",
|
||||
"LabelIntervalEveryMinute": "Každou minutu",
|
||||
"LabelInvert": "Invertovat",
|
||||
"LabelItem": "Položka",
|
||||
"LabelJumpBackwardAmount": "Přeskočit zpět o",
|
||||
@@ -484,6 +486,7 @@
|
||||
"LabelPersonalYearReview": "Váš přehled roku ({0})",
|
||||
"LabelPhotoPathURL": "Cesta k fotografii/URL",
|
||||
"LabelPlayMethod": "Metoda přehrávání",
|
||||
"LabelPlaybackRateIncrementDecrement": "Velikost kroku pro změnu rychlosti přehrávání",
|
||||
"LabelPlayerChapterNumberMarker": "{0} z {1}",
|
||||
"LabelPlaylists": "Seznamy skladeb",
|
||||
"LabelPodcast": "Podcast",
|
||||
@@ -706,6 +709,7 @@
|
||||
"MessageBackupsLocationPathEmpty": "Umístění záloh nemůže být prázdné",
|
||||
"MessageBatchQuickMatchDescription": "Rychlá párování se pokusí přidat chybějící obálky a metadata pro vybrané položky. Povolením níže uvedených možností umožníte funkci Rychlé párování přepsat stávající obálky a/nebo metadata.",
|
||||
"MessageBookshelfNoCollections": "Ještě jste nevytvořili žádnou sbírku",
|
||||
"MessageBookshelfNoCollectionsHelp": "Kolekce jsou veřejné. Mohou je zobrazit všichni uživatelé s přístupem do knihovny.",
|
||||
"MessageBookshelfNoRSSFeeds": "Nejsou otevřeny žádné RSS kanály",
|
||||
"MessageBookshelfNoResultsForFilter": "Filtr \"{0}: {1}\"",
|
||||
"MessageBookshelfNoResultsForQuery": "Žádné výsledky pro dotaz",
|
||||
@@ -816,6 +820,7 @@
|
||||
"MessageNoTasksRunning": "Nejsou spuštěny žádné úlohy",
|
||||
"MessageNoUpdatesWereNecessary": "Nebyly nutné žádné aktualizace",
|
||||
"MessageNoUserPlaylists": "Nemáte žádné seznamy skladeb",
|
||||
"MessageNoUserPlaylistsHelp": "Seznamy skladeb jsou soukromé. Zobrazit je může pouze uživatel, který je vytvořil.",
|
||||
"MessageNotYetImplemented": "Ještě není implementováno",
|
||||
"MessageOpmlPreviewNote": "Poznámka: Toto je náhled načteného OMPL souboru. Aktuální název podcastu bude načten z RSS feedu.",
|
||||
"MessageOr": "nebo",
|
||||
|
||||
@@ -219,7 +219,8 @@
|
||||
"LabelAccountTypeAdmin": "Admin",
|
||||
"LabelAccountTypeGuest": "Gast",
|
||||
"LabelAccountTypeUser": "Benutzer",
|
||||
"LabelActivity": "Aktivitäten",
|
||||
"LabelActivities": "Aktivitäten",
|
||||
"LabelActivity": "Aktivität",
|
||||
"LabelAddToCollection": "Zur Sammlung hinzufügen",
|
||||
"LabelAddToCollectionBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Sammlung hinzu",
|
||||
"LabelAddToPlaylist": "Zur Wiedergabeliste hinzufügen",
|
||||
@@ -283,6 +284,7 @@
|
||||
"LabelContinueSeries": "Serien fortsetzen",
|
||||
"LabelCover": "Titelbild",
|
||||
"LabelCoverImageURL": "URL des Titelbildes",
|
||||
"LabelCoverProvider": "Titelbildanbieter",
|
||||
"LabelCreatedAt": "Erstellt am",
|
||||
"LabelCronExpression": "Cron-Ausdruck",
|
||||
"LabelCurrent": "Aktuell",
|
||||
@@ -391,6 +393,7 @@
|
||||
"LabelIntervalEvery6Hours": "Alle 6 Stunden",
|
||||
"LabelIntervalEveryDay": "Jeden Tag",
|
||||
"LabelIntervalEveryHour": "Jede Stunde",
|
||||
"LabelIntervalEveryMinute": "Jede Minute",
|
||||
"LabelInvert": "Umkehren",
|
||||
"LabelItem": "Medium",
|
||||
"LabelJumpBackwardAmount": "Zurückspringen Zeit",
|
||||
@@ -844,6 +847,7 @@
|
||||
"MessageRestoreBackupConfirm": "Bist du dir sicher, dass du die Sicherung wiederherstellen willst, welche am",
|
||||
"MessageRestoreBackupWarning": "Bei der Wiederherstellung einer Sicherung wird die gesamte Datenbank unter /config und die Titelbilder in /metadata/items und /metadata/authors überschrieben.<br /><br />Bei der Sicherung werden keine Dateien in deinen Bibliotheksordnern verändert. Wenn du die Servereinstellungen aktiviert hast, um Cover und Metadaten in deinen Bibliotheksordnern zu speichern, werden diese nicht gesichert oder überschrieben.<br /><br />Alle Clients, die Ihren Server nutzen, werden automatisch aktualisiert.",
|
||||
"MessageScheduleLibraryScanNote": "Für die meisten Nutzer wird empfohlen, diese Funktion deaktiviert zu lassen und stattdessen die Ordnerüberwachung aktiviert zu lassen. Die Ordnerüberwachung erkennt automatisch Änderungen in deinen Bibliotheksordnern. Da die Ordnerüberwachung jedoch nicht mit jedem Dateisystem (z.B. NFS) funktioniert, können alternativ hier geplante Bibliotheks-Scans aktiviert werden.",
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "Immer {0} um {1} ausführen",
|
||||
"MessageSearchResultsFor": "Suchergebnisse für",
|
||||
"MessageSelected": "{0} ausgewählt",
|
||||
"MessageServerCouldNotBeReached": "Server kann nicht erreicht werden",
|
||||
|
||||
@@ -219,6 +219,7 @@
|
||||
"LabelAccountTypeAdmin": "Admin",
|
||||
"LabelAccountTypeGuest": "Guest",
|
||||
"LabelAccountTypeUser": "User",
|
||||
"LabelActivities": "Activities",
|
||||
"LabelActivity": "Activity",
|
||||
"LabelAddToCollection": "Add to Collection",
|
||||
"LabelAddToCollectionBatch": "Add {0} Books to Collection",
|
||||
@@ -283,6 +284,7 @@
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCover": "Cover",
|
||||
"LabelCoverImageURL": "Cover Image URL",
|
||||
"LabelCoverProvider": "Cover Provider",
|
||||
"LabelCreatedAt": "Created At",
|
||||
"LabelCronExpression": "Cron Expression",
|
||||
"LabelCurrent": "Current",
|
||||
@@ -391,6 +393,7 @@
|
||||
"LabelIntervalEvery6Hours": "Every 6 hours",
|
||||
"LabelIntervalEveryDay": "Every day",
|
||||
"LabelIntervalEveryHour": "Every hour",
|
||||
"LabelIntervalEveryMinute": "Every minute",
|
||||
"LabelInvert": "Invert",
|
||||
"LabelItem": "Item",
|
||||
"LabelJumpBackwardAmount": "Jump backward amount",
|
||||
@@ -845,6 +848,7 @@
|
||||
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
|
||||
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
|
||||
"MessageScheduleLibraryScanNote": "For most users, it is recommended to leave this feature disabled and keep the folder watcher setting enabled. The folder watcher will automatically detect changes in your library folders. The folder watcher doesn't work for every file system (like NFS) so scheduled library scans can be used instead.",
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "Run every {0} at {1}",
|
||||
"MessageSearchResultsFor": "Search results for",
|
||||
"MessageSelected": "{0} selected",
|
||||
"MessageServerCouldNotBeReached": "Server could not be reached",
|
||||
|
||||
@@ -707,7 +707,7 @@
|
||||
"MessageBackupsLocationEditNote": "Remarque : Mettre à jour l'emplacement de sauvegarde ne déplacera pas ou ne modifiera pas les sauvegardes existantes",
|
||||
"MessageBackupsLocationNoEditNote": "Remarque : l’emplacement de sauvegarde est défini via une variable d’environnement et ne peut pas être modifié ici.",
|
||||
"MessageBackupsLocationPathEmpty": "L'emplacement de secours ne peut pas être vide",
|
||||
"MessageBatchEditPopulateMapDetailsAllHelp": "Remplir les champs disponibles avec les données de tous les éléments. les champs avec des valeurs multiples seront fusionnés",
|
||||
"MessageBatchEditPopulateMapDetailsAllHelp": "Remplir les champs disponibles avec les données de tous les éléments. Les champs avec des valeurs multiples seront fusionnés.",
|
||||
"MessageBatchQuickMatchDescription": "La recherche par correspondance rapide tentera d’ajouter les couvertures et métadonnées manquantes pour les éléments sélectionnés. Activez les options ci-dessous pour permettre la Recherche par correspondance d’écraser les couvertures et/ou métadonnées existantes.",
|
||||
"MessageBookshelfNoCollections": "Vous n’avez pas encore de collections",
|
||||
"MessageBookshelfNoRSSFeeds": "Aucun flux RSS n’est ouvert",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"ButtonCancel": "Odustani",
|
||||
"ButtonCancelEncode": "Otkaži kodiranje",
|
||||
"ButtonChangeRootPassword": "Promijeni zaporku root korisnika",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "Provjeri i preuzmi nove epizode",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "Provjeri i preuzmi nove nastavke",
|
||||
"ButtonChooseAFolder": "Odaberi mapu",
|
||||
"ButtonChooseFiles": "Odaberi datoteke",
|
||||
"ButtonClearFilter": "Poništi filter",
|
||||
@@ -219,6 +219,7 @@
|
||||
"LabelAccountTypeAdmin": "Administrator",
|
||||
"LabelAccountTypeGuest": "Gost",
|
||||
"LabelAccountTypeUser": "Korisnik",
|
||||
"LabelActivities": "Aktivnosti",
|
||||
"LabelActivity": "Aktivnost",
|
||||
"LabelAddToCollection": "Dodaj u zbirku",
|
||||
"LabelAddToCollectionBatch": "Dodaj {0} knjiga u zbirku",
|
||||
@@ -283,6 +284,7 @@
|
||||
"LabelContinueSeries": "Nastavi serijal",
|
||||
"LabelCover": "Naslovnica",
|
||||
"LabelCoverImageURL": "URL naslovnice",
|
||||
"LabelCoverProvider": "Pružatelj naslovnica",
|
||||
"LabelCreatedAt": "Izrađen",
|
||||
"LabelCronExpression": "Cron izraz",
|
||||
"LabelCurrent": "Trenutan",
|
||||
@@ -355,7 +357,7 @@
|
||||
"LabelFileModifiedDate": "Izmijenjeno {0}",
|
||||
"LabelFilename": "Naziv datoteke",
|
||||
"LabelFilterByUser": "Filtriraj po korisniku",
|
||||
"LabelFindEpisodes": "Pronađi epizode",
|
||||
"LabelFindEpisodes": "Pronađi nastavke",
|
||||
"LabelFinished": "Dovršeno",
|
||||
"LabelFolder": "Mapa",
|
||||
"LabelFolders": "Mape",
|
||||
@@ -391,6 +393,7 @@
|
||||
"LabelIntervalEvery6Hours": "Svakih 6 sati",
|
||||
"LabelIntervalEveryDay": "Svaki dan",
|
||||
"LabelIntervalEveryHour": "Svaki sat",
|
||||
"LabelIntervalEveryMinute": "Svaku minutu",
|
||||
"LabelInvert": "Obrni",
|
||||
"LabelItem": "Stavka",
|
||||
"LabelJumpBackwardAmount": "Dužina skoka unatrag",
|
||||
@@ -400,8 +403,8 @@
|
||||
"LabelLanguages": "Jezici",
|
||||
"LabelLastBookAdded": "Zadnja dodana knjiga",
|
||||
"LabelLastBookUpdated": "Zadnja ažurirana knjiga",
|
||||
"LabelLastSeen": "Zadnji puta viđen",
|
||||
"LabelLastTime": "Zadnje vrijeme",
|
||||
"LabelLastSeen": "Zadnje gledano",
|
||||
"LabelLastTime": "Vrijeme zadnjeg slušanja",
|
||||
"LabelLastUpdate": "Zadnje ažuriranje",
|
||||
"LabelLayout": "Prikaz",
|
||||
"LabelLayoutSinglePage": "Jedna stranica",
|
||||
@@ -418,7 +421,7 @@
|
||||
"LabelLogLevelDebug": "Debug",
|
||||
"LabelLogLevelInfo": "Info",
|
||||
"LabelLogLevelWarn": "Warn",
|
||||
"LabelLookForNewEpisodesAfterDate": "Traži nove epizode nakon ovog datuma",
|
||||
"LabelLookForNewEpisodesAfterDate": "Traži nove nastavke nakon ovog datuma",
|
||||
"LabelLowestPriority": "Najniži prioritet",
|
||||
"LabelMatchExistingUsersBy": "Prepoznaj postojeće korisnike pomoću",
|
||||
"LabelMatchExistingUsersByDescription": "Rabi se za povezivanje postojećih korisnika. Nakon što se spoje, korisnike se prepoznaje temeljem jedinstvene oznake vašeg pružatelja SSO usluga",
|
||||
@@ -447,7 +450,7 @@
|
||||
"LabelNew": "Novo",
|
||||
"LabelNewPassword": "Nova zaporka",
|
||||
"LabelNewestAuthors": "Najnoviji autori",
|
||||
"LabelNewestEpisodes": "Najnovije epizode",
|
||||
"LabelNewestEpisodes": "Najnoviji nastavci",
|
||||
"LabelNextBackupDate": "Sljedeća izrada sigurnosne kopije",
|
||||
"LabelNextScheduledRun": "Sljedeće zakazano izvođenje",
|
||||
"LabelNoCustomMetadataProviders": "Nema prilagođenih pružatelja meta-podataka",
|
||||
@@ -845,6 +848,7 @@
|
||||
"MessageRestoreBackupConfirm": "Sigurno želite vratiti sigurnosnu kopiju izrađenu",
|
||||
"MessageRestoreBackupWarning": "Vraćanjem sigurnosne kopije prepisat ćete cijelu bazu podataka koja se nalazi u /config i slike naslovnice u /metadata/items i /metadata/authors.<br /><br />Sigurnosne kopije ne mijenjaju datoteke koje se nalaze u mapama vaših knjižnica. Ako ste u postavkama poslužitelja uključili mogućnost spremanja naslovnica i meta-podataka u mape knjižnice, te se datoteke neće niti sigurnosno pohraniti niti prepisati. <br /><br />Svi klijenti koji se spajaju na vaš poslužitelj automatski će se osvježiti.",
|
||||
"MessageScheduleLibraryScanNote": "Za većinu korisnika se preporučuje ostaviti ovu funkciju deaktiviranom i ostaviti postavku promatrača mape aktiviranom. Promatrač mapa će automatski otkriti promjene u mapama vaše knjižnice. Promatrač mapa ne radi na svakom datotečnom sustavu (kao što je NFS) pa se umjesto njega mogu koristiti planirana pretraživanja knjižnice.",
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "Pokreni svaki {0} u {1}",
|
||||
"MessageSearchResultsFor": "Rezultati pretrage za",
|
||||
"MessageSelected": "{0} odabrano",
|
||||
"MessageServerCouldNotBeReached": "Nije moguće pristupiti poslužitelju",
|
||||
|
||||
@@ -219,6 +219,7 @@
|
||||
"LabelAccountTypeAdmin": "Amministratore",
|
||||
"LabelAccountTypeGuest": "Ospite",
|
||||
"LabelAccountTypeUser": "Utente",
|
||||
"LabelActivities": "Attività",
|
||||
"LabelActivity": "Attività",
|
||||
"LabelAddToCollection": "Aggiungi alla Raccolta",
|
||||
"LabelAddToCollectionBatch": "Aggiungi {0} Libri alla Raccolta",
|
||||
@@ -283,6 +284,7 @@
|
||||
"LabelContinueSeries": "Continua serie",
|
||||
"LabelCover": "Copertina",
|
||||
"LabelCoverImageURL": "Indirizzo della cover URL",
|
||||
"LabelCoverProvider": "Cover Provider",
|
||||
"LabelCreatedAt": "Creato A",
|
||||
"LabelCronExpression": "Espressione Cron",
|
||||
"LabelCurrent": "Attuale",
|
||||
@@ -391,6 +393,7 @@
|
||||
"LabelIntervalEvery6Hours": "Ogni 6 ore",
|
||||
"LabelIntervalEveryDay": "Ogni Giorno",
|
||||
"LabelIntervalEveryHour": "Ogni ora",
|
||||
"LabelIntervalEveryMinute": "Ogni minuto",
|
||||
"LabelInvert": "Inverti",
|
||||
"LabelItem": "Oggetti",
|
||||
"LabelJumpBackwardAmount": "secondi di avvolgimento",
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"ButtonApplyChapters": "Zatwierdź rozdziały",
|
||||
"ButtonAuthors": "Autorzy",
|
||||
"ButtonBack": "Wstecz",
|
||||
"ButtonBatchEditPopulateFromExisting": "Powiel z poprzednich",
|
||||
"ButtonBatchEditPopulateMapDetails": "Powiel szczegóły mapy",
|
||||
"ButtonBrowseForFolder": "Wyszukaj folder",
|
||||
"ButtonCancel": "Anuluj",
|
||||
"ButtonCancelEncode": "Anuluj enkodowanie",
|
||||
@@ -31,6 +33,7 @@
|
||||
"ButtonEditPodcast": "Edytuj podcast",
|
||||
"ButtonEnable": "Włącz",
|
||||
"ButtonFireAndFail": "Fail start",
|
||||
"ButtonFireOnTest": "Uruchom po zdarzeniu testowym",
|
||||
"ButtonForceReScan": "Wymuś ponowne skanowanie",
|
||||
"ButtonFullPath": "Pełna ścieżka",
|
||||
"ButtonHide": "Ukryj",
|
||||
@@ -87,6 +90,8 @@
|
||||
"ButtonSaveTracklist": "Zapisz listę odtwarzania",
|
||||
"ButtonScan": "Zeskanuj",
|
||||
"ButtonScanLibrary": "Skanuj bibliotekę",
|
||||
"ButtonScrollLeft": "Przewiń w lewo",
|
||||
"ButtonScrollRight": "Przewiń w prawo",
|
||||
"ButtonSearch": "Szukaj",
|
||||
"ButtonSelectFolderPath": "Wybierz ścieżkę folderu",
|
||||
"ButtonSeries": "Seria",
|
||||
@@ -155,13 +160,14 @@
|
||||
"HeaderMapDetails": "Szczegóły mapowania",
|
||||
"HeaderMatch": "Dopasuj",
|
||||
"HeaderMetadataOrderOfPrecedence": "Kolejność metadanych",
|
||||
"HeaderMetadataToEmbed": "Osadź metadane",
|
||||
"HeaderMetadataToEmbed": "Metadane do osadzenia",
|
||||
"HeaderNewAccount": "Nowe konto",
|
||||
"HeaderNewLibrary": "Nowa biblioteka",
|
||||
"HeaderNotificationCreate": "Utwórz powiadomienie",
|
||||
"HeaderNotificationUpdate": "Zaktualizuj powiadomienie",
|
||||
"HeaderNotifications": "Powiadomienia",
|
||||
"HeaderOpenIDConnectAuthentication": "Uwierzytelnianie OpenID Connect",
|
||||
"HeaderOpenListeningSessions": "Otwarte sesje słuchania",
|
||||
"HeaderOpenRSSFeed": "Utwórz kanał RSS",
|
||||
"HeaderOtherFiles": "Inne pliki",
|
||||
"HeaderPasswordAuthentication": "Uwierzytelnianie hasłem",
|
||||
@@ -188,6 +194,7 @@
|
||||
"HeaderSettingsExperimental": "Funkcje eksperymentalne",
|
||||
"HeaderSettingsGeneral": "Ogólne",
|
||||
"HeaderSettingsScanner": "Skanowanie",
|
||||
"HeaderSettingsWebClient": "Klient webowy",
|
||||
"HeaderSleepTimer": "Wyłącznik czasowy",
|
||||
"HeaderStatsLargestItems": "Największe pozycje",
|
||||
"HeaderStatsLongestItems": "Najdłuższe pozycje (godziny)",
|
||||
@@ -438,7 +445,7 @@
|
||||
"LabelNotificationsMaxQueueSize": "Maksymalny rozmiar kolejki dla powiadomień",
|
||||
"LabelNotificationsMaxQueueSizeHelp": "Zdarzenia są ograniczone do 1 na sekundę. Zdarzenia będą ignorowane jeśli kolejka ma maksymalny rozmiar. Zapobiega to spamowaniu powiadomieniami.",
|
||||
"LabelNumberOfBooks": "Liczba książek",
|
||||
"LabelNumberOfEpisodes": "# odcinków",
|
||||
"LabelNumberOfEpisodes": "# Odcinków",
|
||||
"LabelOpenRSSFeed": "Otwórz kanał RSS",
|
||||
"LabelOverwrite": "Nadpisz",
|
||||
"LabelPassword": "Hasło",
|
||||
|
||||
1
client/strings/ro.json
Normal file
1
client/strings/ro.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -206,6 +206,7 @@
|
||||
"LabelAccountTypeAdmin": "Administratör",
|
||||
"LabelAccountTypeGuest": "Gäst",
|
||||
"LabelAccountTypeUser": "Användare",
|
||||
"LabelActivities": "Aktiviteter",
|
||||
"LabelActivity": "Aktivitet",
|
||||
"LabelAddToCollection": "Lägg till i en samling",
|
||||
"LabelAddToCollectionBatch": "Lägg till {0} böcker i samlingen",
|
||||
@@ -267,6 +268,7 @@
|
||||
"LabelContinueSeries": "Fortsätt med serien",
|
||||
"LabelCover": "Omslag",
|
||||
"LabelCoverImageURL": "URL till omslagsbild",
|
||||
"LabelCoverProvider": "Källa för omslag",
|
||||
"LabelCreatedAt": "Skapad",
|
||||
"LabelCronExpression": "Schemaläggning med hjälp av Cron (Cron Expression)",
|
||||
"LabelCurrent": "Nuvarande",
|
||||
@@ -370,6 +372,7 @@
|
||||
"LabelIntervalEvery6Hours": "Var 6:e timme",
|
||||
"LabelIntervalEveryDay": "Varje dag",
|
||||
"LabelIntervalEveryHour": "Varje timme",
|
||||
"LabelIntervalEveryMinute": "Varje minut",
|
||||
"LabelInvert": "Invertera",
|
||||
"LabelItem": "Objekt",
|
||||
"LabelJumpBackwardAmount": "Inställning för \"hopp bakåt\"",
|
||||
@@ -464,12 +467,13 @@
|
||||
"LabelPodcasts": "Podcasts",
|
||||
"LabelPort": "Port",
|
||||
"LabelPrefixesToIgnore": "Prefix att ignorera (skiftlägesokänsligt)",
|
||||
"LabelPreventIndexing": "Förhindra att ditt flöde indexeras av iTunes och Google-podcastsökmotorer",
|
||||
"LabelPreventIndexing": "Förhindra att ditt flöde indexeras av sökmotorer från iTunes och Google",
|
||||
"LabelPrimaryEbook": "Primär e-bok",
|
||||
"LabelProgress": "Framsteg",
|
||||
"LabelProvider": "Källa",
|
||||
"LabelPubDate": "Publiceringsdatum",
|
||||
"LabelPublishYear": "Publiceringsår",
|
||||
"LabelPublishedDate": "Publicerad {0}",
|
||||
"LabelPublishedDecade": "Årtionde för publicering",
|
||||
"LabelPublisher": "Utgivare",
|
||||
"LabelPublishers": "Utgivare",
|
||||
@@ -794,11 +798,13 @@
|
||||
"MessageRestoreBackupConfirm": "Är du säker på att du vill läsa in säkerhetskopian som skapades den",
|
||||
"MessageRestoreBackupWarning": "Att återställa en säkerhetskopia kommer att skriva över hela databasen som finns i /config och omslagsbilder i /metadata/items & /metadata/authors.<br /><br />Säkerhetskopior ändrar inte några filer i dina biblioteksmappar. Om du har aktiverat serverinställningar för att lagra omslagskonst och metadata i dina biblioteksmappar säkerhetskopieras eller skrivs de inte över.<br /><br />Alla klienter som använder din server kommer att uppdateras automatiskt.",
|
||||
"MessageScheduleLibraryScanNote": "För de flesta användare rekommenderas att denna funktion ej aktiveras. Istället bör funktionen 'Watcher' vara aktiverad. Watcher kommer då automatiskt identifiera förändringar i biblioteket. För vissa filsystem (som t.ex. NFS) fungerar inte Watcher. Då kan schemalagda skanningar av biblioteken användas istället.",
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "Startar varje {0} klockan {1}",
|
||||
"MessageSearchResultsFor": "Sökresultat för",
|
||||
"MessageSelected": "{0} valda",
|
||||
"MessageServerCouldNotBeReached": "Servern kunde inte nås",
|
||||
"MessageSetChaptersFromTracksDescription": "Ställ in kapitel med varje ljudfil som ett kapitel och kapitelrubrik som ljudfilens namn",
|
||||
"MessageStartPlaybackAtTime": "Starta uppspelning av \"{0}\" vid tidpunkt {1}?",
|
||||
"MessageTaskAudioFileNotWritable": "Det går inte att skriva till ljudfilen \"{0}\"",
|
||||
"MessageTaskCanceledByUser": "Uppgiften avslutades av användaren",
|
||||
"MessageTaskDownloadingEpisodeDescription": "Laddar ner avsnitt \"{0}\"",
|
||||
"MessageTaskEmbeddingMetadata": "Infogar metadata",
|
||||
@@ -812,7 +818,11 @@
|
||||
"MessageTaskFailedToMoveM4bFile": "Misslyckades med att flytta M4B-filen",
|
||||
"MessageTaskFailedToWriteMetadataFile": "Misslyckades med att skapa filen med metadata",
|
||||
"MessageTaskMatchingBooksInLibrary": "Matchar böcker i biblioteket \"{0}\"",
|
||||
"MessageTaskNoFilesToScan": "Inga filer finns tillgängliga för skanning",
|
||||
"MessageTaskOpmlImportDescription": "Skapar podcasts från {0} RSS-flöden",
|
||||
"MessageTaskOpmlImportFeedDescription": "Importerar RSS-flödet \"{0}\"",
|
||||
"MessageTaskOpmlImportFeedPodcastDescription": "Skapar podcast \"{0}\"",
|
||||
"MessageTaskOpmlImportFeedPodcastExists": "En podcast finns redan med den adressen",
|
||||
"MessageTaskOpmlImportFeedPodcastFailed": "Misslyckades med att skapa podcast",
|
||||
"MessageTaskOpmlImportFinished": "Adderade {0} podcasts",
|
||||
"MessageTaskOpmlParseFailed": "Misslyckades att tolka OPML-filen",
|
||||
@@ -823,6 +833,7 @@
|
||||
"MessageTaskScanItemsUpdated": "{0} uppdaterades",
|
||||
"MessageTaskScanNoChangesNeeded": "Inget adderades eller uppdaterades",
|
||||
"MessageTaskScanningLibrary": "Biblioteket \"{0}\" har skannats",
|
||||
"MessageTaskTargetDirectoryNotWritable": "Det är inte tillåtet att skriva i den angivna katalogen",
|
||||
"MessageThinking": "Tänker...",
|
||||
"MessageUploaderItemFailed": "Misslyckades med att ladda upp",
|
||||
"MessageUploaderItemSuccess": "har blivit uppladdad!",
|
||||
@@ -884,6 +895,7 @@
|
||||
"ToastBackupRestoreFailed": "Det gick inte att återställa säkerhetskopian",
|
||||
"ToastBackupUploadFailed": "Det gick inte att ladda upp säkerhetskopian",
|
||||
"ToastBackupUploadSuccess": "Säkerhetskopian uppladdad",
|
||||
"ToastBatchQuickMatchStarted": "Snabbmatchning av {0} böcker har påbörjats!",
|
||||
"ToastBatchUpdateFailed": "Batchuppdateringen misslyckades",
|
||||
"ToastBatchUpdateSuccess": "Batchuppdateringen lyckades",
|
||||
"ToastBookmarkCreateFailed": "Det gick inte att skapa bokmärket",
|
||||
|
||||
@@ -219,6 +219,7 @@
|
||||
"LabelAccountTypeAdmin": "Адміністратор",
|
||||
"LabelAccountTypeGuest": "Гість",
|
||||
"LabelAccountTypeUser": "Користувач",
|
||||
"LabelActivities": "Діяльність",
|
||||
"LabelActivity": "Активність",
|
||||
"LabelAddToCollection": "Додати у добірку",
|
||||
"LabelAddToCollectionBatch": "Додати книги до добірки: {0}",
|
||||
@@ -283,6 +284,7 @@
|
||||
"LabelContinueSeries": "Продовжити серії",
|
||||
"LabelCover": "Обкладинка",
|
||||
"LabelCoverImageURL": "URL-адреса обкладинки",
|
||||
"LabelCoverProvider": "Постачальник покриття",
|
||||
"LabelCreatedAt": "Дата створення",
|
||||
"LabelCronExpression": "Команда cron",
|
||||
"LabelCurrent": "Поточне",
|
||||
@@ -391,6 +393,7 @@
|
||||
"LabelIntervalEvery6Hours": "Кожні 6 годин",
|
||||
"LabelIntervalEveryDay": "Щодня",
|
||||
"LabelIntervalEveryHour": "Щогодини",
|
||||
"LabelIntervalEveryMinute": "Кожну хвилину",
|
||||
"LabelInvert": "Інвертувати",
|
||||
"LabelItem": "Елемент",
|
||||
"LabelJumpBackwardAmount": "Час переходу назад",
|
||||
@@ -845,6 +848,7 @@
|
||||
"MessageRestoreBackupConfirm": "Ви дійсно бажаєте відновити резервну копію від",
|
||||
"MessageRestoreBackupWarning": "Відновлення резервної копії перезапише всю базу даних, розташовану в /config, і зображення обкладинок в /metadata/items та /metadata/authors.<br /><br />Резервні копії не змінюють жодних файлів у теках бібліотеки. Якщо у налаштуваннях сервера увімкнено збереження обкладинок і метаданих у теках бібліотеки, вони не створюються під час резервного копіювання і не перезаписуються..<br /><br />Всі клієнти, що користуються вашим сервером, будуть автоматично оновлені.",
|
||||
"MessageScheduleLibraryScanNote": "Для більшості користувачів рекомендується залишити цю функцію вимкненою та залишити параметр перегляду папок увімкненим. Засіб спостереження за папками автоматично виявить зміни в папках вашої бібліотеки. Засіб спостереження за папками не працює для кожної файлової системи (наприклад, NFS), тому замість нього можна використовувати сканування бібліотек за розкладом.",
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "Запуск кожні {0} о {1}",
|
||||
"MessageSearchResultsFor": "Результати пошуку для",
|
||||
"MessageSelected": "Вибрано: {0}",
|
||||
"MessageServerCouldNotBeReached": "Не вдалося підключитися до сервера",
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.19.4",
|
||||
"version": "2.19.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.19.4",
|
||||
"version": "2.19.5",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.19.4",
|
||||
"version": "2.19.5",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast server",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -5,7 +5,7 @@ const Logger = require('./Logger')
|
||||
const Task = require('./objects/Task')
|
||||
const TaskManager = require('./managers/TaskManager')
|
||||
|
||||
const { filePathToPOSIX, isSameOrSubPath, getFileMTimeMs } = require('./utils/fileUtils')
|
||||
const { filePathToPOSIX, isSameOrSubPath, getFileMTimeMs, shouldIgnoreFile } = require('./utils/fileUtils')
|
||||
|
||||
/**
|
||||
* @typedef PendingFileUpdate
|
||||
@@ -286,15 +286,10 @@ class FolderWatcher extends EventEmitter {
|
||||
|
||||
const relPath = path.replace(folderPath, '')
|
||||
|
||||
if (Path.extname(relPath).toLowerCase() === '.part') {
|
||||
Logger.debug(`[Watcher] Ignoring .part file "${relPath}"`)
|
||||
return false
|
||||
}
|
||||
|
||||
// Ignore files/folders starting with "."
|
||||
const hasDotPath = relPath.split('/').find((p) => p.startsWith('.'))
|
||||
if (hasDotPath) {
|
||||
Logger.debug(`[Watcher] Ignoring dot path "${relPath}" | Piece "${hasDotPath}"`)
|
||||
// Check for ignored extensions or directories, such as dotfiles and hidden directories
|
||||
const shouldIgnore = shouldIgnoreFile(relPath)
|
||||
if (shouldIgnore) {
|
||||
Logger.debug(`[Watcher] Ignoring ${shouldIgnore} - "${relPath}"`)
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -254,6 +254,11 @@ class LibraryController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async update(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to update library`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
// Validation
|
||||
const updatePayload = {}
|
||||
const keysToCheck = ['name', 'provider', 'mediaType', 'icon']
|
||||
@@ -519,6 +524,11 @@ class LibraryController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async delete(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to delete library`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
// Remove library watcher
|
||||
Watcher.removeLibrary(req.library)
|
||||
|
||||
@@ -639,6 +649,11 @@ class LibraryController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async removeLibraryItemsWithIssues(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to delete library items missing or invalid`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const libraryItemsWithIssues = await Database.libraryItemModel.findAll({
|
||||
where: {
|
||||
libraryId: req.library.id,
|
||||
|
||||
@@ -130,7 +130,21 @@ class MigrationManager {
|
||||
|
||||
async initUmzug(umzugStorage = new SequelizeStorage({ sequelize: this.sequelize })) {
|
||||
// This check is for dependency injection in tests
|
||||
const files = (await fs.readdir(this.migrationsDir)).filter((file) => !file.startsWith('.')).map((file) => path.join(this.migrationsDir, file))
|
||||
const files = (await fs.readdir(this.migrationsDir))
|
||||
.filter((file) => {
|
||||
// Only include .js files and exclude dot files
|
||||
return !file.startsWith('.') && path.extname(file).toLowerCase() === '.js'
|
||||
})
|
||||
.map((file) => path.join(this.migrationsDir, file))
|
||||
|
||||
// Validate migration names
|
||||
for (const file of files) {
|
||||
const migrationName = path.basename(file, path.extname(file))
|
||||
const migrationVersion = this.extractVersionFromTag(migrationName)
|
||||
if (!migrationVersion) {
|
||||
throw new Error(`Invalid migration file: "${migrationName}". Unable to extract version from filename.`)
|
||||
}
|
||||
}
|
||||
|
||||
const parent = new Umzug({
|
||||
migrations: {
|
||||
|
||||
@@ -72,6 +72,15 @@ class PodcastManager {
|
||||
*/
|
||||
async startPodcastEpisodeDownload(podcastEpisodeDownload) {
|
||||
if (this.currentDownload) {
|
||||
// Prevent downloading episodes from the same URL for the same library item.
|
||||
// Allow downloading for different library items in case of the same podcast existing in multiple libraries (e.g. different folders)
|
||||
if (this.downloadQueue.some((d) => d.url === podcastEpisodeDownload.url && d.libraryItem.id === podcastEpisodeDownload.libraryItem.id)) {
|
||||
Logger.warn(`[PodcastManager] Episode already in queue: "${this.currentDownload.episodeTitle}"`)
|
||||
return
|
||||
} else if (this.currentDownload.url === podcastEpisodeDownload.url && this.currentDownload.libraryItem.id === podcastEpisodeDownload.libraryItem.id) {
|
||||
Logger.warn(`[PodcastManager] Episode download already in progress for "${podcastEpisodeDownload.episodeTitle}"`)
|
||||
return
|
||||
}
|
||||
this.downloadQueue.push(podcastEpisodeDownload)
|
||||
SocketAuthority.emitter('episode_download_queued', podcastEpisodeDownload.toJSONForClient())
|
||||
return
|
||||
|
||||
@@ -705,13 +705,14 @@ class User extends Model {
|
||||
ebookLocation: progressPayload.ebookLocation || null,
|
||||
ebookProgress: isNullOrNaN(progressPayload.ebookProgress) ? 0 : Number(progressPayload.ebookProgress),
|
||||
finishedAt: progressPayload.finishedAt || null,
|
||||
createdAt: progressPayload.createdAt || new Date(),
|
||||
extraData: {
|
||||
libraryItemId: progressPayload.libraryItemId,
|
||||
progress: isNullOrNaN(progressPayload.progress) ? 0 : Number(progressPayload.progress)
|
||||
}
|
||||
}
|
||||
if (newMediaProgressPayload.isFinished) {
|
||||
newMediaProgressPayload.finishedAt = new Date()
|
||||
newMediaProgressPayload.finishedAt = newMediaProgressPayload.finishedAt || new Date()
|
||||
newMediaProgressPayload.extraData.progress = 1
|
||||
} else {
|
||||
newMediaProgressPayload.finishedAt = null
|
||||
|
||||
@@ -43,7 +43,8 @@ class PodcastEpisodeDownload {
|
||||
season: this.rssPodcastEpisode?.season ?? null,
|
||||
episode: this.rssPodcastEpisode?.episode ?? null,
|
||||
episodeType: this.rssPodcastEpisode?.episodeType ?? 'full',
|
||||
publishedAt: this.rssPodcastEpisode?.publishedAt ?? null
|
||||
publishedAt: this.rssPodcastEpisode?.publishedAt ?? null,
|
||||
guid: this.rssPodcastEpisode?.guid ?? null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,9 @@ class CustomProviderAdapter {
|
||||
}
|
||||
const queryString = new URLSearchParams(queryObj).toString()
|
||||
|
||||
const url = `${provider.url}/search?${queryString}`
|
||||
Logger.debug(`[CustomMetadataProvider] Search url: ${url}`)
|
||||
|
||||
// Setup headers
|
||||
const axiosOptions = {
|
||||
timeout
|
||||
@@ -52,7 +55,7 @@ class CustomProviderAdapter {
|
||||
}
|
||||
|
||||
const matches = await axios
|
||||
.get(`${provider.url}/search?${queryString}`, axiosOptions)
|
||||
.get(url, axiosOptions)
|
||||
.then((res) => {
|
||||
if (!res?.data || !Array.isArray(res.data.matches)) return null
|
||||
return res.data.matches
|
||||
@@ -66,25 +69,57 @@ class CustomProviderAdapter {
|
||||
throw new Error('Custom provider returned malformed response')
|
||||
}
|
||||
|
||||
const toStringOrUndefined = (value) => {
|
||||
if (typeof value === 'string' || typeof value === 'number') return String(value)
|
||||
if (Array.isArray(value) && value.every((v) => typeof v === 'string' || typeof v === 'number')) return value.join(',')
|
||||
return undefined
|
||||
}
|
||||
const validateSeriesArray = (series) => {
|
||||
if (!Array.isArray(series) || !series.length) return undefined
|
||||
return series
|
||||
.map((s) => {
|
||||
if (!s?.series || typeof s.series !== 'string') return undefined
|
||||
const _series = {
|
||||
series: s.series
|
||||
}
|
||||
if (s.sequence && (typeof s.sequence === 'string' || typeof s.sequence === 'number')) {
|
||||
_series.sequence = String(s.sequence)
|
||||
}
|
||||
return _series
|
||||
})
|
||||
.filter((s) => s !== undefined)
|
||||
}
|
||||
|
||||
// re-map keys to throw out
|
||||
return matches.map(({ title, subtitle, author, narrator, publisher, publishedYear, description, cover, isbn, asin, genres, tags, series, language, duration }) => {
|
||||
return {
|
||||
title,
|
||||
subtitle,
|
||||
author,
|
||||
narrator,
|
||||
publisher,
|
||||
publishedYear,
|
||||
description: htmlSanitizer.sanitize(description),
|
||||
cover,
|
||||
isbn,
|
||||
asin,
|
||||
genres,
|
||||
tags: tags?.join(',') || null,
|
||||
series: series?.length ? series : null,
|
||||
language,
|
||||
duration
|
||||
return matches.map((match) => {
|
||||
const { title, subtitle, author, narrator, publisher, publishedYear, description, cover, isbn, asin, genres, tags, series, language, duration } = match
|
||||
|
||||
const payload = {
|
||||
title: toStringOrUndefined(title),
|
||||
subtitle: toStringOrUndefined(subtitle),
|
||||
author: toStringOrUndefined(author),
|
||||
narrator: toStringOrUndefined(narrator),
|
||||
publisher: toStringOrUndefined(publisher),
|
||||
publishedYear: toStringOrUndefined(publishedYear),
|
||||
description: description && typeof description === 'string' ? htmlSanitizer.sanitize(description) : undefined,
|
||||
cover: toStringOrUndefined(cover),
|
||||
isbn: toStringOrUndefined(isbn),
|
||||
asin: toStringOrUndefined(asin),
|
||||
genres: Array.isArray(genres) && genres.every((g) => typeof g === 'string') ? genres : undefined,
|
||||
tags: toStringOrUndefined(tags),
|
||||
series: validateSeriesArray(series),
|
||||
language: toStringOrUndefined(language),
|
||||
duration: !isNaN(duration) && duration !== null ? Number(duration) : undefined
|
||||
}
|
||||
|
||||
// Remove undefined values
|
||||
for (const key in payload) {
|
||||
if (payload[key] === undefined) {
|
||||
delete payload[key]
|
||||
}
|
||||
}
|
||||
|
||||
return payload
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,40 @@ async function readTextFile(path) {
|
||||
}
|
||||
module.exports.readTextFile = readTextFile
|
||||
|
||||
/**
|
||||
* Check if file or directory should be ignored. Returns a string of the reason to ignore, or null if not ignored
|
||||
*
|
||||
* @param {string} path
|
||||
* @returns {string}
|
||||
*/
|
||||
module.exports.shouldIgnoreFile = (path) => {
|
||||
// Check if directory or file name starts with "."
|
||||
if (Path.basename(path).startsWith('.')) {
|
||||
return 'dotfile'
|
||||
}
|
||||
if (path.split('/').find((p) => p.startsWith('.'))) {
|
||||
return 'dotpath'
|
||||
}
|
||||
|
||||
// If these strings exist anywhere in the filename or directory name, ignore. Vendor specific hidden directories
|
||||
const includeAnywhereIgnore = ['@eaDir']
|
||||
const filteredInclude = includeAnywhereIgnore.filter((str) => path.includes(str))
|
||||
if (filteredInclude.length) {
|
||||
return `${filteredInclude[0]} directory`
|
||||
}
|
||||
|
||||
const extensionIgnores = ['.part', '.tmp', '.crdownload', '.download', '.bak', '.old', '.temp', '.tempfile', '.tempfile~']
|
||||
|
||||
// Check extension
|
||||
if (extensionIgnores.includes(Path.extname(path).toLowerCase())) {
|
||||
// Return the extension that is ignored
|
||||
return `${Path.extname(path)} file`
|
||||
}
|
||||
|
||||
// Should not ignore this file or directory
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef FilePathItem
|
||||
* @property {string} name - file name e.g. "audiofile.m4b"
|
||||
@@ -147,7 +181,7 @@ module.exports.readTextFile = readTextFile
|
||||
* @param {string} [relPathToReplace]
|
||||
* @returns {FilePathItem[]}
|
||||
*/
|
||||
async function recurseFiles(path, relPathToReplace = null) {
|
||||
module.exports.recurseFiles = async (path, relPathToReplace = null) => {
|
||||
path = filePathToPOSIX(path)
|
||||
if (!path.endsWith('/')) path = path + '/'
|
||||
|
||||
@@ -197,14 +231,10 @@ async function recurseFiles(path, relPathToReplace = null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (item.extension === '.part') {
|
||||
Logger.debug(`[fileUtils] Ignoring .part file "${relpath}"`)
|
||||
return false
|
||||
}
|
||||
|
||||
// Ignore any file if a directory or the filename starts with "."
|
||||
if (relpath.split('/').find((p) => p.startsWith('.'))) {
|
||||
Logger.debug(`[fileUtils] Ignoring path has . "${relpath}"`)
|
||||
// Check for ignored extensions or directories
|
||||
const shouldIgnore = this.shouldIgnoreFile(relpath)
|
||||
if (shouldIgnore) {
|
||||
Logger.debug(`[fileUtils] Ignoring ${shouldIgnore} - "${relpath}"`)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -235,7 +265,6 @@ async function recurseFiles(path, relPathToReplace = null) {
|
||||
|
||||
return list
|
||||
}
|
||||
module.exports.recurseFiles = recurseFiles
|
||||
|
||||
/**
|
||||
*
|
||||
|
||||
@@ -344,22 +344,28 @@ module.exports = {
|
||||
countCache.clear()
|
||||
},
|
||||
|
||||
async findAndCountAll(findOptions, limit, offset) {
|
||||
const findOptionsKey = stringifySequelizeQuery(findOptions)
|
||||
Logger.debug(`[LibraryItemsBookFilters] findOptionsKey: ${findOptionsKey}`)
|
||||
async findAndCountAll(findOptions, limit, offset, useCountCache) {
|
||||
const model = Database.bookModel
|
||||
if (useCountCache) {
|
||||
const countCacheKey = stringifySequelizeQuery(findOptions)
|
||||
Logger.debug(`[LibraryItemsBookFilters] countCacheKey: ${countCacheKey}`)
|
||||
if (!countCache.has(countCacheKey)) {
|
||||
const count = await model.count(findOptions)
|
||||
countCache.set(countCacheKey, count)
|
||||
}
|
||||
|
||||
findOptions.limit = limit || null
|
||||
findOptions.offset = offset
|
||||
|
||||
const rows = await model.findAll(findOptions)
|
||||
|
||||
return { rows, count: countCache.get(countCacheKey) }
|
||||
}
|
||||
|
||||
findOptions.limit = limit || null
|
||||
findOptions.offset = offset
|
||||
|
||||
if (countCache.has(findOptionsKey)) {
|
||||
const rows = await Database.bookModel.findAll(findOptions)
|
||||
|
||||
return { rows, count: countCache.get(findOptionsKey) }
|
||||
} else {
|
||||
const result = await Database.bookModel.findAndCountAll(findOptions)
|
||||
countCache.set(findOptionsKey, result.count)
|
||||
return result
|
||||
}
|
||||
return await model.findAndCountAll(findOptions)
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -434,19 +440,17 @@ module.exports = {
|
||||
|
||||
const libraryItemIncludes = []
|
||||
const bookIncludes = []
|
||||
if (includeRSSFeed) {
|
||||
|
||||
if (filterGroup === 'feed-open' || includeRSSFeed) {
|
||||
const rssFeedRequired = filterGroup === 'feed-open'
|
||||
libraryItemIncludes.push({
|
||||
model: Database.feedModel,
|
||||
required: filterGroup === 'feed-open',
|
||||
separate: true
|
||||
required: rssFeedRequired,
|
||||
separate: !rssFeedRequired
|
||||
})
|
||||
}
|
||||
if (filterGroup === 'feed-open' && !includeRSSFeed) {
|
||||
libraryItemIncludes.push({
|
||||
model: Database.feedModel,
|
||||
required: true
|
||||
})
|
||||
} else if (filterGroup === 'share-open') {
|
||||
|
||||
if (filterGroup === 'share-open') {
|
||||
bookIncludes.push({
|
||||
model: Database.mediaItemShareModel,
|
||||
required: true
|
||||
@@ -608,7 +612,7 @@ module.exports = {
|
||||
}
|
||||
|
||||
const findAndCountAll = process.env.QUERY_PROFILING ? profile(this.findAndCountAll) : this.findAndCountAll
|
||||
const { rows: books, count } = await findAndCountAll(findOptions, limit, offset)
|
||||
const { rows: books, count } = await findAndCountAll(findOptions, limit, offset, !filterGroup)
|
||||
|
||||
const libraryItems = books.map((bookExpanded) => {
|
||||
const libraryItem = bookExpanded.libraryItem
|
||||
|
||||
@@ -105,22 +105,27 @@ module.exports = {
|
||||
countCache.clear()
|
||||
},
|
||||
|
||||
async findAndCountAll(findOptions, model, limit, offset) {
|
||||
const cacheKey = stringifySequelizeQuery(findOptions)
|
||||
if (!countCache.has(cacheKey)) {
|
||||
const count = await model.count(findOptions)
|
||||
countCache.set(cacheKey, count)
|
||||
async findAndCountAll(findOptions, model, limit, offset, useCountCache) {
|
||||
if (useCountCache) {
|
||||
const countCacheKey = stringifySequelizeQuery(findOptions)
|
||||
Logger.debug(`[LibraryItemsPodcastFilters] countCacheKey: ${countCacheKey}`)
|
||||
if (!countCache.has(countCacheKey)) {
|
||||
const count = await model.count(findOptions)
|
||||
countCache.set(countCacheKey, count)
|
||||
}
|
||||
|
||||
findOptions.limit = limit || null
|
||||
findOptions.offset = offset
|
||||
|
||||
const rows = await model.findAll(findOptions)
|
||||
|
||||
return { rows, count: countCache.get(countCacheKey) }
|
||||
}
|
||||
|
||||
findOptions.limit = limit
|
||||
findOptions.limit = limit || null
|
||||
findOptions.offset = offset
|
||||
|
||||
const rows = await model.findAll(findOptions)
|
||||
|
||||
return {
|
||||
rows,
|
||||
count: countCache.get(cacheKey)
|
||||
}
|
||||
return await model.findAndCountAll(findOptions)
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -199,7 +204,7 @@ module.exports = {
|
||||
|
||||
const findAndCountAll = process.env.QUERY_PROFILING ? profile(this.findAndCountAll) : this.findAndCountAll
|
||||
|
||||
const { rows: podcasts, count } = await findAndCountAll(findOptions, Database.podcastModel, limit, offset)
|
||||
const { rows: podcasts, count } = await findAndCountAll(findOptions, Database.podcastModel, limit, offset, !filterGroup)
|
||||
|
||||
const libraryItems = podcasts.map((podcastExpanded) => {
|
||||
const libraryItem = podcastExpanded.libraryItem
|
||||
@@ -323,7 +328,7 @@ module.exports = {
|
||||
|
||||
const findAndCountAll = process.env.QUERY_PROFILING ? profile(this.findAndCountAll) : this.findAndCountAll
|
||||
|
||||
const { rows: podcastEpisodes, count } = await findAndCountAll(findOptions, Database.podcastEpisodeModel, limit, offset)
|
||||
const { rows: podcastEpisodes, count } = await findAndCountAll(findOptions, Database.podcastEpisodeModel, limit, offset, !filterGroup)
|
||||
|
||||
const libraryItems = podcastEpisodes.map((ep) => {
|
||||
const libraryItem = ep.podcast.libraryItem
|
||||
|
||||
@@ -126,9 +126,9 @@ describe('migration-v2.15.0-series-column-unique', () => {
|
||||
it('upgrade with duplicate series and no sequence', async () => {
|
||||
// Add some entries to the Series table using the UUID for the ids
|
||||
await queryInterface.bulkInsert('Series', [
|
||||
{ id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: series2Id, name: 'Series 2', libraryId: library2Id, createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: series3Id, name: 'Series 3', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(7), updatedAt: new Date(7) },
|
||||
{ id: series2Id, name: 'Series 2', libraryId: library2Id, createdAt: new Date(7), updatedAt: new Date(8) },
|
||||
{ id: series3Id, name: 'Series 3', libraryId: library1Id, createdAt: new Date(7), updatedAt: new Date(9) },
|
||||
{ id: series1Id_dup, name: 'Series 1', libraryId: library1Id, createdAt: new Date(0), updatedAt: new Date(0) },
|
||||
{ id: series3Id_dup, name: 'Series 3', libraryId: library1Id, createdAt: new Date(0), updatedAt: new Date(0) },
|
||||
{ id: series1Id_dup2, name: 'Series 1', libraryId: library1Id, createdAt: new Date(0), updatedAt: new Date(0) }
|
||||
@@ -203,8 +203,8 @@ describe('migration-v2.15.0-series-column-unique', () => {
|
||||
it('upgrade with one book in two of the same series, both sequence are null', async () => {
|
||||
// Create two different series with the same name in the same library
|
||||
await queryInterface.bulkInsert('Series', [
|
||||
{ id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }
|
||||
{ id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(8), updatedAt: new Date(20) },
|
||||
{ id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(5), updatedAt: new Date(10) }
|
||||
])
|
||||
// Create a book that is in both series
|
||||
await queryInterface.bulkInsert('BookSeries', [
|
||||
@@ -236,8 +236,8 @@ describe('migration-v2.15.0-series-column-unique', () => {
|
||||
it('upgrade with one book in two of the same series, one sequence is null', async () => {
|
||||
// Create two different series with the same name in the same library
|
||||
await queryInterface.bulkInsert('Series', [
|
||||
{ id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }
|
||||
{ id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(5), updatedAt: new Date(9) },
|
||||
{ id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(5), updatedAt: new Date(7) }
|
||||
])
|
||||
// Create a book that is in both series
|
||||
await queryInterface.bulkInsert('BookSeries', [
|
||||
@@ -268,8 +268,8 @@ describe('migration-v2.15.0-series-column-unique', () => {
|
||||
it('upgrade with one book in two of the same series, both sequence are not null', async () => {
|
||||
// Create two different series with the same name in the same library
|
||||
await queryInterface.bulkInsert('Series', [
|
||||
{ id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }
|
||||
{ id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(1), updatedAt: new Date(3) },
|
||||
{ id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(2), updatedAt: new Date(2) }
|
||||
])
|
||||
// Create a book that is in both series
|
||||
await queryInterface.bulkInsert('BookSeries', [
|
||||
|
||||
127
test/server/utils/fileUtils.test.js
Normal file
127
test/server/utils/fileUtils.test.js
Normal file
@@ -0,0 +1,127 @@
|
||||
const chai = require('chai')
|
||||
const expect = chai.expect
|
||||
const sinon = require('sinon')
|
||||
const fileUtils = require('../../../server/utils/fileUtils')
|
||||
const fs = require('fs')
|
||||
const Logger = require('../../../server/Logger')
|
||||
|
||||
describe('fileUtils', () => {
|
||||
it('shouldIgnoreFile', () => {
|
||||
global.isWin = process.platform === 'win32'
|
||||
|
||||
const testCases = [
|
||||
{ path: 'test.txt', expected: null },
|
||||
{ path: 'folder/test.mp3', expected: null },
|
||||
{ path: 'normal/path/file.m4b', expected: null },
|
||||
{ path: 'test.txt.part', expected: '.part file' },
|
||||
{ path: 'test.txt.tmp', expected: '.tmp file' },
|
||||
{ path: 'test.txt.crdownload', expected: '.crdownload file' },
|
||||
{ path: 'test.txt.download', expected: '.download file' },
|
||||
{ path: 'test.txt.bak', expected: '.bak file' },
|
||||
{ path: 'test.txt.old', expected: '.old file' },
|
||||
{ path: 'test.txt.temp', expected: '.temp file' },
|
||||
{ path: 'test.txt.tempfile', expected: '.tempfile file' },
|
||||
{ path: 'test.txt.tempfile~', expected: '.tempfile~ file' },
|
||||
{ path: '.gitignore', expected: 'dotfile' },
|
||||
{ path: 'folder/.hidden', expected: 'dotfile' },
|
||||
{ path: '.git/config', expected: 'dotpath' },
|
||||
{ path: 'path/.hidden/file.txt', expected: 'dotpath' },
|
||||
{ path: '@eaDir', expected: '@eaDir directory' },
|
||||
{ path: 'folder/@eaDir', expected: '@eaDir directory' },
|
||||
{ path: 'path/@eaDir/file.txt', expected: '@eaDir directory' },
|
||||
{ path: '.hidden/test.tmp', expected: 'dotpath' },
|
||||
{ path: '@eaDir/test.part', expected: '@eaDir directory' }
|
||||
]
|
||||
|
||||
testCases.forEach(({ path, expected }) => {
|
||||
const result = fileUtils.shouldIgnoreFile(path)
|
||||
expect(result).to.equal(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('recurseFiles', () => {
|
||||
let readdirStub, realpathStub, statStub
|
||||
|
||||
beforeEach(() => {
|
||||
global.isWin = process.platform === 'win32'
|
||||
|
||||
// Mock file structure with normalized paths
|
||||
const mockDirContents = new Map([
|
||||
['/test', ['file1.mp3', 'subfolder', 'ignoreme', 'temp.mp3.tmp']],
|
||||
['/test/subfolder', ['file2.m4b']],
|
||||
['/test/ignoreme', ['.ignore', 'ignored.mp3']]
|
||||
])
|
||||
|
||||
const mockStats = new Map([
|
||||
['/test/file1.mp3', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1' }],
|
||||
['/test/subfolder', { isDirectory: () => true, size: 0, mtimeMs: Date.now(), ino: '2' }],
|
||||
['/test/subfolder/file2.m4b', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '3' }],
|
||||
['/test/ignoreme', { isDirectory: () => true, size: 0, mtimeMs: Date.now(), ino: '4' }],
|
||||
['/test/ignoreme/.ignore', { isDirectory: () => false, size: 0, mtimeMs: Date.now(), ino: '5' }],
|
||||
['/test/ignoreme/ignored.mp3', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '6' }],
|
||||
['/test/temp.mp3.tmp', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '7' }]
|
||||
])
|
||||
|
||||
// Stub fs.readdir
|
||||
readdirStub = sinon.stub(fs, 'readdir')
|
||||
readdirStub.callsFake((path, callback) => {
|
||||
const contents = mockDirContents.get(path)
|
||||
if (contents) {
|
||||
callback(null, contents)
|
||||
} else {
|
||||
callback(new Error(`ENOENT: no such file or directory, scandir '${path}'`))
|
||||
}
|
||||
})
|
||||
|
||||
// Stub fs.realpath
|
||||
realpathStub = sinon.stub(fs, 'realpath')
|
||||
realpathStub.callsFake((path, callback) => {
|
||||
// Return normalized path
|
||||
callback(null, fileUtils.filePathToPOSIX(path).replace(/\/$/, ''))
|
||||
})
|
||||
|
||||
// Stub fs.stat
|
||||
statStub = sinon.stub(fs, 'stat')
|
||||
statStub.callsFake((path, callback) => {
|
||||
const normalizedPath = fileUtils.filePathToPOSIX(path).replace(/\/$/, '')
|
||||
const stats = mockStats.get(normalizedPath)
|
||||
if (stats) {
|
||||
callback(null, stats)
|
||||
} else {
|
||||
callback(new Error(`ENOENT: no such file or directory, stat '${normalizedPath}'`))
|
||||
}
|
||||
})
|
||||
|
||||
// Stub Logger
|
||||
sinon.stub(Logger, 'debug')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
it('should return filtered file list', async () => {
|
||||
const files = await fileUtils.recurseFiles('/test')
|
||||
expect(files).to.be.an('array')
|
||||
expect(files).to.have.lengthOf(2)
|
||||
|
||||
expect(files[0]).to.deep.equal({
|
||||
name: 'file1.mp3',
|
||||
path: 'file1.mp3',
|
||||
reldirpath: '',
|
||||
fullpath: '/test/file1.mp3',
|
||||
extension: '.mp3',
|
||||
deep: 0
|
||||
})
|
||||
|
||||
expect(files[1]).to.deep.equal({
|
||||
name: 'file2.m4b',
|
||||
path: 'subfolder/file2.m4b',
|
||||
reldirpath: 'subfolder',
|
||||
fullpath: '/test/subfolder/file2.m4b',
|
||||
extension: '.m4b',
|
||||
deep: 1
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user