diff --git a/backend/favorites.json b/backend/favorites.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/backend/favorites.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/backend/src/FavoritesStore.ts b/backend/src/FavoritesStore.ts new file mode 100644 index 0000000..736cdbc --- /dev/null +++ b/backend/src/FavoritesStore.ts @@ -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 { + return this.favorites; + } + + async addFavorite(item: PlaylistItem, fetchMetadata: boolean = true): Promise { + // 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 { + 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 { + this.favorites = this.favorites.filter(f => f.id !== id); + await this.saveFavorites(); + } + + async clearFavorites(): Promise { + this.favorites = []; + await this.saveFavorites(); + } +} \ No newline at end of file diff --git a/backend/src/MediaPlayer.ts b/backend/src/MediaPlayer.ts index 4b68728..049b40a 100644 --- a/backend/src/MediaPlayer.ts +++ b/backend/src/MediaPlayer.ts @@ -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 = 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 { @@ -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 { + 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(func: () => Promise): Promise { 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 => { diff --git a/backend/src/server.ts b/backend/src/server.ts index 1189a98..7f9c07b 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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")); diff --git a/backend/src/types.ts b/backend/src/types.ts new file mode 100644 index 0000000..d524aef --- /dev/null +++ b/backend/src/types.ts @@ -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; +} \ No newline at end of file diff --git a/frontend/src/api/player.tsx b/frontend/src/api/player.tsx index b8186c7..2010d1f 100644 --- a/frontend/src/api/player.tsx +++ b/frontend/src/api/player.tsx @@ -61,6 +61,16 @@ export const API = { body: JSON.stringify({ url }), }); }, + + async replaceCurrentFile(url: string): Promise { + await fetch('/api/playlist/replace', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ url }), + }); + }, async removeFromPlaylist(index: number): Promise { await fetch(`/api/playlist/${index}`, { @@ -116,5 +126,38 @@ export const API = { }; return ws; + }, + + async getFavorites(): Promise { + const response = await fetch('/api/favorites'); + return response.json(); + }, + + async addToFavorites(url: string): Promise { + // Maybe a little weird to make an empty PlaylistItem here, but we do want to support adding + // known PlaylistItems to favorites in the future. + const playlistItem: PlaylistItem = { + filename: url, + title: null, + id: 0, + playing: null, + metadata: undefined, + }; + + await fetch('/api/favorites', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(playlistItem), + }); + }, + + async removeFromFavorites(id: number): Promise { + await fetch(`/api/favorites/${id}`, { method: 'DELETE' }); + }, + + async clearFavorites(): Promise { + await fetch('/api/favorites', { method: 'DELETE' }); } }; diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index ee5ebc9..a49d2c3 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -18,18 +18,81 @@ const EmptyContent: React.FC<{ label: string}> = ({label}) => ( ); +interface SonglistContentProps { + songs: PlaylistItem[]; + isPlaying: boolean; + onNeedsRefresh: () => void; +} + +const PlaylistContent: React.FC = ({ songs, isPlaying, onNeedsRefresh }) => { + const handleDelete = (index: number) => { + API.removeFromPlaylist(index); + onNeedsRefresh(); + }; + + const handleSkipTo = (index: number) => { + API.skipTo(index); + onNeedsRefresh(); + }; + + return ( + songs.length > 0 ? ( + + ) : ( + + ) + ); +}; + +const FavoritesContent: React.FC = ({ songs, isPlaying, onNeedsRefresh }) => { + const handleDelete = (index: number) => { + API.removeFromFavorites(index); + onNeedsRefresh(); + }; + + const handleSkipTo = (index: number) => { + API.replaceCurrentFile(songs[index].filename); + API.play(); + onNeedsRefresh(); + }; + + return ( + songs.length > 0 ? ( + + ) : ( + + ) + ); +}; + const App: React.FC = () => { const [isPlaying, setIsPlaying] = useState(false); const [nowPlayingSong, setNowPlayingSong] = useState(null); const [nowPlayingFileName, setNowPlayingFileName] = useState(null); const [volume, setVolume] = useState(100); const [volumeSettingIsLocked, setVolumeSettingIsLocked] = useState(false); - const [songs, setSongs] = useState([]); + const [playlist, setPlaylist] = useState([]); + const [favorites, setFavorites] = useState([]); const [selectedTab, setSelectedTab] = useState(Tabs.Playlist); const fetchPlaylist = useCallback(async () => { const playlist = await API.getPlaylist(); - setSongs(playlist); + setPlaylist(playlist); + }, []); + + const fetchFavorites = useCallback(async () => { + const favorites = await API.getFavorites(); + setFavorites(favorites); }, []); const fetchNowPlaying = useCallback(async () => { @@ -46,8 +109,13 @@ const App: React.FC = () => { const handleAddURL = async (url: string) => { const urlToAdd = url.trim(); if (urlToAdd) { - await API.addToPlaylist(urlToAdd); - fetchPlaylist(); + if (selectedTab === Tabs.Favorites) { + await API.addToFavorites(urlToAdd); + fetchFavorites(); + } else { + await API.addToPlaylist(urlToAdd); + fetchPlaylist(); + } if (!isPlaying) { await API.play(); @@ -55,26 +123,6 @@ const App: React.FC = () => { } }; - const handleDelete = (index: number) => { - setSongs(songs.filter((_, i) => i !== index)); - API.removeFromPlaylist(index); - fetchPlaylist(); - fetchNowPlaying(); - }; - - const handleSkipTo = async (index: number) => { - const song = songs[index]; - if (song.playing) { - togglePlayPause(); - } else { - await API.skipTo(index); - await API.play(); - } - - fetchNowPlaying(); - fetchPlaylist(); - }; - const togglePlayPause = async () => { if (isPlaying) { await API.pause(); @@ -109,8 +157,11 @@ const App: React.FC = () => { fetchPlaylist(); fetchNowPlaying(); break; + case 'favorites_update': + fetchFavorites(); + break; } - }, [fetchPlaylist, fetchNowPlaying]); + }, [fetchPlaylist, fetchNowPlaying, fetchFavorites]); // Use the hook useEventWebSocket(handleWebSocketEvent); @@ -130,24 +181,18 @@ const App: React.FC = () => { }; }, [fetchPlaylist, fetchNowPlaying]); + const refreshContent = () => { + fetchPlaylist(); + fetchNowPlaying(); + fetchFavorites(); + } + // Initial data fetch useEffect(() => { fetchPlaylist(); fetchNowPlaying(); - }, [fetchPlaylist, fetchNowPlaying]); - - const playlistContent = ( - songs.length > 0 ? ( - - ) : ( - - ) - ); + fetchFavorites(); + }, [fetchPlaylist, fetchNowPlaying, fetchFavorites]); return (
@@ -168,10 +213,18 @@ const App: React.FC = () => { }> - {playlistContent} + }> - + ({ ...f, playing: f.filename === nowPlayingFileName }))} + isPlaying={isPlaying} + onNeedsRefresh={refreshContent} + />