TeaWeb/shared/js/music/PlaylistManager.ts

556 lines
19 KiB
TypeScript

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<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 querySongs(forceQuery: boolean) : Promise<void>;
abstract addSong(url: string, urlLoader: "any" | "youtube" | "ffmpeg" | "channel", targetSongId: number | 0, mode?: "before" | "after" | "last") : Promise<void>;
abstract deleteEntry(entryId: number) : Promise<void>;
abstract 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.getCommandHandler().registerCommandHandler("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.getCommandHandler().registerCommandHandler("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.getCommandHandler().registerCommandHandler("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.getCommandHandler().registerCommandHandler("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.getCommandHandler().registerCommandHandler("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);
}
}