mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-01-01 04:00:45 -05:00
Compare commits
24 Commits
v2.19.5
...
validate_m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c29935e57b | ||
|
|
d41b48c89a | ||
|
|
b17e6010fd | ||
|
|
a296ac6132 | ||
|
|
5746e848b0 | ||
|
|
c6b5d4aa26 | ||
|
|
43a507faa8 | ||
|
|
828d5d2afc | ||
|
|
6075f2686f | ||
|
|
ae3517bcde | ||
|
|
0a00ebcde1 | ||
|
|
68ef0f83e1 | ||
|
|
e4a34b0145 | ||
|
|
0ca65d1f79 | ||
|
|
bd3d396f37 | ||
|
|
fd1c8ee513 | ||
|
|
b0045b5b8b | ||
|
|
6674189acd | ||
|
|
72169990ac | ||
|
|
5f105dc6cc | ||
|
|
706b2d7d72 | ||
|
|
007691ffe5 | ||
|
|
2fdab39e27 | ||
|
|
9b01d11b27 |
@@ -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) => {
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', [
|
||||
|
||||
Reference in New Issue
Block a user