2020-12-29 16:53:04 +01:00
import { ConnectionHandler } from "tc-shared/ConnectionHandler" ;
import { LogCategory , logError , logWarn } from "tc-shared/log" ;
import { Registry } from "tc-shared/events" ;
import { CommandResult } from "tc-shared/connection/ServerConnectionDeclaration" ;
import { ErrorCode } from "tc-shared/connection/ErrorCode" ;
import _ = require ( "lodash" ) ;
2021-04-24 13:59:49 +02:00
import { tra } from "tc-shared/i18n/localize" ;
2020-12-29 16:53:04 +01:00
export type PlaylistEntry = {
type : "song" ,
id : number ,
previousId : number ,
url : string ,
urlLoader : string ,
invokerDatabaseId : number ,
metadata : PlaylistSongMetadata
}
export type PlaylistSongMetadata = {
status : "loading"
} | {
status : "unparsed" ,
metadata : string
} | {
status : "loaded" ,
metadata : string ,
title : string ,
description : string ,
thumbnailUrl? : string ,
length : number ,
} ;
function parseUnparsedSongMetadata ( metadata : string ) : PlaylistSongMetadata {
const meta = JSON . parse ( metadata ) ;
return {
status : "loaded" ,
metadata : metadata ,
title : meta [ "title" ] ,
description : meta [ "description" ] ,
length : parseInt ( meta [ "length" ] ) ,
thumbnailUrl : ! meta [ "thumbnail" ] || meta [ "thumbnail" ] === "none" ? undefined : meta [ "thumbnail" ]
} ;
}
function parsePlayListSongEntry ( data : any ) {
const result : PlaylistEntry = {
type : "song" ,
id : parseInt ( data [ "song_id" ] ) ,
previousId : parseInt ( data [ "song_previous_song_id" ] ) ,
url : data [ "song_url" ] ,
urlLoader : data [ "song_url_loader" ] ,
invokerDatabaseId : parseInt ( data [ "song_invoker" ] ) ,
metadata : { status : "loading" }
} ;
if ( isNaN ( result . id ) ) {
throw tra ( "failed to parse song_id as an integer ({})" , data [ "song_id" ] ) ;
}
if ( isNaN ( result . previousId ) ) {
throw tra ( "failed to parse song_previous_song_id as an integer ({})" , data [ "song_previous_song_id" ] ) ;
}
if ( isNaN ( result . invokerDatabaseId ) ) {
throw tra ( "failed to parse song_invoker as an integer ({})" , data [ "song_invoker" ] ) ;
}
if ( parseInt ( data [ "song_loaded" ] ) === 1 ) {
if ( typeof data [ "song_metadata_title" ] !== "undefined" ) {
result . metadata = {
status : "loaded" ,
metadata : data [ "song_metadata" ] ,
title : data [ "song_metadata_title" ] ,
description : data [ "song_metadata_description" ] ,
length : parseInt ( data [ "song_metadata_length" ] ) ,
thumbnailUrl : ! data [ "song_metadata_thumbnail_url" ] || data [ "song_metadata_thumbnail_url" ] === "none" ? undefined : data [ "song_metadata_thumbnail_url" ]
} ;
if ( isNaN ( result . metadata . length ) ) {
throw tra ( "failed to parse song_metadata_length as an integer ({})" , data [ "song_metadata_length" ] ) ;
}
} else if ( typeof data [ "song_metadata" ] === "string" ) {
try {
result . metadata = parseUnparsedSongMetadata ( data [ "song_metadata" ] ) ;
} catch ( _error ) {
result . metadata = {
status : "unparsed" ,
metadata : data [ "song_metadata" ]
} ;
}
} else {
throw tr ( "Missing song metadata" ) ;
}
}
return result ;
}
export interface SubscribedPlaylistEvents {
notify_status_changed : { } ,
notify_entry_added : { entry : PlaylistEntry } ,
notify_entry_deleted : { entry : PlaylistEntry } ,
notify_entry_reordered : { entry : PlaylistEntry , oldPreviousId : number } ,
notify_entry_updated : { entry : PlaylistEntry } ,
}
export type SubscribedPlaylistStatus = {
status : "loaded" ,
songs : PlaylistEntry [ ]
} | {
status : "loading" ,
} | {
status : "error" ,
error : string
} | {
status : "no-permissions" ,
failedPermission : string
} | {
status : "unloaded"
}
export abstract class SubscribedPlaylist {
readonly events : Registry < SubscribedPlaylistEvents > ;
readonly playlistId : number ;
readonly serverUniqueId : string ;
protected status : SubscribedPlaylistStatus ;
protected refCount : number ;
protected constructor ( serverUniqueId : string , playlistId : number ) {
this . refCount = 1 ;
this . events = new Registry < SubscribedPlaylistEvents > ( ) ;
this . playlistId = playlistId ;
this . serverUniqueId = serverUniqueId ;
this . status = { status : "unloaded" } ;
}
ref ( ) : SubscribedPlaylist {
this . refCount ++ ;
return this ;
}
unref() {
if ( -- this . refCount === 0 ) {
this . destroy ( ) ;
}
}
destroy() {
this . events . destroy ( ) ;
}
/ * *
* Query the playlist songs from the remote server .
* The playlist status will change on a successfully or failed query .
*
* @param forceQuery Forcibly query even we ' re subscribed and already aware of all songs .
* /
abstract async querySongs ( forceQuery : boolean ) : Promise < void > ;
abstract async addSong ( url : string , urlLoader : "any" | "youtube" | "ffmpeg" | "channel" , targetSongId : number | 0 , mode ? : "before" | "after" | "last" ) : Promise < void > ;
abstract async deleteEntry ( entryId : number ) : Promise < void > ;
abstract async reorderEntry ( entryId : number , targetEntryId : number , mode : "before" | "after" ) : Promise < void > ;
getStatus ( ) : Readonly < SubscribedPlaylistStatus > {
return this . status ;
}
protected setStatus ( status : SubscribedPlaylistStatus ) {
if ( _ . isEqual ( this . status , status ) ) {
return ;
}
this . status = status ;
this . events . fire ( "notify_status_changed" ) ;
}
}
class InternalSubscribedPlaylist extends SubscribedPlaylist {
private readonly handle : PlaylistManager ;
private readonly listenerConnection : ( ( ) = > void ) [ ] ;
private playlistSubscribed = false ;
constructor ( handle : PlaylistManager , playlistId : number ) {
super ( handle . connection . getCurrentServerUniqueId ( ) , playlistId ) ;
this . handle = handle ;
this . listenerConnection = [ ] ;
const serverConnection = this . handle . connection . serverConnection ;
this . listenerConnection . push ( serverConnection . command_handler_boss ( ) . register_explicit_handler ( "notifyplaylistsongadd" , command = > {
const playlistId = parseInt ( command . arguments [ 0 ] [ "playlist_id" ] ) ;
if ( isNaN ( playlistId ) ) {
logWarn ( LogCategory . NETWORKING , tr ( "Received a playlist song add notify with an invalid playlist id (%o)" ) , command . arguments [ 0 ] [ "playlist_id" ] ) ;
return ;
}
if ( playlistId !== this . playlistId || this . status . status !== "loaded" ) {
return ;
}
const song = parsePlayListSongEntry ( command . arguments [ 0 ] ) ;
InternalSubscribedPlaylist . insertEntry ( this . status . songs , song ) ;
this . events . fire ( "notify_entry_added" , {
entry : song
} ) ;
} ) ) ;
this . listenerConnection . push ( serverConnection . command_handler_boss ( ) . register_explicit_handler ( "notifyplaylistsongremove" , command = > {
const playlistId = parseInt ( command . arguments [ 0 ] [ "playlist_id" ] ) ;
if ( isNaN ( playlistId ) ) {
logWarn ( LogCategory . NETWORKING , tr ( "Received a playlist song remove notify with an invalid playlist id (%o)" ) , command . arguments [ 0 ] [ "playlist_id" ] ) ;
return ;
}
if ( playlistId !== this . playlistId || this . status . status !== "loaded" ) {
return ;
}
const songId = parseInt ( command . arguments [ 0 ] [ "song_id" ] ) ;
const song = this . removeEntry ( this . status . songs , songId ) ;
if ( ! song ) {
return ;
}
this . events . fire ( "notify_entry_deleted" , {
entry : song
} ) ;
} ) ) ;
this . listenerConnection . push ( serverConnection . command_handler_boss ( ) . register_explicit_handler ( "notifyplaylistsongreorder" , command = > {
const playlistId = parseInt ( command . arguments [ 0 ] [ "playlist_id" ] ) ;
if ( isNaN ( playlistId ) ) {
logWarn ( LogCategory . NETWORKING , tr ( "Received a playlist song reorder notify with an invalid playlist id (%o)" ) , command . arguments [ 0 ] [ "playlist_id" ] ) ;
return ;
}
if ( playlistId !== this . playlistId || this . status . status !== "loaded" ) {
return ;
}
const entryId = parseInt ( command . arguments [ 0 ] [ "song_id" ] ) ;
const previousEntryId = parseInt ( command . arguments [ 0 ] [ "song_previous_song_id" ] ) ;
if ( isNaN ( entryId ) ) {
logError ( LogCategory . NETWORKING , tr ( "Failed to parse song_id of playlist song reorder notify: %o" ) , command . arguments [ 0 ] [ "song_id" ] ) ;
return ;
}
if ( isNaN ( entryId ) ) {
logError ( LogCategory . NETWORKING , tr ( "Failed to parse song_previous_song_id of playlist song reorder notify: %o" ) , command . arguments [ 0 ] [ "song_previous_song_id" ] ) ;
return ;
}
const entry = this . removeEntry ( this . status . songs , entryId ) ;
if ( ! entry ) {
return ;
}
const oldOrderId = entry . previousId ;
entry . previousId = previousEntryId ;
InternalSubscribedPlaylist . insertEntry ( this . status . songs , entry ) ;
this . events . fire ( "notify_entry_reordered" , {
entry : entry ,
oldPreviousId : oldOrderId
} ) ;
} ) ) ;
this . listenerConnection . push ( serverConnection . command_handler_boss ( ) . register_explicit_handler ( "notifyplaylistsongloaded" , command = > {
const playlistId = parseInt ( command . arguments [ 0 ] [ "playlist_id" ] ) ;
if ( isNaN ( playlistId ) ) {
logWarn ( LogCategory . NETWORKING , tr ( "Received a playlist song loaded notify with an invalid playlist id (%o)" ) , command . arguments [ 0 ] [ "playlist_id" ] ) ;
return ;
}
if ( playlistId !== this . playlistId || this . status . status !== "loaded" ) {
return ;
}
const entryId = parseInt ( command . arguments [ 0 ] [ "song_id" ] ) ;
const entry = this . status . songs . find ( entry = > entry . id === entryId ) ;
const success = parseInt ( command . arguments [ 0 ] [ "success" ] ) === 1 ;
if ( ! success ) {
/* TODO: Change the entry type to failed and respect: load_error_msg */
this . removeEntry ( this . status . songs , entryId ) ;
this . events . fire ( "notify_entry_deleted" , {
entry : entry ,
} ) ;
} else if ( entry . metadata . status !== "loaded" ) {
try {
entry . metadata = parseUnparsedSongMetadata ( command . arguments [ 0 ] [ "song_metadata" ] ) ;
} catch ( error ) {
entry . metadata = {
status : "unparsed" ,
metadata : command.arguments [ 0 ] [ "song_metadata" ]
} ;
}
this . events . fire ( "notify_entry_updated" , {
entry : entry
} ) ;
}
} ) ) ;
}
destroy() {
super . destroy ( ) ;
this . listenerConnection . forEach ( callback = > callback ( ) ) ;
this . listenerConnection . splice ( 0 , this . listenerConnection . length ) ;
if ( this . handle [ "subscribedPlaylist" ] === this ) {
this . handle [ "subscribedPlaylist" ] = undefined ;
}
}
handleUnsubscribed() {
this . playlistSubscribed = false ;
this . setStatus ( { status : "unloaded" } ) ;
if ( this . handle [ "subscribedPlaylist" ] === this ) {
this . handle [ "subscribedPlaylist" ] = undefined ;
}
}
async querySongs ( forceQuery : boolean ) : Promise < void > {
if ( this . status . status === "loading" ) {
return ;
} else if ( this . status . status === "loaded" && ! forceQuery ) {
return ;
}
this . setStatus ( { status : "loading" } ) ;
try {
/* firstly subscribe to the playlist */
if ( ! this . playlistSubscribed ) {
await this . handle . connection . serverConnection . send_command ( "playlistsetsubscription" , { playlist_id : this.playlistId } ) ;
if ( this . handle [ "subscribedPlaylist" ] !== this ) {
this . handle [ "subscribedPlaylist" ] ? . handleUnsubscribed ( ) ;
}
this . handle [ "subscribedPlaylist" ] = this ;
this . playlistSubscribed = true ;
}
/* now we can query the entries */
const entries = await this . handle . queryPlaylistEntries ( this . playlistId ) ;
/* TODO: Sort these entries! */
this . setStatus ( { status : "loaded" , songs : entries } ) ;
} catch ( error ) {
if ( error instanceof CommandResult ) {
if ( error . id === ErrorCode . SERVER_INSUFFICIENT_PERMISSIONS ) {
this . setStatus ( { status : "no-permissions" , failedPermission : this.handle.connection.permissions.getFailedPermission ( error ) } ) ;
return ;
} else if ( error . id === ErrorCode . DATABASE_EMPTY_RESULT ) {
this . setStatus ( { status : "loaded" , songs : [ ] } ) ;
return ;
}
error = error . formattedMessage ( ) ;
} else if ( typeof error !== "string" ) {
logError ( LogCategory . GENERAL , tr ( "Failed to query subscribed playlist entries: %o" ) , error ) ;
error = tr ( "Lookup the console for details" ) ;
}
this . setStatus ( { status : "error" , error : error } ) ;
}
}
async deleteEntry ( entryId : number ) : Promise < void > {
await this . handle . removeSong ( this . playlistId , entryId ) ;
}
private calculatePreviousSong ( targetEntryId : number | undefined , mode : "before" | "after" | "last" ) : number {
if ( targetEntryId === 0 ) {
return 0 ;
} else if ( mode === "before" ) {
if ( this . status . status !== "loaded" ) {
throw tr ( "Invalid playlist state" ) ;
}
const songIndex = this . status . songs . findIndex ( song = > song . id === targetEntryId ) ;
if ( songIndex === - 1 ) {
throw tr ( "Invalid target id" ) ;
}
return songIndex === 0 ? 0 : this.status.songs [ songIndex - 1 ] . id ;
} else if ( mode === "last" ) {
if ( this . status . status !== "loaded" ) {
throw tr ( "Invalid playlist state" ) ;
}
return this . status . songs . last ( ) ? . id || 0 ;
} else {
return targetEntryId ;
}
}
async reorderEntry ( entryId : number , targetEntryId : number , mode : "before" | "after" ) : Promise < void > {
await this . handle . reorderSong ( this . playlistId , entryId , this . calculatePreviousSong ( targetEntryId , mode ) ) ;
}
async addSong ( url : string , urlLoader : "any" | "youtube" | "ffmpeg" | "channel" , targetSongId : number | 0 , mode ? : "before" | "after" | "last" ) : Promise < void > {
await this . handle . addSong ( this . playlistId , url , urlLoader , this . calculatePreviousSong ( targetSongId , mode || "after" ) ) ;
}
private static insertEntry ( playlist : PlaylistEntry [ ] , entry : PlaylistEntry ) {
const index = playlist . findIndex ( e = > e . id === entry . previousId ) ;
const previousEntry = playlist [ index ] ;
const nextEntry = playlist [ index + 1 ] ;
playlist . splice ( index + 1 , 0 , entry ) ;
entry . previousId = previousEntry ? previousEntry.id : 0 ;
if ( nextEntry ) {
nextEntry . previousId = entry . id ;
}
}
private removeEntry ( playlist : PlaylistEntry [ ] , entryId : number ) : PlaylistEntry | undefined {
const index = playlist . findIndex ( entry = > entry . id === entryId ) ;
if ( index === - 1 ) {
return undefined ;
}
const [ song ] = playlist . splice ( index , 1 ) ;
const previousEntry = playlist [ index - 1 ] ;
const nextEntry = playlist [ index ] ;
if ( nextEntry ) {
nextEntry . previousId = previousEntry ? previousEntry.id : 0 ;
}
return song ;
}
}
export class PlaylistManager {
readonly connection : ConnectionHandler ;
private listenerConnection : ( ( ) = > void ) [ ] ;
private playlistEntryListCache : {
[ key : number ] : {
result : PlaylistEntry [ ] ,
promise : Promise < void >
}
} = { } ;
/* Use internally by InternalSubscribedPlaylist. Do not remove! */
private subscribedPlaylist : InternalSubscribedPlaylist ;
constructor ( connection : ConnectionHandler ) {
this . connection = connection ;
this . listenerConnection = [ ] ;
this . listenerConnection . push ( connection . serverConnection . command_handler_boss ( ) . register_explicit_handler ( "notifyplaylistsonglist" , command = > {
const playlistId = parseInt ( command . arguments [ 0 ] [ "playlist_id" ] ) ;
if ( isNaN ( playlistId ) ) {
logWarn ( LogCategory . NETWORKING , tr ( "Received playlist song list notify with an invalid playlist id (%o)" ) , command . arguments [ 0 ] [ "playlist_id" ] ) ;
return ;
}
const cache = this . playlistEntryListCache [ playlistId ] ;
if ( cache ) {
for ( const entry of command . arguments ) {
if ( ! ( "song_id" in entry ) ) {
/* Some TeaSpeak versions are sending empty bulks... */
continue ;
}
try {
cache . result . push ( parsePlayListSongEntry ( entry ) ) ;
} catch ( error ) {
logWarn ( LogCategory . NETWORKING , tr ( "Failed to parse playlist entry: %o" ) , error ) ;
}
}
}
} ) ) ;
}
destroy() {
this . listenerConnection . forEach ( callback = > callback ( ) ) ;
this . listenerConnection . splice ( 0 , this . listenerConnection . length ) ;
this . playlistEntryListCache = { } ;
}
async queryPlaylistEntries ( playlistId : number ) : Promise < PlaylistEntry [ ] > {
let cache = this . playlistEntryListCache [ playlistId ] ;
if ( ! cache ) {
cache = this . playlistEntryListCache [ playlistId ] = {
result : [ ] ,
promise : ( async ( ) = > {
try {
await this . connection . serverConnection . send_command ( "playlistsonglist" , { "playlist_id" : playlistId } ) ;
} finally {
delete this . playlistEntryListCache [ playlistId ] ;
}
} ) ( )
} ;
}
await cache . promise ;
return cache . result ;
}
async reorderSong ( playlistId : number , songId : number , previousSongId : number ) {
await this . connection . serverConnection . send_command ( "playlistsongreorder" , {
"playlist_id" : playlistId ,
"song_id" : songId ,
"song_previous_song_id" : previousSongId
} ) ;
}
async addSong ( playlistId : number , url : string , urlLoader : "any" | "youtube" | "ffmpeg" | "channel" , previousSongId : number | 0 ) {
await this . connection . serverConnection . send_command ( "playlistsongadd" , {
"playlist_id" : playlistId ,
"previous" : previousSongId ,
"url" : url ,
"type" : urlLoader
} ) ;
}
async removeSong ( playlistId : number , entryId : number ) {
await this . connection . serverConnection . send_command ( "playlistsongremove" , {
"playlist_id" : playlistId ,
"song_id" : entryId ,
} ) ;
}
/ * *
* @param playlistId
* @return Returns a subscribed playlist .
* Attention : You have to manually destroy the object !
* /
createSubscribedPlaylist ( playlistId : number ) : SubscribedPlaylist {
return new InternalSubscribedPlaylist ( this , playlistId ) ;
}
}