diff --git a/server/objects/metadata/AudioMetaTags.js b/server/objects/metadata/AudioMetaTags.js index 404c7483..78c9d92a 100644 --- a/server/objects/metadata/AudioMetaTags.js +++ b/server/objects/metadata/AudioMetaTags.js @@ -9,6 +9,7 @@ class AudioMetaTags { this.tagTitleSort = null this.tagSeries = null this.tagSeriesPart = null + this.tagGrouping = null this.tagTrack = null this.tagDisc = null this.tagSubtitle = null @@ -116,6 +117,7 @@ class AudioMetaTags { this.tagTitleSort = metadata.tagTitleSort || null this.tagSeries = metadata.tagSeries || null this.tagSeriesPart = metadata.tagSeriesPart || null + this.tagGrouping = metadata.tagGrouping || null this.tagTrack = metadata.tagTrack || null this.tagDisc = metadata.tagDisc || null this.tagSubtitle = metadata.tagSubtitle || null @@ -156,6 +158,7 @@ class AudioMetaTags { this.tagTitleSort = payload.file_tag_titlesort || null this.tagSeries = payload.file_tag_series || null this.tagSeriesPart = payload.file_tag_seriespart || null + this.tagGrouping = payload.file_tag_grouping || null this.tagTrack = payload.file_tag_track || null this.tagDisc = payload.file_tag_disc || null this.tagSubtitle = payload.file_tag_subtitle || null @@ -196,6 +199,7 @@ class AudioMetaTags { tagTitleSort: payload.file_tag_titlesort || null, tagSeries: payload.file_tag_series || null, tagSeriesPart: payload.file_tag_seriespart || null, + tagGrouping: payload.file_tag_grouping || null, tagTrack: payload.file_tag_track || null, tagDisc: payload.file_tag_disc || null, tagSubtitle: payload.file_tag_subtitle || null, diff --git a/server/scanner/AudioFileScanner.js b/server/scanner/AudioFileScanner.js index 2a70e6a0..3c364c10 100644 --- a/server/scanner/AudioFileScanner.js +++ b/server/scanner/AudioFileScanner.js @@ -4,6 +4,7 @@ const prober = require('../utils/prober') const { LogLevel } = require('../utils/constants') const { parseOverdriveMediaMarkersAsChapters } = require('../utils/parsers/parseOverdriveMediaMarkers') const parseNameString = require('../utils/parsers/parseNameString') +const parseSeriesString = require('../utils/parsers/parseSeriesString') const LibraryItem = require('../models/LibraryItem') const AudioFile = require('../objects/files/AudioFile') @@ -256,6 +257,7 @@ class AudioFileScanner { }, { tag: 'tagSeries', + altTag: 'tagGrouping', key: 'series' }, { @@ -276,8 +278,10 @@ class AudioFileScanner { const audioFileMetaTags = firstScannedFile.metaTags MetadataMapArray.forEach((mapping) => { let value = audioFileMetaTags[mapping.tag] + let isAltTag = false if (!value && mapping.altTag) { value = audioFileMetaTags[mapping.altTag] + isAltTag = true } if (value && typeof value === 'string') { @@ -290,12 +294,28 @@ class AudioFileScanner { } else if (mapping.key === 'genres') { bookMetadata.genres = this.parseGenresString(value) } else if (mapping.key === 'series') { - bookMetadata.series = [ - { - name: value, - sequence: audioFileMetaTags.tagSeriesPart || null + // If series was embedded in the grouping tag, then parse it with semicolon separator and sequence in the same string + // e.g. "Test Series; Series Name #1; Other Series #2" + if (isAltTag) { + const series = value + .split(';') + .map((seriesWithPart) => { + seriesWithPart = seriesWithPart.trim() + return parseSeriesString.parse(seriesWithPart) + }) + .filter(Boolean) + if (series.length) { + bookMetadata.series = series } - ] + } else { + // Original embed used "series" and "series-part" tags + bookMetadata.series = [ + { + name: value, + sequence: audioFileMetaTags.tagSeriesPart || null + } + ] + } } else { bookMetadata[mapping.key] = value } diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js index c19ec07a..c7024225 100644 --- a/server/utils/ffmpegHelpers.js +++ b/server/utils/ffmpegHelpers.js @@ -380,9 +380,8 @@ function getFFMetadataObject(libraryItem, audioFilesLength) { copyright: metadata.publisher, publisher: metadata.publisher, // mp3 only TRACKTOTAL: `${audioFilesLength}`, // mp3 only - grouping: metadata.series?.map((s) => s.name + (s.sequence ? ` #${s.sequence}` : '')).join(', ') + grouping: metadata.series?.map((s) => s.name + (s.sequence ? ` #${s.sequence}` : '')).join('; ') } - Object.keys(ffmetadata).forEach((key) => { if (!ffmetadata[key]) { delete ffmetadata[key] diff --git a/server/utils/generators/abmetadataGenerator.js b/server/utils/generators/abmetadataGenerator.js index e0b78d2e..01f85328 100644 --- a/server/utils/generators/abmetadataGenerator.js +++ b/server/utils/generators/abmetadataGenerator.js @@ -1,4 +1,5 @@ const Logger = require('../../Logger') +const parseSeriesString = require('../parsers/parseSeriesString') function parseJsonMetadataText(text) { try { @@ -19,39 +20,25 @@ function parseJsonMetadataText(text) { delete abmetadataData.metadata if (abmetadataData.series?.length) { - abmetadataData.series = [...new Set(abmetadataData.series.map(t => t?.trim()).filter(t => t))] - abmetadataData.series = abmetadataData.series.map(series => { - let sequence = null - let name = series - // Series sequence match any characters after " #" other than whitespace and another # - // e.g. "Name #1a" is valid. "Name #1#a" or "Name #1 a" is not valid. - const matchResults = series.match(/ #([^#\s]+)$/) // Pull out sequence # - if (matchResults && matchResults.length && matchResults.length > 1) { - sequence = matchResults[1] // Group 1 - name = series.replace(matchResults[0], '') - } - return { - name, - sequence - } - }) + abmetadataData.series = [...new Set(abmetadataData.series.map((t) => t?.trim()).filter((t) => t))] + abmetadataData.series = abmetadataData.series.map((series) => parseSeriesString.parse(series)) } // clean tags & remove dupes if (abmetadataData.tags?.length) { - abmetadataData.tags = [...new Set(abmetadataData.tags.map(t => t?.trim()).filter(t => t))] + abmetadataData.tags = [...new Set(abmetadataData.tags.map((t) => t?.trim()).filter((t) => t))] } if (abmetadataData.chapters?.length) { abmetadataData.chapters = cleanChaptersArray(abmetadataData.chapters, abmetadataData.title) } // clean remove dupes if (abmetadataData.authors?.length) { - abmetadataData.authors = [...new Set(abmetadataData.authors.map(t => t?.trim()).filter(t => t))] + abmetadataData.authors = [...new Set(abmetadataData.authors.map((t) => t?.trim()).filter((t) => t))] } if (abmetadataData.narrators?.length) { - abmetadataData.narrators = [...new Set(abmetadataData.narrators.map(t => t?.trim()).filter(t => t))] + abmetadataData.narrators = [...new Set(abmetadataData.narrators.map((t) => t?.trim()).filter((t) => t))] } if (abmetadataData.genres?.length) { - abmetadataData.genres = [...new Set(abmetadataData.genres.map(t => t?.trim()).filter(t => t))] + abmetadataData.genres = [...new Set(abmetadataData.genres.map((t) => t?.trim()).filter((t) => t))] } return abmetadataData } catch (error) { diff --git a/server/utils/parsers/parseSeriesString.js b/server/utils/parsers/parseSeriesString.js new file mode 100644 index 00000000..ed5f00e3 --- /dev/null +++ b/server/utils/parsers/parseSeriesString.js @@ -0,0 +1,27 @@ +/** + * Parse a series string into a name and sequence + * + * @example + * Name #1a => { name: 'Name', sequence: '1a' } + * Name #1 => { name: 'Name', sequence: '1' } + * + * @param {string} seriesString + * @returns {{name: string, sequence: string}|null} + */ +module.exports.parse = (seriesString) => { + if (!seriesString || typeof seriesString !== 'string') return null + + let sequence = null + let name = seriesString + // Series sequence match any characters after " #" other than whitespace and another # + // e.g. "Name #1a" is valid. "Name #1#a" or "Name #1 a" is not valid. + const matchResults = seriesString.match(/ #([^#\s]+)$/) // Pull out sequence # + if (matchResults && matchResults.length && matchResults.length > 1) { + sequence = matchResults[1] // Group 1 + name = seriesString.replace(matchResults[0], '') + } + return { + name, + sequence + } +} diff --git a/server/utils/prober.js b/server/utils/prober.js index 9b4d34e9..b54b981d 100644 --- a/server/utils/prober.js +++ b/server/utils/prober.js @@ -189,6 +189,7 @@ 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', 'part'), + file_tag_grouping: tryGrabTags(format, 'grouping'), file_tag_isbn: tryGrabTags(format, 'isbn'), // custom file_tag_language: tryGrabTags(format, 'language', 'lang'), file_tag_asin: tryGrabTags(format, 'asin', 'audible_asin'), // custom