Finish Favorites UI and jumping

This commit is contained in:
2025-02-23 13:46:31 -08:00
parent d6a375fff3
commit fe05a27b51
7 changed files with 336 additions and 68 deletions

View 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();
}
}

View File

@@ -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 => {

View File

@@ -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
View 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;
}