12 Commits
2.1.1 ... 2.3

12 changed files with 496 additions and 33 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
build/ build/
result
node_modules/ node_modules/
tsconfig.tsbuildinfo tsconfig.tsbuildinfo

View File

@@ -7,14 +7,11 @@ WORKDIR /app
COPY backend/package*.json ./backend/ COPY backend/package*.json ./backend/
COPY frontend/package*.json ./frontend/ COPY frontend/package*.json ./frontend/
# Install dependencies
RUN cd backend && npm ci
RUN cd frontend && npm ci
# Copy source files # Copy source files
COPY . . COPY . .
# Build frontend and backend # Build frontend and backend
RUN npm install
RUN npm run build --workspaces RUN npm run build --workspaces
# Production stage # Production stage

View File

@@ -32,3 +32,40 @@ On some systems, you may need to add `--security-opt seccomp=unconfined` to allo
Once running, you should be able to access the UI via http://localhost:8080. Once running, you should be able to access the UI via http://localhost:8080.
### Video
QueueCube supports video as well. Just set the environment variable `ENABLE_VIDEO=1`.
#### Video + Docker
When running in a Docker container, there are a few extra steps needed to make this work.
First, make sure you're passing in `/dev/dri` as a volume in the container. Since mpd is capable of rendering directly to the GPU, this would be the most performant.
```
volumes:
- /dev/dri:/dev/dri
```
as well as the X11 socket:
```
- /tmp/.X11-unix:/tmp/.X11-unix
```
In order to be able to interact with the X session from the Docker container, you may also need to explicitly allow connections to the host via:
```
$ xhost +local:
```
Since the container will also need to be able to access the GPU device, it may be required to run the container in "privileged" mode:
```
privileged: true
```
(or `--privileged`).
On Podman rootless, it seems to be enough to just run it with the "unconfined" security profile (`seccomp=unconfined`).

View File

@@ -1 +0,0 @@
[]

View File

