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,