Finish Favorites UI and jumping
This commit is contained in:
112
backend/src/FavoritesStore.ts
Normal file
112
backend/src/FavoritesStore.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { PlaylistItem } from './types';
|
||||
import { getLinkPreview } from 'link-preview-js';
|
||||
|
||||
export class FavoritesStore {
|
||||
onFavoritesChanged: (favorites: PlaylistItem[]) => void = () => {};
|
||||
|
||||
private storePath: string;
|
||||
private favorites: PlaylistItem[] = [];
|
||||
|
||||
constructor() {
|
||||
this.storePath = this.determineStorePath();
|
||||
this.loadFavorites();
|
||||
}
|
||||
|
||||
private determineStorePath(): string {
|
||||
const storeFilename = 'favorites.json';
|
||||
var storePath = path.join(os.tmpdir(), 'queuecube');
|
||||
|
||||
// Check for explicitly set path
|
||||
if (process.env.STORE_PATH) {
|
||||
storePath = path.resolve(process.env.STORE_PATH);
|
||||
}
|
||||
|
||||
// In production (in a container), use /app/data
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
storePath = path.resolve('/app/data');
|
||||
}
|
||||
|
||||
fs.mkdir(storePath, { recursive: true }).catch(err => {
|
||||
console.error('Failed to create intermediate directory:', err);
|
||||
});
|
||||
|
||||
return path.join(storePath, storeFilename);
|
||||
}
|
||||
|
||||
private async loadFavorites() {
|
||||
try {
|
||||
// Ensure parent directory exists
|
||||
await fs.mkdir(path.dirname(this.storePath), { recursive: true });
|
||||
|
||||
const data = await fs.readFile(this.storePath, 'utf-8');
|
||||
this.favorites = JSON.parse(data);
|
||||
} catch (error) {
|
||||
// If file doesn't exist or is invalid, start with empty array
|
||||
this.favorites = [];
|
||||
await this.saveFavorites();
|
||||
}
|
||||
}
|
||||
|
||||
private async saveFavorites() {
|
||||
await fs.writeFile(this.storePath, JSON.stringify(this.favorites, null, 2));
|
||||
this.onFavoritesChanged(this.favorites);
|
||||
}
|
||||
|
||||
async getFavorites(): Promise<PlaylistItem[]> {
|
||||
return this.favorites;
|
||||
}
|
||||
|
||||
async addFavorite(item: PlaylistItem, fetchMetadata: boolean = true): Promise<void> {
|
||||
// Check if the item already exists by filename
|
||||
const exists = this.favorites.some(f => f.filename === item.filename);
|
||||
if (!exists) {
|
||||
this.favorites.push({
|
||||
...item,
|
||||
id: this.favorites.length // Generate new ID
|
||||
});
|
||||
await this.saveFavorites();
|
||||
} else {
|
||||
// Otherwise, update the item with the new metadata
|
||||
const index = this.favorites.findIndex(f => f.filename === item.filename);
|
||||
this.favorites[index] = {
|
||||
...this.favorites[index],
|
||||
...item,
|
||||
};
|
||||
await this.saveFavorites();
|
||||
}
|
||||
|
||||
// If the item is missing metadata, fetch it
|
||||
if (fetchMetadata && !item.metadata) {
|
||||
await this.fetchMetadata(item);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchMetadata(item: PlaylistItem): Promise<void> {
|
||||
console.log("Fetching metadata for " + item.filename);
|
||||
const metadata = await getLinkPreview(item.filename);
|
||||
|
||||
item.metadata = {
|
||||
title: (metadata as any)?.title,
|
||||
description: (metadata as any)?.description,
|
||||
siteName: (metadata as any)?.siteName,
|
||||
};
|
||||
|
||||
console.log("Metadata fetched for " + item.filename);
|
||||
console.log(item);
|
||||
|
||||
await this.addFavorite(item, false);
|
||||
}
|
||||
|
||||
async removeFavorite(id: number): Promise<void> {
|
||||
this.favorites = this.favorites.filter(f => f.id !== id);
|
||||
await this.saveFavorites();
|
||||
}
|
||||
|
||||
async clearFavorites(): Promise<void> {
|
||||
this.favorites = [];
|
||||
await this.saveFavorites();
|
||||
}
|
||||
}
|
||||
@@ -2,31 +2,18 @@ import { ChildProcess, spawn } from "child_process";
|
||||
import { Socket } from "net";
|
||||
import { WebSocket } from "ws";
|
||||
import { getLinkPreview } from "link-preview-js";
|
||||
|
||||
import { PlaylistItem, LinkMetadata } from './types';
|
||||
import { FavoritesStore } from "./FavoritesStore";
|
||||
interface PendingCommand {
|
||||
resolve: (value: any) => void;
|
||||
reject: (reason: any) => void;
|
||||
}
|
||||
|
||||
interface LinkMetadata {
|
||||
title?: string;
|
||||
description?: string;
|
||||
siteName?: string;
|
||||
}
|
||||
|
||||
interface PlaylistItem {
|
||||
id: number;
|
||||
filename: string;
|
||||
title?: string;
|
||||
playing?: boolean;
|
||||
current?: boolean;
|
||||
metadata?: LinkMetadata;
|
||||
}
|
||||
|
||||
export class MediaPlayer {
|
||||
private playerProcess: ChildProcess;
|
||||
private socket: Socket;
|
||||
private eventSubscribers: WebSocket[] = [];
|
||||
private favoritesStore: FavoritesStore;
|
||||
|
||||
private pendingCommands: Map<number, PendingCommand> = new Map();
|
||||
private requestId: number = 1;
|
||||
@@ -53,6 +40,11 @@ export class MediaPlayer {
|
||||
this.connectToSocket(socketPath);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
this.favoritesStore = new FavoritesStore();
|
||||
this.favoritesStore.onFavoritesChanged = (favorites) => {
|
||||
this.handleEvent("favorites_update", { favorites });
|
||||
};
|
||||
}
|
||||
|
||||
public async getPlaylist(): Promise<PlaylistItem[]> {
|
||||
@@ -123,14 +115,11 @@ export class MediaPlayer {
|
||||
}
|
||||
|
||||
public async append(url: string) {
|
||||
const result = await this.modify(() => this.writeCommand("loadfile", [url, "append-play"]));
|
||||
|
||||
// Asynchronously fetch the metadata for this after we update the playlist
|
||||
this.fetchMetadataAndNotify(url).catch(error => {
|
||||
console.warn(`Failed to fetch metadata for ${url}:`, error);
|
||||
});
|
||||
|
||||
return result;
|
||||
await this.loadFile(url, "append-play");
|
||||
}
|
||||
|
||||
public async replace(url: string) {
|
||||
await this.loadFile(url, "replace");
|
||||
}
|
||||
|
||||
public async play() {
|
||||
@@ -169,6 +158,30 @@ export class MediaPlayer {
|
||||
this.eventSubscribers = this.eventSubscribers.filter(subscriber => subscriber !== ws);
|
||||
}
|
||||
|
||||
public async getFavorites(): Promise<PlaylistItem[]> {
|
||||
return this.favoritesStore.getFavorites();
|
||||
}
|
||||
|
||||
public async addFavorite(item: PlaylistItem) {
|
||||
return this.favoritesStore.addFavorite(item);
|
||||
}
|
||||
|
||||
public async removeFavorite(id: number) {
|
||||
return this.favoritesStore.removeFavorite(id);
|
||||
}
|
||||
|
||||
public async clearFavorites() {
|
||||
return this.favoritesStore.clearFavorites();
|
||||
}
|
||||
|
||||
private async loadFile(url: string, mode: string) {
|
||||
this.modify(() => this.writeCommand("loadfile", [url, mode]));
|
||||
|
||||
this.fetchMetadataAndNotify(url).catch(error => {
|
||||
console.warn(`Failed to fetch metadata for ${url}:`, error);
|
||||
});
|
||||
}
|
||||
|
||||
private async modify<T>(func: () => Promise<T>): Promise<T> {
|
||||
return func()
|
||||
.then((result) => {
|
||||
@@ -205,12 +218,16 @@ export class MediaPlayer {
|
||||
|
||||
private async fetchMetadataAndNotify(url: string) {
|
||||
try {
|
||||
console.log("Fetching metadata for " + url);
|
||||
const metadata = await getLinkPreview(url);
|
||||
this.metadata.set(url, {
|
||||
title: (metadata as any)?.title,
|
||||
description: (metadata as any)?.description,
|
||||
siteName: (metadata as any)?.siteName,
|
||||
});
|
||||
|
||||
console.log("Metadata fetched for " + url);
|
||||
console.log(this.metadata.get(url));
|
||||
|
||||
// Notify clients that metadata has been updated
|
||||
this.handleEvent("metadata_update", {
|
||||
@@ -228,7 +245,7 @@ export class MediaPlayer {
|
||||
}
|
||||
|
||||
private handleEvent(event: string, data: any) {
|
||||
console.log("MPV Event [" + event + "]: " + JSON.stringify(data, null, 2));
|
||||
console.log("Event [" + event + "]: " + JSON.stringify(data, null, 2));
|
||||
|
||||
// Notify all subscribers
|
||||
this.eventSubscribers.forEach(subscriber => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import express from "express";
|
||||
import expressWs from "express-ws";
|
||||
import { MediaPlayer } from "./MediaPlayer";
|
||||
import { searchInvidious, getInvidiousThumbnailURL, fetchThumbnail } from "./InvidiousAPI";
|
||||
import fetch from "node-fetch";
|
||||
import { searchInvidious, fetchThumbnail } from "./InvidiousAPI";
|
||||
import { PlaylistItem } from './types';
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -38,6 +38,12 @@ apiRouter.delete("/playlist/:index", withErrorHandling(async (req, res) => {
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.post("/playlist/replace", withErrorHandling(async (req, res) => {
|
||||
const { url } = req.body as { url: string };
|
||||
await mediaPlayer.replace(url);
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.post("/play", withErrorHandling(async (req, res) => {
|
||||
await mediaPlayer.play();
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
@@ -128,6 +134,28 @@ apiRouter.get("/thumbnail", withErrorHandling(async (req, res) => {
|
||||
}
|
||||
}));
|
||||
|
||||
apiRouter.get("/favorites", withErrorHandling(async (req, res) => {
|
||||
const favorites = await mediaPlayer.getFavorites();
|
||||
res.send(JSON.stringify(favorites));
|
||||
}));
|
||||
|
||||
apiRouter.post("/favorites", withErrorHandling(async (req, res) => {
|
||||
const item = req.body as PlaylistItem;
|
||||
await mediaPlayer.addFavorite(item);
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.delete("/favorites/:id", withErrorHandling(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
await mediaPlayer.removeFavorite(parseInt(id));
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.delete("/favorites", withErrorHandling(async (req, res) => {
|
||||
await mediaPlayer.clearFavorites();
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
// Serve static files for React app (after building)
|
||||
app.use(express.static("dist/frontend"));
|
||||
|
||||
|
||||
14
backend/src/types.ts
Normal file
14
backend/src/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export interface LinkMetadata {
|
||||
title?: string;
|
||||
description?: string;
|
||||
siteName?: string;
|
||||
}
|
||||
|
||||
export interface PlaylistItem {
|
||||
id: number;
|
||||
filename: string;
|
||||
title?: string;
|
||||
playing?: boolean;
|
||||
current?: boolean;
|
||||
metadata?: LinkMetadata;
|
||||
}
|
||||
Reference in New Issue
Block a user