diff --git a/client/components/modals/podcast/NewModal.vue b/client/components/modals/podcast/NewModal.vue
index 33e6dbc4..bd950b42 100644
--- a/client/components/modals/podcast/NewModal.vue
+++ b/client/components/modals/podcast/NewModal.vue
@@ -35,6 +35,9 @@
+
+
+
@@ -95,6 +98,7 @@ export default {
itunesArtistId: '',
autoDownloadEpisodes: false,
language: '',
+ filenameFormat: '',
explicit: false,
type: ''
}
@@ -193,6 +197,7 @@ export default {
itunesId: this.podcast.itunesId,
itunesArtistId: this.podcast.itunesArtistId,
language: this.podcast.language,
+ podcastFilenameFormat: this.podcast.podcastFilenameFormat,
explicit: this.podcast.explicit,
type: this.podcast.type
},
@@ -230,6 +235,7 @@ export default {
this.podcast.itunesId = this._podcastData.id || ''
this.podcast.itunesArtistId = this._podcastData.artistId || ''
this.podcast.language = this._podcastData.language || this.feedMetadata.language || ''
+ this.podcast.filenameFormat = this._podcastData.filenameFormat || '%T'
this.podcast.autoDownloadEpisodes = false
this.podcast.type = this._podcastData.type || this.feedMetadata.type || 'episodic'
diff --git a/client/components/widgets/PodcastDetailsEdit.vue b/client/components/widgets/PodcastDetailsEdit.vue
index 0be67df3..ba8403a8 100644
--- a/client/components/widgets/PodcastDetailsEdit.vue
+++ b/client/components/widgets/PodcastDetailsEdit.vue
@@ -43,6 +43,16 @@
+
+
+
+
{{ $strings.PodcastFilenameFormatHelp }}
+
+ info
+
+
+
+
@@ -71,6 +81,7 @@ export default {
itunesArtistId: null,
explicit: false,
language: null,
+ podcastFilenameFormat: null,
type: null
},
newTags: []
@@ -158,6 +169,7 @@ export default {
if (this.$refs.feedUrlInput) this.$refs.feedUrlInput.blur()
if (this.$refs.itunesIdInput) this.$refs.itunesIdInput.blur()
if (this.$refs.languageInput) this.$refs.languageInput.blur()
+ if (this.$refs.podcastFilenameFormatInput) this.$refs.podcastFilenameFormatInput.blur()
if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) {
this.$refs.genresSelect.forceBlur()
@@ -240,6 +252,7 @@ export default {
this.details.itunesId = this.mediaMetadata.itunesId || ''
this.details.itunesArtistId = this.mediaMetadata.itunesArtistId || ''
this.details.language = this.mediaMetadata.language || ''
+ this.details.podcastFilenameFormat = this.mediaMetadata.podcastFilenameFormat || '%T'
this.details.explicit = !!this.mediaMetadata.explicit
this.details.type = this.mediaMetadata.type || 'episodic'
diff --git a/client/package-lock.json b/client/package-lock.json
index 56a98514..c2d0fcb1 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "audiobookshelf-client",
- "version": "2.23.0",
+ "version": "2.23.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
- "version": "2.23.0",
+ "version": "2.23.1",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",
diff --git a/client/package.json b/client/package.json
index a2bd7e62..9fdc5c49 100644
--- a/client/package.json
+++ b/client/package.json
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
- "version": "2.23.0",
+ "version": "2.23.1",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",
diff --git a/client/strings/en-us.json b/client/strings/en-us.json
index 939eb9f4..4c3255ca 100644
--- a/client/strings/en-us.json
+++ b/client/strings/en-us.json
@@ -924,6 +924,8 @@
"PlaceholderNewPlaylist": "New playlist name",
"PlaceholderSearch": "Search..",
"PlaceholderSearchEpisode": "Search episode..",
+ "PodcastFilenameFormatHelpContent": "%S for Season, %E for Episode, %Y for Year, %M for Month, %D for Day and %T for title",
+ "PodcastFilenameFormatHelp": "Podcast filename format",
"StatsAuthorsAdded": "authors added",
"StatsBooksAdded": "books added",
"StatsBooksAdditional": "Some additions include…",
diff --git a/package-lock.json b/package-lock.json
index 91a5f283..df2135d2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
- "version": "2.23.0",
+ "version": "2.23.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
- "version": "2.23.0",
+ "version": "2.23.1",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",
diff --git a/package.json b/package.json
index 9b08daee..c8a43116 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
- "version": "2.23.0",
+ "version": "2.23.1",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js
index e63441f0..758d802c 100644
--- a/server/controllers/LibraryController.js
+++ b/server/controllers/LibraryController.js
@@ -95,7 +95,7 @@ class LibraryController {
return res.status(400).send('Invalid request. Settings "metadataPrecedence" must be an array')
}
newLibraryPayload.settings[key] = [...req.body.settings[key]]
- } else if (key === 'autoScanCronExpression' || key === 'podcastSearchRegion') {
+ } else if (key === 'autoScanCronExpression' || key === 'podcastSearchRegion' || key === 'podcastFilenameFormat') {
if (!req.body.settings[key]) continue
if (typeof req.body.settings[key] !== 'string') {
return res.status(400).send(`Invalid request. Settings "${key}" must be a string`)
@@ -318,7 +318,7 @@ class LibraryController {
updatedSettings[key] = [...req.body.settings[key]]
Logger.debug(`[LibraryController] Library "${req.library.name}" updating setting "${key}" to "${updatedSettings[key]}"`)
}
- } else if (key === 'autoScanCronExpression' || key === 'podcastSearchRegion') {
+ } else if (key === 'autoScanCronExpression' || key === 'podcastSearchRegion' || key === 'podcastFilenameFormat' ) {
if (req.body.settings[key] !== null && typeof req.body.settings[key] !== 'string') {
Logger.error(`[LibraryController] Invalid request. Settings "${key}" must be a string`)
return res.status(400).send(`Invalid request. Settings "${key}" must be a string`)
diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js
index 052ba8b3..0a4c9a29 100644
--- a/server/managers/PodcastManager.js
+++ b/server/managers/PodcastManager.js
@@ -622,6 +622,7 @@ class PodcastManager {
itunesId: '',
itunesArtistId: '',
language: '',
+ podcastFilenameFormat: '',
numEpisodes: feed.numEpisodes
}
}
diff --git a/server/migrations/v2.23.1-add-podcastFilenameFormat.js b/server/migrations/v2.23.1-add-podcastFilenameFormat.js
new file mode 100644
index 00000000..4e204afc
--- /dev/null
+++ b/server/migrations/v2.23.1-add-podcastFilenameFormat.js
@@ -0,0 +1,64 @@
+/**
+ * @typedef MigrationContext
+ * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
+ * @property {import('../Logger')} logger - a Logger object.
+ *
+ * @typedef MigrationOptions
+ * @property {MigrationContext} context - an object containing the migration context.
+ */
+
+/**
+ * This upward migration script adds a new variable to allow saving podcast filename formats
+ *
+ * @param {MigrationOptions} options - an object containing the migration context.
+ * @returns {Promise} - A promise that resolves when the migration is complete.
+ */
+const migrationVersion = '2.23.1'
+const migrationName = `${migrationVersion}-add-podcastFilenameFormat-to-podcasts`
+const loggerPrefix = `[${migrationVersion} migration]`
+async function up({ context: { queryInterface, logger } }) {
+ // Upwards migration script
+ logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
+
+ // Run reindex nocase to fix potential corruption issues due to the bad sqlite extension introduced in v2.12.0
+ logger.info(`${loggerPrefix} adding the variable`)
+ await addColumn(queryInterface, logger, 'podcasts', 'podcastFilenameFormat', { type: queryInterface.sequelize.Sequelize.STRING, allowNull: true })
+
+ logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
+}
+
+/**
+ * This downward migration script is a no-op.
+ *
+ * @param {MigrationOptions} options - an object containing the migration context.
+ * @returns {Promise} - A promise that resolves when the migration is complete.
+ */
+async function down({ context: { queryInterface, logger } }) {
+ // Downward migration script
+ logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
+
+ logger.info(`${loggerPrefix} Dropping column from table`)
+ await removeColumn(queryInterface, logger, 'podcasts', 'podcastFilenameFormat')
+
+
+ logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
+}
+
+async function addColumn(queryInterface, logger, table, column, options) {
+ logger.info(`${loggerPrefix} adding column "${column}" to table "${table}"`)
+ const tableDescription = await queryInterface.describeTable(table)
+ if (!tableDescription[column]) {
+ await queryInterface.addColumn(table, column, options)
+ logger.info(`${loggerPrefix} added column "${column}" to table "${table}"`)
+ } else {
+ logger.info(`${loggerPrefix} column "${column}" already exists in table "${table}"`)
+ }
+}
+
+async function removeColumn(queryInterface, logger, table, column) {
+ logger.info(`${loggerPrefix} removing column "${column}" from table "${table}"`)
+ await queryInterface.removeColumn(table, column)
+ logger.info(`${loggerPrefix} removed column "${column}" from table "${table}"`)
+}
+
+module.exports = { up, down }
diff --git a/server/models/Podcast.js b/server/models/Podcast.js
index fa27821d..c0ddd5b4 100644
--- a/server/models/Podcast.js
+++ b/server/models/Podcast.js
@@ -39,6 +39,8 @@ class Podcast extends Model {
/** @type {string} */
this.language
/** @type {string} */
+ this.podcastFilenameFormat
+ /** @type {string} */
this.podcastType
/** @type {boolean} */
this.explicit
@@ -94,6 +96,7 @@ class Podcast extends Model {
itunesId: typeof payload.metadata.itunesId === 'string' ? payload.metadata.itunesId : null,
itunesArtistId: typeof payload.metadata.itunesArtistId === 'string' ? payload.metadata.itunesArtistId : null,
language: typeof payload.metadata.language === 'string' ? payload.metadata.language : null,
+ podcastFilenameFormat: typeof payload.metadata.podcastFilenameFormat === 'string' ? payload.metadata.podcastFilenameFormat : null,
podcastType: typeof payload.metadata.type === 'string' ? payload.metadata.type : null,
explicit: !!payload.metadata.explicit,
autoDownloadEpisodes: !!payload.autoDownloadEpisodes,
@@ -131,6 +134,7 @@ class Podcast extends Model {
itunesId: DataTypes.STRING,
itunesArtistId: DataTypes.STRING,
language: DataTypes.STRING,
+ podcastFilenameFormat: DataTypes.STRING,
podcastType: DataTypes.STRING,
explicit: DataTypes.BOOLEAN,
@@ -186,6 +190,7 @@ class Podcast extends Model {
itunesId: this.itunesId,
itunesArtistId: this.itunesArtistId,
language: this.language,
+ podcastFilenameFormat: this.podcastFilenameFormat,
explicit: !!this.explicit,
podcastType: this.podcastType
}
@@ -202,7 +207,7 @@ class Podcast extends Model {
let hasUpdates = false
if (payload.metadata) {
- const stringKeys = ['title', 'author', 'releaseDate', 'feedUrl', 'imageUrl', 'description', 'itunesPageUrl', 'itunesId', 'itunesArtistId', 'language', 'type']
+ const stringKeys = ['title', 'author', 'releaseDate', 'feedUrl', 'imageUrl', 'description', 'itunesPageUrl', 'itunesId', 'itunesArtistId', 'language', 'type','podcastFilenameFormat']
stringKeys.forEach((key) => {
let newKey = key
if (key === 'type') {
@@ -386,6 +391,7 @@ class Podcast extends Model {
itunesArtistId: this.itunesArtistId,
explicit: this.explicit,
language: this.language,
+ podcastFilenameFormat: this.podcastFilenameFormat,
type: this.podcastType
}
}
diff --git a/server/objects/PodcastEpisodeDownload.js b/server/objects/PodcastEpisodeDownload.js
index 3c1d82ac..c7175bff 100644
--- a/server/objects/PodcastEpisodeDownload.js
+++ b/server/objects/PodcastEpisodeDownload.js
@@ -21,6 +21,7 @@ class PodcastEpisodeDownload {
this.appendRandomId = false
this.targetFilename = null
+ this.podcastFilenameFormat = null
this.startedAt = null
this.createdAt = null
@@ -89,13 +90,38 @@ class PodcastEpisodeDownload {
if (!this.rssPodcastEpisode.publishedAt) return null
return new Date(this.rssPodcastEpisode.publishedAt).getFullYear()
}
+ formatFilename() {
+ const {publishedAt, season, episode, title} = this.rssPodcastEpisode;
+ const fileformat = this.libraryItem.media.podcastFilenameFormat || '%T'
+ let filename = fileformat
+ if (publishedAt) {
+ const dt = new Date(publishedAt)
+ const year = dt.getFullYear()
+ const month = String(dt.getMonth() + 1).padStart(2, '0')
+ const day = String(dt.getDate()).padStart(2,'0')
+ filename = filename
+ .replace("%Y",year)
+ .replace("%M",month)
+ .replace("%D",day)
+ }
+ if (season) {
+ filename = filename.replace("%S",season)
+ }
+ if (episode){
+ filename = filename.replace("%E",episode)
+ }
+ if (title){
+ filename = filename.replace("%T",title.trim() || '')
+ }
+ return filename
+ }
/**
* @param {string} title
*/
- getSanitizedFilename(title) {
+ getSanitizedFilename() {
const appendage = this.appendRandomId ? ` (${this.id})` : ''
- const filename = `${title.trim()}${appendage}.${this.fileExtension}`
+ const filename = `${this.formatFilename()}${appendage}.${this.fileExtension}`
return sanitizeFilename(filename)
}
@@ -104,7 +130,7 @@ class PodcastEpisodeDownload {
*/
setAppendRandomId(appendRandomId) {
this.appendRandomId = appendRandomId
- this.targetFilename = this.getSanitizedFilename(this.rssPodcastEpisode.title || '')
+ this.targetFilename = this.getSanitizedFilename()
}
/**
@@ -126,9 +152,9 @@ class PodcastEpisodeDownload {
this.url = encodeURI(url)
}
- this.targetFilename = this.getSanitizedFilename(this.rssPodcastEpisode.title || '')
this.libraryItem = libraryItem
+ this.targetFilename = this.getSanitizedFilename()
this.isAutoDownload = isAutoDownload
this.createdAt = Date.now()
this.libraryId = libraryId
diff --git a/server/utils/migrations/dbMigration.js b/server/utils/migrations/dbMigration.js
index 1d4c4798..9c47a39b 100644
--- a/server/utils/migrations/dbMigration.js
+++ b/server/utils/migrations/dbMigration.js
@@ -175,6 +175,7 @@ function migratePodcast(oldLibraryItem, LibraryItem) {
itunesArtistId: oldPodcastMetadata.itunesArtistId,
language: oldPodcastMetadata.language,
podcastType: oldPodcastMetadata.type,
+ podcastFilenameFormat: oldPodcastMetadata.podcastFilenameFormat,
explicit: !!oldPodcastMetadata.explicit,
autoDownloadEpisodes: !!oldPodcast.autoDownloadEpisodes,
autoDownloadSchedule: oldPodcast.autoDownloadSchedule,