@@ -106,6 +106,24 @@ export class FavoritesStore {
await this.saveFavorites(); await this.saveFavorites();
} }
async updateFavoriteTitle(filename: string, title: string): Promise<void> {
console.log(`Updating title for favorite ${filename} to "${title}"`);
const index = this.favorites.findIndex(f => f.filename === filename);
if (index !== -1) {
// Create metadata object if it doesn't exist
if (!this.favorites[index].metadata) {
this.favorites[index].metadata = {};
}
// Update the title in metadata
this.favorites[index].metadata!.title = title;
await this.saveFavorites();
} else {
throw new Error(`Favorite with filename ${filename} not found`);
}
}
async clearFavorites(): Promise<void> { async clearFavorites(): Promise<void> {
this.favorites = []; this.favorites = [];
await this.saveFavorites(); await this.saveFavorites();

View File

@@ -33,10 +33,12 @@ export class MediaPlayer {
constructor() { constructor() {
const socketFilename = Math.random().toString(36).substring(2, 10); const socketFilename = Math.random().toString(36).substring(2, 10);
const socketPath = `/tmp/mpv-${socketFilename}`; const socketPath = `/tmp/mpv-${socketFilename}`;
const enableVideo = process.env.ENABLE_VIDEO || false;
console.log("Starting player process"); console.log("Starting player process (video: " + (enableVideo ? "enabled" : "disabled") + ")");
this.playerProcess = spawn("mpv", [ this.playerProcess = spawn("mpv", [
"--no-video", "--video=" + (enableVideo ? "auto" : "no"),
"--fullscreen",
"--no-terminal", "--no-terminal",
"--idle=yes", "--idle=yes",
"--input-ipc-server=" + socketPath "--input-ipc-server=" + socketPath
@@ -173,15 +175,19 @@ export class MediaPlayer {
} }
public async addFavorite(filename: string) { public async addFavorite(filename: string) {
return this.favoritesStore.addFavorite(filename); return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.addFavorite(filename));
} }
public async removeFavorite(filename: string) { public async removeFavorite(filename: string) {
return this.favoritesStore.removeFavorite(filename); return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.removeFavorite(filename));
} }
public async clearFavorites() { public async clearFavorites() {
return this.favoritesStore.clearFavorites(); return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.clearFavorites());
}
public async updateFavoriteTitle(filename: string, title: string) {
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.updateFavoriteTitle(filename, title));
} }
private async loadFile(url: string, mode: string) { private async loadFile(url: string, mode: string) {
@@ -255,7 +261,7 @@ export class MediaPlayer {
} }
private handleEvent(event: string, data: any) { private handleEvent(event: string, data: any) {
console.log("Event [" + event + "]: " + JSON.stringify(data, null, 2)); console.log("Event [" + event + "]: ", data);
// Notify all subscribers // Notify all subscribers
this.eventSubscribers.forEach(subscriber => { this.eventSubscribers.forEach(subscriber => {
@@ -292,4 +298,4 @@ export class MediaPlayer {
} }
} }
} }
} }

View File

@@ -1,5 +1,6 @@
import express from "express"; import express from "express";
import expressWs from "express-ws"; import expressWs from "express-ws";
import path from "path";
import { MediaPlayer } from "./MediaPlayer"; import { MediaPlayer } from "./MediaPlayer";
import { searchInvidious, fetchThumbnail } from "./InvidiousAPI"; import { searchInvidious, fetchThumbnail } from "./InvidiousAPI";
import { PlaylistItem } from './types'; import { PlaylistItem } from './types';
@@ -157,15 +158,31 @@ apiRouter.delete("/favorites", withErrorHandling(async (req, res) => {
res.send(JSON.stringify({ success: true })); res.send(JSON.stringify({ success: true }));
})); }));
apiRouter.put("/favorites/:filename/title", withErrorHandling(async (req, res) => {
const { filename } = req.params as { filename: string };
const { title } = req.body as { title: string };
if (!title) {
res.status(400).send(JSON.stringify({
success: false,
error: "Title is required"
}));
return;
}
await mediaPlayer.updateFavoriteTitle(filename, title);
res.send(JSON.stringify({ success: true }));
}));
// Serve static files for React app (after building) // Serve static files for React app (after building)
app.use(express.static("dist/frontend")); app.use(express.static(path.join(__dirname, "../dist/frontend")));
// Mount API routes under /api // Mount API routes under /api
app.use("/api", apiRouter); app.use("/api", apiRouter);
// Serve React app for all other routes (client-side routing) // Serve React app for all other routes (client-side routing)
app.get("*", (req, res) => { app.get("*", (req, res) => {
res.sendFile("dist/frontend/index.html", { root: "." }); res.sendFile(path.join(__dirname, "../dist/frontend/index.html"));
}); });
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;

61
flake.lock generated Normal file
View File

@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1740828860,
"narHash": "sha256-cjbHI+zUzK5CPsQZqMhE3npTyYFt9tJ3+ohcfaOF/WM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "303bd8071377433a2d8f76e684ec773d70c5b642",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

196
flake.nix Normal file
View File

@@ -0,0 +1,196 @@
{
description = "NodeJS application with mpv, yt-dlp, and pulseaudio dependencies";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
let
# Define the NixOS module for the systemd service
nixosModule = { config, lib, pkgs, ... }:
let
cfg = config.services.queuecube;
in {
options.services.queuecube = {
enable = lib.mkEnableOption "QueueCube media player service";
port = lib.mkOption {
type = lib.types.port;
default = 3000;
description = "Port on which QueueCube will listen";
};
enable_video = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable video playback";
};
store_path = lib.mkOption {
type = lib.types.str;
default = "/var/tmp/queuecube";
description = "Path to the store for QueueCube";
};
invidious = lib.mkOption {
type = lib.types.submodule {
options = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable Invidious";
};
url = lib.mkOption {
type = lib.types.str;
default = "http://invidious.nor";
description = "URL of the Invidious instance to use";
};
};
};
default = {
enable = false;
url = "http://invidious.nor";
};
};
user = lib.mkOption {
type = lib.types.str;
description = "User account under which QueueCube runs (required)";
};
};
config = lib.mkIf cfg.enable {
users.users.${cfg.user} = {
packages = [ self.packages.${pkgs.system}.queuecube ];
};
systemd.user.services.queuecube = {
description = "QueueCube media player service";
wantedBy = [ "default.target" ];
after = [ "pipewire.service" "pipewire-pulse.service" ];
serviceConfig = {
ExecStart = "${self.packages.${pkgs.system}.queuecube}/bin/queuecube";
Restart = "on-failure";
RestartSec = 5;
# Allow access to X11 for mpv
Environment = [ "DISPLAY=:0" ];
};
environment = {
PORT = toString cfg.port;
ENABLE_VIDEO = if cfg.enable_video then "1" else "0";
USE_INVIDIOUS = if cfg.invidious.enable then "1" else "0";
INVIDIOUS_URL = cfg.invidious.url;
STORE_PATH = cfg.store_path;
};
};
};
};
in
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
# Define the package using buildNpmPackage
queuecube = pkgs.buildNpmPackage {
pname = "queuecube";
version = "0.1.0";
src = ./.;
# Skip the standard buildPhase and provide our own
dontNpmBuild = true;
buildPhase = ''
# First install all dependencies
npm install
# Then run the build with workspaces flag
npm run build --workspaces
'';
# Runtime dependencies
buildInputs = with pkgs; [
mpv
yt-dlp
pulseaudio
];
# Create a wrapper script to ensure runtime deps are available
postInstall = ''
# Create the necessary directories
mkdir -p $out/lib/node_modules/queuecube
# Copy the entire project with built files
cp -r . $out/lib/node_modules/queuecube
# Install the frontend build to the backend dist directory
mkdir -p $out/lib/node_modules/queuecube/backend/dist/
cp -r frontend/dist $out/lib/node_modules/queuecube/backend/dist/frontend
# Create bin directory if it doesn't exist
mkdir -p $out/bin
# Create executable script
cat > $out/bin/queuecube <<EOF
#!/bin/sh
exec ${pkgs.nodejs}/bin/node $out/lib/node_modules/queuecube/backend/build/server.js
EOF
# Make it executable
chmod +x $out/bin/queuecube
# Wrap the program to include runtime deps in PATH
wrapProgram $out/bin/queuecube \
--prefix PATH : ${pkgs.lib.makeBinPath [
pkgs.mpv
pkgs.yt-dlp
pkgs.pulseaudio
]}
'';
# Let buildNpmPackage handle npm package hash
npmDepsHash = "sha256-BqjJ4CxTPc14Od88sAm/ASwsLszkvcHHeNoZupotlFw=";
meta = with pkgs.lib; {
description = "NodeJS application with media playback capabilities";
platforms = platforms.linux;
};
};
in {
packages = {
default = queuecube;
queuecube = queuecube;
};
apps.default = {
type = "app";
program = "${queuecube}/bin/queuecube";
};
# Development environment
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
nodejs_20
nodePackages.npm
mpv
yt-dlp
pulseaudio
];
};
# Add a basic check to verify the package builds
checks.queuecube = queuecube;
}
) // {
# Export the NixOS module
nixosModules.default = nixosModule;
nixosModules.queuecube = nixosModule;
};
}

