2023-08-27 17:19:57 -05:00
const Path = require ( 'path' )
const Logger = require ( '../Logger' )
const prober = require ( '../utils/prober' )
2023-10-08 17:10:43 -05:00
const { LogLevel } = require ( '../utils/constants' )
const { parseOverdriveMediaMarkersAsChapters } = require ( '../utils/parsers/parseOverdriveMediaMarkers' )
const parseNameString = require ( '../utils/parsers/parseNameString' )
2024-10-20 16:58:13 -05:00
const parseSeriesString = require ( '../utils/parsers/parseSeriesString' )
2023-08-27 17:19:57 -05:00
const LibraryItem = require ( '../models/LibraryItem' )
const AudioFile = require ( '../objects/files/AudioFile' )
2023-08-26 16:33:27 -05:00
class AudioFileScanner {
2024-05-24 16:49:39 -05:00
constructor ( ) { }
2023-08-26 16:33:27 -05:00
2023-08-27 17:19:57 -05:00
/ * *
* Is array of numbers sequential , i . e . 1 , 2 , 3 , 4
2024-05-24 16:49:39 -05:00
* @ param { number [ ] } nums
2023-08-27 17:19:57 -05:00
* @ returns { boolean }
* /
isSequential ( nums ) {
if ( ! nums ? . length ) return false
if ( nums . length === 1 ) return true
let prev = nums [ 0 ]
for ( let i = 1 ; i < nums . length ; i ++ ) {
if ( nums [ i ] - prev > 1 ) return false
prev = nums [ i ]
}
return true
}
/ * *
2024-05-24 16:49:39 -05:00
* Remove
* @ param { number [ ] } nums
2023-08-27 17:19:57 -05:00
* @ returns { number [ ] }
* /
removeDupes ( nums ) {
if ( ! nums || ! nums . length ) return [ ]
if ( nums . length === 1 ) return nums
let nodupes = [ nums [ 0 ] ]
nums . forEach ( ( num ) => {
if ( num > nodupes [ nodupes . length - 1 ] ) nodupes . push ( num )
} )
return nodupes
}
/ * *
* Order audio files by track / disc number
2024-05-24 16:49:39 -05:00
* @ param { string } libraryItemRelPath
* @ param { import ( '../models/Book' ) . AudioFileObject [ ] } audioFiles
2023-08-27 17:19:57 -05:00
* @ returns { import ( '../models/Book' ) . AudioFileObject [ ] }
* /
2023-09-01 18:01:17 -05:00
runSmartTrackOrder ( libraryItemRelPath , audioFiles ) {
if ( ! audioFiles . length ) return [ ]
2023-08-27 17:19:57 -05:00
let discsFromFilename = [ ]
let tracksFromFilename = [ ]
let discsFromMeta = [ ]
let tracksFromMeta = [ ]
audioFiles . forEach ( ( af ) => {
if ( af . discNumFromFilename !== null ) discsFromFilename . push ( af . discNumFromFilename )
if ( af . discNumFromMeta !== null ) discsFromMeta . push ( af . discNumFromMeta )
if ( af . trackNumFromFilename !== null ) tracksFromFilename . push ( af . trackNumFromFilename )
if ( af . trackNumFromMeta !== null ) tracksFromMeta . push ( af . trackNumFromMeta )
} )
discsFromFilename . sort ( ( a , b ) => a - b )
discsFromMeta . sort ( ( a , b ) => a - b )
tracksFromFilename . sort ( ( a , b ) => a - b )
tracksFromMeta . sort ( ( a , b ) => a - b )
let discKey = null
if ( discsFromMeta . length === audioFiles . length && this . isSequential ( discsFromMeta ) ) {
discKey = 'discNumFromMeta'
} else if ( discsFromFilename . length === audioFiles . length && this . isSequential ( discsFromFilename ) ) {
discKey = 'discNumFromFilename'
}
let trackKey = null
tracksFromFilename = this . removeDupes ( tracksFromFilename )
tracksFromMeta = this . removeDupes ( tracksFromMeta )
if ( tracksFromFilename . length > tracksFromMeta . length ) {
trackKey = 'trackNumFromFilename'
} else {
trackKey = 'trackNumFromMeta'
}
if ( discKey !== null ) {
2023-09-01 18:01:17 -05:00
Logger . debug ( ` [AudioFileScanner] Smart track order for " ${ libraryItemRelPath } " using disc key ${ discKey } and track key ${ trackKey } ` )
2023-08-27 17:19:57 -05:00
audioFiles . sort ( ( a , b ) => {
let Dx = a [ discKey ] - b [ discKey ]
if ( Dx === 0 ) Dx = a [ trackKey ] - b [ trackKey ]
return Dx
} )
} else {
2023-09-01 18:01:17 -05:00
Logger . debug ( ` [AudioFileScanner] Smart track order for " ${ libraryItemRelPath } " using track key ${ trackKey } ` )
2023-08-27 17:19:57 -05:00
audioFiles . sort ( ( a , b ) => a [ trackKey ] - b [ trackKey ] )
}
for ( let i = 0 ; i < audioFiles . length ; i ++ ) {
audioFiles [ i ] . index = i + 1
}
return audioFiles
}
/ * *
* Get track and disc number from audio filename
2024-05-24 16:49:39 -05:00
* @ param { { title : string , subtitle : string , series : string , sequence : string , publishedYear : string , narrators : string } } mediaMetadataFromScan
* @ param { LibraryItem . LibraryFileObject } audioLibraryFile
2023-08-27 17:19:57 -05:00
* @ returns { { trackNumber : number , discNumber : number } }
* /
getTrackAndDiscNumberFromFilename ( mediaMetadataFromScan , audioLibraryFile ) {
const { title , author , series , publishedYear } = mediaMetadataFromScan
const { filename , path } = audioLibraryFile . metadata
let partbasename = Path . basename ( filename , Path . extname ( filename ) )
// Remove title, author, series, and publishedYear from filename if there
if ( title ) partbasename = partbasename . replace ( title , '' )
if ( author ) partbasename = partbasename . replace ( author , '' )
if ( series ) partbasename = partbasename . replace ( series , '' )
if ( publishedYear ) partbasename = partbasename . replace ( publishedYear )
// Look for disc number
let discNumber = null
const discMatch = partbasename . match ( /\b(disc|cd) ?(\d\d?)\b/i )
if ( discMatch && discMatch . length > 2 && discMatch [ 2 ] ) {
if ( ! isNaN ( discMatch [ 2 ] ) ) {
discNumber = Number ( discMatch [ 2 ] )
}
// Remove disc number from filename
partbasename = partbasename . replace ( /\b(disc|cd) ?(\d\d?)\b/i , '' )
}
// Look for disc number in folder path e.g. /Book Title/CD01/audiofile.mp3
const pathdir = Path . dirname ( path ) . split ( '/' ) . pop ( )
2024-12-01 23:57:47 -08:00
if ( pathdir && /^(cd|dis[ck])\s*\d{1,3}$/i . test ( pathdir ) ) {
const discFromFolder = Number ( pathdir . replace ( /^(cd|dis[ck])\s*/i , '' ) )
2023-08-27 17:19:57 -05:00
if ( ! isNaN ( discFromFolder ) && discFromFolder !== null ) discNumber = discFromFolder
}
const numbersinpath = partbasename . match ( /\d{1,4}/g )
const trackNumber = numbersinpath && numbersinpath . length ? parseInt ( numbersinpath [ 0 ] ) : null
return {
trackNumber ,
discNumber
}
}
/ * *
2024-05-24 16:49:39 -05:00
*
* @ param { string } mediaType
* @ param { LibraryItem . LibraryFileObject } libraryFile
* @ param { { title : string , subtitle : string , series : string , sequence : string , publishedYear : string , narrators : string } } mediaMetadataFromScan
2023-08-27 17:19:57 -05:00
* @ returns { Promise < AudioFile > }
* /
async scan ( mediaType , libraryFile , mediaMetadataFromScan ) {
const probeData = await prober . probe ( libraryFile . metadata . path )
if ( probeData . error ) {
2023-09-04 13:59:37 -05:00
Logger . error ( ` [AudioFileScanner] ${ probeData . error } : " ${ libraryFile . metadata . path } " ` )
2023-08-27 17:19:57 -05:00
return null
}
if ( ! probeData . audioStream ) {
2023-09-04 13:59:37 -05:00
Logger . error ( '[AudioFileScanner] Invalid audio file no audio stream' )
2023-08-27 17:19:57 -05:00
return null
}
const audioFile = new AudioFile ( )
audioFile . trackNumFromMeta = probeData . audioMetaTags . trackNumber
audioFile . discNumFromMeta = probeData . audioMetaTags . discNumber
if ( mediaType === 'book' ) {
const { trackNumber , discNumber } = this . getTrackAndDiscNumberFromFilename ( mediaMetadataFromScan , libraryFile )
audioFile . trackNumFromFilename = trackNumber
audioFile . discNumFromFilename = discNumber
}
audioFile . setDataFromProbe ( libraryFile , probeData )
return audioFile
}
/ * *
* Scan LibraryFiles and return AudioFiles
* @ param { string } mediaType
2024-05-24 16:49:39 -05:00
* @ param { import ( './LibraryItemScanData' ) } libraryItemScanData
2023-08-27 17:19:57 -05:00
* @ param { LibraryItem . LibraryFileObject [ ] } audioLibraryFiles
* @ returns { Promise < AudioFile [ ] > }
* /
async executeMediaFileScans ( mediaType , libraryItemScanData , audioLibraryFiles ) {
const batchSize = 32
const results = [ ]
for ( let batch = 0 ; batch < audioLibraryFiles . length ; batch += batchSize ) {
const proms = [ ]
for ( let i = batch ; i < Math . min ( batch + batchSize , audioLibraryFiles . length ) ; i ++ ) {
proms . push ( this . scan ( mediaType , audioLibraryFiles [ i ] , libraryItemScanData . mediaMetadata ) )
}
2024-05-24 16:49:39 -05:00
results . push ( ... ( await Promise . all ( proms ) . then ( ( scanResults ) => scanResults . filter ( ( sr ) => sr ) ) ) )
2023-08-27 17:19:57 -05:00
}
2023-08-26 16:33:27 -05:00
2023-08-27 17:19:57 -05:00
return results
}
2023-09-04 13:59:37 -05:00
/ * *
2024-05-24 16:49:39 -05:00
*
2025-01-02 15:42:52 -06:00
* @ param { string } audioFilePath
2023-09-04 13:59:37 -05:00
* @ returns { object }
* /
2025-01-02 15:42:52 -06:00
probeAudioFile ( audioFilePath ) {
Logger . debug ( ` [AudioFileScanner] Running ffprobe for audio file at " ${ audioFilePath } " ` )
return prober . rawProbe ( audioFilePath )
2023-09-04 13:59:37 -05:00
}
2023-10-08 17:10:43 -05:00
/ * *
* Set book metadata & chapters from audio file meta tags
2024-05-24 16:49:39 -05:00
*
2023-10-08 17:10:43 -05:00
* @ param { string } bookTitle
2024-05-24 16:49:39 -05:00
* @ param { import ( '../models/Book' ) . AudioFileObject } audioFile
* @ param { Object } bookMetadata
2023-10-09 16:41:43 -05:00
* @ param { import ( './LibraryScan' ) } libraryScan
2023-10-08 17:10:43 -05:00
* /
setBookMetadataFromAudioMetaTags ( bookTitle , audioFiles , bookMetadata , libraryScan ) {
const MetadataMapArray = [
{
tag : 'tagComposer' ,
key : 'narrators'
} ,
{
tag : 'tagDescription' ,
altTag : 'tagComment' ,
key : 'description'
} ,
{
tag : 'tagPublisher' ,
key : 'publisher'
} ,
{
tag : 'tagDate' ,
key : 'publishedYear'
} ,
{
tag : 'tagSubtitle' ,
key : 'subtitle'
} ,
{
tag : 'tagAlbum' ,
altTag : 'tagTitle' ,
2024-05-24 16:49:39 -05:00
key : 'title'
2023-10-08 17:10:43 -05:00
} ,
{
tag : 'tagArtist' ,
altTag : 'tagAlbumArtist' ,
key : 'authors'
} ,
{
tag : 'tagGenre' ,
key : 'genres'
} ,
{
tag : 'tagSeries' ,
2024-10-20 16:58:13 -05:00
altTag : 'tagGrouping' ,
2023-10-08 17:10:43 -05:00
key : 'series'
} ,
{
tag : 'tagIsbn' ,
key : 'isbn'
} ,
{
tag : 'tagLanguage' ,
key : 'language'
} ,
{
tag : 'tagASIN' ,
key : 'asin'
}
]
const firstScannedFile = audioFiles [ 0 ]
const audioFileMetaTags = firstScannedFile . metaTags
MetadataMapArray . forEach ( ( mapping ) => {
let value = audioFileMetaTags [ mapping . tag ]
2024-10-20 16:58:13 -05:00
let isAltTag = false
2023-10-08 17:10:43 -05:00
if ( ! value && mapping . altTag ) {
value = audioFileMetaTags [ mapping . altTag ]
2024-10-20 16:58:13 -05:00
isAltTag = true
2023-10-08 17:10:43 -05:00
}
if ( value && typeof value === 'string' ) {
value = value . trim ( ) // Trim whitespace
if ( mapping . key === 'narrators' ) {
bookMetadata . narrators = parseNameString . parse ( value ) ? . names || [ ]
} else if ( mapping . key === 'authors' ) {
bookMetadata . authors = parseNameString . parse ( value ) ? . names || [ ]
} else if ( mapping . key === 'genres' ) {
bookMetadata . genres = this . parseGenresString ( value )
} else if ( mapping . key === 'series' ) {
2024-10-20 16:58:13 -05:00
// 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
2023-10-08 17:10:43 -05:00
}
2024-10-20 16:58:13 -05:00
} else {
2025-03-21 17:53:17 -05:00
// Detect if multiple series are in the series & series-part tags.
// Note: This requires that every series has a sequence and that they are separated by a semicolon.
if ( value . includes ( ';' ) && audioFileMetaTags . tagSeriesPart ? . includes ( ';' ) ) {
const seriesSplit = value
. split ( ';' )
. map ( ( s ) => s . trim ( ) )
. filter ( Boolean )
const seriesSequenceSplit = audioFileMetaTags . tagSeriesPart
. split ( ';' )
. map ( ( s ) => s . trim ( ) )
. filter ( Boolean )
if ( seriesSplit . length > 1 && seriesSplit . length === seriesSequenceSplit . length ) {
bookMetadata . series = seriesSplit . map ( ( series , index ) => ( {
name : series ,
sequence : seriesSequenceSplit [ index ] || null
} ) )
libraryScan . addLog ( LogLevel . DEBUG , ` Detected multiple series in series/series-part tags: ${ bookMetadata . series . map ( ( s ) => ` ${ s . name } # ${ s . sequence } ` ) . join ( ', ' ) } ` )
return
}
}
2024-10-20 16:58:13 -05:00
// Original embed used "series" and "series-part" tags
bookMetadata . series = [
{
name : value ,
sequence : audioFileMetaTags . tagSeriesPart || null
}
]
}
2023-10-08 17:10:43 -05:00
} else {
bookMetadata [ mapping . key ] = value
}
}
} )
// Set chapters
const chapters = this . getBookChaptersFromAudioFiles ( bookTitle , audioFiles , libraryScan )
if ( chapters . length ) {
bookMetadata . chapters = chapters
}
}
2023-10-09 16:41:43 -05:00
/ * *
* Set podcast metadata from first audio file
2024-05-24 16:49:39 -05:00
*
* @ param { import ( '../models/Book' ) . AudioFileObject } audioFile
* @ param { Object } podcastMetadata
2023-10-09 16:41:43 -05:00
* @ param { import ( './LibraryScan' ) } libraryScan
* /
setPodcastMetadataFromAudioMetaTags ( audioFile , podcastMetadata , libraryScan ) {
const audioFileMetaTags = audioFile . metaTags
const MetadataMapArray = [
{
tag : 'tagAlbum' ,
altTag : 'tagSeries' ,
key : 'title'
} ,
{
2024-08-20 16:41:17 -05:00
tag : 'tagAlbumArtist' ,
altTag : 'tagArtist' ,
2023-10-09 16:41:43 -05:00
key : 'author'
} ,
{
tag : 'tagGenre' ,
key : 'genres'
} ,
{
tag : 'tagLanguage' ,
key : 'language'
} ,
{
tag : 'tagItunesId' ,
key : 'itunesId'
} ,
{
tag : 'tagPodcastType' ,
2024-05-24 16:49:39 -05:00
key : 'podcastType'
2023-10-09 16:41:43 -05:00
}
]
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' ) {
podcastMetadata . genres = this . parseGenresString ( value )
libraryScan . addLog ( LogLevel . DEBUG , ` Mapping metadata to key ${ tagToUse } => ${ mapping . key } : ${ podcastMetadata . genres . join ( ', ' ) } ` )
} else {
podcastMetadata [ mapping . key ] = value
libraryScan . addLog ( LogLevel . DEBUG , ` Mapping metadata to key ${ tagToUse } => ${ mapping . key } : ${ podcastMetadata [ mapping . key ] } ` )
}
}
} )
}
/ * *
2024-05-24 16:49:39 -05:00
*
2023-10-09 16:41:43 -05:00
* @ param { import ( '../models/PodcastEpisode' ) } podcastEpisode Not the model when creating new podcast
* @ param { import ( './ScanLogger' ) } scanLogger
* /
setPodcastEpisodeMetadataFromAudioMetaTags ( podcastEpisode , scanLogger ) {
const MetadataMapArray = [
{
tag : 'tagComment' ,
2024-04-11 17:29:23 -05:00
altTag : 'tagDescription' ,
2023-10-09 16:41:43 -05:00
key : 'description'
} ,
{
tag : 'tagSubtitle' ,
key : 'subtitle'
} ,
{
tag : 'tagDate' ,
key : 'pubDate'
} ,
{
tag : 'tagDisc' ,
2024-05-24 16:49:39 -05:00
key : 'season'
2023-10-09 16:41:43 -05:00
} ,
{
tag : 'tagTrack' ,
altTag : 'tagSeriesPart' ,
key : 'episode'
} ,
{
tag : 'tagTitle' ,
key : 'title'
} ,
{
tag : 'tagEpisodeType' ,
key : 'episodeType'
}
]
const audioFileMetaTags = podcastEpisode . audioFile . metaTags
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' ) {
const pubJsDate = new Date ( value )
if ( pubJsDate && ! isNaN ( pubJsDate ) ) {
podcastEpisode . publishedAt = pubJsDate . valueOf ( )
podcastEpisode . pubDate = value
scanLogger . addLog ( LogLevel . DEBUG , ` Mapping metadata to key ${ tagToUse } => ${ mapping . key } : ${ podcastEpisode [ mapping . key ] } ` )
} else {
scanLogger . addLog ( LogLevel . WARN , ` Mapping pubDate with tag ${ tagToUse } has invalid date " ${ value } " ` )
}
} else if ( mapping . key === 'episodeType' ) {
if ( [ 'full' , 'trailer' , 'bonus' ] . includes ( value ) ) {
podcastEpisode . episodeType = value
scanLogger . addLog ( LogLevel . DEBUG , ` Mapping metadata to key ${ tagToUse } => ${ mapping . key } : ${ podcastEpisode [ mapping . key ] } ` )
} else {
scanLogger . addLog ( LogLevel . WARN , ` Mapping episodeType with invalid value " ${ value } ". Must be one of [full, trailer, bonus]. ` )
}
} else {
podcastEpisode [ mapping . key ] = value
scanLogger . addLog ( LogLevel . DEBUG , ` Mapping metadata to key ${ tagToUse } => ${ mapping . key } : ${ podcastEpisode [ mapping . key ] } ` )
}
}
} )
}
2023-10-08 17:10:43 -05:00
/ * *
* @ param { string } bookTitle
2024-05-24 16:49:39 -05:00
* @ param { AudioFile [ ] } audioFiles
2023-10-09 16:41:43 -05:00
* @ param { import ( './LibraryScan' ) } libraryScan
2023-10-08 17:10:43 -05:00
* @ returns { import ( '../models/Book' ) . ChapterObject [ ] }
* /
getBookChaptersFromAudioFiles ( bookTitle , audioFiles , libraryScan ) {
// If overdrive media markers are present then use those instead
const overdriveChapters = parseOverdriveMediaMarkersAsChapters ( audioFiles )
if ( overdriveChapters ? . length ) {
libraryScan . addLog ( LogLevel . DEBUG , 'Overdrive Media Markers and preference found! Using these for chapter definitions' )
return overdriveChapters
}
let chapters = [ ]
// If first audio file has embedded chapters then use embedded chapters
if ( audioFiles [ 0 ] . chapters ? . length ) {
// If all files chapters are the same, then only make chapters for the first file
2024-05-24 16:49:39 -05:00
if ( audioFiles . length === 1 || ( audioFiles . length > 1 && audioFiles [ 0 ] . chapters . length === audioFiles [ 1 ] . chapters ? . length && audioFiles [ 0 ] . chapters . every ( ( c , i ) => c . title === audioFiles [ 1 ] . chapters [ i ] . title && c . start === audioFiles [ 1 ] . chapters [ i ] . start ) ) ) {
2023-10-08 17:10:43 -05:00
libraryScan . addLog ( LogLevel . DEBUG , ` setChapters: Using embedded chapters in first audio file ${ audioFiles [ 0 ] . metadata ? . path } ` )
chapters = audioFiles [ 0 ] . chapters . map ( ( c ) => ( { ... c } ) )
} else {
libraryScan . addLog ( LogLevel . DEBUG , ` setChapters: Using embedded chapters from all audio files ${ audioFiles [ 0 ] . metadata ? . path } ` )
let currChapterId = 0
let currStartTime = 0
audioFiles . forEach ( ( file ) => {
if ( file . duration ) {
2024-09-24 10:54:25 -05:00
// Multi-file audiobook may include the previous and next chapters embedded with close to 0 duration
// Filter these out and log a warning
// See https://github.com/advplyr/audiobookshelf/issues/3361
const afChaptersCleaned =
2025-01-12 09:56:48 -06:00
file . chapters ? . filter ( ( c , i ) => {
2024-09-24 10:54:25 -05:00
if ( c . end - c . start < 0.1 ) {
2025-01-12 09:56:48 -06:00
libraryScan . addLog ( LogLevel . WARN , ` Audio file " ${ file . metadata . filename } " Chapter " ${ c . title } " (index ${ i } ) has invalid duration of ${ c . end - c . start } seconds. Skipping this chapter. ` )
2024-09-24 10:54:25 -05:00
return false
}
return true
} ) || [ ]
2025-01-12 09:56:48 -06:00
const afChapters = afChaptersCleaned . map ( ( c , i ) => ( {
2024-09-24 10:54:25 -05:00
... c ,
2025-01-12 09:56:48 -06:00
id : currChapterId + i ,
2024-09-24 10:54:25 -05:00
start : c . start + currStartTime ,
end : c . end + currStartTime
} ) )
2023-10-08 17:10:43 -05:00
chapters = chapters . concat ( afChapters )
2024-09-24 10:54:25 -05:00
currChapterId += afChaptersCleaned . length ? ? 0
2023-10-08 17:10:43 -05:00
currStartTime += file . duration
}
} )
return chapters
}
} else if ( audioFiles . length > 1 ) {
// In some cases the ID3 title tag for each file is the chapter title, the criteria to determine if this will be used
// 1. Every audio file has an ID3 title tag set
// 2. None of the title tags are the same as the book title
// 3. Every ID3 title tag is unique
2024-05-24 16:49:39 -05:00
const metaTagTitlesFound = [ ... new Set ( audioFiles . map ( ( af ) => af . metaTags ? . tagTitle ) . filter ( ( tagTitle ) => ! ! tagTitle && tagTitle !== bookTitle ) ) ]
2023-10-08 17:10:43 -05:00
const useMetaTagAsTitle = metaTagTitlesFound . length === audioFiles . length
// Build chapters from audio files
let currChapterId = 0
let currStartTime = 0
audioFiles . forEach ( ( file ) => {
if ( file . duration ) {
let title = file . metadata . filename ? Path . basename ( file . metadata . filename , Path . extname ( file . metadata . filename ) ) : ` Chapter ${ currChapterId } `
if ( useMetaTagAsTitle ) {
title = file . metaTags . tagTitle
}
chapters . push ( {
id : currChapterId ++ ,
start : currStartTime ,
end : currStartTime + file . duration ,
title
} )
currStartTime += file . duration
}
} )
}
return chapters
}
/ * *
* Parse a genre string into multiple genres
* @ example "Fantasy;Sci-Fi;History" => [ "Fantasy" , "Sci-Fi" , "History" ]
2024-05-24 16:49:39 -05:00
*
* @ param { string } genreTag
2023-10-08 17:10:43 -05:00
* @ returns { string [ ] }
* /
parseGenresString ( genreTag ) {
if ( ! genreTag ? . length ) return [ ]
const separators = [ '/' , '//' , ';' ]
for ( let i = 0 ; i < separators . length ; i ++ ) {
if ( genreTag . includes ( separators [ i ] ) ) {
2024-05-24 16:49:39 -05:00
return genreTag
. split ( separators [ i ] )
. map ( ( genre ) => genre . trim ( ) )
. filter ( ( g ) => ! ! g )
2023-10-08 17:10:43 -05:00
}
}
return [ genreTag ]
}
2023-08-26 16:33:27 -05:00
}
2024-05-24 16:49:39 -05:00
module . exports = new AudioFileScanner ( )