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"); import {tra} from "tc-shared/i18n/localize"; 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; 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(); 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; abstract async addSong(url: string, urlLoader: "any" | "youtube" | "ffmpeg" | "channel", targetSongId: number | 0, mode?: "before" | "after" | "last") : Promise; abstract async deleteEntry(entryId: number) : Promise; abstract async reorderEntry(entryId: number, targetEntryId: number, mode: "before" | "after") : Promise; getStatus() : Readonly { 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 { 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 { 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 { 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 { 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 } } = {}; /* 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 { 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); } }