13 Commits
1.3 ... 1.8

19 changed files with 613 additions and 49 deletions

View File

@@ -1,5 +1,5 @@
# Build stage # Build stage
FROM node:20-slim AS builder FROM --platform=$TARGETPLATFORM node:20-slim AS builder
WORKDIR /app WORKDIR /app
@@ -18,9 +18,11 @@ COPY . .
RUN npm run build RUN npm run build
# Production stage # Production stage
FROM alpine:edge FROM --platform=$TARGETPLATFORM debian:testing-20250203
RUN apk update && apk add mpv npm yt-dlp pulseaudio pulseaudio-utils RUN apt-get update && apt-get install -y \
mpv npm yt-dlp pulseaudio pulseaudio-utils \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app

110
Dockerfile.from_source Normal file
View File

@@ -0,0 +1,110 @@
ARG YTDLP_VERSION='2024.03.10'
ARG MPV_VERSION='0.39'
# Build stage for Node.js application
FROM --platform=$TARGETPLATFORM node:23-alpine AS node-builder
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY frontend/package*.json ./frontend/
# Install dependencies
RUN npm ci
RUN cd frontend && npm ci
# Copy source files
COPY . .
# Build frontend and backend
RUN npm run build
# Build stage for mpv
FROM --platform=$TARGETPLATFORM alpine AS mpv-builder
# Install build dependencies for mpv
RUN apk add --no-cache \
git \
python3 \
python3-dev \
py3-pip \
meson \
ninja \
pkgconfig \
gcc \
g++ \
musl-dev \
make \
ffmpeg-dev \
mesa-dev \
alsa-lib-dev \
libplacebo-dev \
libass-dev \
lua \
lua-dev \
pulseaudio-dev
# Clone and build mpv
ARG MPV_VERSION
WORKDIR /src
RUN git clone --depth 1 -b release/${MPV_VERSION} https://github.com/mpv-player/mpv.git && \
cd mpv && \
meson setup build --prefix=/tmp/mpv-prefix && \
meson compile -C build && \
meson install -C build && \
tar -czf /tmp/mpv-install.tar.gz -C /tmp/mpv-prefix .
# Build stage for yt-dlp
FROM --platform=$TARGETPLATFORM python:3.11-alpine AS ytdlp-builder
# Install build dependencies for yt-dlp
RUN apk add --no-cache \
git \
make \
py3-pip \
python3-dev \
gcc \
musl-dev
# Clone and build yt-dlp
ARG YTDLP_VERSION
WORKDIR /build
RUN git clone https://github.com/yt-dlp/yt-dlp.git --single-branch --branch ${YTDLP_VERSION} .
RUN python3 devscripts/install_deps.py --include pyinstaller
RUN python3 devscripts/make_lazy_extractors.py
RUN python3 -m bundle.pyinstaller --name=yt-dlp
# Production stage
FROM --platform=$TARGETPLATFORM alpine:latest
# Install runtime dependencies
RUN apk add --no-cache \
python3 \
ffmpeg \
mesa \
alsa-lib \
pulseaudio \
pulseaudio-utils \
libstdc++ \
ca-certificates \
npm
WORKDIR /app
# Install only production dependencies for Node.js
COPY package*.json ./
RUN npm ci --production
# Copy built files from previous stages
COPY --from=node-builder /app/build ./build
COPY --from=node-builder /app/frontend/dist ./dist/frontend
COPY --from=ytdlp-builder /build/dist/yt-dlp /usr/bin/
COPY --from=mpv-builder /tmp/mpv-install.tar.gz /tmp/
RUN tar -xzf /tmp/mpv-install.tar.gz -C / && \
rm /tmp/mpv-install.tar.gz
EXPOSE 3000
CMD ["node", "build/server.js"]

View File

@@ -1,3 +1,5 @@
VERSION := $(shell git describe --always --dirty)
.PHONY: build .PHONY: build
build: build:
npm install npm install
@@ -5,4 +7,9 @@ build:
.PHONY: run .PHONY: run
run: run:
npm run start npm run start
.PHONY: images
images:
docker buildx build --platform linux/arm/v7 -t musicqueue:$(VERSION)-armv7l .
docker buildx build --platform linux/amd64 -t musicqueue:$(VERSION)-amd64 .

