2025-01-04 19:41:09 +01:00
const Path = require ( 'path' )
2022-11-24 22:53:58 +01:00
const Logger = require ( '../Logger' )
const SocketAuthority = require ( '../SocketAuthority' )
2023-07-05 01:14:44 +02:00
const Database = require ( '../Database' )
2024-11-07 23:32:05 +01:00
const Watcher = require ( '../Watcher' )
2022-11-24 22:53:58 +01:00
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' )
2024-07-17 00:05:52 +02:00
const { removeFile , downloadFile , sanitizeFilename , filePathToPOSIX , getFileTimestampsWithIno } = require ( '../utils/fileUtils' )
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
2023-10-20 23:39:32 +02:00
const TaskManager = require ( './TaskManager' )
2024-07-17 00:05:52 +02:00
const CoverManager = require ( '../managers/CoverManager' )
2024-09-28 00:33:23 +02:00
const NotificationManager = require ( '../managers/NotificationManager' )
2023-10-20 23:39:32 +02:00
2022-03-22 01:24:38 +01:00
const LibraryFile = require ( '../objects/files/LibraryFile' )
const PodcastEpisodeDownload = require ( '../objects/PodcastEpisodeDownload' )
const AudioFile = require ( '../objects/files/AudioFile' )
2022-03-20 22:41:06 +01:00
class PodcastManager {
2024-11-07 23:32:05 +01:00
constructor ( ) {
2024-12-13 23:06:00 +01:00
/** @type {PodcastEpisodeDownload[]} */
2022-03-20 22:41:06 +01:00
this . downloadQueue = [ ]
2024-12-13 23:06:00 +01:00
/** @type {PodcastEpisodeDownload} */
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-04-24 02:41:06 +02:00
getEpisodeDownloadsInQueue ( libraryItemId ) {
2024-05-18 16:33:48 +02:00
return this . downloadQueue . filter ( ( d ) => d . libraryItemId === libraryItemId )
2022-04-24 02:41:06 +02:00
}
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 } ) ` )
2024-05-18 16:33:48 +02:00
this . downloadQueue = this . downloadQueue . filter ( ( d ) => d . libraryItemId !== libraryItemId )
2024-11-12 06:37:38 +01:00
SocketAuthority . emitter ( 'episode_download_queue_cleared' , libraryItemId )
2022-04-24 02:41:06 +02:00
}
}
2025-01-03 23:48:24 +01:00
/ * *
*
* @ param { import ( '../models/LibraryItem' ) } libraryItem
2025-01-04 19:41:09 +01:00
* @ param { import ( '../utils/podcastUtils' ) . RssPodcastEpisode [ ] } episodesToDownload
* @ param { boolean } isAutoDownload - If this download was triggered by auto download
2025-01-03 23:48:24 +01:00
* /
2022-08-16 00:35:13 +02:00
async downloadPodcastEpisodes ( libraryItem , episodesToDownload , isAutoDownload ) {
2023-04-09 21:32:51 +02:00
for ( const ep of episodesToDownload ) {
const newPeDl = new PodcastEpisodeDownload ( )
2025-01-04 19:41:09 +01:00
newPeDl . setData ( ep , 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
}
2024-12-13 23:06:00 +01:00
/ * *
*
* @ param { PodcastEpisodeDownload } podcastEpisodeDownload
* @ returns
* /
2023-03-05 17:35:34 +01:00
async startPodcastEpisodeDownload ( podcastEpisodeDownload ) {
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 taskData = {
2023-03-05 17:35:34 +01:00
libraryId : podcastEpisodeDownload . libraryId ,
2024-05-18 16:33:48 +02:00
libraryItemId : podcastEpisodeDownload . libraryItemId
2023-03-05 12:15:36 +01:00
}
2024-09-21 00:18:29 +02:00
const taskTitleString = {
text : 'Downloading episode' ,
key : 'MessageDownloadingEpisode'
}
const taskDescriptionString = {
2025-01-04 19:41:09 +01:00
text : ` Downloading episode " ${ podcastEpisodeDownload . episodeTitle } ". ` ,
2024-09-21 00:18:29 +02:00
key : 'MessageTaskDownloadingEpisodeDescription' ,
2025-01-04 19:41:09 +01:00
subs : [ podcastEpisodeDownload . episodeTitle ]
2024-09-21 00:18:29 +02:00
}
const task = TaskManager . createAndAddTask ( 'download-podcast-episode' , taskTitleString , taskDescriptionString , false , taskData )
2023-03-05 12:15:36 +01:00
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
2025-01-04 19:41:09 +01:00
// If this file already exists then append a uuid to the filename
2023-05-28 18:24:51 +02:00
// 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 ) ) {
2025-01-04 19:41:09 +01:00
this . currentDownload . appendRandomId = true
2023-05-28 18:24:51 +02:00
}
2022-03-22 01:24:38 +01:00
// Ignores all added files to this dir
2024-11-07 23:32:05 +01:00
Watcher . addIgnoreDir ( this . currentDownload . libraryItem . path )
2024-11-08 00:26:51 +01:00
Watcher . ignoreFilePathsDownloading . add ( this . currentDownload . targetPath )
2022-03-22 01:24:38 +01:00
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 )
}
2023-04-01 23:31:04 +02:00
let success = false
2024-12-13 23:06:00 +01:00
if ( this . currentDownload . isMp3 ) {
2023-04-01 23:31:04 +02:00
// 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
2024-05-18 16:33:48 +02:00
success = await downloadFile ( this . currentDownload . url , this . currentDownload . targetPath )
. then ( ( ) => true )
. catch ( ( error ) => {
Logger . error ( ` [PodcastManager] Podcast Episode download failed ` , error )
return false
} )
2023-04-01 23:31:04 +02:00
}
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 )
2024-09-21 21:02:57 +02:00
const taskFailedString = {
text : 'Failed' ,
key : 'MessageTaskFailed'
}
task . setFailed ( taskFailedString )
2022-03-22 01:24:38 +01:00
} else {
2025-01-04 19:41:09 +01:00
Logger . info ( ` [PodcastManager] Successfully downloaded podcast episode " ${ this . currentDownload . episodeTitle } " ` )
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 {
2024-09-21 21:02:57 +02:00
const taskFailedString = {
text : 'Failed' ,
key : 'MessageTaskFailed'
}
task . setFailed ( taskFailedString )
2022-04-24 02:41:06 +02:00
this . currentDownload . setFinished ( false )
2022-03-22 01:24:38 +01:00
}
2023-10-20 23:39:32 +02:00
TaskManager . taskFinished ( task )
2023-03-05 12:15:36 +01:00
2022-11-24 22:53:58 +01:00
SocketAuthority . emitter ( 'episode_download_finished' , this . currentDownload . toJSONForClient ( ) )
2022-04-24 02:41:06 +02:00
2024-11-07 23:32:05 +01:00
Watcher . removeIgnoreDir ( this . currentDownload . libraryItem . path )
2024-11-08 00:26:51 +01:00
Watcher . ignoreFilePathsDownloading . delete ( this . currentDownload . targetPath )
2022-03-22 01:24:38 +01:00
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
}
}
2025-01-04 19:41:09 +01:00
/ * *
* Scans the downloaded audio file , create the podcast episode , remove oldest episode if necessary
* @ returns { Promise < boolean > } - Returns true if added
* /
2022-03-22 01:24:38 +01:00
async scanAddPodcastEpisodeAudioFile ( ) {
2025-01-04 19:41:09 +01:00
const libraryFile = new LibraryFile ( )
await libraryFile . setDataFromPath ( 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
}
2025-01-04 19:41:09 +01:00
const libraryItem = await Database . libraryItemModel . getExpandedById ( 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
2025-01-04 19:41:09 +01:00
const podcastEpisode = await Database . podcastEpisodeModel . createFromRssPodcastEpisode ( this . currentDownload . rssPodcastEpisode , libraryItem . media . id , audioFile )
2023-04-09 21:32:51 +02:00
2025-01-04 19:41:09 +01:00
libraryItem . libraryFiles . push ( libraryFile . toJSON ( ) )
libraryItem . changed ( 'libraryFiles' , true )
2023-04-09 21:32:51 +02:00
2025-01-04 19:41:09 +01:00
libraryItem . media . podcastEpisodes . push ( podcastEpisode )
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
2025-01-04 19:41:09 +01:00
const numEpisodesWithPubDate = libraryItem . media . podcastEpisodes . filter ( ( ep ) => ! ! ep . publishedAt ) . length
if ( libraryItem . media . maxEpisodesToKeep && numEpisodesWithPubDate > libraryItem . media . maxEpisodesToKeep ) {
Logger . info ( ` [PodcastManager] # of episodes ( ${ numEpisodesWithPubDate } ) exceeds max episodes to keep ( ${ libraryItem . media . maxEpisodesToKeep } ) ` )
const episodeToRemove = await this . getRemoveOldestEpisode ( libraryItem , podcastEpisode . id )
if ( episodeToRemove ) {
// Remove episode from playlists
await Database . playlistModel . removeMediaItemsFromPlaylists ( [ episodeToRemove . id ] )
// Remove media progress for this episode
await Database . mediaProgressModel . destroy ( {
where : {
mediaItemId : episodeToRemove . id
}
} )
await episodeToRemove . destroy ( )
libraryItem . media . podcastEpisodes = libraryItem . media . podcastEpisodes . filter ( ( ep ) => ep . id !== episodeToRemove . id )
// Remove library file
libraryItem . libraryFiles = libraryItem . libraryFiles . filter ( ( lf ) => lf . ino !== episodeToRemove . audioFile . ino )
}
2022-08-16 00:35:13 +02:00
}
}
2025-01-04 19:41:09 +01:00
await libraryItem . save ( )
SocketAuthority . emitter ( 'item_updated' , libraryItem . toOldJSONExpanded ( ) )
const podcastEpisodeExpanded = podcastEpisode . toOldJSONExpanded ( libraryItem . id )
podcastEpisodeExpanded . libraryItem = libraryItem . toOldJSONExpanded ( )
2023-05-27 16:13:44 +02:00
SocketAuthority . emitter ( 'episode_added' , podcastEpisodeExpanded )
2022-09-21 01:08:41 +02:00
2024-05-18 16:33:48 +02:00
if ( this . currentDownload . isAutoDownload ) {
// Notifications only for auto downloaded episodes
2024-09-28 00:33:23 +02:00
NotificationManager . onPodcastEpisodeDownloaded ( libraryItem , podcastEpisode )
2022-09-21 01:08:41 +02:00
}
2022-03-22 01:24:38 +01:00
return true
}
2025-01-04 19:41:09 +01:00
/ * *
* Find oldest episode publishedAt and delete the audio file
*
* @ param { import ( '../models/LibraryItem' ) . LibraryItemExpanded } libraryItem
* @ param { string } episodeIdJustDownloaded
* @ returns { Promise < import ( '../models/PodcastEpisode' ) | null > } - Returns the episode to remove
* /
async getRemoveOldestEpisode ( libraryItem , episodeIdJustDownloaded ) {
let smallestPublishedAt = 0
/** @type {import('../models/PodcastEpisode')} */
let oldestEpisode = null
/** @type {import('../models/PodcastEpisode')[]} */
const podcastEpisodes = libraryItem . media . podcastEpisodes
for ( const ep of podcastEpisodes ) {
if ( ep . id === episodeIdJustDownloaded || ! ep . publishedAt ) continue
if ( ! smallestPublishedAt || ep . publishedAt < smallestPublishedAt ) {
smallestPublishedAt = ep . publishedAt
oldestEpisode = ep
}
}
2023-10-17 00:47:44 +02:00
if ( oldestEpisode ? . audioFile ) {
2022-08-16 00:35:13 +02:00
Logger . info ( ` [PodcastManager] Deleting oldest episode " ${ oldestEpisode . title } " ` )
const successfullyDeleted = await removeFile ( oldestEpisode . audioFile . metadata . path )
if ( successfullyDeleted ) {
2025-01-04 19:41:09 +01:00
return oldestEpisode
2022-08-16 00:35:13 +02:00
} else {
Logger . warn ( ` [PodcastManager] Failed to remove oldest episode " ${ oldestEpisode . title } " ` )
}
}
2025-01-04 19:41:09 +01:00
return null
2022-03-22 01:24:38 +01:00
}
2022-03-20 22:41:06 +01:00
2025-01-04 19:41:09 +01:00
/ * *
*
* @ param { LibraryFile } libraryFile
* @ returns { Promise < AudioFile | null > }
* /
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 )
2025-01-04 19:41:09 +01:00
return null
2022-03-22 01:24:38 +01:00
}
2023-04-09 21:32:51 +02:00
const newAudioFile = new AudioFile ( )
2022-05-31 02:26:53 +02:00
newAudioFile . setDataFromProbe ( libraryFile , mediaProbeData )
2023-07-05 01:14:44 +02:00
newAudioFile . index = 1
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
2025-01-03 23:48:24 +01:00
/ * *
*
* @ param { import ( '../models/LibraryItem' ) } libraryItem
* @ returns { Promise < boolean > } - Returns false if auto download episodes was disabled ( disabled if reaches max failed checks )
* /
2022-08-20 01:41:58 +02:00
async runEpisodeCheck ( libraryItem ) {
2025-01-03 23:48:24 +01:00
const lastEpisodeCheck = libraryItem . media . lastEpisodeCheck ? . valueOf ( ) || 0
const latestEpisodePublishedAt = libraryItem . media . getLatestEpisodePublishedAt ( )
Logger . info ( ` [PodcastManager] runEpisodeCheck: " ${ libraryItem . media . title } " | Last check: ${ new Date ( lastEpisodeCheck ) } | ${ latestEpisodePublishedAt ? ` Latest episode pubDate: ${ new Date ( latestEpisodePublishedAt ) } ` : 'No latest episode' } ` )
2022-08-20 01:41:58 +02:00
2025-01-04 22:20:41 +01:00
// Use latest episode pubDate if exists OR fallback to using lastEpisodeCheck
// lastEpisodeCheck will be the current time when adding a new podcast
const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheck
2025-01-03 23:48:24 +01:00
Logger . debug ( ` [PodcastManager] runEpisodeCheck: " ${ libraryItem . media . title } " checking for episodes after ${ new Date ( dateToCheckForEpisodesAfter ) } ` )
2022-08-20 01:41:58 +02:00
2025-01-04 19:41:09 +01:00
const newEpisodes = await this . checkPodcastForNewEpisodes ( libraryItem , dateToCheckForEpisodesAfter , libraryItem . media . maxNewEpisodesToDownload )
2023-10-17 00:47:44 +02:00
Logger . debug ( ` [PodcastManager] runEpisodeCheck: ${ newEpisodes ? . length || 'N/A' } episodes found ` )
2022-08-20 01:41:58 +02:00
2024-05-18 16:33:48 +02:00
if ( ! newEpisodes ) {
// Failed
2022-08-20 01:41:58 +02:00
// 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 ) {
2025-01-03 23:48:24 +01:00
Logger . error ( ` [PodcastManager] runEpisodeCheck ${ this . failedCheckMap [ libraryItem . id ] } failed attempts at checking episodes for " ${ libraryItem . media . title } " - disabling auto download ` )
2022-08-20 01:41:58 +02:00
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 {
2025-01-03 23:48:24 +01:00
Logger . warn ( ` [PodcastManager] runEpisodeCheck ${ this . failedCheckMap [ libraryItem . id ] } failed attempts at checking episodes for " ${ libraryItem . media . 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 ]
2025-01-03 23:48:24 +01:00
Logger . info ( ` [PodcastManager] Found ${ newEpisodes . length } new episodes for podcast " ${ libraryItem . media . title } " - starting download ` )
2022-08-20 01:41:58 +02:00
this . downloadPodcastEpisodes ( libraryItem , newEpisodes , true )
} else {
delete this . failedCheckMap [ libraryItem . id ]
2025-01-03 23:48:24 +01:00
Logger . debug ( ` [PodcastManager] No new episodes for " ${ libraryItem . media . title } " ` )
2022-03-26 17:59:34 +01:00
}
2022-08-20 01:41:58 +02:00
2025-01-03 23:48:24 +01:00
libraryItem . media . lastEpisodeCheck = new Date ( )
await libraryItem . media . save ( )
libraryItem . changed ( 'updatedAt' , true )
await libraryItem . save ( )
SocketAuthority . emitter ( 'item_updated' , libraryItem . toOldJSONExpanded ( ) )
2022-08-20 01:41:58 +02:00
return libraryItem . media . autoDownloadEpisodes
2022-03-26 17:59:34 +01:00
}
2025-01-03 23:48:24 +01:00
/ * *
*
* @ param { import ( '../models/LibraryItem' ) } podcastLibraryItem
* @ param { number } dateToCheckForEpisodesAfter - Unix timestamp
* @ param { number } maxNewEpisodes
2025-01-04 19:41:09 +01:00
* @ returns { Promise < import ( '../utils/podcastUtils' ) . RssPodcastEpisode [ ] | null > }
2025-01-03 23:48:24 +01:00
* /
2022-09-03 15:06:52 +02:00
async checkPodcastForNewEpisodes ( podcastLibraryItem , dateToCheckForEpisodesAfter , maxNewEpisodes = 3 ) {
2025-01-03 23:48:24 +01:00
if ( ! podcastLibraryItem . media . feedURL ) {
Logger . error ( ` [PodcastManager] checkPodcastForNewEpisodes no feed url for ${ podcastLibraryItem . media . title } (ID: ${ podcastLibraryItem . id } ) ` )
2025-01-04 19:41:09 +01:00
return null
2022-03-27 01:58:59 +01:00
}
2025-01-03 23:48:24 +01:00
const feed = await getPodcastFeed ( podcastLibraryItem . media . feedURL )
2023-10-17 00:47:44 +02:00
if ( ! feed ? . episodes ) {
2025-01-03 23:48:24 +01:00
Logger . error ( ` [PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${ podcastLibraryItem . media . title } (ID: ${ podcastLibraryItem . id } ) ` , feed )
2025-01-04 19:41:09 +01:00
return null
2022-03-27 01:58:59 +01:00
}
2022-05-02 02:54:33 +02:00
2022-03-27 01:58:59 +01:00
// Filter new and not already has
2024-05-18 16:33:48 +02:00
let newEpisodes = feed . episodes . filter ( ( ep ) => ep . publishedAt > dateToCheckForEpisodesAfter && ! podcastLibraryItem . media . checkHasEpisodeByFeedEpisode ( ep ) )
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
}
2025-01-03 23:48:24 +01:00
/ * *
*
* @ param { import ( '../models/LibraryItem' ) } libraryItem
* @ param { * } maxEpisodesToDownload
2025-01-04 19:41:09 +01:00
* @ returns { Promise < import ( '../utils/podcastUtils' ) . RssPodcastEpisode [ ] > }
2025-01-03 23:48:24 +01:00
* /
2022-09-03 15:06:52 +02:00
async checkAndDownloadNewEpisodes ( libraryItem , maxEpisodesToDownload ) {
2025-01-03 23:48:24 +01:00
const lastEpisodeCheck = libraryItem . media . lastEpisodeCheck ? . valueOf ( ) || 0
const lastEpisodeCheckDate = lastEpisodeCheck > 0 ? libraryItem . media . lastEpisodeCheck : 'Never'
Logger . info ( ` [PodcastManager] checkAndDownloadNewEpisodes for " ${ libraryItem . media . title } " - Last episode check: ${ lastEpisodeCheckDate } ` )
2025-01-04 19:41:09 +01:00
const newEpisodes = await this . checkPodcastForNewEpisodes ( libraryItem , lastEpisodeCheck , maxEpisodesToDownload )
if ( newEpisodes ? . length ) {
2025-01-03 23:48:24 +01:00
Logger . info ( ` [PodcastManager] Found ${ newEpisodes . length } new episodes for podcast " ${ libraryItem . media . 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 {
2025-01-03 23:48:24 +01:00
Logger . info ( ` [PodcastManager] No new episodes found for podcast " ${ libraryItem . media . title } " ` )
2022-04-29 23:42:40 +02:00
}
2025-01-03 23:48:24 +01:00
libraryItem . media . lastEpisodeCheck = new Date ( )
await libraryItem . media . save ( )
libraryItem . changed ( 'updatedAt' , true )
await libraryItem . save ( )
SocketAuthority . emitter ( 'item_updated' , libraryItem . toOldJSONExpanded ( ) )
2022-04-29 23:42:40 +02:00
2025-01-04 19:41:09 +01:00
return newEpisodes || [ ]
2022-03-27 01:58:59 +01:00
}
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 = [ ]
2024-05-18 16:33:48 +02:00
feed . episodes . forEach ( ( ep ) => {
2022-07-31 20:12:37 +02:00
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 )
}
2024-07-17 00:05:52 +02:00
getParsedOPMLFileFeeds ( opmlText ) {
return opmlParser . parse ( opmlText )
}
2022-05-29 18:46:45 +02:00
async getOPMLFeeds ( opmlText ) {
2024-07-17 00:05:52 +02:00
const extractedFeeds = opmlParser . parse ( opmlText )
if ( ! extractedFeeds ? . length ) {
2022-05-29 18:46:45 +02:00
Logger . error ( '[PodcastManager] getOPMLFeeds: No RSS feeds found in OPML' )
return {
error : 'No RSS feeds found in OPML'
}
}
2024-07-17 00:05:52 +02:00
const rssFeedData = [ ]
2022-05-29 18:46:45 +02:00
for ( let feed of extractedFeeds ) {
2024-07-17 00:05:52 +02:00
const 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-08-19 22:19:27 +02:00
/ * *
* OPML file string for podcasts in a library
2024-05-18 16:33:48 +02:00
* @ param { import ( '../models/Podcast' ) [ ] } podcasts
2023-08-19 22:19:27 +02:00
* @ returns { string } XML string
* /
generateOPMLFileText ( podcasts ) {
return opmlGenerator . generate ( podcasts )
2023-05-28 22:10:34 +02:00
}
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 ( ) ,
2024-05-18 16:33:48 +02:00
queue : this . downloadQueue . filter ( ( item ) => ! libraryId || item . libraryId === libraryId ) . map ( ( item ) => item . toJSONForClient ( ) )
2023-03-05 17:35:34 +01:00
}
2023-02-27 03:56:07 +01:00
}
2024-07-17 00:05:52 +02:00
/ * *
*
* @ param { string [ ] } rssFeedUrls
* @ param { import ( '../models/LibraryFolder' ) } folder
* @ param { boolean } autoDownloadEpisodes
* @ param { import ( '../managers/CronManager' ) } cronManager
* /
async createPodcastsFromFeedUrls ( rssFeedUrls , folder , autoDownloadEpisodes , cronManager ) {
2024-09-21 00:18:29 +02:00
const taskTitleString = {
text : 'OPML import' ,
key : 'MessageTaskOpmlImport'
}
const taskDescriptionString = {
text : ` Creating podcasts from ${ rssFeedUrls . length } RSS feeds ` ,
key : 'MessageTaskOpmlImportDescription' ,
subs : [ rssFeedUrls . length ]
}
const task = TaskManager . createAndAddTask ( 'opml-import' , taskTitleString , taskDescriptionString , true , null )
2024-07-17 00:05:52 +02:00
let numPodcastsAdded = 0
Logger . info ( ` [PodcastManager] createPodcastsFromFeedUrls: Importing ${ rssFeedUrls . length } RSS feeds to folder " ${ folder . path } " ` )
for ( const feedUrl of rssFeedUrls ) {
const feed = await getPodcastFeed ( feedUrl ) . catch ( ( ) => null )
if ( ! feed ? . episodes ) {
2024-09-21 00:18:29 +02:00
const taskTitleStringFeed = {
text : 'OPML import feed' ,
key : 'MessageTaskOpmlImportFeed'
}
const taskDescriptionStringFeed = {
text : ` Importing RSS feed " ${ feedUrl } " ` ,
key : 'MessageTaskOpmlImportFeedDescription' ,
subs : [ feedUrl ]
}
const taskErrorString = {
text : 'Failed to get podcast feed' ,
key : 'MessageTaskOpmlImportFeedFailed'
}
TaskManager . createAndEmitFailedTask ( 'opml-import-feed' , taskTitleStringFeed , taskDescriptionStringFeed , taskErrorString )
2024-07-17 00:05:52 +02:00
Logger . error ( ` [PodcastManager] createPodcastsFromFeedUrls: Failed to get podcast feed for " ${ feedUrl } " ` )
continue
}
const podcastFilename = sanitizeFilename ( feed . metadata . title )
const podcastPath = filePathToPOSIX ( ` ${ folder . path } / ${ podcastFilename } ` )
// Check if a library item with this podcast folder exists already
const existingLibraryItem =
( await Database . libraryItemModel . count ( {
where : {
path : podcastPath
}
} ) ) > 0
if ( existingLibraryItem ) {
Logger . error ( ` [PodcastManager] createPodcastsFromFeedUrls: Podcast already exists at path " ${ podcastPath } " ` )
2024-09-21 00:18:29 +02:00
const taskTitleStringFeed = {
text : 'OPML import feed' ,
key : 'MessageTaskOpmlImportFeed'
}
const taskDescriptionStringPodcast = {
text : ` Creating podcast " ${ feed . metadata . title } " ` ,
key : 'MessageTaskOpmlImportFeedPodcastDescription' ,
subs : [ feed . metadata . title ]
}
const taskErrorString = {
text : 'Podcast already exists at path' ,
key : 'MessageTaskOpmlImportFeedPodcastExists'
}
TaskManager . createAndEmitFailedTask ( 'opml-import-feed' , taskTitleStringFeed , taskDescriptionStringPodcast , taskErrorString )
2024-07-17 00:05:52 +02:00
continue
}
const successCreatingPath = await fs
. ensureDir ( podcastPath )
. then ( ( ) => true )
. catch ( ( error ) => {
Logger . error ( ` [PodcastManager] Failed to ensure podcast dir " ${ podcastPath } " ` , error )
return false
} )
if ( ! successCreatingPath ) {
Logger . error ( ` [PodcastManager] createPodcastsFromFeedUrls: Failed to create podcast folder at " ${ podcastPath } " ` )
2024-09-21 00:18:29 +02:00
const taskTitleStringFeed = {
text : 'OPML import feed' ,
key : 'MessageTaskOpmlImportFeed'
}
const taskDescriptionStringPodcast = {
text : ` Creating podcast " ${ feed . metadata . title } " ` ,
key : 'MessageTaskOpmlImportFeedPodcastDescription' ,
subs : [ feed . metadata . title ]
}
const taskErrorString = {
text : 'Failed to create podcast folder' ,
key : 'MessageTaskOpmlImportFeedPodcastFailed'
}
TaskManager . createAndEmitFailedTask ( 'opml-import-feed' , taskTitleStringFeed , taskDescriptionStringPodcast , taskErrorString )
2024-07-17 00:05:52 +02:00
continue
}
2025-01-04 19:41:09 +01:00
let newLibraryItem = null
const transaction = await Database . sequelize . transaction ( )
try {
const libraryItemFolderStats = await getFileTimestampsWithIno ( podcastPath )
const podcastPayload = {
autoDownloadEpisodes ,
metadata : {
title : feed . metadata . title ,
author : feed . metadata . author ,
description : feed . metadata . description ,
releaseDate : '' ,
genres : [ ... feed . metadata . categories ] ,
feedUrl : feed . metadata . feedUrl ,
imageUrl : feed . metadata . image ,
itunesPageUrl : '' ,
itunesId : '' ,
itunesArtistId : '' ,
language : '' ,
numEpisodes : feed . numEpisodes
}
}
const podcast = await Database . podcastModel . createFromRequest ( podcastPayload , transaction )
newLibraryItem = await Database . libraryItemModel . create (
{
ino : libraryItemFolderStats . ino ,
path : podcastPath ,
relPath : podcastFilename ,
mediaId : podcast . id ,
mediaType : 'podcast' ,
isFile : false ,
isMissing : false ,
isInvalid : false ,
mtime : libraryItemFolderStats . mtimeMs || 0 ,
ctime : libraryItemFolderStats . ctimeMs || 0 ,
birthtime : libraryItemFolderStats . birthtimeMs || 0 ,
size : 0 ,
libraryFiles : [ ] ,
extraData : { } ,
libraryId : folder . libraryId ,
libraryFolderId : folder . id
} ,
{ transaction }
)
await transaction . commit ( )
} catch ( error ) {
await transaction . rollback ( )
Logger . error ( ` [PodcastManager] createPodcastsFromFeedUrls: Failed to create podcast library item for " ${ feed . metadata . title } " ` , error )
const taskTitleStringFeed = {
text : 'OPML import feed' ,
key : 'MessageTaskOpmlImportFeed'
}
const taskDescriptionStringPodcast = {
text : ` Creating podcast " ${ feed . metadata . title } " ` ,
key : 'MessageTaskOpmlImportFeedPodcastDescription' ,
subs : [ feed . metadata . title ]
2024-07-17 00:05:52 +02:00
}
2025-01-04 19:41:09 +01:00
const taskErrorString = {
text : 'Failed to create podcast library item' ,
key : 'MessageTaskOpmlImportFeedPodcastFailed'
}
TaskManager . createAndEmitFailedTask ( 'opml-import-feed' , taskTitleStringFeed , taskDescriptionStringPodcast , taskErrorString )
continue
2024-07-17 00:05:52 +02:00
}
2025-01-04 19:41:09 +01:00
newLibraryItem . media = await newLibraryItem . getMediaExpanded ( )
2024-07-17 00:05:52 +02:00
// Download and save cover image
2025-01-04 19:41:09 +01:00
if ( typeof feed . metadata . image === 'string' && feed . metadata . image . startsWith ( 'http' ) ) {
2024-07-17 00:05:52 +02:00
// Podcast cover will always go into library item folder
2025-01-04 19:41:09 +01:00
const coverResponse = await CoverManager . downloadCoverFromUrlNew ( feed . metadata . image , newLibraryItem . id , newLibraryItem . path , true )
if ( coverResponse . error ) {
Logger . error ( ` [PodcastManager] Download cover error from " ${ feed . metadata . image } ": ${ coverResponse . error } ` )
} else if ( coverResponse . cover ) {
const coverImageFileStats = await getFileTimestampsWithIno ( coverResponse . cover )
if ( ! coverImageFileStats ) {
Logger . error ( ` [PodcastManager] Failed to get cover image stats for " ${ coverResponse . cover } " ` )
} else {
// Add libraryFile to libraryItem and coverPath to podcast
const newLibraryFile = {
ino : coverImageFileStats . ino ,
fileType : 'image' ,
addedAt : Date . now ( ) ,
updatedAt : Date . now ( ) ,
metadata : {
filename : Path . basename ( coverResponse . cover ) ,
ext : Path . extname ( coverResponse . cover ) . slice ( 1 ) ,
path : coverResponse . cover ,
relPath : Path . basename ( coverResponse . cover ) ,
size : coverImageFileStats . size ,
mtimeMs : coverImageFileStats . mtimeMs || 0 ,
ctimeMs : coverImageFileStats . ctimeMs || 0 ,
birthtimeMs : coverImageFileStats . birthtimeMs || 0
}
}
newLibraryItem . libraryFiles . push ( newLibraryFile )
newLibraryItem . changed ( 'libraryFiles' , true )
await newLibraryItem . save ( )
newLibraryItem . media . coverPath = coverResponse . cover
await newLibraryItem . media . save ( )
2024-07-17 00:05:52 +02:00
}
}
}
2025-01-04 19:41:09 +01:00
SocketAuthority . emitter ( 'item_added' , newLibraryItem . toOldJSONExpanded ( ) )
2024-07-17 00:05:52 +02:00
// Turn on podcast auto download cron if not already on
2025-01-04 19:41:09 +01:00
if ( newLibraryItem . media . autoDownloadEpisodes ) {
cronManager . checkUpdatePodcastCron ( newLibraryItem )
2024-07-17 00:05:52 +02:00
}
numPodcastsAdded ++
}
2025-01-04 19:41:09 +01:00
2024-09-21 21:02:57 +02:00
const taskFinishedString = {
text : ` Added ${ numPodcastsAdded } podcasts ` ,
key : 'MessageTaskOpmlImportFinished' ,
subs : [ numPodcastsAdded ]
}
task . setFinished ( taskFinishedString )
2024-07-17 00:05:52 +02:00
TaskManager . taskFinished ( task )
Logger . info ( ` [PodcastManager] createPodcastsFromFeedUrls: Finished OPML import. Created ${ numPodcastsAdded } podcasts out of ${ rssFeedUrls . length } RSS feed URLs ` )
}
2022-03-20 22:41:06 +01:00
}
2023-02-27 03:56:07 +01:00
module . exports = PodcastManager