Compare commits

...

24 Commits

Author SHA1 Message Date
advplyr c29935e57b Update migration manager to validate migration files #4042 2025-03-06 17:24:33 -06:00
advplyr d41b48c89a Merge pull request #4075 from Vito0912/feat/fixCrashCustomProvider
Fixes search not returning results if description field is not provided by a custom provider
2025-03-04 17:53:58 -06:00
advplyr b17e6010fd Add validation for custom metadata provider responses 2025-03-04 17:50:40 -06:00
Vito0912 a296ac6132 fix crash 2025-03-04 18:06:58 +01:00
advplyr 5746e848b0 Fix:Trim whitespace from custom metadata provider name & url #4069 2025-03-02 17:13:27 -06:00
advplyr c6b5d4aa26 Update author by string translation #4017 2025-03-01 17:48:11 -06:00
advplyr 43a507faa8 Merge pull request #4030 from 4ch1m/add_filename_sorting_for_podcasts-view
new sort option for podcasts view (-> sort by filename)
2025-02-28 17:45:43 -06:00
advplyr 828d5d2afc Update episode row to show filename when sorting by filename 2025-02-28 17:42:56 -06:00
advplyr 6075f2686f Merge pull request #3546 from justcallmelarry/master
API PATCH /me/progress/:id - allow providing createdAt and respect provided finishedAt when syncing progress
2025-02-28 17:25:46 -06:00
advplyr ae3517bcde Merge pull request #4055 from nichwall/2_15_0_migration_fix
Fix: flaky 2.15.0 migration test
2025-02-27 18:28:21 -06:00
Nicholas Wallace 0a00ebcde1 Fix: flaky 2.15.0 migration test 2025-02-26 21:40:56 -07:00
advplyr 68ef0f83e1 Update select all in feed modal to check downloading 2025-02-26 18:00:36 -06:00
advplyr e4a34b0145 Merge pull request #4041 from nichwall/podcast_queue_no_duplicates
Prevent duplicate episodes from being added to queue
2025-02-26 17:58:27 -06:00
advplyr 0ca65d1f79 Show download icon for queued/downloaded episodes in rss feed modal 2025-02-26 17:56:17 -06:00
advplyr bd3d396f37 Merge pull request #4035 from nichwall/podcast_episode_play_order
Play first podcast episode in table
2025-02-25 17:31:48 -06:00
advplyr fd1c8ee513 Update episode list to come from component ref, populate queue from table order when playing episode 2025-02-25 17:25:56 -06:00
advplyr b0045b5b8b Update browser confirm prompts to use confirm prompt modal instead 2025-02-24 17:44:17 -06:00
Nicholas Wallace 6674189acd Add: prevent duplicates from being added to queue 2025-02-23 19:23:26 -07:00
Nicholas Wallace 72169990ac Fix: double reverse of array 2025-02-22 22:06:51 -07:00
Nicholas Wallace 5f105dc6cc Change: Play button for podcast picks first episode in table 2025-02-22 21:50:37 -07:00
Nicholas Wallace 706b2d7d72 Add: store for filtered podcast episodes 2025-02-22 21:50:09 -07:00
Achim 007691ffe5 add "sort by filename" 2025-02-22 17:08:29 +01:00
Lauri Vuorela 2fdab39e27 Merge branch 'advplyr:master' into master 2024-10-29 22:08:01 +01:00
Lauri Vuorela 9b01d11b27 allow setting createdAt and respect set finishedAt when syncing progress 2024-10-22 23:58:09 +02:00
15 changed files with 327 additions and 143 deletions
@@ -10,14 +10,14 @@
<div class="w-full p-8"> <div class="w-full p-8">
<div class="flex mb-2"> <div class="flex mb-2">
<div class="w-3/4 p-1"> <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>
<div class="w-1/4 p-1"> <div class="w-1/4 p-1">
<ui-text-input-with-label value="Book" readonly :label="$strings.LabelMediaType" /> <ui-text-input-with-label value="Book" readonly :label="$strings.LabelMediaType" />
</div> </div>
</div> </div>
<div class="w-full mb-2 p-1"> <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>
<div class="w-full mb-2 p-1"> <div class="w-full mb-2 p-1">
<ui-text-input-with-label v-model="newAuthHeaderValue" :label="$strings.LabelProviderAuthorizationValue" type="password" /> <ui-text-input-with-label v-model="newAuthHeaderValue" :label="$strings.LabelProviderAuthorizationValue" type="password" />
@@ -65,7 +65,11 @@ export default {
} }
}, },
methods: { methods: {
submitForm() { async submitForm() {
// Remove focus from active input
document.activeElement?.blur?.()
await this.$nextTick()
if (!this.newName || !this.newUrl) { if (!this.newName || !this.newUrl) {
this.$toast.error(this.$strings.ToastProviderNameAndUrlRequired) this.$toast.error(this.$strings.ToastProviderNameAndUrlRequired)
return return
@@ -18,7 +18,7 @@
<ui-textarea-with-label v-model="newCollectionDescription" :label="$strings.LabelDescription" /> <ui-textarea-with-label v-model="newCollectionDescription" :label="$strings.LabelDescription" />
</div> </div>
</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> <ui-btn v-if="userCanDelete" small color="error" type="button" @click.stop="removeClick">{{ $strings.ButtonRemove }}</ui-btn>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn color="success" type="submit">{{ $strings.ButtonSave }}</ui-btn> <ui-btn color="success" type="submit">{{ $strings.ButtonSave }}</ui-btn>
@@ -94,21 +94,32 @@ export default {
this.newCollectionDescription = this.collection.description || '' this.newCollectionDescription = this.collection.description || ''
}, },
removeClick() { removeClick() {
if (confirm(this.$getString('MessageConfirmRemoveCollection', [this.collectionName]))) { const payload = {
this.processing = true message: this.$getString('MessageConfirmRemoveCollection', [this.collectionName]),
this.$axios callback: (confirmed) => {
.$delete(`/api/collections/${this.collection.id}`) if (confirmed) {
.then(() => { this.deleteCollection()
this.processing = false }
this.show = false },
this.$toast.success(this.$strings.ToastCollectionRemoveSuccess) type: 'yesNo'
})
.catch((error) => {
console.error('Failed to remove collection', error)
this.processing = false
this.$toast.error(this.$strings.ToastRemoveFailed)
})
} }
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() { submitForm() {
if (this.newCollectionName === this.collectionName && this.newCollectionDescription === this.collection.description) { if (this.newCollectionName === this.collectionName && this.newCollectionDescription === this.collection.description) {
@@ -16,11 +16,12 @@
v-for="(episode, index) in episodesList" v-for="(episode, index) in episodesList"
:key="index" :key="index"
class="relative" 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)" @click="toggleSelectEpisode(episode)"
> >
<div class="absolute top-0 left-0 h-full flex items-center p-2"> <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" /> <ui-checkbox v-else v-model="selectedEpisodes[episode.cleanUrl]" small checkbox-bg="primary" border-color="gray-600" />
</div> </div>
<div class="px-8 py-2"> <div class="px-8 py-2">
@@ -58,6 +59,14 @@ export default {
episodes: { episodes: {
type: Array, type: Array,
default: () => [] default: () => []
},
downloadQueue: {
type: Array,
default: () => []
},
episodesDownloading: {
type: Array,
default: () => []
} }
}, },
data() { data() {
@@ -79,6 +88,21 @@ export default {
handler(newVal) { handler(newVal) {
if (newVal) this.init() 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: { computed: {
@@ -132,6 +156,13 @@ export default {
} }
return false 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. * 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 * Fallback to checking the clean url
@@ -173,13 +204,13 @@ export default {
}, },
toggleSelectAll(val) { toggleSelectAll(val) {
for (const episode of this.episodesList) { 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) else this.$set(this.selectedEpisodes, episode.cleanUrl, val)
} }
}, },
checkSetIsSelectedAll() { checkSetIsSelectedAll() {
for (const episode of this.episodesList) { 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 this.selectAll = false
return return
} }
@@ -187,7 +218,7 @@ export default {
this.selectAll = true this.selectAll = true
}, },
toggleSelectEpisode(episode) { toggleSelectEpisode(episode) {
if (this.getIsEpisodeDownloaded(episode)) return if (episode.isDownloaded || episode.isDownloading) return
this.$set(this.selectedEpisodes, episode.cleanUrl, !this.selectedEpisodes[episode.cleanUrl]) this.$set(this.selectedEpisodes, episode.cleanUrl, !this.selectedEpisodes[episode.cleanUrl])
this.checkSetIsSelectedAll() this.checkSetIsSelectedAll()
}, },
@@ -223,6 +254,23 @@ export default {
}) })
}, },
init() { 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.downloadedEpisodeGuidMap = {}
this.downloadedEpisodeUrlMap = {} this.downloadedEpisodeUrlMap = {}
@@ -230,18 +278,16 @@ export default {
if (episode.guid) this.downloadedEpisodeGuidMap[episode.guid] = episode.id if (episode.guid) this.downloadedEpisodeGuidMap[episode.guid] = episode.id
if (episode.enclosure?.url) this.downloadedEpisodeUrlMap[this.getCleanEpisodeUrl(episode.enclosure.url)] = episode.id if (episode.enclosure?.url) this.downloadedEpisodeUrlMap[this.getCleanEpisodeUrl(episode.enclosure.url)] = episode.id
}) })
},
this.episodesCleaned = this.episodes updateEpisodeDownloadStatuses() {
.filter((ep) => ep.enclosure?.url) this.updateDownloadedEpisodeMaps()
.map((_ep) => { this.episodesCleaned = this.episodesCleaned.map((ep) => {
return { return {
..._ep, ...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 = {}
} }
}, },
mounted() {} mounted() {}
+26 -15
View File
@@ -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="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> </div>
</td> </td>
</tr> </tr>
@@ -107,21 +107,32 @@ export default {
}) })
}, },
deleteBackupClick(backup) { deleteBackupClick(backup) {
if (confirm(this.$getString('MessageConfirmDeleteBackup', [this.$formatDatetime(backup.createdAt, this.dateFormat, this.timeFormat)]))) { const payload = {
this.processing = true message: this.$getString('MessageConfirmDeleteBackup', [this.$formatDatetime(backup.createdAt, this.dateFormat, this.timeFormat)]),
this.$axios callback: (confirmed) => {
.$delete(`/api/backups/${backup.id}`) if (confirmed) {
.then((data) => { this.deleteBackup(backup)
this.setBackups(data.backups || []) }
this.$toast.success(this.$strings.ToastBackupDeleteSuccess) },
this.processing = false type: 'yesNo'
})
.catch((error) => {
console.error(error)
this.$toast.error(this.$strings.ToastBackupDeleteFailed)
this.processing = false
})
} }
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) { applyBackup(backup) {
this.selectedBackup = backup this.selectedBackup = backup
+29 -17
View File
@@ -91,24 +91,36 @@ export default {
}, },
deleteUserClick(user) { deleteUserClick(user) {
if (this.isDeletingUser) return if (this.isDeletingUser) return
if (confirm(this.$getString('MessageRemoveUserWarning', [user.username]))) {
this.isDeletingUser = true const payload = {
this.$axios message: this.$getString('MessageRemoveUserWarning', [user.username]),
.$delete(`/api/users/${user.id}`) callback: (confirmed) => {
.then((data) => { if (confirmed) {
this.isDeletingUser = false this.deleteUser(user)
if (data.error) { }
this.$toast.error(data.error) },
} else { type: 'yesNo'
this.$toast.success(this.$strings.ToastUserDeleteSuccess)
}
})
.catch((error) => {
console.error('Failed to delete user', error)
this.$toast.error(this.$strings.ToastUserDeleteFailed)
this.isDeletingUser = false
})
} }
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) { editUser(user) {
this.$emit('edit', user) this.$emit('edit', user)
@@ -10,8 +10,13 @@
<div class="h-10 flex items-center mt-1.5 mb-0.5 overflow-hidden"> <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> <p class="text-sm text-gray-200 line-clamp-2" v-html="episodeSubtitle"></p>
</div> </div>
<div class="h-8 flex items-center"> <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?.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?.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> <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: { episode: {
type: Object, type: Object,
default: () => null default: () => null
} },
sortKey: String
}, },
data() { data() {
return { return {
@@ -1,3 +1,4 @@
<template> <template>
<div id="lazy-episodes-table" class="w-full py-6"> <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"> <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, text: this.$strings.LabelEpisode,
value: 'episode' value: 'episode'
},
{
text: this.$strings.LabelFilename,
value: 'audioFile.metadata.filename'
} }
] ]
}, },
@@ -171,8 +176,17 @@ export default {
return episodeProgress && !episodeProgress.isFinished return episodeProgress && !episodeProgress.isFinished
}) })
.sort((a, b) => { .sort((a, b) => {
let aValue = a[this.sortKey] let aValue
let bValue = b[this.sortKey] 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 // Sort episodes with no pub date as the oldest
if (this.sortKey === 'publishedAt') { if (this.sortKey === 'publishedAt') {
@@ -361,20 +375,20 @@ export default {
playEpisode(episode) { playEpisode(episode) {
const queueItems = [] 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) const episodeIndex = episodesInListeningOrder.findIndex((e) => e.id === episode.id)
for (let i = episodeIndex; i < episodesInListeningOrder.length; i++) { for (let i = episodeIndex; i < episodesInListeningOrder.length; i++) {
const episode = episodesInListeningOrder[i] const _episode = episodesInListeningOrder[i]
const podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id) const podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, _episode.id)
if (!podcastProgress || !podcastProgress.isFinished) { if (!podcastProgress?.isFinished || episode.id === _episode.id) {
queueItems.push({ queueItems.push({
libraryItemId: this.libraryItem.id, libraryItemId: this.libraryItem.id,
libraryId: this.libraryItem.libraryId, libraryId: this.libraryItem.libraryId,
episodeId: episode.id, episodeId: _episode.id,
title: episode.title, title: _episode.title,
subtitle: this.mediaMetadata.title, subtitle: this.mediaMetadata.title,
caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate, caption: _episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(_episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
duration: episode.audioFile.duration || null, duration: _episode.audioFile.duration || null,
coverPath: this.media.coverPath || null coverPath: this.media.coverPath || null
}) })
} }
@@ -440,7 +454,8 @@ export default {
propsData: { propsData: {
index, index,
libraryItemId: this.libraryItem.id, libraryItemId: this.libraryItem.id,
episode: this.episodesList[index] episode: this.episodesList[index],
sortKey: this.sortKey
}, },
created() { created() {
this.$on('selected', (payload) => { this.$on('selected', (payload) => {
+24 -14
View File
@@ -176,21 +176,31 @@ export default {
this.$store.commit('globals/setEditCollection', this.collection) this.$store.commit('globals/setEditCollection', this.collection)
}, },
removeClick() { removeClick() {
if (confirm(this.$getString('MessageConfirmRemoveCollection', [this.collectionName]))) { const payload = {
this.processing = true message: this.$getString('MessageConfirmRemoveCollection', [this.collectionName]),
this.$axios callback: (confirmed) => {
.$delete(`/api/collections/${this.collection.id}`) if (confirmed) {
.then(() => { this.deleteCollection()
this.$toast.success(this.$strings.ToastCollectionRemoveSuccess) }
}) },
.catch((error) => { type: 'yesNo'
console.error('Failed to remove collection', error)
this.$toast.error(this.$strings.ToastCollectionRemoveFailed)
})
.finally(() => {
this.processing = false
})
} }
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() { clickPlay() {
const queueItems = [] const queueItems = []
+32 -20
View File
@@ -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-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"> <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">,&nbsp;</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">,&nbsp;</span></nuxt-link>
</p> </p>
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</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-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" /> <tables-ebook-files-table v-if="ebookFiles.length" :library-item="libraryItem" class="mt-6" />
@@ -141,7 +141,7 @@
</div> </div>
</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" /> <modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :playback-rate="1" :library-item-id="libraryItemId" hide-create @select="selectBookmark" />
</div> </div>
</template> </template>
@@ -534,13 +534,15 @@ export default {
let episodeId = null let episodeId = null
const queueItems = [] const queueItems = []
if (this.isPodcast) { 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 // Find the first unplayed episode from the table
let episodeIndex = episodesInListeningOrder.findLastIndex((ep) => { let episodeIndex = episodesInListeningOrder.findIndex((ep) => {
const podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, ep.id) const podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, ep.id)
return !podcastProgress || !podcastProgress.isFinished return !podcastProgress || !podcastProgress.isFinished
}) })
// If all episodes are played, use the first episode
if (episodeIndex < 0) episodeIndex = 0 if (episodeIndex < 0) episodeIndex = 0
episodeId = episodesInListeningOrder[episodeIndex].id episodeId = episodesInListeningOrder[episodeIndex].id
@@ -599,19 +601,31 @@ export default {
}, },
clearProgressClick() { clearProgressClick() {
if (!this.userMediaProgress) return if (!this.userMediaProgress) return
if (confirm(this.$strings.MessageConfirmResetProgress)) {
this.resettingProgress = true const payload = {
this.$axios message: this.$strings.MessageConfirmResetProgress,
.$delete(`/api/me/progress/${this.userMediaProgress.id}`) callback: (confirmed) => {
.then(() => { if (confirmed) {
console.log('Progress reset complete') this.clearProgress()
this.resettingProgress = false }
}) },
.catch((error) => { type: 'yesNo'
console.error('Progress reset failed', error)
this.resettingProgress = false
})
} }
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() { clickRSSFeed() {
this.$store.commit('globals/setRSSFeedOpenCloseModal', { this.$store.commit('globals/setRSSFeedOpenCloseModal', {
@@ -646,13 +660,11 @@ export default {
}, },
rssFeedOpen(data) { rssFeedOpen(data) {
if (data.entityId === this.libraryItemId) { if (data.entityId === this.libraryItemId) {
console.log('RSS Feed Opened', data)
this.rssFeed = data this.rssFeed = data
} }
}, },
rssFeedClosed(data) { rssFeedClosed(data) {
if (data.entityId === this.libraryItemId) { if (data.entityId === this.libraryItemId) {
console.log('RSS Feed Closed', data)
this.rssFeed = null this.rssFeed = null
} }
}, },
+15 -1
View File
@@ -130,7 +130,21 @@ class MigrationManager {
async initUmzug(umzugStorage = new SequelizeStorage({ sequelize: this.sequelize })) { async initUmzug(umzugStorage = new SequelizeStorage({ sequelize: this.sequelize })) {
// This check is for dependency injection in tests // 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({ const parent = new Umzug({
migrations: { migrations: {
+9
View File
@@ -72,6 +72,15 @@ class PodcastManager {
*/ */
async startPodcastEpisodeDownload(podcastEpisodeDownload) { async startPodcastEpisodeDownload(podcastEpisodeDownload) {
if (this.currentDownload) { 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) this.downloadQueue.push(podcastEpisodeDownload)
SocketAuthority.emitter('episode_download_queued', podcastEpisodeDownload.toJSONForClient()) SocketAuthority.emitter('episode_download_queued', podcastEpisodeDownload.toJSONForClient())
return return
+2 -1
View File
@@ -705,13 +705,14 @@ class User extends Model {
ebookLocation: progressPayload.ebookLocation || null, ebookLocation: progressPayload.ebookLocation || null,
ebookProgress: isNullOrNaN(progressPayload.ebookProgress) ? 0 : Number(progressPayload.ebookProgress), ebookProgress: isNullOrNaN(progressPayload.ebookProgress) ? 0 : Number(progressPayload.ebookProgress),
finishedAt: progressPayload.finishedAt || null, finishedAt: progressPayload.finishedAt || null,
createdAt: progressPayload.createdAt || new Date(),
extraData: { extraData: {
libraryItemId: progressPayload.libraryItemId, libraryItemId: progressPayload.libraryItemId,
progress: isNullOrNaN(progressPayload.progress) ? 0 : Number(progressPayload.progress) progress: isNullOrNaN(progressPayload.progress) ? 0 : Number(progressPayload.progress)
} }
} }
if (newMediaProgressPayload.isFinished) { if (newMediaProgressPayload.isFinished) {
newMediaProgressPayload.finishedAt = new Date() newMediaProgressPayload.finishedAt = newMediaProgressPayload.finishedAt || new Date()
newMediaProgressPayload.extraData.progress = 1 newMediaProgressPayload.extraData.progress = 1
} else { } else {
newMediaProgressPayload.finishedAt = null newMediaProgressPayload.finishedAt = null
+2 -1
View File
@@ -43,7 +43,8 @@ class PodcastEpisodeDownload {
season: this.rssPodcastEpisode?.season ?? null, season: this.rssPodcastEpisode?.season ?? null,
episode: this.rssPodcastEpisode?.episode ?? null, episode: this.rssPodcastEpisode?.episode ?? null,
episodeType: this.rssPodcastEpisode?.episodeType ?? 'full', episodeType: this.rssPodcastEpisode?.episodeType ?? 'full',
publishedAt: this.rssPodcastEpisode?.publishedAt ?? null publishedAt: this.rssPodcastEpisode?.publishedAt ?? null,
guid: this.rssPodcastEpisode?.guid ?? null
} }
} }
+49 -17
View File
@@ -69,25 +69,57 @@ class CustomProviderAdapter {
throw new Error('Custom provider returned malformed response') 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 // 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 matches.map((match) => {
return { const { title, subtitle, author, narrator, publisher, publishedYear, description, cover, isbn, asin, genres, tags, series, language, duration } = match
title,
subtitle, const payload = {
author, title: toStringOrUndefined(title),
narrator, subtitle: toStringOrUndefined(subtitle),
publisher, author: toStringOrUndefined(author),
publishedYear, narrator: toStringOrUndefined(narrator),
description: htmlSanitizer.sanitize(description), publisher: toStringOrUndefined(publisher),
cover, publishedYear: toStringOrUndefined(publishedYear),
isbn, description: description && typeof description === 'string' ? htmlSanitizer.sanitize(description) : undefined,
asin, cover: toStringOrUndefined(cover),
genres, isbn: toStringOrUndefined(isbn),
tags: tags?.join(',') || null, asin: toStringOrUndefined(asin),
series: series?.length ? series : null, genres: Array.isArray(genres) && genres.every((g) => typeof g === 'string') ? genres : undefined,
language, tags: toStringOrUndefined(tags),
duration 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 () => { it('upgrade with duplicate series and no sequence', async () => {
// Add some entries to the Series table using the UUID for the ids // Add some entries to the Series table using the UUID for the ids
await queryInterface.bulkInsert('Series', [ await queryInterface.bulkInsert('Series', [
{ id: series1Id, name: 'Series 1', 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(), updatedAt: new Date() }, { 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(), updatedAt: new Date() }, { 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: 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: 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) } { 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 () => { 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 // Create two different series with the same name in the same library
await queryInterface.bulkInsert('Series', [ await queryInterface.bulkInsert('Series', [
{ id: series1Id, 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(), updatedAt: new Date() } { id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(5), updatedAt: new Date(10) }
]) ])
// Create a book that is in both series // Create a book that is in both series
await queryInterface.bulkInsert('BookSeries', [ 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 () => { 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 // Create two different series with the same name in the same library
await queryInterface.bulkInsert('Series', [ await queryInterface.bulkInsert('Series', [
{ id: series1Id, 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(), updatedAt: new Date() } { id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(5), updatedAt: new Date(7) }
]) ])
// Create a book that is in both series // Create a book that is in both series
await queryInterface.bulkInsert('BookSeries', [ 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 () => { 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 // Create two different series with the same name in the same library
await queryInterface.bulkInsert('Series', [ await queryInterface.bulkInsert('Series', [
{ id: series1Id, 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(), updatedAt: new Date() } { id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(2), updatedAt: new Date(2) }
]) ])
// Create a book that is in both series // Create a book that is in both series
await queryInterface.bulkInsert('BookSeries', [ await queryInterface.bulkInsert('BookSeries', [