View File

@@ -15,7 +15,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.19.0", "@eslint/js": "^9.19.0",
"@types/react": "^19.0.8", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.3", "@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.19.0", "eslint": "^9.19.0",
@@ -1564,11 +1564,10 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.0.8", "version": "19.0.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz",
"integrity": "sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==", "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }

View File

@@ -17,7 +17,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.19.0", "@eslint/js": "^9.19.0",
"@types/react": "^19.0.8", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.3", "@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.19.0", "eslint": "^9.19.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@@ -1,6 +1,6 @@
export interface NowPlayingResponse { export interface NowPlayingResponse {
success: boolean; success: boolean;
nowPlaying: string; playingItem: PlaylistItem;
isPaused: boolean; isPaused: boolean;
volume: number; volume: number;
isIdle: boolean; isIdle: boolean;
@@ -21,6 +21,10 @@ export interface PlaylistItem {
metadata?: Metadata; metadata?: Metadata;
} }
export const getDisplayTitle = (item: PlaylistItem): string => {
return item.title || item.metadata?.title || item.filename;
}
export interface MetadataUpdateEvent { export interface MetadataUpdateEvent {
event: 'metadata_update'; event: 'metadata_update';
data: { data: {
@@ -29,6 +33,19 @@ export interface MetadataUpdateEvent {
}; };
} }
export interface SearchResult {
type: string;
title: string;
author: string;
mediaUrl: string;
thumbnailUrl: string;
}
export interface SearchResponse {
success: boolean;
results: SearchResult[];
}
export const API = { export const API = {
async getPlaylist(): Promise<PlaylistItem[]> { async getPlaylist(): Promise<PlaylistItem[]> {
const response = await fetch('/api/playlist'); const response = await fetch('/api/playlist');
@@ -86,6 +103,11 @@ export const API = {
}); });
}, },
async search(query: string): Promise<SearchResponse> {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
return response.json();
},
subscribeToEvents(onMessage: (event: any) => void): WebSocket { subscribeToEvents(onMessage: (event: any) => void): WebSocket {
const ws = new WebSocket(`ws://${window.location.host}/api/events`); const ws = new WebSocket(`ws://${window.location.host}/api/events`);
ws.onmessage = (event) => { ws.onmessage = (event) => {

View File

@@ -1,11 +1,14 @@
import React, { useState } from 'react'; import React, { useState, KeyboardEvent, ChangeEvent } from 'react';
import { FaSearch } from 'react-icons/fa';
import InvidiousSearchModal from './InvidiousSearchModal';
import { USE_INVIDIOUS } from '../config';
interface AddSongPanelProps { interface AddSongPanelProps {
onAddURL: (url: string) => void; onAddURL: (url: string) => void;
} }
const AddSongPanel: React.FC<AddSongPanelProps> = ({ onAddURL }) => { const AddSongPanel: React.FC<AddSongPanelProps> = ({ onAddURL }) => {
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
const handleAddURL = () => { const handleAddURL = () => {
onAddURL(url); onAddURL(url);
@@ -15,13 +18,22 @@ const AddSongPanel: React.FC<AddSongPanelProps> = ({ onAddURL }) => {
return ( return (
<div className="flex items-center justify-center h-fit bg-black/50 md:rounded-b-2xl text-white"> <div className="flex items-center justify-center h-fit bg-black/50 md:rounded-b-2xl text-white">
<div className="flex flex-row items-center gap-4 w-full px-8 py-4"> <div className="flex flex-row items-center gap-4 w-full px-8 py-4">
{USE_INVIDIOUS && (
<button
className="bg-violet-500/20 text-white p-2 rounded-lg border-2 border-violet-500"
onClick={() => setIsSearchModalOpen(true)}
>
<FaSearch />
</button>
)}
<input <input
type="text" type="text"
value={url} value={url}
onChange={(e) => setUrl(e.target.value)} onChange={(e: ChangeEvent<HTMLInputElement>) => setUrl(e.target.value)}
placeholder="Add any URL..." placeholder="Add any URL..."
className="p-2 rounded-lg border-2 border-violet-500 flex-grow" className="p-2 rounded-lg border-2 border-violet-500 flex-grow"
onKeyDown={(e) => { onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleAddURL(); handleAddURL();
} }
@@ -35,6 +47,15 @@ const AddSongPanel: React.FC<AddSongPanelProps> = ({ onAddURL }) => {
Add Add
</button> </button>
</div> </div>
<InvidiousSearchModal
isOpen={isSearchModalOpen}
onClose={() => setIsSearchModalOpen(false)}
onSelectVideo={(videoUrl) => {
onAddURL(videoUrl);
setIsSearchModalOpen(false);
}}
/>
</div> </div>
); );
}; };

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback } 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 { API, PlaylistItem } from '../api/player'; import { API, getDisplayTitle, PlaylistItem } from '../api/player';
const App: React.FC = () => { const App: React.FC = () => {
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
@@ -11,7 +11,7 @@ const App: React.FC = () => {
const [volume, setVolume] = useState(100); const [volume, setVolume] = useState(100);
const [volumeSettingIsLocked, setVolumeSettingIsLocked] = useState(false); const [volumeSettingIsLocked, setVolumeSettingIsLocked] = useState(false);
const [songs, setSongs] = useState<PlaylistItem[]>([]); const [songs, setSongs] = useState<PlaylistItem[]>([]);
const [_, setWs] = useState<WebSocket | null>(null); const [ws, setWs] = useState<WebSocket | null>(null);
const fetchPlaylist = useCallback(async () => { const fetchPlaylist = useCallback(async () => {
const playlist = await API.getPlaylist(); const playlist = await API.getPlaylist();
@@ -20,8 +20,8 @@ const App: React.FC = () => {
const fetchNowPlaying = useCallback(async () => { const fetchNowPlaying = useCallback(async () => {
const nowPlaying = await API.getNowPlaying(); const nowPlaying = await API.getNowPlaying();
setNowPlayingSong(nowPlaying.nowPlaying); setNowPlayingSong(getDisplayTitle(nowPlaying.playingItem));
setNowPlayingFileName(nowPlaying.currentFile); setNowPlayingFileName(nowPlaying.playingItem.filename);
setIsPlaying(!nowPlaying.isPaused); setIsPlaying(!nowPlaying.isPaused);
if (!volumeSettingIsLocked) { if (!volumeSettingIsLocked) {
@@ -29,11 +29,15 @@ const App: React.FC = () => {
} }
}, [volumeSettingIsLocked]); }, [volumeSettingIsLocked]);
const handleAddURL = (url: string) => { const handleAddURL = async (url: string) => {
const urlToAdd = url.trim(); const urlToAdd = url.trim();
if (urlToAdd) { if (urlToAdd) {
API.addToPlaylist(urlToAdd); await API.addToPlaylist(urlToAdd);
fetchPlaylist(); fetchPlaylist();
if (!isPlaying) {
await API.play();
}
} }
}; };
@@ -100,14 +104,25 @@ const App: React.FC = () => {
fetchNowPlaying(); fetchNowPlaying();
}, [fetchPlaylist, fetchNowPlaying]); }, [fetchPlaylist, fetchNowPlaying]);
// WebSocket connection // Update WebSocket connection
useEffect(() => { useEffect(() => {
const ws = API.subscribeToEvents(handleWebSocketEvent); const websocket = API.subscribeToEvents(handleWebSocketEvent);
setWs(ws); setWs(websocket);
// Handle page visibility changes, so if the user navigates back to this tab, we reconnect the WebSocket
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible' && (!ws || ws.readyState === WebSocket.CLOSED)) {
const newWs = API.subscribeToEvents(handleWebSocketEvent);
setWs(newWs);
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => { return () => {
if (ws) { document.removeEventListener('visibilitychange', handleVisibilityChange);
ws.close(); if (websocket) {
websocket.close();
} }
}; };
}, [handleWebSocketEvent]); }, [handleWebSocketEvent]);

View File

@@ -0,0 +1,111 @@
import React, { useState, KeyboardEvent } from 'react';
import { FaSearch, FaSpinner, FaTimes } from 'react-icons/fa';
import { API, SearchResult } from '../api/player';
interface InvidiousSearchModalProps {
isOpen: boolean;
onClose: () => void;
onSelectVideo: (url: string) => void;
}
const ResultCell: React.FC<{ result: SearchResult, onClick: () => void }> = ({ result, onClick, ...props }) => {
return (
<div className="flex gap-4 bg-black/20 p-2 rounded-lg cursor-pointer hover:bg-black/30 transition-colors" onClick={onClick} {...props}>
<img src={result.thumbnailUrl} alt={result.title} className="w-32 h-18 object-cover rounded" />
<div className="flex flex-col justify-center">
<h3 className="text-white font-semibold">{result.title}</h3>
<p className="text-white/60">{result.author}</p>
</div>
</div>
);
};
const InvidiousSearchModal: React.FC<InvidiousSearchModalProps> = ({ isOpen, onClose, onSelectVideo }) => {
const [searchQuery, setSearchQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const handleSearch = async () => {
if (!searchQuery.trim()) return;
setIsLoading(true);
try {
const response = await API.search(searchQuery);
if (response.success) {
setResults(response.results);
} else {
console.error('Search failed:', response);
}
} catch (error) {
console.error('Failed to search:', error);
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch();
}
};
const _onSelectVideo = (url: string) => {
setSearchQuery('');
setResults([]);
onSelectVideo(url);
};
if (!isOpen) 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-xl rounded-lg p-4 shadow-lg">
<div className="flex justify-between items-center mb-4">
<h2 className="text-white text-xl font-bold">Search YouTube (Invidious)</h2>
<button onClick={onClose} className="text-white/60 hover:text-white">
<FaTimes size={24} />
</button>
</div>
<div className="flex gap-2 mb-4">
<input
type="text"
autoFocus
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search videos..."
className="p-2 rounded-lg border-2 border-violet-500 flex-grow bg-black/20 text-white"
/>
<button
onClick={handleSearch}
disabled={isLoading}
className="bg-violet-500 text-white p-2 rounded-lg px-4 border-2 border-violet-500 disabled:opacity-50"
>
{isLoading ? <span className="animate-spin"><FaSpinner /></span> : <span><FaSearch /></span>}
</button>
</div>
<div className="max-h-[60vh] overflow-y-auto">
{isLoading ? (
<div className="text-white text-center py-12">Searching...</div>
) : (
<div className="grid gap-4">
{results.map((result) => (
<ResultCell
key={result.mediaUrl}
result={result}
onClick={() => _onSelectVideo(result.mediaUrl)}
/>
))}
</div>
)}
</div>
</div>
</div>
);
};
export default InvidiousSearchModal;

View File

@@ -23,15 +23,14 @@ interface NowPlayingProps extends HTMLAttributes<HTMLDivElement> {
const NowPlaying: React.FC<NowPlayingProps> = (props) => { const NowPlaying: React.FC<NowPlayingProps> = (props) => {
return ( return (
<div className={classNames(props.className, 'bg-black/50 h-[150px] p-5')}> <div className={classNames(props.className, 'bg-black/50 h-fit p-5')}>
<div className="flex flex-col w-full gap-2"> <div className="flex flex-col w-full gap-2">
<div className="flex flex-row w-full h-full bg-black/50 rounded-lg p-5 items-center"> <div className="flex flex-col md:flex-row w-full h-full bg-black/50 rounded-lg p-5 items-center gap-4">
<div className="flex-grow min-w-0"> <div className="flex-grow min-w-0 w-full md:w-auto text-white">
<div className="text-white text-lg font-bold truncate">{props.songName}</div> <div className="text-lg font-bold truncate text-center md:text-left">{props.songName}</div>
<div className="text-white text-sm truncate">{props.fileName}</div> <div className="text-sm truncate text-center md:text-left">{props.fileName}</div>
</div> </div>
<div className="flex flex-row items-center gap-4"> <div className="flex flex-row items-center gap-4 justify-center md:justify-end">
<div className="flex items-center gap-2 text-white"> <div className="flex items-center gap-2 text-white">
<FaVolumeUp size={20} /> <FaVolumeUp size={20} />
<input <input
@@ -42,7 +41,7 @@ const NowPlaying: React.FC<NowPlayingProps> = (props) => {
onMouseDown={() => props.onVolumeWillChange(props.volume)} onMouseDown={() => props.onVolumeWillChange(props.volume)}
onMouseUp={() => props.onVolumeDidChange(props.volume)} onMouseUp={() => props.onVolumeDidChange(props.volume)}
onChange={(e) => props.onVolumeSettingChange(Number(e.target.value))} onChange={(e) => props.onVolumeSettingChange(Number(e.target.value))}
className="w-24 h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full hover:[&::-webkit-slider-thumb]:bg-violet-300" className="fancy-slider w-48 md:w-24 h-2"
/> />
</div> </div>

View File

@@ -1,7 +1,7 @@
import classNames from 'classnames'; import classNames from 'classnames';
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { FaPlay, FaVolumeUp, FaVolumeOff } from 'react-icons/fa'; import { FaPlay, FaVolumeUp, FaVolumeOff } from 'react-icons/fa';
import { PlaylistItem } from '../api/player'; import { getDisplayTitle, PlaylistItem } from '../api/player';
export enum PlayState { export enum PlayState {
NotPlaying, NotPlaying,
@@ -36,10 +36,10 @@ const SongRow: React.FC<SongRowProps> = ({ song, playState, onDelete, onPlay })
}; };
}, [showDeleteConfirm]); }, [showDeleteConfirm]);
const displayTitle = song.metadata?.title || song.title || song.filename; const displayTitle = getDisplayTitle(song);
return ( return (
<div className={classNames("flex flex-row w-full h-[100px] px-2 py-5 items-center border-b gap-2 transition-colors", { <div className={classNames("flex flex-row w-full h-24 px-2 py-5 items-center border-b gap-2 transition-colors", {
"bg-black/10": (playState === PlayState.Playing || playState === PlayState.Paused), "bg-black/10": (playState === PlayState.Playing || playState === PlayState.Paused),
"bg-black/30": playState === PlayState.NotPlaying, "bg-black/30": playState === PlayState.NotPlaying,
})}> })}>

6
frontend/src/config.ts Normal file
View File

@@ -0,0 +1,6 @@
export const USE_INVIDIOUS = import.meta.env.VITE_USE_INVIDIOUS || true;
export const INVIDIOUS_BASE_URL = import.meta.env.VITE_INVIDIOUS_BASE_URL || 'http://invidious.nor';
export const INVIDIOUS_API_ENDPOINT = `${INVIDIOUS_BASE_URL}/api/v1`;
export const getInvidiousSearchURL = (query: string): string =>
`${INVIDIOUS_API_ENDPOINT}/search?q=${encodeURIComponent(query)}`;

View File

@@ -1 +1,11 @@
@import "tailwindcss"; @import "tailwindcss";
@layer components {
.fancy-slider {
@apply bg-gray-700 rounded-lg appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-white
[&::-webkit-slider-thumb]:rounded-full
hover:[&::-webkit-slider-thumb]:bg-violet-300;
}
}

117
package-lock.json generated
View File

@@ -9,10 +9,12 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@types/node-fetch": "^2.6.12",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"express": "^4.21.2", "express": "^4.21.2",
"express-ws": "^5.0.2", "express-ws": "^5.0.2",
"link-preview-js": "^3.0.14", "link-preview-js": "^3.0.14",
"node-fetch": "^2.7.0",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
@@ -103,12 +105,20 @@
"version": "22.13.4", "version": "22.13.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.4.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.4.tgz",
"integrity": "sha512-ywP2X0DYtX3y08eFVx5fNIw7/uIv8hYUKgXoK8oayJlLnKcRfEYCxWMVE1XagUdVtCJlZT1AU4LXEABW+L1Peg==", "integrity": "sha512-ywP2X0DYtX3y08eFVx5fNIw7/uIv8hYUKgXoK8oayJlLnKcRfEYCxWMVE1XagUdVtCJlZT1AU4LXEABW+L1Peg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.20.0" "undici-types": "~6.20.0"
} }
}, },
"node_modules/@types/node-fetch": {
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz",
"integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==",
"dependencies": {
"@types/node": "*",
"form-data": "^4.0.0"
}
},
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.9.18", "version": "6.9.18",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz",
@@ -215,6 +225,11 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -462,6 +477,17 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -568,6 +594,14 @@
"ms": "2.0.0" "ms": "2.0.0"
} }
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -720,6 +754,20 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -858,6 +906,20 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/form-data": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/forwarded": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -994,6 +1056,20 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -1248,6 +1324,25 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/nodemon": { "node_modules/nodemon": {
"version": "3.1.9", "version": "3.1.9",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz",
@@ -1832,6 +1927,11 @@
"nodetouch": "bin/nodetouch.js" "nodetouch": "bin/nodetouch.js"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/tree-kill": { "node_modules/tree-kill": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
@@ -1886,7 +1986,6 @@
"version": "6.20.0", "version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unpipe": { "node_modules/unpipe": {
@@ -1926,6 +2025,20 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/wrap-ansi": { "node_modules/wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

View File

@@ -20,10 +20,12 @@
"typescript": "^5.7.3" "typescript": "^5.7.3"
}, },
"dependencies": { "dependencies": {
"@types/node-fetch": "^2.6.12",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"express": "^4.21.2", "express": "^4.21.2",
"express-ws": "^5.0.2", "express-ws": "^5.0.2",
"link-preview-js": "^3.0.14", "link-preview-js": "^3.0.14",
"node-fetch": "^2.7.0",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"ws": "^8.18.0" "ws": "^8.18.0"
} }

78
src/InvidiousAPI.ts Normal file
View File

@@ -0,0 +1,78 @@
import fetch from 'node-fetch';
interface InvidiousVideoThumbnail {
quality: string;
url: string;
width: number;
height: number;
}
interface InvidiousResult {
type: string;
title: string;
videoId: string;
playlistId: string;
author: string;
videoThumbnails?: InvidiousVideoThumbnail[];
}
export interface SearchResult {
type: string;
title: string;
author: string;
mediaUrl: string;
thumbnailUrl: string;
}
const USE_INVIDIOUS = process.env.USE_INVIDIOUS || true;
const INVIDIOUS_BASE_URL = process.env.INVIDIOUS_BASE_URL || 'http://invidious.nor';
const INVIDIOUS_API_ENDPOINT = `${INVIDIOUS_BASE_URL}/api/v1`;
export const getInvidiousSearchURL = (query: string): string =>
`${INVIDIOUS_API_ENDPOINT}/search?q=${encodeURIComponent(query)}`;
export const getInvidiousThumbnailURL = (url: string): string =>
`${INVIDIOUS_BASE_URL}${url}`;
const preferredThumbnailAPIURL = (thumbnails: InvidiousVideoThumbnail[] | undefined): string => {
if (!thumbnails || thumbnails.length === 0) {
return '/assets/placeholder.jpg';
}
const mediumThumbnail = thumbnails.find(t => t.quality === 'medium');
const thumbnail = mediumThumbnail || thumbnails[0];
return `/api/thumbnail?url=${encodeURIComponent(thumbnail.url)}`;
};
const getMediaURL = (result: InvidiousResult): string => {
if (result.type === 'video') {
return `https://www.youtube.com/watch?v=${result.videoId}`;
} else if (result.type === 'playlist') {
return `https://www.youtube.com/playlist?list=${result.playlistId}`;
}
throw new Error(`Unknown result type: ${result.type}`);
};
export const searchInvidious = async (query: string): Promise<SearchResult[]> => {
try {
const response = await fetch(getInvidiousSearchURL(query));
if (!response.ok) {
throw new Error(`Invidious HTTP error: ${response.status}`);
}
const data = await response.json() as Array<InvidiousResult>;
return data.filter(item => {
return item.type === 'video' || item.type === 'playlist';
}).map(item => ({
type: item.type,
title: item.title,
author: item.author,
mediaUrl: getMediaURL(item),
thumbnailUrl: preferredThumbnailAPIURL(item.videoThumbnails)
}));
} catch (error) {
console.error('Failed to search Invidious:', error);
throw error;
}
}

View File

@@ -14,6 +14,15 @@ interface LinkMetadata {
siteName?: string; siteName?: string;
} }
interface PlaylistItem {
id: number;
filename: string;
title?: string;
playing?: boolean;
current?: boolean;
metadata?: LinkMetadata;
}
export class MediaPlayer { export class MediaPlayer {
private playerProcess: ChildProcess; private playerProcess: ChildProcess;
private socket: Socket; private socket: Socket;
@@ -46,23 +55,43 @@ export class MediaPlayer {
}); });
} }
public async getPlaylist(): Promise<any> { public async getPlaylist(): Promise<PlaylistItem[]> {
return this.writeCommand("get_property", ["playlist"]) return this.writeCommand("get_property", ["playlist"])
.then((response) => { .then((response) => {
// Enhance playlist items with metadata // Enhance playlist items with metadata
const playlist = response.data; const playlist = response.data as PlaylistItem[];
return playlist.map((item: any) => ({ return playlist.map((item: PlaylistItem) => ({
...item, ...item,
metadata: this.metadata.get(item.filename) || {} metadata: this.metadata.get(item.filename) || {}
})); }));
}); });
} }
public async getNowPlaying(): Promise<string> { public async getNowPlaying(): Promise<PlaylistItem> {
return this.writeCommand("get_property", ["media-title"]) const playlist = await this.getPlaylist();
.then((response) => { const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current);
return response.data; const fetchMediaTitle = async (): Promise<string> => {
}); return (await this.writeCommand("get_property", ["media-title"])).data;
};
if (currentlyPlayingSong !== undefined) {
// Use media title if we don't have a title
if (currentlyPlayingSong.title === undefined && currentlyPlayingSong.metadata?.title === undefined) {
return {
...currentlyPlayingSong,
title: await fetchMediaTitle()
};
}
return currentlyPlayingSong;
}
const mediaTitle = await fetchMediaTitle();
return {
id: 0,
filename: mediaTitle,
title: mediaTitle
};
} }
public async getCurrentFile(): Promise<string> { public async getCurrentFile(): Promise<string> {

View File

@@ -1,6 +1,8 @@
import express from "express"; import express from "express";
import expressWs from "express-ws"; import expressWs from "express-ws";
import { MediaPlayer } from "./MediaPlayer"; import { MediaPlayer } from "./MediaPlayer";
import { searchInvidious, getInvidiousThumbnailURL } from "./InvidiousAPI";
import fetch from "node-fetch";
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
@@ -63,7 +65,7 @@ apiRouter.post("/previous", withErrorHandling(async (req, res) => {
})); }));
apiRouter.get("/nowplaying", withErrorHandling(async (req, res) => { apiRouter.get("/nowplaying", withErrorHandling(async (req, res) => {
const nowPlaying = await mediaPlayer.getNowPlaying(); const playingItem = await mediaPlayer.getNowPlaying();
const currentFile = await mediaPlayer.getCurrentFile(); const currentFile = await mediaPlayer.getCurrentFile();
const pauseState = await mediaPlayer.getPauseState(); const pauseState = await mediaPlayer.getPauseState();
const volume = await mediaPlayer.getVolume(); const volume = await mediaPlayer.getVolume();
@@ -71,7 +73,7 @@ apiRouter.get("/nowplaying", withErrorHandling(async (req, res) => {
res.send(JSON.stringify({ res.send(JSON.stringify({
success: true, success: true,
nowPlaying: nowPlaying, playingItem: playingItem,
isPaused: pauseState, isPaused: pauseState,
volume: volume, volume: volume,
isIdle: idle, isIdle: idle,
@@ -95,6 +97,44 @@ apiRouter.ws("/events", (ws, req) => {
}); });
}); });
apiRouter.get("/search", withErrorHandling(async (req, res) => {
const query = req.query.q as string;
if (!query) {
res.status(400)
.send(JSON.stringify({ success: false, error: "Query parameter 'q' is required" }));
return;
}
const results = await searchInvidious(query);
res.send(JSON.stringify({ success: true, results }));
}));
apiRouter.get("/thumbnail", withErrorHandling(async (req, res) => {
const thumbnailUrl = req.query.url as string;
if (!thumbnailUrl) {
res.status(400)
.send(JSON.stringify({ success: false, error: "URL parameter is required" }));
return;
}
try {
const response = await fetch(getInvidiousThumbnailURL(thumbnailUrl));
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Forward the content type header
res.set('Content-Type', response.headers.get('content-type') || 'image/jpeg');
// Pipe the thumbnail data directly to the response
response.body.pipe(res);
} catch (error) {
console.error('Failed to proxy thumbnail:', error);
res.status(500)
.send(JSON.stringify({ success: false, error: 'Failed to fetch thumbnail' }));
}
}));
// 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("dist/frontend"));