mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-11-03 19:07:00 -05:00 
			
		
		
		
	Add:Parsing meta tags from podcast episode audio file #1488
This commit is contained in:
		
							parent
							
								
									704fbaced8
								
							
						
					
					
						commit
						212b97fa20
					
				@ -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
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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,
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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',
 | 
			
		||||
 | 
			
		||||
@ -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'),
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user