View File

@@ -158,5 +158,15 @@ export const API = {
async clearFavorites(): Promise<void> { async clearFavorites(): Promise<void> {
await fetch('/api/favorites', { method: 'DELETE' }); await fetch('/api/favorites', { method: 'DELETE' });
},
async updateFavoriteTitle(filename: string, title: string): Promise<void> {
await fetch(`/api/favorites/${encodeURIComponent(filename)}/title`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ title }),
});
} }
}; };

View File

@@ -2,9 +2,10 @@ import React, { useState, useEffect, useCallback, ReactNode } from 'react';
import SongTable from './SongTable'; import SongTable from './SongTable';
import NowPlaying from './NowPlaying'; import NowPlaying from './NowPlaying';
import AddSongPanel from './AddSongPanel'; import AddSongPanel from './AddSongPanel';
import RenameFavoriteModal from './RenameFavoriteModal';
import { TabView, Tab } from './TabView'; import { TabView, Tab } from './TabView';
import { API, getDisplayTitle, PlaylistItem, ServerEvent } from '../api/player'; import { API, getDisplayTitle, PlaylistItem, ServerEvent } from '../api/player';
import { FaMusic, FaHeart, FaPlus } from 'react-icons/fa'; import { FaMusic, FaHeart, FaPlus, FaEdit } from 'react-icons/fa';
import useWebSocket from 'react-use-websocket'; import useWebSocket from 'react-use-websocket';
import classNames from 'classnames'; import classNames from 'classnames';
@@ -88,6 +89,8 @@ const App: React.FC = () => {
const [playlist, setPlaylist] = useState<PlaylistItem[]>([]); const [playlist, setPlaylist] = useState<PlaylistItem[]>([]);
const [favorites, setFavorites] = useState<PlaylistItem[]>([]); const [favorites, setFavorites] = useState<PlaylistItem[]>([]);
const [selectedTab, setSelectedTab] = useState<Tabs>(Tabs.Playlist); const [selectedTab, setSelectedTab] = useState<Tabs>(Tabs.Playlist);
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
const [favoriteToRename, setFavoriteToRename] = useState<PlaylistItem | null>(null);
const fetchPlaylist = useCallback(async () => { const fetchPlaylist = useCallback(async () => {
const playlist = await API.getPlaylist(); const playlist = await API.getPlaylist();
@@ -177,7 +180,9 @@ const App: React.FC = () => {
} }
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]); }, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
useWebSocket('/api/events', { const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/events`;
console.log('Connecting to WebSocket at', wsUrl);
useWebSocket(wsUrl, {
onOpen: () => { onOpen: () => {
console.log('WebSocket connected'); console.log('WebSocket connected');
}, },
@@ -256,22 +261,35 @@ const App: React.FC = () => {
const favoritesAuxControlProvider = (song: PlaylistItem) => { const favoritesAuxControlProvider = (song: PlaylistItem) => {
const isInPlaylist = playlist.some(p => p.filename === song.filename); const isInPlaylist = playlist.some(p => p.filename === song.filename);
return ( return (
<AuxButton <div className="flex">
className={classNames({ <AuxButton
"text-white/40": isInPlaylist, className="text-white hover:text-white"
"text-white": !isInPlaylist, title="Rename favorite"
})} onClick={() => {
title={isInPlaylist ? "Remove from playlist" : "Add to playlist"} setFavoriteToRename(song);
onClick={() => { setIsRenameModalOpen(true);
if (isInPlaylist) { }}
API.removeFromPlaylist(playlist.findIndex(p => p.filename === song.filename)); >
} else { <FaEdit />
API.addToPlaylist(song.filename); </AuxButton>
}
}} <AuxButton
> className={classNames({
<FaPlus /> "text-white/40": isInPlaylist,
</AuxButton> "text-white": !isInPlaylist,
})}
title={isInPlaylist ? "Remove from playlist" : "Add to playlist"}
onClick={() => {
if (isInPlaylist) {
API.removeFromPlaylist(playlist.findIndex(p => p.filename === song.filename));
} else {
API.addToPlaylist(song.filename);
}
}}
>
<FaPlus />
</AuxButton>
</div>
); );
}; };
@@ -312,9 +330,15 @@ const App: React.FC = () => {
</TabView> </TabView>
<AddSongPanel onAddURL={handleAddURL} /> <AddSongPanel onAddURL={handleAddURL} />
<RenameFavoriteModal
isOpen={isRenameModalOpen}
onClose={() => setIsRenameModalOpen(false)}
favorite={favoriteToRename}
/>
</div> </div>
</div> </div>
); );
}; };
export default App; export default App;

View File

@@ -0,0 +1,97 @@
import React, { useState, KeyboardEvent, useEffect } from 'react';
import { FaTimes, FaCheck } from 'react-icons/fa';
import { API, PlaylistItem, getDisplayTitle } from '../api/player';
interface RenameFavoriteModalProps {
isOpen: boolean;
onClose: () => void;
favorite: PlaylistItem | null;
}
const RenameFavoriteModal: React.FC<RenameFavoriteModalProps> = ({ isOpen, onClose, favorite }) => {
const [title, setTitle] = useState('');
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (favorite) {
setTitle(getDisplayTitle(favorite));
}
}, [favorite]);
const handleSave = async () => {
if (!favorite || !title.trim()) return;
setIsSaving(true);
try {
await API.updateFavoriteTitle(favorite.filename, title);
onClose();
} catch (error) {
console.error('Failed to rename favorite:', error);
} finally {
setIsSaving(false);
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSave();
} else if (e.key === 'Escape') {
onClose();
}
};
if (!isOpen || !favorite) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-violet-900 w-full max-w-md rounded-lg p-4 shadow-lg">
<div className="flex justify-between items-center mb-4">
<h2 className="text-white text-xl font-bold">Rename Favorite</h2>
<button onClick={onClose} className="text-white/60 hover:text-white">
<FaTimes size={24} />
</button>
</div>
<div className="mb-4">
<label className="text-white/80 block mb-2">Original filename:</label>
<div className="text-white/60 text-sm truncate bg-black/20 p-2 rounded-lg mb-4">
{favorite.filename}
</div>
<label className="text-white/80 block mb-2">Title:</label>
<input
type="text"
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter new title..."
className="p-2 rounded-lg border-2 border-violet-500 w-full bg-black/20 text-white"
/>
</div>
<div className="flex justify-end gap-2">
<button
onClick={onClose}
className="bg-black/30 text-white p-2 rounded-lg px-4 hover:bg-black/40"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={isSaving || !title.trim()}
className="bg-violet-500 text-white p-2 rounded-lg px-4 disabled:opacity-50 flex items-center gap-2"
>
<FaCheck size={16} />
Save
</button>
</div>
</div>
</div>
);
};
export default RenameFavoriteModal;