2022-11-24 22:53:58 +01:00
const Logger = require ( '../Logger' )
const SocketAuthority = require ( '../SocketAuthority' )
2022-07-06 02:53:01 +02:00
const fs = require ( '../libs/fsExtra' )
2022-03-26 17:59:34 +01:00
2022-09-16 01:35:56 +02:00
const { getPodcastFeed } = require ( '../utils/podcastUtils' )
2023-04-01 23:31:04 +02:00
const { removeFile , downloadFile } = require ( '../utils/fileUtils' )
2022-09-30 23:55:31 +02:00
const filePerms = require ( '../utils/filePerms' )
2022-07-31 20:12:37 +02:00
const { levenshteinDistance } = require ( '../utils/index' )
2022-05-29 18:46:45 +02:00
const opmlParser = require ( '../utils/parsers/parseOPML' )
2023-05-28 22:10:34 +02:00
const opmlGenerator = require ( '../utils/generators/opmlGenerator' )
2022-03-22 01:24:38 +01:00
const prober = require ( '../utils/prober' )
2023-03-30 01:05:53 +02:00
const ffmpegHelpers = require ( '../utils/ffmpegHelpers' )
2022-11-24 22:53:58 +01:00
2022-03-22 01:24:38 +01:00
const LibraryFile = require ( '../objects/files/LibraryFile' )
const PodcastEpisodeDownload = require ( '../objects/PodcastEpisodeDownload' )
const PodcastEpisode = require ( '../objects/entities/PodcastEpisode' )
const AudioFile = require ( '../objects/files/AudioFile' )
2023-03-05 17:35:34 +01:00
const Task = require ( "../objects/Task" )
2022-03-22 01:24:38 +01:00
2022-03-20 22:41:06 +01:00
class PodcastManager {
2023-03-05 12:15:36 +01:00
constructor ( db , watcher , notificationManager , taskManager ) {
2022-03-20 22:41:06 +01:00
this . db = db
2022-03-22 01:24:38 +01:00
this . watcher = watcher
2022-09-21 01:08:41 +02:00
this . notificationManager = notificationManager
2023-03-05 12:15:36 +01:00
this . taskManager = taskManager
2022-03-20 22:41:06 +01:00
this . downloadQueue = [ ]
2022-03-22 01:24:38 +01:00
this . currentDownload = null
2022-03-26 17:59:34 +01:00
2022-08-20 01:41:58 +02:00
this . failedCheckMap = { }
this . MaxFailedEpisodeChecks = 24
2022-03-26 17:59:34 +01:00
}
2022-03-27 01:58:59 +01:00
get serverSettings ( ) {
return this . db . serverSettings || { }
}
2022-04-24 02:41:06 +02:00
getEpisodeDownloadsInQueue ( libraryItemId ) {
return this . downloadQueue . filter ( d => d . libraryItemId === libraryItemId )
}
clearDownloadQueue ( libraryItemId = null ) {
if ( ! this . downloadQueue . length ) return
if ( ! libraryItemId ) {
Logger . info ( ` [PodcastManager] Clearing all downloads in queue ( ${ this . downloadQueue . length } ) ` )
this . downloadQueue = [ ]
} else {
var itemDownloads = this . getEpisodeDownloadsInQueue ( libraryItemId )
Logger . info ( ` [PodcastManager] Clearing downloads in queue for item " ${ libraryItemId } " ( ${ itemDownloads . length } ) ` )
this . downloadQueue = this . downloadQueue . filter ( d => d . libraryItemId !== libraryItemId )
}
}
2022-08-16 00:35:13 +02:00
async downloadPodcastEpisodes ( libraryItem , episodesToDownload , isAutoDownload ) {
2023-04-09 21:32:51 +02:00
let index = libraryItem . media . episodes . length + 1
for ( const ep of episodesToDownload ) {
const newPe = new PodcastEpisode ( )
2022-03-22 01:24:38 +01:00
newPe . setData ( ep , index ++ )
2022-04-06 02:40:40 +02:00
newPe . libraryItemId = libraryItem . id
2023-04-09 21:32:51 +02:00
const newPeDl = new PodcastEpisodeDownload ( )
2023-02-27 03:56:07 +01:00
newPeDl . setData ( newPe , libraryItem , isAutoDownload , libraryItem . libraryId )
2023-03-05 17:35:34 +01:00
this . startPodcastEpisodeDownload ( newPeDl )
2023-04-09 21:32:51 +02:00
}
2022-03-20 22:41:06 +01:00
}
2023-03-05 17:35:34 +01:00
async startPodcastEpisodeDownload ( podcastEpisodeDownload ) {
SocketAuthority . emitter ( 'episode_download_queue_updated' , this . getDownloadQueueDetails ( ) )
2022-03-22 01:24:38 +01:00
if ( this . currentDownload ) {
this . downloadQueue . push ( podcastEpisodeDownload )
2022-11-24 22:53:58 +01:00
SocketAuthority . emitter ( 'episode_download_queued' , podcastEpisodeDownload . toJSONForClient ( ) )
2022-03-22 01:24:38 +01:00
return
}
2022-04-24 02:41:06 +02:00
2023-03-05 12:15:36 +01:00
const task = new Task ( )
const taskDescription = ` Downloading episode " ${ podcastEpisodeDownload . podcastEpisode . title } ". `
const taskData = {
2023-03-05 17:35:34 +01:00
libraryId : podcastEpisodeDownload . libraryId ,
libraryItemId : podcastEpisodeDownload . libraryItemId ,
2023-03-05 12:15:36 +01:00
}
2023-05-27 21:51:03 +02:00
task . setData ( 'download-podcast-episode' , 'Downloading Episode' , taskDescription , false , taskData )
2023-03-05 12:15:36 +01:00
this . taskManager . addTask ( task )
2022-11-24 22:53:58 +01:00
SocketAuthority . emitter ( 'episode_download_started' , podcastEpisodeDownload . toJSONForClient ( ) )
2022-03-22 01:24:38 +01:00
this . currentDownload = podcastEpisodeDownload
2023-05-28 18:24:51 +02:00
// If this file already exists then append the episode id to the filename
// e.g. "/tagesschau 20 Uhr.mp3" becomes "/tagesschau 20 Uhr (ep_asdfasdf).mp3"
// this handles podcasts where every title is the same (ref https://github.com/advplyr/audiobookshelf/issues/1802)
if ( await fs . pathExists ( this . currentDownload . targetPath ) ) {
this . currentDownload . appendEpisodeId = true
}
2022-03-22 01:24:38 +01:00
// Ignores all added files to this dir
this . watcher . addIgnoreDir ( this . currentDownload . libraryItem . path )
2022-09-30 23:55:31 +02:00
// Make sure podcast library item folder exists
if ( ! ( await fs . pathExists ( this . currentDownload . libraryItem . path ) ) ) {
Logger . warn ( ` [PodcastManager] Podcast episode download: Podcast folder no longer exists at " ${ this . currentDownload . libraryItem . path } " - Creating it ` )
await fs . mkdir ( this . currentDownload . libraryItem . path )
await filePerms . setDefault ( this . currentDownload . libraryItem . path )
}
2023-04-01 23:31:04 +02:00
let success = false
if ( this . currentDownload . urlFileExtension === 'mp3' ) {
// Download episode and tag it
success = await ffmpegHelpers . downloadPodcastEpisode ( this . currentDownload ) . catch ( ( error ) => {
Logger . error ( ` [PodcastManager] Podcast Episode download failed ` , error )
return false
} )
} else {
// Download episode only
success = await downloadFile ( this . currentDownload . url , this . currentDownload . targetPath ) . then ( ( ) => true ) . catch ( ( error ) => {
Logger . error ( ` [PodcastManager] Podcast Episode download failed ` , error )
return false
} )
}
2022-03-22 01:24:38 +01:00
if ( success ) {
success = await this . scanAddPodcastEpisodeAudioFile ( )
if ( ! success ) {
await fs . remove ( this . currentDownload . targetPath )
2022-04-24 02:41:06 +02:00
this . currentDownload . setFinished ( false )
2023-03-05 12:15:36 +01:00
task . setFailed ( 'Failed to download episode' )
2022-03-22 01:24:38 +01:00
} else {
Logger . info ( ` [PodcastManager] Successfully downloaded podcast episode " ${ this . currentDownload . podcastEpisode . title } " ` )
2022-04-24 02:41:06 +02:00
this . currentDownload . setFinished ( true )
2023-03-05 12:15:36 +01:00
task . setFinished ( )
2022-03-22 01:24:38 +01:00
}
2022-04-24 02:41:06 +02:00
} else {
2023-03-05 12:15:36 +01:00
task . setFailed ( 'Failed to download episode' )
2022-04-24 02:41:06 +02:00
this . currentDownload . setFinished ( false )
2022-03-22 01:24:38 +01:00
}
2023-03-05 12:15:36 +01:00
this . taskManager . taskFinished ( task )
2022-11-24 22:53:58 +01:00
SocketAuthority . emitter ( 'episode_download_finished' , this . currentDownload . toJSONForClient ( ) )
2023-03-05 17:35:34 +01:00
SocketAuthority . emitter ( 'episode_download_queue_updated' , this . getDownloadQueueDetails ( ) )
2022-04-24 02:41:06 +02:00
2022-03-22 01:24:38 +01:00
this . watcher . removeIgnoreDir ( this . currentDownload . libraryItem . path )
this . currentDownload = null
if ( this . downloadQueue . length ) {
2023-03-05 17:35:34 +01:00
this . startPodcastEpisodeDownload ( this . downloadQueue . shift ( ) )
2022-03-22 01:24:38 +01:00
}
}
async scanAddPodcastEpisodeAudioFile ( ) {
2023-03-30 01:05:53 +02:00
const libraryFile = await this . getLibraryFile ( this . currentDownload . targetPath , this . currentDownload . targetRelPath )
2022-04-13 00:32:27 +02:00
2023-03-30 01:05:53 +02:00
const audioFile = await this . probeAudioFile ( libraryFile )
2022-03-22 01:24:38 +01:00
if ( ! audioFile ) {
return false
}
2023-03-30 01:05:53 +02:00
const libraryItem = this . db . libraryItems . find ( li => li . id === this . currentDownload . libraryItem . id )
2022-03-22 01:24:38 +01:00
if ( ! libraryItem ) {
Logger . error ( ` [PodcastManager] Podcast Episode finished but library item was not found ${ this . currentDownload . libraryItem . id } ` )
return false
}
2022-03-27 00:23:33 +01:00
2023-03-30 01:05:53 +02:00
const podcastEpisode = this . currentDownload . podcastEpisode
2022-03-22 01:24:38 +01:00
podcastEpisode . audioFile = audioFile
2023-04-09 21:32:51 +02:00
if ( audioFile . chapters ? . length ) {
podcastEpisode . chapters = audioFile . chapters . map ( ch => ( { ... ch } ) )
}
2022-03-22 01:24:38 +01:00
libraryItem . media . addPodcastEpisode ( podcastEpisode )
2022-04-25 00:03:43 +02:00
if ( libraryItem . isInvalid ) {
// First episode added to an empty podcast
libraryItem . isInvalid = false
}
2022-03-27 00:23:33 +01:00
libraryItem . libraryFiles . push ( libraryFile )
2022-08-16 00:35:13 +02:00
2022-09-21 01:08:41 +02:00
if ( this . currentDownload . isAutoDownload ) {
// Check setting maxEpisodesToKeep and remove episode if necessary
2022-08-16 00:35:13 +02:00
if ( libraryItem . media . maxEpisodesToKeep && libraryItem . media . episodesWithPubDate . length > libraryItem . media . maxEpisodesToKeep ) {
Logger . info ( ` [PodcastManager] # of episodes ( ${ libraryItem . media . episodesWithPubDate . length } ) exceeds max episodes to keep ( ${ libraryItem . media . maxEpisodesToKeep } ) ` )
await this . removeOldestEpisode ( libraryItem , podcastEpisode . id )
}
}
2022-03-22 01:24:38 +01:00
libraryItem . updatedAt = Date . now ( )
await this . db . updateLibraryItem ( libraryItem )
2022-11-24 22:53:58 +01:00
SocketAuthority . emitter ( 'item_updated' , libraryItem . toJSONExpanded ( ) )
2023-05-27 16:13:44 +02:00
const podcastEpisodeExpanded = podcastEpisode . toJSONExpanded ( )
podcastEpisodeExpanded . libraryItem = libraryItem . toJSONExpanded ( )
SocketAuthority . emitter ( 'episode_added' , podcastEpisodeExpanded )
2022-09-21 01:08:41 +02:00
if ( this . currentDownload . isAutoDownload ) { // Notifications only for auto downloaded episodes
2022-09-22 01:01:10 +02:00
this . notificationManager . onPodcastEpisodeDownloaded ( libraryItem , podcastEpisode )
2022-09-21 01:08:41 +02:00
}
2022-03-22 01:24:38 +01:00
return true
}
2022-08-16 00:35:13 +02:00
async removeOldestEpisode ( libraryItem , episodeIdJustDownloaded ) {
var smallestPublishedAt = 0
var oldestEpisode = null
libraryItem . media . episodesWithPubDate . filter ( ep => ep . id !== episodeIdJustDownloaded ) . forEach ( ( ep ) => {
if ( ! smallestPublishedAt || ep . publishedAt < smallestPublishedAt ) {
smallestPublishedAt = ep . publishedAt
oldestEpisode = ep
}
} )
// TODO: Should we check for open playback sessions for this episode?
// TODO: remove all user progress for this episode
if ( oldestEpisode && oldestEpisode . audioFile ) {
Logger . info ( ` [PodcastManager] Deleting oldest episode " ${ oldestEpisode . title } " ` )
const successfullyDeleted = await removeFile ( oldestEpisode . audioFile . metadata . path )
if ( successfullyDeleted ) {
libraryItem . media . removeEpisode ( oldestEpisode . id )
libraryItem . removeLibraryFile ( oldestEpisode . audioFile . ino )
return true
} else {
Logger . warn ( ` [PodcastManager] Failed to remove oldest episode " ${ oldestEpisode . title } " ` )
}
}
return false
}
2022-03-22 01:24:38 +01:00
async getLibraryFile ( path , relPath ) {
var newLibFile = new LibraryFile ( )
await newLibFile . setDataFromPath ( path , relPath )
return newLibFile
}
2022-03-20 22:41:06 +01:00
2022-03-22 01:24:38 +01:00
async probeAudioFile ( libraryFile ) {
2023-04-09 21:32:51 +02:00
const path = libraryFile . metadata . path
const mediaProbeData = await prober . probe ( path )
2022-05-31 02:26:53 +02:00
if ( mediaProbeData . error ) {
Logger . error ( ` [PodcastManager] Podcast Episode downloaded but failed to probe " ${ path } " ` , mediaProbeData . error )
2022-03-22 01:24:38 +01:00
return false
}
2023-04-09 21:32:51 +02:00
const newAudioFile = new AudioFile ( )
2022-05-31 02:26:53 +02:00
newAudioFile . setDataFromProbe ( libraryFile , mediaProbeData )
2022-03-22 01:24:38 +01:00
return newAudioFile
2022-03-20 22:41:06 +01:00
}
2022-03-26 17:59:34 +01:00
2022-08-20 01:41:58 +02:00
// Returns false if auto download episodes was disabled (disabled if reaches max failed checks)
async runEpisodeCheck ( libraryItem ) {
const lastEpisodeCheckDate = new Date ( libraryItem . media . lastEpisodeCheck || 0 )
const latestEpisodePublishedAt = libraryItem . media . latestEpisodePublished
Logger . info ( ` [PodcastManager] runEpisodeCheck: " ${ libraryItem . media . metadata . title } " | Last check: ${ lastEpisodeCheckDate } | ${ latestEpisodePublishedAt ? ` Latest episode pubDate: ${ new Date ( latestEpisodePublishedAt ) } ` : 'No latest episode' } ` )
// Use latest episode pubDate if exists OR fallback to using lastEpisodeCheckDate
// lastEpisodeCheckDate will be the current time when adding a new podcast
const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheckDate
Logger . debug ( ` [PodcastManager] runEpisodeCheck: " ${ libraryItem . media . metadata . title } " checking for episodes after ${ new Date ( dateToCheckForEpisodesAfter ) } ` )
2022-10-26 23:55:16 +02:00
var newEpisodes = await this . checkPodcastForNewEpisodes ( libraryItem , dateToCheckForEpisodesAfter , libraryItem . media . maxNewEpisodesToDownload )
2022-08-20 01:41:58 +02:00
Logger . debug ( ` [PodcastManager] runEpisodeCheck: ${ newEpisodes ? newEpisodes . length : 'N/A' } episodes found ` )
if ( ! newEpisodes ) { // Failed
// Allow up to MaxFailedEpisodeChecks failed attempts before disabling auto download
if ( ! this . failedCheckMap [ libraryItem . id ] ) this . failedCheckMap [ libraryItem . id ] = 0
this . failedCheckMap [ libraryItem . id ] ++
if ( this . failedCheckMap [ libraryItem . id ] >= this . MaxFailedEpisodeChecks ) {
Logger . error ( ` [PodcastManager] runEpisodeCheck ${ this . failedCheckMap [ libraryItem . id ] } failed attempts at checking episodes for " ${ libraryItem . media . metadata . title } " - disabling auto download ` )
libraryItem . media . autoDownloadEpisodes = false
2022-05-02 02:54:33 +02:00
delete this . failedCheckMap [ libraryItem . id ]
2022-04-14 17:15:42 +02:00
} else {
2022-08-20 01:41:58 +02:00
Logger . warn ( ` [PodcastManager] runEpisodeCheck ${ this . failedCheckMap [ libraryItem . id ] } failed attempts at checking episodes for " ${ libraryItem . media . metadata . title } " ` )
2022-03-27 01:58:59 +01:00
}
2022-08-20 01:41:58 +02:00
} else if ( newEpisodes . length ) {
delete this . failedCheckMap [ libraryItem . id ]
Logger . info ( ` [PodcastManager] Found ${ newEpisodes . length } new episodes for podcast " ${ libraryItem . media . metadata . title } " - starting download ` )
this . downloadPodcastEpisodes ( libraryItem , newEpisodes , true )
} else {
delete this . failedCheckMap [ libraryItem . id ]
Logger . debug ( ` [PodcastManager] No new episodes for " ${ libraryItem . media . metadata . title } " ` )
2022-03-26 17:59:34 +01:00
}
2022-08-20 01:41:58 +02:00
libraryItem . media . lastEpisodeCheck = Date . now ( )
libraryItem . updatedAt = Date . now ( )
await this . db . updateLibraryItem ( libraryItem )
2022-11-24 22:53:58 +01:00
SocketAuthority . emitter ( 'item_updated' , libraryItem . toJSONExpanded ( ) )
2022-08-20 01:41:58 +02:00
return libraryItem . media . autoDownloadEpisodes
2022-03-26 17:59:34 +01:00
}
2022-09-03 15:06:52 +02:00
async checkPodcastForNewEpisodes ( podcastLibraryItem , dateToCheckForEpisodesAfter , maxNewEpisodes = 3 ) {
2022-03-27 01:58:59 +01:00
if ( ! podcastLibraryItem . media . metadata . feedUrl ) {
2022-05-02 02:54:33 +02:00
Logger . error ( ` [PodcastManager] checkPodcastForNewEpisodes no feed url for ${ podcastLibraryItem . media . metadata . title } (ID: ${ podcastLibraryItem . id } ) ` )
2022-03-27 01:58:59 +01:00
return false
}
2022-09-16 01:35:56 +02:00
var feed = await getPodcastFeed ( podcastLibraryItem . media . metadata . feedUrl )
2022-03-27 01:58:59 +01:00
if ( ! feed || ! feed . episodes ) {
2022-05-02 02:54:33 +02:00
Logger . error ( ` [PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${ podcastLibraryItem . media . metadata . title } (ID: ${ podcastLibraryItem . id } ) ` , feed )
2022-03-27 01:58:59 +01:00
return false
}
2022-05-02 02:54:33 +02:00
2022-03-27 01:58:59 +01:00
// Filter new and not already has
2022-05-12 01:55:19 +02:00
var newEpisodes = feed . episodes . filter ( ep => ep . publishedAt > dateToCheckForEpisodesAfter && ! podcastLibraryItem . media . checkHasEpisodeByFeedUrl ( ep . enclosure . url ) )
2022-09-03 15:06:52 +02:00
if ( maxNewEpisodes > 0 ) {
newEpisodes = newEpisodes . slice ( 0 , maxNewEpisodes )
}
2022-04-29 23:42:40 +02:00
return newEpisodes
}
2022-09-03 15:06:52 +02:00
async checkAndDownloadNewEpisodes ( libraryItem , maxEpisodesToDownload ) {
2022-04-29 23:42:40 +02:00
const lastEpisodeCheckDate = new Date ( libraryItem . media . lastEpisodeCheck || 0 )
Logger . info ( ` [PodcastManager] checkAndDownloadNewEpisodes for " ${ libraryItem . media . metadata . title } " - Last episode check: ${ lastEpisodeCheckDate } ` )
2022-09-03 15:06:52 +02:00
var newEpisodes = await this . checkPodcastForNewEpisodes ( libraryItem , libraryItem . media . lastEpisodeCheck , maxEpisodesToDownload )
2022-04-29 23:42:40 +02:00
if ( newEpisodes . length ) {
Logger . info ( ` [PodcastManager] Found ${ newEpisodes . length } new episodes for podcast " ${ libraryItem . media . metadata . title } " - starting download ` )
2022-08-16 00:35:13 +02:00
this . downloadPodcastEpisodes ( libraryItem , newEpisodes , false )
2022-04-29 23:42:40 +02:00
} else {
Logger . info ( ` [PodcastManager] No new episodes found for podcast " ${ libraryItem . media . metadata . title } " ` )
}
libraryItem . media . lastEpisodeCheck = Date . now ( )
libraryItem . updatedAt = Date . now ( )
await this . db . updateLibraryItem ( libraryItem )
2022-11-24 22:53:58 +01:00
SocketAuthority . emitter ( 'item_updated' , libraryItem . toJSONExpanded ( ) )
2022-04-29 23:42:40 +02:00
2022-03-27 01:58:59 +01:00
return newEpisodes
}
2022-07-31 20:12:37 +02:00
async findEpisode ( rssFeedUrl , searchTitle ) {
2022-09-16 01:35:56 +02:00
const feed = await getPodcastFeed ( rssFeedUrl ) . catch ( ( ) => {
2022-07-31 20:12:37 +02:00
return null
} )
if ( ! feed || ! feed . episodes ) {
return null
}
const matches = [ ]
feed . episodes . forEach ( ep => {
if ( ! ep . title ) return
const epTitle = ep . title . toLowerCase ( ) . trim ( )
if ( epTitle === searchTitle ) {
matches . push ( {
episode : ep ,
levenshtein : 0
} )
} else {
const levenshtein = levenshteinDistance ( searchTitle , epTitle , true )
if ( levenshtein <= 6 && epTitle . length > levenshtein ) {
matches . push ( {
episode : ep ,
levenshtein
} )
}
}
} )
return matches . sort ( ( a , b ) => a . levenshtein - b . levenshtein )
}
2022-05-29 18:46:45 +02:00
async getOPMLFeeds ( opmlText ) {
var extractedFeeds = opmlParser . parse ( opmlText )
if ( ! extractedFeeds || ! extractedFeeds . length ) {
Logger . error ( '[PodcastManager] getOPMLFeeds: No RSS feeds found in OPML' )
return {
error : 'No RSS feeds found in OPML'
}
}
var rssFeedData = [ ]
for ( let feed of extractedFeeds ) {
2022-09-16 01:35:56 +02:00
var feedData = await getPodcastFeed ( feed . feedUrl , true )
2022-05-29 18:46:45 +02:00
if ( feedData ) {
feedData . metadata . feedUrl = feed . feedUrl
rssFeedData . push ( feedData )
}
}
return {
feeds : rssFeedData
}
}
2023-02-27 03:56:07 +01:00
2023-05-28 22:10:34 +02:00
generateOPMLFileText ( libraryItems ) {
return opmlGenerator . generate ( libraryItems )
}
2023-03-05 17:35:34 +01:00
getDownloadQueueDetails ( libraryId = null ) {
let _currentDownload = this . currentDownload
if ( libraryId && _currentDownload ? . libraryId !== libraryId ) _currentDownload = null
return {
currentDownload : _currentDownload ? . toJSONForClient ( ) ,
queue : this . downloadQueue . filter ( item => ! libraryId || item . libraryId === libraryId ) . map ( item => item . toJSONForClient ( ) )
}
2023-02-27 03:56:07 +01:00
}
2022-03-20 22:41:06 +01:00
}
2023-02-27 03:56:07 +01:00
module . exports = PodcastManager