From 212b97fa206a1a6ec8f0b9b540a1fa6968bfd73a Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 30 Mar 2023 18:04:21 -0500 Subject: [PATCH] Add:Parsing meta tags from podcast episode audio file #1488 --- server/objects/entities/PodcastEpisode.js | 75 ++++++++++++++++++++++ server/objects/mediaTypes/Podcast.js | 12 ++++ server/objects/metadata/AudioMetaTags.js | 24 +++++++ server/objects/metadata/PodcastMetadata.js | 69 ++++++++++++++++++++ server/scanner/MediaFileScanner.js | 10 ++- server/utils/ffmpegHelpers.js | 2 +- server/utils/prober.js | 10 ++- 7 files changed, 197 insertions(+), 5 deletions(-) diff --git a/server/objects/entities/PodcastEpisode.js b/server/objects/entities/PodcastEpisode.js index 0ea5e7c6..80e66611 100644 --- a/server/objects/entities/PodcastEpisode.js +++ b/server/objects/entities/PodcastEpisode.js @@ -1,4 +1,5 @@ const Path = require('path') +const Logger = require('../../Logger') const { getId, cleanStringForSearch } = require('../../utils/index') const AudioFile = require('../files/AudioFile') const AudioTrack = require('../files/AudioTrack') @@ -132,6 +133,9 @@ class PodcastEpisode { this.audioFile = audioFile this.title = Path.basename(audioFile.metadata.filename, Path.extname(audioFile.metadata.filename)) this.index = index + + this.setDataFromAudioMetaTags(audioFile.metaTags, true) + this.addedAt = Date.now() this.updatedAt = Date.now() } @@ -168,5 +172,76 @@ class PodcastEpisode { searchQuery(query) { return cleanStringForSearch(this.title).includes(query) } + + setDataFromAudioMetaTags(audioFileMetaTags, overrideExistingDetails = false) { + if (!audioFileMetaTags) return false + + const MetadataMapArray = [ + { + tag: 'tagComment', + altTag: 'tagSubtitle', + key: 'description' + }, + { + tag: 'tagSubtitle', + key: 'subtitle' + }, + { + tag: 'tagDate', + key: 'pubDate' + }, + { + tag: 'tagDisc', + key: 'season', + }, + { + tag: 'tagTrack', + altTag: 'tagSeriesPart', + key: 'episode' + }, + { + tag: 'tagTitle', + key: 'title' + }, + { + tag: 'tagEpisodeType', + key: 'episodeType' + } + ] + + MetadataMapArray.forEach((mapping) => { + let value = audioFileMetaTags[mapping.tag] + let tagToUse = mapping.tag + if (!value && mapping.altTag) { + tagToUse = mapping.altTag + value = audioFileMetaTags[mapping.altTag] + } + + if (value && typeof value === 'string') { + value = value.trim() // Trim whitespace + + if (mapping.key === 'pubDate' && (!this.pubDate || overrideExistingDetails)) { + const pubJsDate = new Date(value) + if (pubJsDate && !isNaN(pubJsDate)) { + this.publishedAt = pubJsDate.valueOf() + this.pubDate = value + Logger.debug(`[PodcastEpisode] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${this[mapping.key]}`) + } else { + Logger.warn(`[PodcastEpisode] Mapping pubDate with tag ${tagToUse} has invalid date "${value}"`) + } + } else if (mapping.key === 'episodeType' && (!this.episodeType || overrideExistingDetails)) { + if (['full', 'trailer', 'bonus'].includes(value)) { + this.episodeType = value + Logger.debug(`[PodcastEpisode] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${this[mapping.key]}`) + } else { + Logger.warn(`[PodcastEpisode] Mapping episodeType with invalid value "${value}". Must be one of [full, trailer, bonus].`) + } + } else if (!this[mapping.key] || overrideExistingDetails) { + this[mapping.key] = value + Logger.debug(`[PodcastEpisode] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${this[mapping.key]}`) + } + } + }) + } } module.exports = PodcastEpisode diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js index 2f45cc4f..743512e8 100644 --- a/server/objects/mediaTypes/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -175,6 +175,10 @@ class Podcast { return null } + findEpisodeWithInode(inode) { + return this.episodes.find(ep => ep.audioFile.ino === inode) + } + setData(mediaData) { this.metadata = new PodcastMetadata() if (mediaData.metadata) { @@ -315,5 +319,13 @@ class Podcast { getEpisode(episodeId) { return this.episodes.find(ep => ep.id == episodeId) } + + // Audio file metadata tags map to podcast details + setMetadataFromAudioFile(overrideExistingDetails = false) { + if (!this.episodes.length) return false + const audioFile = this.episodes[0].audioFile + if (!audioFile?.metaTags) return false + return this.metadata.setDataFromAudioMetaTags(audioFile.metaTags, overrideExistingDetails) + } } module.exports = Podcast diff --git a/server/objects/metadata/AudioMetaTags.js b/server/objects/metadata/AudioMetaTags.js index caeb2ce6..3e032c32 100644 --- a/server/objects/metadata/AudioMetaTags.js +++ b/server/objects/metadata/AudioMetaTags.js @@ -1,9 +1,12 @@ class AudioMetaTags { constructor(metadata) { this.tagAlbum = null + this.tagAlbumSort = null this.tagArtist = null + this.tagArtistSort = null this.tagGenre = null this.tagTitle = null + this.tagTitleSort = null this.tagSeries = null this.tagSeriesPart = null this.tagTrack = null @@ -20,6 +23,9 @@ class AudioMetaTags { this.tagIsbn = null this.tagLanguage = null this.tagASIN = null + this.tagItunesId = null + this.tagPodcastType = null + this.tagEpisodeType = null this.tagOverdriveMediaMarker = null this.tagOriginalYear = null this.tagReleaseCountry = null @@ -94,9 +100,12 @@ class AudioMetaTags { construct(metadata) { this.tagAlbum = metadata.tagAlbum || null + this.tagAlbumSort = metadata.tagAlbumSort || null this.tagArtist = metadata.tagArtist || null + this.tagArtistSort = metadata.tagArtistSort || null this.tagGenre = metadata.tagGenre || null this.tagTitle = metadata.tagTitle || null + this.tagTitleSort = metadata.tagTitleSort || null this.tagSeries = metadata.tagSeries || null this.tagSeriesPart = metadata.tagSeriesPart || null this.tagTrack = metadata.tagTrack || null @@ -113,6 +122,9 @@ class AudioMetaTags { this.tagIsbn = metadata.tagIsbn || null this.tagLanguage = metadata.tagLanguage || null this.tagASIN = metadata.tagASIN || null + this.tagItunesId = metadata.tagItunesId || null + this.tagPodcastType = metadata.tagPodcastType || null + this.tagEpisodeType = metadata.tagEpisodeType || null this.tagOverdriveMediaMarker = metadata.tagOverdriveMediaMarker || null this.tagOriginalYear = metadata.tagOriginalYear || null this.tagReleaseCountry = metadata.tagReleaseCountry || null @@ -128,9 +140,12 @@ class AudioMetaTags { // Data parsed in prober.js setData(payload) { this.tagAlbum = payload.file_tag_album || null + this.tagAlbumSort = payload.file_tag_albumsort || null this.tagArtist = payload.file_tag_artist || null + this.tagArtistSort = payload.file_tag_artistsort || null this.tagGenre = payload.file_tag_genre || null this.tagTitle = payload.file_tag_title || null + this.tagTitleSort = payload.file_tag_titlesort || null this.tagSeries = payload.file_tag_series || null this.tagSeriesPart = payload.file_tag_seriespart || null this.tagTrack = payload.file_tag_track || null @@ -147,6 +162,9 @@ class AudioMetaTags { this.tagIsbn = payload.file_tag_isbn || null this.tagLanguage = payload.file_tag_language || null this.tagASIN = payload.file_tag_asin || null + this.tagItunesId = payload.file_tag_itunesid || null + this.tagPodcastType = payload.file_tag_podcasttype || null + this.tagEpisodeType = payload.file_tag_episodetype || null this.tagOverdriveMediaMarker = payload.file_tag_overdrive_media_marker || null this.tagOriginalYear = payload.file_tag_originalyear || null this.tagReleaseCountry = payload.file_tag_releasecountry || null @@ -166,9 +184,12 @@ class AudioMetaTags { updateData(payload) { const dataMap = { tagAlbum: payload.file_tag_album || null, + tagAlbumSort: payload.file_tag_albumsort || null, tagArtist: payload.file_tag_artist || null, + tagArtistSort: payload.file_tag_artistsort || null, tagGenre: payload.file_tag_genre || null, tagTitle: payload.file_tag_title || null, + tagTitleSort: payload.file_tag_titlesort || null, tagSeries: payload.file_tag_series || null, tagSeriesPart: payload.file_tag_seriespart || null, tagTrack: payload.file_tag_track || null, @@ -185,6 +206,9 @@ class AudioMetaTags { tagIsbn: payload.file_tag_isbn || null, tagLanguage: payload.file_tag_language || null, tagASIN: payload.file_tag_asin || null, + tagItunesId: payload.file_tag_itunesid || null, + tagPodcastType: payload.file_tag_podcasttype || null, + tagEpisodeType: payload.file_tag_episodetype || null, tagOverdriveMediaMarker: payload.file_tag_overdrive_media_marker || null, tagOriginalYear: payload.file_tag_originalyear || null, tagReleaseCountry: payload.file_tag_releasecountry || null, diff --git a/server/objects/metadata/PodcastMetadata.js b/server/objects/metadata/PodcastMetadata.js index 59b5fa6f..2c371c6c 100644 --- a/server/objects/metadata/PodcastMetadata.js +++ b/server/objects/metadata/PodcastMetadata.js @@ -136,5 +136,74 @@ class PodcastMetadata { } return hasUpdates } + + setDataFromAudioMetaTags(audioFileMetaTags, overrideExistingDetails = false) { + const MetadataMapArray = [ + { + tag: 'tagAlbum', + altTag: 'tagSeries', + key: 'title' + }, + { + tag: 'tagArtist', + key: 'author' + }, + { + tag: 'tagGenre', + key: 'genres' + }, + { + tag: 'tagLanguage', + key: 'language' + }, + { + tag: 'tagItunesId', + key: 'itunesId' + }, + { + tag: 'tagPodcastType', + key: 'type', + } + ] + + const updatePayload = {} + + MetadataMapArray.forEach((mapping) => { + let value = audioFileMetaTags[mapping.tag] + let tagToUse = mapping.tag + if (!value && mapping.altTag) { + value = audioFileMetaTags[mapping.altTag] + tagToUse = mapping.altTag + } + + if (value && typeof value === 'string') { + value = value.trim() // Trim whitespace + + if (mapping.key === 'genres' && (!this.genres.length || overrideExistingDetails)) { + updatePayload.genres = this.parseGenresTag(value) + Logger.debug(`[Podcast] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${updatePayload.genres.join(', ')}`) + } else if (!this[mapping.key] || overrideExistingDetails) { + updatePayload[mapping.key] = value + Logger.debug(`[Podcast] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${updatePayload[mapping.key]}`) + } + } + }) + + if (Object.keys(updatePayload).length) { + return this.update(updatePayload) + } + return false + } + + parseGenresTag(genreTag) { + if (!genreTag || !genreTag.length) return [] + const separators = ['/', '//', ';'] + for (let i = 0; i < separators.length; i++) { + if (genreTag.includes(separators[i])) { + return genreTag.split(separators[i]).map(genre => genre.trim()).filter(g => !!g) + } + } + return [genreTag] + } } module.exports = PodcastMetadata diff --git a/server/scanner/MediaFileScanner.js b/server/scanner/MediaFileScanner.js index a10b949c..adf2098b 100644 --- a/server/scanner/MediaFileScanner.js +++ b/server/scanner/MediaFileScanner.js @@ -296,11 +296,17 @@ class MediaFileScanner { // Update audio file metadata for audio files already there existingAudioFiles.forEach((af) => { - const peAudioFile = libraryItem.media.findFileWithInode(af.ino) - if (peAudioFile.updateFromScan && peAudioFile.updateFromScan(af)) { + const podcastEpisode = libraryItem.media.findEpisodeWithInode(af.ino) + if (podcastEpisode?.audioFile.updateFromScan(af)) { hasUpdated = true + + podcastEpisode.setDataFromAudioMetaTags(podcastEpisode.audioFile.metaTags, false) } }) + + if (libraryItem.media.setMetadataFromAudioFile(preferAudioMetadata)) { + hasUpdated = true + } } else if (libraryItem.mediaType === 'music') { // Music // Only one audio file in library item if (newAudioFiles.length) { // New audio file diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js index e17a94dd..dc16430e 100644 --- a/server/utils/ffmpegHelpers.js +++ b/server/utils/ffmpegHelpers.js @@ -111,7 +111,7 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => { '-metadata', `comment=${podcastEpisodeDownload.podcastEpisode?.description ?? ""}`, // Episode Description '-metadata', - `description=${podcastEpisodeDownload.podcastEpisode?.subtitle ?? ""}`, // Episode Subtitle + `subtitle=${podcastEpisodeDownload.podcastEpisode?.subtitle ?? ""}`, // Episode Subtitle '-metadata', `disc=${podcastEpisodeDownload.podcastEpisode?.season ?? ""}`, // Episode Season '-metadata', diff --git a/server/utils/prober.js b/server/utils/prober.js index 92df3f01..bc0f55cc 100644 --- a/server/utils/prober.js +++ b/server/utils/prober.js @@ -167,11 +167,14 @@ function parseTags(format, verbose) { file_tag_encoder: tryGrabTags(format, 'encoder', 'tsse', 'tss'), file_tag_encodedby: tryGrabTags(format, 'encoded_by', 'tenc', 'ten'), file_tag_title: tryGrabTags(format, 'title', 'tit2', 'tt2'), + file_tag_titlesort: tryGrabTags(format, 'title-sort', 'tsot'), file_tag_subtitle: tryGrabTags(format, 'subtitle', 'tit3', 'tt3'), file_tag_track: tryGrabTags(format, 'track', 'trck', 'trk'), file_tag_disc: tryGrabTags(format, 'discnumber', 'disc', 'disk', 'tpos'), file_tag_album: tryGrabTags(format, 'album', 'talb', 'tal'), + file_tag_albumsort: tryGrabTags(format, 'album-sort', 'tsoa'), file_tag_artist: tryGrabTags(format, 'artist', 'tpe1', 'tp1'), + file_tag_artistsort: tryGrabTags(format, 'artist-sort', 'tsop'), file_tag_albumartist: tryGrabTags(format, 'albumartist', 'album_artist', 'tpe2'), file_tag_date: tryGrabTags(format, 'date', 'tyer', 'tye'), file_tag_composer: tryGrabTags(format, 'composer', 'tcom', 'tcm'), @@ -181,9 +184,12 @@ function parseTags(format, verbose) { file_tag_genre: tryGrabTags(format, 'genre', 'tcon', 'tco'), file_tag_series: tryGrabTags(format, 'series', 'show', 'mvnm'), file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id', 'mvin'), - file_tag_isbn: tryGrabTags(format, 'isbn'), + file_tag_isbn: tryGrabTags(format, 'isbn'), // custom file_tag_language: tryGrabTags(format, 'language', 'lang'), - file_tag_asin: tryGrabTags(format, 'asin'), + file_tag_asin: tryGrabTags(format, 'asin'), // custom + file_tag_itunesid: tryGrabTags(format, 'itunes-id'), // custom + file_tag_podcasttype: tryGrabTags(format, 'podcast-type'), // custom + file_tag_episodetype: tryGrabTags(format, 'episode-type'), // custom file_tag_originalyear: tryGrabTags(format, 'originalyear'), file_tag_releasecountry: tryGrabTags(format, 'MusicBrainz Album Release Country', 'releasecountry'), file_tag_releasestatus: tryGrabTags(format, 'MusicBrainz Album Status', 'releasestatus', 'musicbrainz_albumstatus'),