2025-06-10 14:23:06 -05:00
import { PlaylistContent } from 'common/Packets' ;
import { downloadFile } from 'common/UtilityBackend' ;
import { Logger , LoggerType } from 'common/Logger' ;
import { fs } from 'modules/memfs' ;
import { v4 as uuidv4 } from 'modules/uuid' ;
import { Readable } from 'stream' ;
import * as os from 'os' ;
const logger = new Logger ( 'MediaCache' , LoggerType . BACKEND ) ;
class CacheObject {
public id : string ;
public size : number ;
public url : string ;
public path : string ;
constructor ( ) {
this . id = uuidv4 ( ) ;
this . size = 0 ;
this . path = ` /cache/ ${ this . id } ` ;
this . url = ` app:// ${ this . path } ` ;
}
}
export class MediaCache {
private static instance : MediaCache = null ;
2025-06-11 14:51:55 -05:00
private cache : Map < number , CacheObject > ;
private cacheUrlMap : Map < string , number > ;
2025-06-10 14:23:06 -05:00
private playlist : PlaylistContent ;
2025-06-11 14:51:55 -05:00
private playlistIndex : number ;
2025-06-10 14:23:06 -05:00
private quota : number ;
2025-06-11 14:51:55 -05:00
private cacheSize : number ;
private cacheWindowStart : number ;
private cacheWindowEnd : number ;
private pendingDownloads : Set < number > ;
private isDownloading : boolean ;
2025-06-10 14:23:06 -05:00
constructor ( playlist : PlaylistContent ) {
MediaCache . instance = this ;
this . playlist = playlist ;
2025-06-11 14:51:55 -05:00
this . playlistIndex = playlist . offset ? playlist.offset : 0 ;
this . cache = new Map < number , CacheObject > ( ) ;
this . cacheUrlMap = new Map < string , number > ( ) ;
this . cacheSize = 0 ;
this . cacheWindowStart = 0 ;
this . cacheWindowEnd = 0 ;
this . pendingDownloads = new Set ( ) ;
this . isDownloading = false ;
2025-06-10 14:23:06 -05:00
if ( ! fs . existsSync ( '/cache' ) ) {
fs . mkdirSync ( '/cache' ) ;
}
// @ts-ignore
if ( TARGET === 'electron' ) {
2025-06-11 14:51:55 -05:00
// this.quota = Math.min(Math.floor(os.freemem() / 4), 4 * 1024 * 1024 * 1024); // 4GB
this . quota = Math . min ( Math . floor ( os . freemem ( ) / 4 ) , 35 * 1024 * 1024 ) ; // 4GB
2025-06-10 14:23:06 -05:00
// @ts-ignore
} else if ( TARGET === 'webOS' || TARGET === 'tizenOS' ) {
this . quota = Math . min ( Math . floor ( os . freemem ( ) / 4 ) , 250 * 1024 * 1024 ) ; // 250MB
}
else {
this . quota = Math . min ( Math . floor ( os . freemem ( ) / 4 ) , 250 * 1024 * 1024 ) ; // 250MB
}
logger . info ( 'Created cache with storage byte quota:' , this . quota ) ;
}
public destroy() {
2025-06-11 14:51:55 -05:00
this . cache . forEach ( ( item ) = > { fs . unlinkSync ( item . path ) ; } ) ;
2025-06-10 14:23:06 -05:00
MediaCache . instance = null ;
this . cache . clear ( ) ;
this . cache = null ;
this . cacheUrlMap . clear ( ) ;
this . cacheUrlMap = null ;
this . playlist = null ;
this . quota = 0 ;
this . cacheSize = 0 ;
this . cacheWindowStart = 0 ;
this . cacheWindowEnd = 0 ;
2025-06-11 14:51:55 -05:00
this . pendingDownloads . clear ( ) ;
this . isDownloading = false ;
2025-06-10 14:23:06 -05:00
}
public static getInstance() {
return MediaCache . instance ;
}
public has ( playlistIndex : number ) : boolean {
return this . cache . has ( playlistIndex ) ;
}
public getUrl ( playlistIndex : number ) : string {
return this . cache . get ( playlistIndex ) . url ;
}
public getObject ( url : string , start : number = 0 , end : number = null ) : Readable {
const cacheObject = this . cache . get ( this . cacheUrlMap . get ( url ) ) ;
end = end ? end : cacheObject.size - 1 ;
return fs . createReadStream ( cacheObject . path , { start : start , end : end } ) ;
}
public getObjectSize ( url : string ) : number {
return this . cache . get ( this . cacheUrlMap . get ( url ) ) . size ;
}
2025-06-11 14:51:55 -05:00
public cacheItems ( playlistIndex : number ) {
this . playlistIndex = playlistIndex ;
2025-06-10 14:23:06 -05:00
2025-06-11 14:51:55 -05:00
if ( this . playlist . forwardCache && this . playlist . forwardCache > 0 ) {
let cacheAmount = this . playlist . forwardCache ;
2025-06-10 14:23:06 -05:00
2025-06-11 14:51:55 -05:00
for ( let i = playlistIndex + 1 ; i < this . playlist . items . length ; i ++ ) {
if ( cacheAmount === 0 ) {
2025-06-10 14:23:06 -05:00
break ;
}
2025-06-11 14:51:55 -05:00
if ( this . playlist . items [ i ] . cache ) {
cacheAmount -- ;
if ( ! this . cache . has ( i ) ) {
this . pendingDownloads . add ( i ) ;
2025-06-10 14:23:06 -05:00
}
2025-06-11 14:51:55 -05:00
}
}
}
2025-06-10 14:23:06 -05:00
2025-06-11 14:51:55 -05:00
if ( this . playlist . backwardCache && this . playlist . backwardCache > 0 ) {
let cacheAmount = this . playlist . backwardCache ;
2025-06-10 14:23:06 -05:00
2025-06-11 14:51:55 -05:00
for ( let i = playlistIndex - 1 ; i >= 0 ; i -- ) {
if ( cacheAmount === 0 ) {
2025-06-10 14:23:06 -05:00
break ;
}
2025-06-11 14:51:55 -05:00
if ( this . playlist . items [ i ] . cache ) {
cacheAmount -- ;
if ( ! this . cache . has ( i ) ) {
this . pendingDownloads . add ( i ) ;
}
}
2025-06-10 14:23:06 -05:00
}
}
2025-06-11 14:51:55 -05:00
this . updateCacheWindow ( ) ;
if ( ! this . isDownloading ) {
this . isDownloading = true ;
this . downloadItems ( ) ;
}
2025-06-10 14:23:06 -05:00
}
2025-06-11 14:51:55 -05:00
private downloadItems() {
if ( this . pendingDownloads . size > 0 ) {
let itemIndex = 0 ;
let minDistance = this . playlist . items . length ;
for ( let i of this . pendingDownloads . values ( ) ) {
if ( Math . abs ( this . playlistIndex - i ) < minDistance ) {
minDistance = Math . abs ( this . playlistIndex - i ) ;
itemIndex = i ;
}
else if ( Math . abs ( this . playlistIndex - i ) === minDistance && i > this . playlistIndex ) {
itemIndex = i ;
}
2025-06-10 14:23:06 -05:00
}
2025-06-11 14:51:55 -05:00
this . pendingDownloads . delete ( itemIndex ) ;
2025-06-10 14:23:06 -05:00
2025-06-11 14:51:55 -05:00
// Due to downloads being async, pending downloads can become out-of-sync with the current playlist index/target cache window
if ( ! this . shouldDownloadItem ( itemIndex ) ) {
logger . debug ( ` Discarding download index ${ itemIndex } since its outside cache window [ ${ this . cacheWindowStart } - ${ this . cacheWindowEnd } ] ` ) ;
this . downloadItems ( ) ;
return ;
2025-06-10 14:23:06 -05:00
}
2025-06-11 14:51:55 -05:00
const tempCacheObject = new CacheObject ( ) ;
downloadFile ( this . playlist . items [ itemIndex ] . url , tempCacheObject . path , true , this . playlist . items [ itemIndex ] . headers ,
( downloadedBytes : number ) = > {
let underQuota = true ;
if ( this . cacheSize + downloadedBytes > this . quota ) {
underQuota = this . purgeCacheItems ( itemIndex , downloadedBytes ) ;
}
return underQuota ;
} , null )
. then ( ( ) = > {
this . finalizeCacheItem ( tempCacheObject , itemIndex ) ;
this . downloadItems ( ) ;
} , ( error ) = > {
logger . warn ( error ) ;
this . downloadItems ( ) ;
} ) ;
}
else {
this . isDownloading = false ;
}
}
private shouldDownloadItem ( index : number ) : boolean {
let download = false ;
if ( index > this . playlistIndex ) {
if ( this . playlist . forwardCache && this . playlist . forwardCache > 0 ) {
const indexList = [ . . . this . cache . keys ( ) , index ] . sort ( ( a , b ) = > a - b ) ;
let forwardCacheItems = this . playlist . forwardCache ;
for ( let i of indexList ) {
if ( i > this . playlistIndex ) {
forwardCacheItems -- ;
if ( i === index ) {
download = true ;
}
else if ( forwardCacheItems === 0 ) {
break ;
}
}
}
2025-06-10 14:23:06 -05:00
}
}
2025-06-11 14:51:55 -05:00
else if ( index < this . playlistIndex ) {
if ( this . playlist . backwardCache && this . playlist . backwardCache > 0 ) {
const indexList = [ . . . this . cache . keys ( ) , index ] . sort ( ( a , b ) = > b - a ) ;
let backwardCacheItems = this . playlist . backwardCache ;
2025-06-10 14:23:06 -05:00
2025-06-11 14:51:55 -05:00
for ( let i of indexList ) {
if ( i < this . playlistIndex ) {
backwardCacheItems -- ;
2025-06-10 14:23:06 -05:00
2025-06-11 14:51:55 -05:00
if ( i === index ) {
download = true ;
}
else if ( backwardCacheItems === 0 ) {
break ;
}
}
}
2025-06-10 14:23:06 -05:00
}
}
2025-06-11 14:51:55 -05:00
return download ;
}
private purgeCacheItems ( downloadItem : number , downloadedBytes : number ) : boolean {
let underQuota = true ;
while ( this . cacheSize + downloadedBytes > this . quota ) {
let purgeIndex = this . playlistIndex ;
let purgeDistance = 0 ;
logger . debug ( ` Downloading item ${ downloadItem } with playlist index ${ this . playlistIndex } and cache window: [ ${ this . cacheWindowStart } - ${ this . cacheWindowEnd } ] ` ) ;
// Priority:
// 1. Purge first encountered item outside cache window
// 2. Purge item furthest from view index inside window (except next item from view index)
for ( let index of this . cache . keys ( ) ) {
if ( index === downloadItem || index === this . playlistIndex || index === this . playlistIndex + 1 ) {
continue ;
}
if ( index < this . cacheWindowStart || index > this . cacheWindowEnd ) {
purgeIndex = index ;
break ;
}
else if ( Math . abs ( this . playlistIndex - index ) > purgeDistance ) {
purgeDistance = Math . abs ( this . playlistIndex - index ) ;
purgeIndex = index ;
}
}
if ( purgeIndex !== this . playlistIndex ) {
const deleteItem = this . cache . get ( purgeIndex ) ;
fs . unlinkSync ( deleteItem . path ) ;
this . cacheSize -= deleteItem . size ;
this . cacheUrlMap . delete ( deleteItem . url ) ;
this . cache . delete ( purgeIndex ) ;
this . updateCacheWindow ( ) ;
logger . info ( ` Item ${ downloadItem } pending download ( ${ downloadedBytes } bytes) cannot fit in cache, purging ${ purgeIndex } from cache. Remaining quota ${ this . quota - this . cacheSize } bytes ` ) ;
}
else {
// Cannot purge current item since we may already be streaming it
logger . warn ( ` Aborting item caching, cannot fit item ${ downloadItem } ( ${ downloadedBytes } bytes) within remaining space quota ( ${ this . quota - this . cacheSize } bytes) ` ) ;
underQuota = false ;
break ;
}
2025-06-10 14:23:06 -05:00
}
return underQuota ;
}
2025-06-11 14:51:55 -05:00
private finalizeCacheItem ( cacheObject : CacheObject , index : number ) {
const size = fs . statSync ( cacheObject . path ) . size ;
2025-06-10 14:23:06 -05:00
cacheObject . size = size ;
this . cacheSize += size ;
logger . info ( ` Cached item ${ index } ( ${ cacheObject . size } bytes) with remaining quota ${ this . quota - this . cacheSize } bytes: ${ cacheObject . url } ` ) ;
this . cache . set ( index , cacheObject ) ;
this . cacheUrlMap . set ( cacheObject . url , index ) ;
2025-06-11 14:51:55 -05:00
this . updateCacheWindow ( ) ;
2025-06-10 14:23:06 -05:00
}
2025-06-11 14:51:55 -05:00
private updateCacheWindow() {
const indexList = [ . . . this . cache . keys ( ) ] . sort ( ( a , b ) = > a - b ) ;
2025-06-10 14:23:06 -05:00
if ( this . playlist . forwardCache && this . playlist . forwardCache > 0 ) {
let forwardCacheItems = this . playlist . forwardCache ;
2025-06-11 14:51:55 -05:00
for ( let index of indexList ) {
if ( index > this . playlistIndex ) {
2025-06-10 14:23:06 -05:00
forwardCacheItems -- ;
if ( forwardCacheItems === 0 ) {
this . cacheWindowEnd = index ;
break ;
}
}
}
}
else {
2025-06-11 14:51:55 -05:00
this . cacheWindowEnd = this . playlistIndex ;
2025-06-10 14:23:06 -05:00
}
if ( this . playlist . backwardCache && this . playlist . backwardCache > 0 ) {
let backwardCacheItems = this . playlist . backwardCache ;
2025-06-11 14:51:55 -05:00
for ( let index of indexList ) {
if ( index < this . playlistIndex ) {
2025-06-10 14:23:06 -05:00
backwardCacheItems -- ;
if ( backwardCacheItems === 0 ) {
this . cacheWindowStart = index ;
break ;
}
}
}
}
else {
2025-06-11 14:51:55 -05:00
this . cacheWindowStart = this . playlistIndex
2025-06-10 14:23:06 -05:00
}
}
}