feat: add podcastFilenameFormat

This commit is contained in:
MagiX13 2025-05-24 16:15:47 +02:00
parent 5e5a988f7a
commit 8d988cf353
13 changed files with 132 additions and 13 deletions

View File

@ -35,6 +35,9 @@
<div class="md:w-1/4 p-2">
<ui-text-input-with-label v-model="podcast.language" :label="$strings.LabelLanguage" />
</div>
<div class="md:w-1/4 p-2">
<ui-text-input-with-label v-model="podcast.podcastFilenameFormat" :label="$strings.PodcastFilenameFormatHelp" />
</div>
<div class="md:w-1/4 px-2 pt-7">
<ui-checkbox v-model="podcast.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div>
@ -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'

View File

@ -43,6 +43,16 @@
<div class="w-1/4 px-1">
<ui-dropdown :label="$strings.LabelPodcastType" v-model="details.type" :items="podcastTypes" small class="max-w-52" @input="handleInputChange" />
</div>
<div class="w-1/4 px-1">
<ui-text-input-with-label ref="podcastFilenameFormatInput" v-model="details.podcastFilenameFormat" :label="$strings.PodcastFilenameFormatHelp" trim-whitespace @input="handleInputChange" >
<div class="flex -mb-0.5">
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': checkingNewEpisodes }">{{ $strings.PodcastFilenameFormatHelp }}</p>
<ui-tooltip direction="top" :text="$strings.PodcastFilenameFormatHelpContent">
<span class="material-symbols text-base">info</span>
</ui-tooltip>
</div>
</ui-text-input-with-label>
</div>
</div>
</form>
</div>
@ -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'

View File

@ -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",

View File

@ -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",

View File

@ -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…",

4
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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`)

View File

@ -622,6 +622,7 @@ class PodcastManager {
itunesId: '',
itunesArtistId: '',
language: '',
podcastFilenameFormat: '',
numEpisodes: feed.numEpisodes
}
}

View File

@ -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<void>} - 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<void>} - 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 }

View File

@ -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
}
}

View File

@ -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

View File

@ -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,