Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ec6c2e3b3 | |||
| 4d4b26e105 | |||
| 880057b0fc | |||
| cfe48e28f8 | |||
| 78deeb65bc | |||
| ebb6127fb9 | |||
| e23977548b | |||
| 803cdd2cdf | |||
| f677026072 | |||
| 5197c78897 |
@@ -18,9 +18,11 @@ COPY . .
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM --platform=$TARGETPLATFORM 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
110
Dockerfile.from_source
Normal 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"]
|
||||||
|
|
||||||
9
Makefile
9
Makefile
@@ -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 .
|
||||||
|
|||||||
9
frontend/package-lock.json
generated
9
frontend/package-lock.json
generated
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
BIN
frontend/public/assets/placeholder.jpg
Normal file
BIN
frontend/public/assets/placeholder.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
@@ -22,7 +22,7 @@ export interface PlaylistItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getDisplayTitle = (item: PlaylistItem): string => {
|
export const getDisplayTitle = (item: PlaylistItem): string => {
|
||||||
return item.metadata?.title || item.title || item.filename;
|
return item.title || item.metadata?.title || item.filename;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MetadataUpdateEvent {
|
export interface MetadataUpdateEvent {
|
||||||
@@ -33,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');
|
||||||
@@ -90,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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
111
frontend/src/components/InvidiousSearchModal.tsx
Normal file
111
frontend/src/components/InvidiousSearchModal.tsx
Normal 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;
|
||||||
@@ -39,7 +39,7 @@ const SongRow: React.FC<SongRowProps> = ({ song, playState, onDelete, onPlay })
|
|||||||
const displayTitle = getDisplayTitle(song);
|
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
6
frontend/src/config.ts
Normal 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)}`;
|
||||||
117
package-lock.json
generated
117
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
78
src/InvidiousAPI.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
@@ -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"));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user