Everythying fixed

This commit is contained in:
Jarian 2026-05-10 16:02:58 +00:00
parent ceed10aef4
commit c510943f61
17 changed files with 6831 additions and 427 deletions

View File

@ -8,3 +8,5 @@ additionaldocs/
prompt.md prompt.md
.ruff_cache/ .ruff_cache/
.venv/ .venv/
frontend/node_modules
frontend/dist

View File

@ -1,6 +1,7 @@
FROM python:3.12-slim AS builder FROM python:3.12-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends curl \ RUN apt-get update && apt-get install -y --no-install-recommends curl unzip \
&& curl -fsSL https://deno.land/install.sh | sh \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /build WORKDIR /build
@ -14,12 +15,41 @@ SHELL ["/bin/sh", "-c"]
FROM builder AS runtime FROM builder AS runtime
RUN groupadd -r appuser \ RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
libglib2.0-0 \
libnss3 \
libnspr4 \
libdbus-1-3 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libdrm2 \
libxkbcommon0 \
libatspi2.0-0 \
libx11-6 \
libxcomposite1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libpango-1.0-0 \
libcairo2 \
libasound2 \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd -r appuser \
&& useradd -r -g appuser -d /app -s /bin/bash appuser && useradd -r -g appuser -d /app -s /bin/bash appuser
WORKDIR /app WORKDIR /app
COPY src/ ./src/ COPY src/ ./src/
RUN chown -R appuser:appuser /app /build ENV DENO_INSTALL="/root/.deno"
ENV PATH="${DENO_INSTALL}/bin:${PATH}"
ENV PLAYWRIGHT_BROWSERS_PATH=/app/.cache/ms-playwright
RUN chown -R appuser:appuser /app
RUN chown -R appuser:appuser /build
USER appuser USER appuser

View File

@ -9,11 +9,24 @@ server {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
location /api/proxy {
proxy_pass http://backend:8010;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
location /api { location /api {
proxy_pass http://backend:8010; proxy_pass http://backend:8010;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
} }
} }

5519
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@ import { useAudioPlayer } from "../hooks/useAudioPlayer"
import { useAudioStore } from "../store/audioStore" import { useAudioStore } from "../store/audioStore"
export function AudioPlayer() { export function AudioPlayer() {
const { currentChannel, streamUrl, isPlaying, volume, error } = useAudioStore() const { currentChannel, streamUrl, streamType, isPlaying, volume, error } = useAudioStore()
const { audioRef, play, pause, stop, setVolume, setStateChange } = useAudioPlayer() const { audioRef, play, pause, stop, setVolume, setStateChange } = useAudioPlayer()
const mountedRef = useRef(true) const mountedRef = useRef(true)
@ -34,9 +34,9 @@ export function AudioPlayer() {
useEffect(() => { useEffect(() => {
if (currentChannel && streamUrl) { if (currentChannel && streamUrl) {
play(streamUrl) play(streamUrl, streamType)
} }
}, [currentChannel, streamUrl, play]) }, [currentChannel, streamUrl, streamType, play])
useEffect(() => { useEffect(() => {
if (audioRef.current) { if (audioRef.current) {
@ -49,9 +49,9 @@ export function AudioPlayer() {
pause() pause()
useAudioStore.getState().setIsPlaying(false) useAudioStore.getState().setIsPlaying(false)
} else if (streamUrl) { } else if (streamUrl) {
play(streamUrl) play(streamUrl, streamType)
} }
}, [isPlaying, streamUrl, pause, play]) }, [isPlaying, streamUrl, streamType, pause, play])
const handleStop = useCallback(() => { const handleStop = useCallback(() => {
stop() stop()

View File

@ -43,26 +43,44 @@ export function ChannelList() {
}`} }`}
onClick={() => playChannel(channel)} onClick={() => playChannel(channel)}
> >
<div className="flex items-start justify-between mb-2"> <div className="flex items-start gap-3 mb-2">
<div className="flex-1"> {channel.thumbnail ? (
<h3 className="font-semibold text-lofi-text">{channel.name}</h3> <img
{channel.handle && ( src={channel.thumbnail}
<p className="text-xs text-lofi-muted">{channel.handle}</p> alt={channel.name}
)} className="w-16 h-16 rounded-lg object-cover flex-shrink-0"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none"
}}
/>
) : (
<div className="w-16 h-16 rounded-lg bg-lofi-accent/20 flex items-center justify-center flex-shrink-0">
<span className="text-2xl"></span>
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold text-lofi-text">{channel.name}</h3>
{channel.handle && (
<p className="text-xs text-lofi-muted">{channel.handle}</p>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation()
if (isFav) {
removeFavorite(channel.id)
} else {
addFavorite(channel.id)
}
}}
className="ml-2 text-lg"
>
{isFav ? "\u2665" : "\u2666"}
</button>
</div>
</div> </div>
<button
onClick={(e) => {
e.stopPropagation()
if (isFav) {
removeFavorite(channel.id)
} else {
addFavorite(channel.id)
}
}}
className="ml-2 text-lg"
>
{isFav ? "\u2665" : "\u2666"}
</button>
</div> </div>
<p className="text-sm text-lofi-muted mb-3 line-clamp-2">{channel.description}</p> <p className="text-sm text-lofi-muted mb-3 line-clamp-2">{channel.description}</p>

View File

@ -7,7 +7,7 @@ export function useAudioPlayer() {
const hlsRef = useRef<Hls | null>(null) const hlsRef = useRef<Hls | null>(null)
const onStateChange = useRef<((state: "playing" | "paused" | "stopped" | "error") => void) | null>(null) const onStateChange = useRef<((state: "playing" | "paused" | "stopped" | "error") => void) | null>(null)
const play = useCallback((streamUrl: string) => { const play = useCallback((streamUrl: string, streamType: "hls" | "direct" | null) => {
if (!audioRef.current) return if (!audioRef.current) return
if (hlsRef.current) { if (hlsRef.current) {
@ -15,7 +15,7 @@ export function useAudioPlayer() {
hlsRef.current = null hlsRef.current = null
} }
if (Hls.isSupported()) { if (streamType === "hls" && Hls.isSupported()) {
const hls = new Hls({ const hls = new Hls({
maxBufferLength: 30, maxBufferLength: 30,
maxMaxBufferLength: 60, maxMaxBufferLength: 60,
@ -41,10 +41,12 @@ export function useAudioPlayer() {
}) })
hlsRef.current = hls hlsRef.current = hls
} else if (audioRef.current.canPlayType("application/vnd.apple.mpegurl")) { } else {
audioRef.current.src = streamUrl audioRef.current.src = streamUrl
audioRef.current.play().then(() => { audioRef.current.play().then(() => {
onStateChange.current?.("playing") onStateChange.current?.("playing")
}).catch(() => {
onStateChange.current?.("error")
}) })
} }
}, []) }, [])

View File

@ -7,6 +7,7 @@ interface AudioState {
channels: Channel[] channels: Channel[]
currentChannel: Channel | null currentChannel: Channel | null
streamUrl: string | null streamUrl: string | null
streamType: "hls" | "direct" | null
isPlaying: boolean isPlaying: boolean
volume: number volume: number
isLoading: boolean isLoading: boolean
@ -34,6 +35,7 @@ export const useAudioStore = create<AudioState>((set, get) => ({
channels: [], channels: [],
currentChannel: null, currentChannel: null,
streamUrl: null, streamUrl: null,
streamType: null,
isPlaying: false, isPlaying: false,
volume: 0.7, volume: 0.7,
isLoading: false, isLoading: false,
@ -41,7 +43,7 @@ export const useAudioStore = create<AudioState>((set, get) => ({
favorites: [], favorites: [],
setChannels: (channels) => set({ channels }), setChannels: (channels) => set({ channels }),
setCurrentChannel: (channel) => set({ currentChannel: channel, streamUrl: null }), setCurrentChannel: (channel) => set({ currentChannel: channel, streamUrl: null, streamType: null }),
setStreamUrl: (url) => set({ streamUrl: url }), setStreamUrl: (url) => set({ streamUrl: url }),
setIsPlaying: (playing) => set({ isPlaying: playing }), setIsPlaying: (playing) => set({ isPlaying: playing }),
setVolume: (volume) => set({ volume }), setVolume: (volume) => set({ volume }),
@ -65,8 +67,11 @@ export const useAudioStore = create<AudioState>((set, get) => ({
const latest = await fetchChannelLatest(channel.id) const latest = await fetchChannelLatest(channel.id)
videoId = latest.videoId videoId = latest.videoId
} }
if (!videoId) {
throw new Error("No video found for this channel")
}
const stream = await fetchStream(videoId) const stream = await fetchStream(videoId)
set({ currentChannel: { ...channel, videoId }, streamUrl: stream.url, isPlaying: true, isLoading: false }) set({ currentChannel: { ...channel, videoId }, streamUrl: stream.url, streamType: stream.streamType, isPlaying: true, isLoading: false })
} catch (e) { } catch (e) {
set({ error: e instanceof Error ? e.message : "Failed to load stream", isLoading: false }) set({ error: e instanceof Error ? e.message : "Failed to load stream", isLoading: false })
} }

View File

@ -5,6 +5,7 @@ export interface Channel {
description: string description: string
isLive: boolean isLive: boolean
videoId: string | null videoId: string | null
thumbnail: string | null
} }
export interface StreamInfo { export interface StreamInfo {
@ -15,6 +16,7 @@ export interface StreamInfo {
channel: string channel: string
duration: number | null duration: number | null
isLive: boolean isLive: boolean
thumbnail?: string | null
} }
export interface NowPlaying { export interface NowPlaying {

View File

@ -9,6 +9,7 @@ dependencies = [
"yt-dlp>=2024.0.0", "yt-dlp>=2024.0.0",
"httpx>=0.27.0", "httpx>=0.27.0",
"python-dotenv>=1.0.0", "python-dotenv>=1.0.0",
"playwright>=1.40.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@ -1,122 +1,695 @@
CHANNELS = [ CHANNELS = [
{ {
"id": "UCSJ4g0vg1503", "id": "UCSJ4gkVC6NrvII8umztf0Ow",
"name": "Lofi Girl", "name": "Lofi Girl",
"handle": "@LofiGirl", "handle": "@LofiGirl",
"description": "The most popular lofi hip hop radio - beats to relax/study to", "description": "The most popular lofi hip hop radio - beats to relax/study to",
"thumbnail": "https://yt3.googleusercontent.com/_BSh2VVvVMzqBoKyWbQnyC35XFOV-ZbXavf9nfu3ZjpFUGEImQnlWt9ZlpfGQBqWEbGNc4rPWg=s900-c-k-c0x00ffffff-no-rj",
}, },
{ {
"id": "UCwKZLlBCn5F3xZnlBH4bg0g", "id": "UCOxqgCwgOqC2lMqC5PYz_Dg",
"name": "Chillhop Music", "name": "Chillhop Music",
"handle": "@ChillhopMusic", "handle": "@ChillhopMusic",
"description": "Jazzhop, lofi, chill beats for studying and relaxing", "description": "Jazzhop, lofi, chill beats for studying and relaxing",
"thumbnail": "https://yt3.googleusercontent.com/5sz00tGeNdll17IqVECF7s7shUzz0nlirAK86WgY0yz7-4t2S51_XMvjM7HaJfdwNlM6rm_Hrg=s900-c-k-c0x00ffffff-no-rj",
}, },
{ {
"id": "UCuHkGW_-h3lZHyjHhr5Eogg", "id": "UCFLPmPdKzkwubQjtMX46r7Q",
"name": "The Japanese 100",
"handle": "@TheJapanese100",
"description": "Japanese lofi hip hop beats and anime vibes",
},
{
"id": "UCG6mHIxzEgQtYxu6HdJxHSg",
"name": "Gym Lofi",
"handle": "@GymLofi",
"description": "Lofi beats for your workout sessions",
},
{
"id": "UCxH0cF-8VKk2aBvIvVd0V0g",
"name": "Study Lofi",
"handle": "@StudyLofi",
"description": "Focus beats for studying and concentration",
},
{
"id": "UC0FRLVdHKB5sZemFNkes-qQ",
"name": "Sleepyfish", "name": "Sleepyfish",
"handle": "@Sleepyfish", "handle": "@Sleepyfish",
"description": "Sleepy lofi beats to help you relax and drift off", "description": "Sleepy lofi beats to help you relax and drift off",
"thumbnail": "https://yt3.googleusercontent.com/ytc/AIdro_nfjqbY7TFPjKb3R-QWfuReRCHVZ2mccVbYz23qLRPEUQ=s900-c-k-c0x00ffffff-no-rj",
}, },
{ {
"id": "UC6LfT5AzI2I8HULKgIz4uKg", "id": "UCN4y5bU7xuKlc86wfzaDk6w",
"name": "Lofi Cafe", "name": "Gym Lofi",
"handle": "@loficafe", "handle": "@GymLofi",
"description": "Cozy cafe vibes with smooth lofi hip hop", "description": "Lofi beats for your workout sessions",
"thumbnail": "https://yt3.googleusercontent.com/0wL0QE3VcI19jZM-MWAS8JOSaKfF3L7bhkSuQHuTAW3IzptC9AS4JSVPaPjdE4dpa7ELLltOjw=s900-c-k-c0x00ffffff-no-rj",
}, },
{ {
"id": "UCkGPVPhVBJ-2nYCSZNQ5dJw", "id": "UCBTKBe2IUs9SURddQOT6mpA",
"name": "Relax & Beat", "name": "Study Lofi",
"handle": "@RelaxBeat", "handle": "@StudyLofi",
"description": "Relaxing beats for unwinding after a long day", "description": "Focus beats for studying and concentration",
"thumbnail": "https://yt3.googleusercontent.com/XvuMl2pI3VZUUZL8N79H9doTN5N_kMTnkbfZewJ0ljR9yv_2oa1FScL0SyoKNaO2omxzoQYm1-A=s900-c-k-c0x00ffffff-no-rj",
}, },
{ {
"id": "UC7ChRm7eV6PVd9RfJF7xV1g", "id": "UCnV2UaGCuZzepjpQNVGXHAA",
"name": "Lofi Fantasy", "name": "Lofi Fruits",
"handle": "@lofifantasy", "handle": "@LofiFruits",
"description": "Fantasy-themed lofi beats with magical vibes", "description": "Fruity lofi beats for a sweet relaxation experience",
"thumbnail": "https://yt3.googleusercontent.com/J0d10uCXZc5MfSeUAf_e7S4FfKf4h0ABntObMeA8--yqvsLinuvAscr9kpjFhz_QbXTXGrl9DQ=s900-c-k-c0x00ffffff-no-rj"
}, },
{ {
"id": "UCpXlMgQdJZ3fXzLqNvG7hKw", "id": "UCfj4xwi09E5lWnBay8KYkAA",
"name": "Midnight Lofi", "name": "Lofi Tokyo Dreams",
"handle": "@midnightlofi", "handle": "@LofiTokyoDreamsOfficial",
"description": "Late-night lofi beats for midnight thinkers", "description": "Tokyo-inspired lofi dreams and cityscapes",
"thumbnail": "https://yt3.googleusercontent.com/xvvRyUe7JlR48VBlk_djsKIFtAc9E1dX9gMeRO2XWdgm4JdMMBgv6jQ4aUD1sangY1OKmSvzCw=s900-c-k-c0x00ffffff-no-rj"
}, },
{ {
"id": "UCvR5MjDqJXqN8F5KqYz3LpA", "id": "UCZ8g_Awlv5bj0q0oC8rHwOw",
"name": "Lofi Dreams", "name": "Lemon Lofi Vibes",
"handle": "@lofidreams", "handle": "@LemonLofiVibes",
"description": "Dreamy lofi soundscapes for your imagination", "description": "Fresh lemon lofi vibes for a bright mood",
"thumbnail": "https://yt3.googleusercontent.com/bfWQ8FzOG61KvoKUhEieg5E1ks3LpScq_k8PlwcFXC2_RocFxocUXiAS7FvJX8DZxkcegbD3XQ=s900-c-k-c0x00ffffff-no-rj"
}, },
{ {
"id": "UCnB7hGpJqK5xMvF3RqN8wLg", "id": "UCChP8U3LI7M3jAPsbKe0b1w",
"name": "Lofi Beats", "name": "Little Lofi Cute",
"handle": "@lofibeats", "handle": "@LittleLofiCute",
"description": "Classic lofi hip hop beats collection", "description": "Cute and cozy lofi beats for relaxation",
"thumbnail": "https://yt3.googleusercontent.com/XY5I62ZTW4KGHIKYSX3dzfW6WZ1f0khRSAmHGQ-tOL2Q8oqkuJXJuLLEQzXPfsrRj9PTtiGx=s900-c-k-c0x00ffffff-no-rj"
}, },
{ {
"id": "UCwQ8FmT7pJxK5vN9RqG3hLg", "id": "UCtT5o5ovUH19FEPzYDvw7KQ",
"name": "Lofi Vibes", "name": "Afro Lofi",
"handle": "@lofivibes", "handle": "@afrolofi",
"description": "Good vibes only with smooth lofi music", "description": "African-inspired lofi beats and rhythms",
"thumbnail": "https://yt3.googleusercontent.com/ZFLsGzR5guDF8RfuuhjxxPwmaxU4-E420-TMt_XIH6Ac_bvlxZAah22wSIFVf0ITmTegncd-ug=s900-c-k-c0x00ffffff-no-rj"
}, },
{ {
"id": "UCzN5FpT8qJxK2vM7RqH9wLg", "id": "UCGlBgoZTfFmwJ2OwR6nKJWA",
"name": "Lofi Zone", "name": "Retro Rhythm",
"handle": "@lofizone", "handle": "@retrorhythm",
"description": "Your personal lofi zone for relaxation", "description": "Retro rhythms and vintage lofi beats",
"thumbnail": "https://yt3.googleusercontent.com/b2g1x73TgG1CRZEWedsjfnuUFlVbRZO4GB728MZ2XByxyN-bNvSpak8Hxgxi6TODCbGoo7s6Tg=s900-c-k-c0x00ffffff-no-rj"
}, },
{ {
"id": "UCmP7GqT9rJxL3vN8SqI4hKg", "id": "UC0fiLCwTmAukotCXYnqfj0A",
"name": "Lofi Studio", "name": "The Bootleg Boy",
"handle": "@lofistudio", "handle": "@thebootlegboy",
"description": "Fresh lofi productions from independent artists", "description": "Bootleg lofi remixes and underground beats",
"thumbnail": "https://yt3.googleusercontent.com/2Ffl_YYLewfDeWsDjRJevqcMJBZuZwg05Y_fChz_OU_OocbQbrUUNkzH_ySveha9a348j5RyFFE=s900-c-k-c0x00ffffff-no-rj"
}, },
{ {
"id": "UCrQ9HpU2sJxM4vO9TqJ5iLg", "id": "UC9OIZ77MhlVoi4IxLFXl-nQ",
"name": "Lofi Radio", "name": "Tokyo Tones",
"handle": "@lofiradio", "handle": "@TokyoTones",
"description": "24/7 lofi radio with curated playlists", "description": "Tokyo-inspired tones and city pop lofi",
"thumbnail": "https://yt3.googleusercontent.com/ytc/AIdro_mk0eso1j7BVyOiHSouqRVHgUiOTn6s2_-iPvJ4M-1JF00=s900-c-k-c0x00ffffff-no-rj"
}, },
{ {
"id": "UCsR1IpV3tJxN5wP0UrK6jMg", "id": "UCA0-hrRPtkPh0VZDZM5b4vA",
"name": "Chillstep", "name": "Kuri Lofi",
"handle": "@chillstep", "handle": "@KuriLofi",
"description": "Chillstep and lofi fusion for deep relaxation", "description": "Warm lofi beats with a Japanese touch",
"thumbnail": "https://yt3.googleusercontent.com/l7EeE_DFIDWSwid_ylKbDiRR5LUFXLdj8a2P7VdaaaXoBnHmTrynyKxWEzvvvYF5rKSbgAt1=s900-c-k-c0x00ffffff-no-rj"
}, },
{ {
"id": "UCtS2JpW4uJxO6xQ1VsL7kNg", "id": "UCVWGstdY3EBG5jLy77VBgvg",
"name": "Ambient LoFi", "name": "Chill Out Lofi Music",
"handle": "@ChillOutLofiMusic",
"description": "Chill out lofi music for relaxation",
"thumbnail": "https://yt3.googleusercontent.com/ytc/AIdro_l4q7tXiBrzdx4SKVC8yjQgU7JD3bwwApjfOT4OfVB6UQ=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCiYQtJcBpw0iDi1vge7A3VA",
"name": "Lofi Layla Radio",
"handle": "@LofiLaylaRadio",
"description": "Layla's lofi radio for 24/7 beats",
"thumbnail": "https://yt3.googleusercontent.com/fLYnU3ZuVYPI18O12C_XN3aAw5GGRMr8tiuJsyOi4r7o88Eb_wcjO0DlNWgVewAGl1vxcF5pow=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCy-T_vkbbOzOfP8TtBwkQ9A",
"name": "Lofi Axol",
"handle": "@LofiAxol",
"description": "Axol's lofi beats and chill vibes",
"thumbnail": "https://yt3.googleusercontent.com/bBW8Xb_eCJAt75avl91dc5tYfF6p2fmb3eLXwd3faRy95JpDPGtS8lF4TDwpZDOPRhHf2xEEhg=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCOxtz12SJHLzY2Ylo5O9jpA",
"name": "Fantasy Lofi",
"handle": "@FantasyLofi",
"description": "Fantasy-themed lofi beats and magical vibes",
"thumbnail": "https://yt3.googleusercontent.com/59bpVDbHhcTcAS3KsNVZlGnRJWcfBy6Yhsb0Nn-VN-X9WVVB_uZe1U83coJqZnyLQjANLu4TNEw=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UC4CIdT66HOn4ekJVtNO4Qdg",
"name": "The Anime Lofi",
"handle": "@theanimelofi",
"description": "Anime-inspired lofi beats and vibes",
"thumbnail": "https://yt3.googleusercontent.com/TDXLbwJ0JJwZUKDZ5tx0lupUz3Aqo740dltwkzzAjuecEae5z3pWClUSmRZiybvdRL72oSX5=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCZJgUI_GHPtGzgKDX5fd5ug",
"name": "Synthwave",
"handle": "@Synthwave",
"description": "Retrowave synthwave radio - driving into the neon sunset",
"thumbnail": "https://yt3.googleusercontent.com/ytc/AIdro_kQOi47fKmr7VDOu4IcqV3CKkLHtkdmhT9Q3T-xtKnYLQ=s900-c-k-c0x00ffffff-no-rj",
},
{
"id": "UCIJoLZB58Pr5IuRCKTWU03g",
"name": "Neon Night Drive",
"handle": "@NeonNightDrive80",
"description": "Neon-lit synthwave for night drives",
"thumbnail": "https://yt3.googleusercontent.com/J0HdrdNbG8fP2F67mlvOjvd04m7Qniz0j_OAptAZNcbVZEJvrEQfVTlQxiUCe9J3yB3GF42W8w=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCCGwlz1e1ke32Z0QlpPsQdQ",
"name": "New Retro Net",
"handle": "@NewRetroNet",
"description": "Retro net synthwave and outrun beats",
"thumbnail": "https://yt3.googleusercontent.com/ytc/AIdro_nVsuvMP7gZfpywpeLE9Da5F7YhrpUKMaEa5QU1zNp6DXM=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCD-4g5w1h8xQpLaNS_ghU4g",
"name": "New Retro Wave",
"handle": "@NewRetroWave",
"description": "New retro wave synth and electronic beats",
"thumbnail": "https://yt3.googleusercontent.com/ytc/AIdro_mUNOKQGUK_JbpYN_9cZwpIigj1yyoSl5TQ8PONJ82GB0I=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCWFwan02r888uUSizV__y7w",
"name": "Nostalgic Synthwave",
"handle": "@NostalgicSynthwave",
"description": "Nostalgic synthwave for 80s vibes",
"thumbnail": "https://yt3.googleusercontent.com/-BgleAs8Od00FPTw5hAegpcDB9rfa2MbPuHbkuN0hBjFHfkUWuUiu9ifWqVoim9xefeQj-51Cw=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCHYxYgm5wmXXbQTRjEUYWfA",
"name": "Retro Pulse FM",
"handle": "@RetroPulseFM",
"description": "Retro pulse synthwave radio",
"thumbnail": "https://yt3.googleusercontent.com/y9GNb6aJRqghGxiKg7d8mpSpRMZ8UghSOQie0bgZgDHfb_BoCRrRs0jpA_no4SXGN6xR95CIiYw=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UC-6oT0FOyAqCGfdNLi4fmXA",
"name": "Nestalgia Music",
"handle": "@nestalgiamusic",
"description": "Nostalgic music and retro vibes",
"thumbnail": "https://yt3.googleusercontent.com/4KyiWYz5cb5A9o7lixz_JuH3HR1Mm4mPBr1ympNKLYXrTqlFzMMV9irQGCFvIFlv5hYN81zz9g=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCB5N4w_M0dQ7WQMdhbo3F0Q",
"name": "Cassette Dreams",
"handle": "@CassetteDreamsz",
"description": "Cassette-inspired lofi and vaporwave dreams",
"thumbnail": "https://yt3.googleusercontent.com/biZ_vlEFQRWnxCpy_UFp1y9vi7RzV99hI8RAiH9RyY6LISmmPrVWJNoMslXszW5PfRguATd1=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCDhsul46SOkngNl899Fnjyg",
"name": "Cyberpunk Music Lab",
"handle": "@cyberpunkmusiclab6695",
"description": "Cyberpunk-inspired electronic music",
"thumbnail": "https://yt3.googleusercontent.com/ytc/AIdro_ne1pPCNH7o9xo0AcG33QCPQgbMwlaSn4tDj90WoNWjvg=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCqi2s9vPPExltBzpSTLhYaw",
"name": "Glitch Black",
"handle": "@GlitchBlack",
"description": "Dark glitch and experimental electronic",
"thumbnail": "https://yt3.googleusercontent.com/ytc/AIdro_kysY0Ape-zZTfVrkM_gxKtHjAdmjfdm4kbHVw3YFs2kjQ=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCTVbOTvSFbrPwN13ovB_MUw",
"name": "Sponk Wave",
"handle": "@SponkWave",
"description": "Sponk wave and retro electronic beats",
"thumbnail": "https://yt3.googleusercontent.com/n9dBEZoEACP5RWHEKW27WM99x8q7MLoEPfn43cS0siR-odEKzCy81uD52mAT0eZ_CYBPfGZp=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UC5Sl4VbJELXi9SCaWbTdXfA",
"name": "Jazz Cafe Ambience",
"handle": "@JazzCafeAmbience",
"description": "Smooth jazz cafe ambience for relaxation",
"thumbnail": "https://yt3.googleusercontent.com/gzBzC7Sd8W73dxVJ3JR4jpSlos_zJEjZ61kOqs_x-Dojm0TXOJFjeDePSEdWVyyPKSe43h5x=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCRVO_YZslLcQ-CIGkWOC-Qw",
"name": "Coffee Shop Ambience Jazz",
"handle": "@CoffeeShopAmbienceJazz",
"description": "Coffee shop jazz ambience for focus",
"thumbnail": "https://yt3.googleusercontent.com/rZNawSKVPav3qAf6Nm85MOI6ax-Cl15BqcIhnB8CmhM3U8RozYwyUQjnhahZvnyQF3EIcn64=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCiLZqIgKpHHTVwFP9_YNTWg",
"name": "Bossa Bossa Bossa",
"handle": "@Bossabossabossa",
"description": "Bossa nova rhythms and smooth vibes",
"thumbnail": "https://yt3.googleusercontent.com/YFV851u9LttKCGmDxcWtdP3PEkMYxjrpGYs4M91hL3i2jh5FqMwdI60qlgIbxlLawBnYFJ4BvKs=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCpuuoKx5EgzHcFqmpWLkJeg",
"name": "Bossa Cafe Music",
"handle": "@BossaCafemusic",
"description": "Bossa nova cafe music for relaxation",
"thumbnail": "https://yt3.googleusercontent.com/Ld_Ul9VqgSD4XCfqGbHwcUAvP5egG4dXefd0tuIOsEixms755ctiWwVpu7H1WwGKOLZRvoLorWk=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCxHKHrkbtWYu1Nbx9Sn2Ztw",
"name": "Bossa Lounges",
"handle": "@BossaLounges",
"description": "Bossa nova lounge music for unwinding",
"thumbnail": "https://yt3.googleusercontent.com/fx9n84KOY8DODmFyrHqa-K6dAZ3HQK-x63qdlIAVJnUaLBZy7kDfVGNqwLAvgY69dbrjiQPoyw=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCFjM4SzH8zAvsC0azlStgaw",
"name": "Bossa Nova Jazz Channel",
"handle": "@bossanovajazzchannel",
"description": "Bossa nova jazz for smooth relaxation",
"thumbnail": "https://yt3.googleusercontent.com/vLZPlFqzYMu-lSso_V0fMQmxyMmO4hjgdM1-aPQeGdY8AXLkQnw2uCQ72blFLe3MHh3FdSzMAQ=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCP1unfBUSCu-gf5H2ZhIk1g",
"name": "Bossa Nova Popular",
"handle": "@BossaNovaPopular",
"description": "Popular bossa nova tracks and vibes",
"thumbnail": "https://yt3.googleusercontent.com/ewM9r1qcKtGRBbrSo6lSPkdPMbXxdGHwIxfi98sjDLMCS6aEypw6DGBeeepJ2HoqGSSHDyY6gg=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCZ018oE9ESp_0EljOTNX9-w",
"name": "Cappuccino Jazzanova",
"handle": "@CappuccinoJazzanova",
"description": "Cappuccino-inspired jazz for coffee lovers",
"thumbnail": "https://yt3.googleusercontent.com/8uNUPlTpjgcYP3zyBIyRWr8Tn05r-w9Z1nlrgIa_w7DerCbQxBi4gIZWx1C8Hw87dTtEcRDA=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCicGSpg0J8OyQ2r2xs4Zrpw",
"name": "Pure Jazz Sanctuary",
"handle": "@PureJazzSanctuary",
"description": "Pure jazz sanctuary for deep relaxation",
"thumbnail": "https://yt3.googleusercontent.com/i3uUGEj38Z09NXrSL2AawRMih2IkVupSHbAqVADF6usjs0k4sokQFlifIPPiV6EWu7l2Wkre7g=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCkRXwVKzbjAsWTWgol1lKsg",
"name": "This Is Jazz Noir",
"handle": "@thisisjazznoir",
"description": "Jazz noir for dark and moody vibes",
"thumbnail": "https://yt3.googleusercontent.com/Qr7ZBOvIFFfBxZecZR2tS5jPueIqpIRduXHrkaT5t0TkCSkP-MaCO-nOc3ShsRrhMqxc32RgCQc=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCmCB1HBrYFuBPyvkcweFhlg",
"name": "Smooth Moonlight",
"handle": "@SmoothMoonlight77",
"description": "Smooth moonlight jazz for late nights",
"thumbnail": "https://yt3.googleusercontent.com/lOtlEtf3h6iz0JD6PG9hmXMVL9sI4vUHE9S1a6JfmFilWcq7qYFTIxhi0OpMtpJFxmlGPG_uxKk=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCZR3-lM6Z-n5_UGHlwx_Rpw",
"name": "Relax Jazz Cafe",
"handle": "@RelaxJazzCafe1990",
"description": "Relaxing jazz cafe music from 1990",
"thumbnail": "https://yt3.googleusercontent.com/ch7-gru5HaQqKEkGHQwCP4hmVmjD08v6u6Zl-S5lBJiZKskbpbwBIb1jTgm4xJWS4gxt5NMieA=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCK4riubfpFSNhACUvXCk0Gg",
"name": "Soft Morning Jazz",
"handle": "@SoftMorningJazz",
"description": "Soft morning jazz for a gentle start",
"thumbnail": "https://yt3.googleusercontent.com/fFDZjqtWIZyyO6_3JnRdOim-YACbI8QDHMwKWce8xmbe6S_aD5Sf7bbZBmErwfgk2ptk6UBq6c4=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCppYFSXQ9YTbqtdjOep0ufg",
"name": "J Pool Music",
"handle": "@JPoolmusic",
"description": "Pool-side jazz and chill vibes",
"thumbnail": "https://yt3.googleusercontent.com/ytc/AIdro_l69jy33MRg0-sNSUoKzgrJv_2KS1YQASMaqB0iajacgxM=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCS-7DLlM6p8sYaDKjkYe8FA",
"name": "Tranquil Cafe Jazz",
"handle": "@TranquilCafeJazz",
"description": "Tranquil cafe jazz for peace",
"thumbnail": "https://yt3.googleusercontent.com/syfthbGfs46MjARxw9pw6L9ZZnxuk19-sZui7vT8xONQ64pdiwy1DGjvssUB1AhCjW9HbeFGshg=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCTco4QOIOzvOacXmNZ643rQ",
"name": "Sweet Jazz Cafe",
"handle": "@SweetJazzCafe",
"description": "Sweet jazz cafe for a cozy atmosphere",
"thumbnail": "https://yt3.googleusercontent.com/ytc/AIdro_mbdw79JGc5mlEFb4L_fDS_eNfnZ3cB5TODqlDQLt_5YWQ-7_FLklstw7UB2tMe0YfK6w=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UC0t_KwohSkvbJ-96UoCKysA",
"name": "Nova Jazz",
"handle": "@NovaJazz",
"description": "Nova jazz for modern relaxation",
"thumbnail": "https://yt3.googleusercontent.com/ytc/AIdro_kXrVzf2kng6Bt3fDoTQur3nO0uITlQpQYpnFjviaOOtG-Tqfo3rW8xJWXn5hrlnURcGA=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UC3Kags250mSxyV-0R5s5qUQ",
"name": "Golden Note Room",
"handle": "@GoldenNoteRoom",
"description": "Golden note jazz room for premium vibes",
"thumbnail": "https://yt3.googleusercontent.com/pa7fAkBWAJWjq5ofzNUk8GswyNUMZBSAB9jEZAzxnM3yZZVewdwL6v1tREUaS9gcIcKidIVp=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCzo-s-TNyLPq41bW7VUDQ0w",
"name": "Classical Oasis",
"handle": "@classicaloasis",
"description": "Classical music oasis for deep relaxation",
"thumbnail": "https://yt3.googleusercontent.com/3Legqqhl1N__Wtr5rwAd67kOopev1ew8F-qH3F8mvKc0MwZO_lkkY_22cLgvf97wQ-2wYyBY=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCcbGEzL5fNAhqijZL8wX7zQ",
"name": "Just Classical",
"handle": "@justclassical",
"description": "Pure classical music for focus",
"thumbnail": "https://yt3.googleusercontent.com/DDR6WlUoDf-wINED-PuM0EoPjCmBBc5xAw7xl1R7qwKyfjL27tZDTX9zrUDRPcdhs5erJ52Y=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UC_kqgIRwOD3XCZXXr4B6bPQ",
"name": "DW Classical Music",
"handle": "@DWClassicalMusic",
"description": "DW classical music broadcasts",
"thumbnail": "https://yt3.googleusercontent.com/44X3mAeQaaV8xZeFaIA2qJk2tVVQ8SYz1ntyxz9Pv8HRt_90vtmipkPQIeTNjgHW0Lp88q3j=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UC-smeLB9AnOTeypr1YyjJ3A",
"name": "Arte Concert",
"handle": "@arteconcert",
"description": "Arte concert classical performances",
"thumbnail": "https://yt3.googleusercontent.com/n4N9HRJyehIwaMc_LajTA_H1ixfbmEkmkwEu_-St1U01GNgYLVytQdvvH4CYs00_46oj4AYLCgw=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCcnOcXZK7Zns0WJXQrPOb2Q",
"name": "Canon Classical",
"handle": "@canonclassical",
"description": "Canon classical music collection",
"thumbnail": "https://yt3.googleusercontent.com/6s8KlJG9N1pbCO_ay-qUnqJdXSriaWo0rztlGcA1rQnnf81lq_bm34EiuDuX12JvvZQclQ726w=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UC34DbNyD_0t8tnOc5V38Big",
"name": "Deutsche Grammophon",
"handle": "@deutschegrammophon",
"description": "Deutsche Grammophon classical recordings",
"thumbnail": "https://yt3.googleusercontent.com/jxZwgLUVlayzv3G-M1gLstGfWLDI-Tn06tUysMexZNwzNei1tytkr5XDv905feXG4uCdTL7bMQ=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCPUB5D0J_TStRlg399Mfc8A",
"name": "Oslo Philharmonic",
"handle": "@OsloPhilharmonic",
"description": "Oslo Philharmonic orchestra performances",
"thumbnail": "https://yt3.googleusercontent.com/IWq6fyrpQ9X5hAFzfuVxLjK4_YARHjk-_dRYiCecHG45wjhIE1YmL0an6HClzBkOjZHwy0BF=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCTKLQNGNPyb182JxSbyPCgQ",
"name": "Baltic Sea Philharmonic",
"handle": "@BalticSeaPhilharmonic",
"description": "Baltic Sea Philharmonic orchestra",
"thumbnail": "https://yt3.googleusercontent.com/E7xQafQt2gw40EybufXGc2Fbt8VOrzUfFrFTFolbTWFSaZSfnQuuHMywUTlAPTpd3v9gvd5vOw=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCQWm2uTSX_Un3qrZsimMgDQ",
"name": "Ne Prosto Orchestra",
"handle": "@NeProstoOrchestra",
"description": "Ne Prosto Orchestra classical performances",
"thumbnail": "https://yt3.googleusercontent.com/70HGRoYIh6mKhv7v_YIALSvTUX8yUQmRRl_RcVzL7enVykU0oLSb19tmJTfIvisdG1u5nSOi=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCf1EknL5GWR1GyyC0LlIgBQ",
"name": "Lyceum Philharmonic",
"handle": "@LyceumPhilharmonic",
"description": "Lyceum Philharmonic orchestra",
"thumbnail": "https://yt3.googleusercontent.com/l7w6qC3v1rEWJrl3ZYYvyzeQ2KLiu2qgaT3ZNBX8PjmhnYU7RI3SYEmSkSsUVHofsdtHkgHYlw=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UC0KnpsNHuZPwJnF_RVbTrLw",
"name": "Classical Music Compilation Zone",
"handle": "@ClassicalMusicCompilationZone",
"description": "Classical music compilations for focus",
"thumbnail": "https://yt3.googleusercontent.com/ytc/AIdro_nc6UQSQSGqofMrLMib1pvFWKULW7u0HktTkve2k5ddOA=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCJ80_CMnIOrKtMyFbIFIQ7A",
"name": "Nightride FM",
"handle": "@NightrideFM",
"description": "Nightride FM deep house and techno",
"thumbnail": "https://yt3.googleusercontent.com/U_bXLU8CTmYo3LHxZP00zxYnL5wE6socvOK4B5oOymD4r82a3Dyr09n21N0ZmCWBilmygCUpubU=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCnOxaDXBiBXg9Nn9hKWu6aw",
"name": "Defected Music",
"handle": "@DefectedMusic",
"description": "Defected Music house and disco",
"thumbnail": "https://yt3.googleusercontent.com/gWdgYFBUdFm3hoAijXcWg3sXmDU6q9V1pjR_QTuJX-_0NJQx4xLwkpv4-gDjdoP3RmZEsNkJ=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCs2cG_5juMhivutuveanCiw",
"name": "KDR Music House",
"handle": "@kdrmusichouse",
"description": "KDR Music house and electronic",
"thumbnail": "https://yt3.googleusercontent.com/ah4cI00wnC6AAQkXRH3TmYCY-CtqBo6BG2i8hEfLlphPHPzlHAp2wqmMdDsWT8l-lV3eXZZRSg=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCy2PiFPBY3_szhiVcOWBfgw",
"name": "Peace House Tunes",
"handle": "@PeaceHouseTunes",
"description": "Peaceful house music for relaxation",
"thumbnail": "https://yt3.googleusercontent.com/O8FOszNM9hn2uBcRYVqV1yQhZw8eHtpgdop6fNNJM62i4n_HapWZ2g03G5xERRuEEVlKrqroqpg=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCGZXYc32ri4D0gSLPf2pZXQ",
"name": "Armada Music TV",
"handle": "@armadamusictv",
"description": "Armada Music trance and progressive",
"thumbnail": "https://yt3.googleusercontent.com/-5P3p8rEXiE5hqjsJ6KiHlf4ToVRHl7Gor15bhIFgw73aUSlA1KjMqrx3PWYcBvNg9PJYXA02A=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCbDgBFAketcO26wz-pR6OKA",
"name": "Anjunadeep",
"handle": "@anjunadeep",
"description": "Anjunadeep deep house and melodic",
"thumbnail": "https://yt3.googleusercontent.com/Gl5BB12JN5UugNAqyVgAS9SM4cwNOWl_-DP_Nynx-aZl8U1McqeraK4OM6VRsMras9g6Dzhi=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCw49uOTAJjGUdoAeUcp7tOg",
"name": "Hospital Records",
"handle": "@hospitalrecords",
"description": "Hospital Records drum and bass",
"thumbnail": "https://yt3.googleusercontent.com/nrP74cXHfHc6k-ZDSvqnk2w8HosW0Nyd1d5F3rZvFeNruIfYz5UvHId5k7gpa_8gqJvKsPEZ6ik=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCX4sShAQf01LYjYQhG2ZgKg",
"name": "Monstercat Silk",
"handle": "@monstercatsilk",
"description": "Monstercat Silk chill and melodic",
"thumbnail": "https://yt3.googleusercontent.com/VmwWA_exW4qoX1YSPfCJlpDh3lDjguBsaXSSPu6yltzZoLcu7oXvcCAUUheg8SLLzGWvnURTbA=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCKpOFjnfAxjwdt9B7F71GqA",
"name": "Elevate Records",
"handle": "@ElevateRecords",
"description": "Elevate Records deep house and tech",
"thumbnail": "https://yt3.googleusercontent.com/otJNtKTMgHCarBuk5DE8fH9EfsULnf3QdfgAMJN3aaefqNY_2EjepNStgrAkmyVvnhmud24-_V0=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCvYuEpgW5JEUuAy4sNzdDFQ",
"name": "Black Hole Recordings",
"handle": "@blackholerecordings",
"description": "Black Hole Recordings techno",
"thumbnail": "https://yt3.googleusercontent.com/LR2voNEVq8PU4imXAkkaG_XFQkHLIkRKv70x8eRxFKuhr9HhTHTylSbEatpaU50AkyoxhUfw=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCDf6reK_hHcz0d7KWBhmPhA",
"name": "Art Of Minimal Techno",
"handle": "@artofminimaltechno",
"description": "Art of minimal techno beats",
"thumbnail": "https://yt3.googleusercontent.com/I-229_uSZ_1Ss32SBk---aSQcEROvW2RYGeqKcbRKmDHfJ6T59G5bEqOW1MAa5oLfMuLZeHV=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCqY3gnYuuzONai4jUhnCv6g",
"name": "Minimal Group",
"handle": "@MinimalGroupOfficial",
"description": "Minimal Group techno and minimal",
"thumbnail": "https://yt3.googleusercontent.com/bptutUqNVB44huffgzk2RStIT3lzC3KtVYcKKa9EqW_tIsAg2Ud_YmzehnhWB8uf0ilx0l1X=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCva3iYHZRE86jaHF1seHJxA",
"name": "Minimal Maximus Technomania",
"handle": "@MinimalmaximusTechnomania",
"description": "Minimal maximus techno mania",
"thumbnail": "https://yt3.googleusercontent.com/q5IFMMpOW6fDfYPi_k45NCaamDbORhQVVB2jN3NvGLEmJIhnMw808QrXVRR1mQ-NJNMzKSBX=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCKf1tehxoPaA6z0vJDoLR8A",
"name": "Subatomic Sound",
"handle": "@subatomicsound",
"description": "Subatomic Sound electronic and techno",
"thumbnail": "https://yt3.googleusercontent.com/ytc/AIdro_mjHxTSlZL9atJzB68bpAuFe7KGaaV1l5Xw50eDXhalxLM=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCalCDSmZAYD73tqVZ4l8yJg",
"name": "A State Of Trance",
"handle": "@astateoftrance",
"description": "A State Of Trance radio and mixes",
"thumbnail": "https://yt3.googleusercontent.com/1xT2Ct8u5EfpFQNs_zuQb5g1xtNSlwhzchqnWjCPb59-DXulmdK8ABYwE8_49xDi1kGitBWdPIM=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UC7EepV8oA9GoW_nDeCAWrkw",
"name": "Trance Fy Music",
"handle": "@TranceFyMusic",
"description": "Trance Fy Music trance radio",
"thumbnail": "https://yt3.googleusercontent.com/Bfd1IQhJv2hmwXMfWqPhTpfnhh1cjpBouRWd7eDMZYp5rwrLi9YjA06XRmZnRMz03InAjtvHfRU=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCQcdnG6NngIjyf80nZWRZ5Q",
"name": "Trance Whispers",
"handle": "@TranceWhispers",
"description": "Trance Whispers ambient trance",
"thumbnail": "https://yt3.googleusercontent.com/Q3tBuBeDSeqfTaq-I6p-9lv6RXnzZr-j-_f1xufEb_kdghkJQZtoONjBpb9lEriqVFrrTNrc3g=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCbupDeH76m1O9vszk1nTIog",
"name": "Psy Love",
"handle": "@PsyLove",
"description": "Psy Love psytrance and Goa",
"thumbnail": "https://yt3.googleusercontent.com/F6-1-HAZorcNF6K46Joj4mT8wJjD1ysUSuELz1qY8dg2gMbcAbU4m42eDLNoE0zQTj886fla=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCHSPZJD03gr4IibWb0eOROA",
"name": "Northern Dubstep Pas",
"handle": "@northerndubsteppas",
"description": "Northern dubstep and bass music",
"thumbnail": "https://yt3.googleusercontent.com/ytc/AIdro_mq9LVF6g-XeemRzoVxMQBnG_mCux_LK2SfrZtiyUsFXg=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UC_i0BwEcw4HHzSz2vMyxbDA",
"name": "Ambient Lofi",
"handle": "@ambientlofi", "handle": "@ambientlofi",
"description": "Ambient lofi soundscapes for meditation", "description": "Ambient lofi soundscapes for meditation",
"thumbnail": "https://yt3.googleusercontent.com/BgpSgxV_wGR-TKQNtCJX4kxugFVUUJdjE5djhqga-HJNbsD0ZCCgZKBxCmJZ2R0XRTX7N-JO=s900-c-k-c0x00ffffff-no-rj",
}, },
{ {
"id": "UCuT3KpX5vJxP7yR2WtM8lOg", "id": "UC4sRUMkLcE4cImnKPoUo-Hg",
"name": "College Mystery", "name": "Relaxing Ambient Escape",
"handle": "@collegemystery", "handle": "@RelaxingAmbientEscape",
"description": "Mysterious lofi beats for college nights", "description": "Relaxing ambient escape for peace",
"thumbnail": "https://yt3.googleusercontent.com/pVjrEubSQfJu0xlxqmjdtataNAm0Z6I6G6QGmUGRsozrXbjCFlEXWh9Jn29jUEm-U3mzjFI0nA=s900-c-k-c0x00ffffff-no-rj"
}, },
{ {
"id": "UCvU4LpY6wJxQ8zS3XuN9mPg", "id": "UCF02M7hAKgRNq7jmMAYh2bg",
"name": "Lofi Hip Hop", "name": "Futurescapes",
"handle": "@lofihiphop", "handle": "@Futurescapes",
"description": "The original lofi hip hop experience", "description": "Futurescapes ambient and electronic",
"thumbnail": "https://yt3.googleusercontent.com/yTBoZe8bA8J09eOyHDqwTw2y7Kzmk4aFSq6pWQ1GfqMuILeXErkti5lSJp4xCeLfl_HkLSsWbx4=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCmla4OjsAqsyAbjS5XYqfPg",
"name": "Easy Sounds Relaxation",
"handle": "@EasySoundsRelaxationChannel",
"description": "Easy sounds for relaxation and sleep",
"thumbnail": "https://yt3.googleusercontent.com/ocbMQOTQhM_FCH_vd0Ji6TWCCMPLIaIYoy-dLXDZ6H5HveQ5WB2G6Y-cktcgQK364oNY06P_=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCM4svmA3yxv9JxTE44R3YPw",
"name": "Relax Corner",
"handle": "@RelaxCorner",
"description": "Relax corner for peace and calm",
"thumbnail": "https://yt3.googleusercontent.com/VN4eW1j9QKPmCqAXX5v-8xJ4uyAz_oOb1V_cAXxSJ-d9TOh0Gi93jm6aasrNBoB9gr36O99cXA=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UC2c1kbLVZXj2Ilmg8fieRGw",
"name": "Inner Healing Sleep Sounds",
"handle": "@InnerHealingSleepSounds",
"description": "Inner healing sleep sounds for rest",
"thumbnail": "https://yt3.googleusercontent.com/f4aKmJsc88nqcML2CY5PTr_o5AW5jO_f4ROuY_wurp1ONoCSzFeJ3WkUoZc6NMPbofWxbkEmmA=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCa6DBGeztqfXOwcpUnk0Ccg",
"name": "Pure Sleeping Vibes",
"handle": "@puresleepingvibes",
"description": "Pure sleeping vibes for deep rest",
"thumbnail": "https://yt3.googleusercontent.com/ytc/AIdro_nC-PFpKHQIRsb0NDS7C1xtBB3FPv6k7a9MuFC3jT4zJQ=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCpIQRQM9-UII2sguAkImNQg",
"name": "Relaxing Sleep Music",
"handle": "@RelaxingSleepMusic95",
"description": "Relaxing sleep music for rest",
"thumbnail": "https://yt3.googleusercontent.com/8sWwFnogiooKVqZ5kr2X9zRyowBMfc0MV80yKHL3cswPsCoOG5V7-PE21Ff7_guWqtSNg7Qlp0M=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCjzHeG1KWoonmf9d5KBvSiw",
"name": "Soothing Relaxation",
"handle": "@SoothingRelaxation",
"description": "Soothing relaxation music for peace",
"thumbnail": "https://yt3.googleusercontent.com/iXm_TniUe0iI9JPfyqk-FKc4Wllsq54HK3nNQjwdY_eMNBRUTJHvl8CXooNgrejaSV7m68vR=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCX9_ziW7WnghYgwZJ7-_vDQ",
"name": "Sleep Soundly",
"handle": "@SleepSoundly",
"description": "Sleep soundly with peaceful music",
"thumbnail": "https://yt3.googleusercontent.com/ytc/AIdro_nfwL5491BwUS8w2gbM7OfI-qB0DatYtrn60XKBXK0=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCvlbxQUaS6a-i9Ek5T-aIFw",
"name": "Sleeping Forest",
"handle": "@sleepingforestant",
"description": "Sleeping forest ambient sounds",
"thumbnail": "https://yt3.googleusercontent.com/521vxJjHI3y0GoPXk0A1WY3VRK--t1qaMSTZ6LxRVUGWhVF3a57_IqRrf_Bmceqz8VVrVIKr8A=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCJuMbdKSMThk2RpALASyXVQ",
"name": "Calmed By Nature",
"handle": "@CalmedByNature",
"description": "Calmed by nature sounds and music",
"thumbnail": "https://yt3.googleusercontent.com/ytc/AIdro_n0SkOMDWP9_2oAmNP5uX8ySoNmHdIgJHGyRZpvcf1oNbY=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCCyDdKgBt6wTfl5ia-AKkcg",
"name": "Calm Horizon",
"handle": "@Calm_Horizon_21",
"description": "Calm horizon ambient and meditation",
"thumbnail": "https://yt3.googleusercontent.com/fwRxvOHVqdQf1q12Nk4k8M9rOpXjyBT84NzKz9WcYJ1AFAlacl2011PhFdan8nbWtGraYkqIFQ=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCSXm6c-n6lsjtyjvdD0bFVw",
"name": "Liquicity",
"handle": "@Liquicity",
"description": "Liquicity drum and bass radio",
"thumbnail": "https://yt3.googleusercontent.com/ytc/AIdro_keH0Kk1YqYA45rjktVPJ6nU3XBnrztNjqxqzLr9ncschM=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCYQHXu4Ea4NTvBggGHT7cOQ",
"name": "DNB Allstars",
"handle": "@dnballstars",
"description": "DNB Allstars drum and bass",
"thumbnail": "https://yt3.googleusercontent.com/iMQKLz1Nm2ROm-ZbK2DcyJZowa7Qr1UcvgpPg90y_7DOJCky80z48dShug4hr6BONX9Wc8_iGvQ=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCpYkkFDnvHka9CBuwxPpqXw",
"name": "UKF On Air",
"handle": "@UKFOnAir",
"description": "UKF On Air drum and bass",
"thumbnail": "https://yt3.googleusercontent.com/ytc/AIdro_lGCVrCmMMqmXGZ7s1R3D9iWflI-_G0yz84W62tf_tpOdE=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCGOHFpsA_rZ3zk2VdQCU2qQ",
"name": "Fear N Loathing",
"handle": "@FearNLoathing",
"description": "Fear N Loathing drum and bass",
"thumbnail": "https://yt3.googleusercontent.com/ytc/AIdro_mKeV20onxhR93_VxB-e9K2JRTmqxgE802_2G0Az8aRSoI=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCKWPSyqIez4GueaXImPnecA",
"name": "Dub Zone",
"handle": "@dub_zone5",
"description": "Dub Zone dub and bass music",
"thumbnail": "https://yt3.googleusercontent.com/HcRo07DWZE7aTB9KGZKPO7EMm2-HljWj-L3c3RhcogvhhOD0MXXu6CNVgkDF5lWQB-qOWO7XIaQ=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCerYu_6oSsXQ8KloFJ9_SYQ",
"name": "Mutuca Dubz",
"handle": "@mutucadubz",
"description": "Mutuca Dubz dub and bass",
"thumbnail": "https://yt3.googleusercontent.com/f4p9obHQV1yL84ZFEsJU4CLIzEABhbgipjwI31H1hMm6S66L5rlfx3U6U4zNxCIyBHcvg18Rtp0=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCuoFay4RgXTstakS4HLrybg",
"name": "Tribal Need",
"handle": "@tribalneed",
"description": "Tribal Need dubstep and bass",
"thumbnail": "https://yt3.googleusercontent.com/-jpP1u6-MwGizudLxaW8moYdsyQoqybUHkF0Sx6n2LJ_KHXQChSFihsxDPad2w-vHMhqba2Zzmk=s900-c-k-c0x00ffffff-no-rj"
},
{
"id": "UCfRsou5aIVXUAl4-F5xC93w",
"name": "Sphere Of Hip Hop",
"handle": "@sphereofhiphop",
"description": "Sphere Of Hip Hop lofi and chill",
"thumbnail": "https://yt3.googleusercontent.com/b8I2f0ojdr2Ay-GL_fBrhHp3sON_SP800fPgFYBtzl2y2PEAN7FiMoKtIe96bsC1MGJAZQg3hA=s900-c-k-c0x00ffffff-no-rj"
}, },
] ]

View File

@ -1,13 +1,17 @@
import asyncio
import logging import logging
import time
from urllib.parse import urljoin
from fastapi import FastAPI, HTTPException import httpx
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from src.channels import CHANNELS from src.channels import CHANNELS
from src.config import settings from src.config import settings
from src.modules.discovery import find_live_video from src.modules.discovery import find_live_video
from src.modules.stream_extractor import extract_audio_stream from src.modules.stream_extractor import extract_audio_stream
from src.modules.validator import validate_stream
logging.basicConfig( logging.basicConfig(
level=settings.LOG_LEVEL.upper(), level=settings.LOG_LEVEL.upper(),
@ -17,45 +21,147 @@ logger = logging.getLogger(__name__)
app = FastAPI(title="Lofi Radio Backend", version="0.1.0") app = FastAPI(title="Lofi Radio Backend", version="0.1.0")
# Cache: video_id -> (stream_url, stream_type, expires_at)
_stream_cache: dict[str, tuple[str, str, float]] = {}
CACHE_TTL = 15 * 60 # 15 minutes
def find_latest_video(channel_id: str) -> str | None:
"""Find the latest video ID for a channel using yt-dlp."""
import yt_dlp
channel = next((c for c in CHANNELS if c["id"] == channel_id), None) def _get_cached_stream(video_id: str) -> tuple[str, str]:
handle = channel.get("handle", "") if channel else "" """Get or refresh cached stream for a video. Returns (url, stream_type)."""
urls_to_try = [] now = time.time()
if video_id in _stream_cache:
url, stype, expires = _stream_cache[video_id]
if now < expires:
return url, stype
if handle and handle.startswith("@"): info = extract_audio_stream(video_id)
urls_to_try.append(f"https://www.youtube.com/{handle}") if not info:
urls_to_try.append(f"https://www.youtube.com/channel/{channel_id}") raise HTTPException(status_code=503, detail="Unable to extract stream")
ydl_opts = { _stream_cache[video_id] = (info["url"], info["streamType"], now + CACHE_TTL)
"flat_playlist": True, return info["url"], info["streamType"]
"playlistend": 1,
"logger": logger,
}
for url in urls_to_try:
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
entries = info.get("_entries", []) or info.get("entries", [])
if entries:
video_id = entries[0].get("id")
if video_id:
logger.info(
"Found latest video for channel %s: %s (via %s)",
channel_id,
video_id,
url,
)
return video_id
except Exception as e:
logger.debug("yt-dlp failed for %s: %s", url, e)
logger.warning("No videos found for channel %s", channel_id) def _fetch_playlist(playlist_url: str) -> str:
return None """Fetch HLS playlist content from YouTube."""
resp = httpx.get(playlist_url, timeout=15, follow_redirects=True, headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://www.youtube.com/",
})
if resp.status_code != 200:
raise HTTPException(status_code=502, detail="Failed to fetch playlist")
return resp.text
def _rewrite_playlist(playlist_content: str, video_id: str) -> str:
"""Rewrite HLS playlist segment URIs to go through server proxy."""
proxy_base = f"/api/proxy/segment?video={video_id}"
lines = playlist_content.split("\n")
result = []
seg_idx = 0
for line in lines:
stripped = line.strip()
if stripped and not stripped.startswith("#"):
result.append(f"{proxy_base}&idx={seg_idx}")
seg_idx += 1
else:
result.append(line)
return "\n".join(result)
def _extract_segments(playlist_content: str, playlist_url: str) -> list[str]:
"""Extract segment URLs from HLS playlist."""
segments = []
for line in playlist_content.split("\n"):
stripped = line.strip()
if stripped and not stripped.startswith("#"):
seg_url = urljoin(playlist_url, stripped)
segments.append(seg_url)
return segments
@app.get("/api/proxy/hls")
def proxy_hls(video: str = Query(...)):
"""Proxy HLS playlist - fetches fresh playlist and rewrites segments."""
stream_url, stream_type = _get_cached_stream(video)
if stream_type == "hls":
content = _fetch_playlist(stream_url)
rewritten = _rewrite_playlist(content, video)
return StreamingResponse(
iter([rewritten]),
media_type="application/x-mpegURL",
headers={"Cache-Control": "no-cache"},
)
else:
m3u8 = (
f"#EXTM3U\n"
f"#EXT-X-VERSION:3\n"
f"#EXT-X-TARGETDURATION:30\n"
f"#EXT-X-MEDIA-SEQUENCE:0\n"
f"#EXTINF:30.0,\n"
f"/api/proxy/segment?video={video}&idx=0\n"
)
return StreamingResponse(
iter([m3u8]),
media_type="application/x-mpegURL",
headers={"Cache-Control": "no-cache"},
)
@app.get("/api/proxy/segment")
def proxy_segment(video: str = Query(...), idx: int = Query(...)):
"""Proxy individual HLS segment."""
stream_url, _ = _get_cached_stream(video)
content = _fetch_playlist(stream_url)
segments = _extract_segments(content, stream_url)
if idx < 0 or idx >= len(segments):
raise HTTPException(status_code=404, detail="Segment not found")
seg_url = segments[idx]
resp = httpx.get(seg_url, timeout=30, follow_redirects=True, headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://www.youtube.com/",
})
if resp.status_code != 200:
raise HTTPException(status_code=502, detail="Failed to fetch segment")
return StreamingResponse(
iter([resp.content]),
media_type="video/MP2T",
headers={"Cache-Control": "no-cache"},
)
def _stream_direct(url: str):
"""Stream direct audio from YouTube."""
import urllib.request
req = urllib.request.Request(url, headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://www.youtube.com/",
})
resp = urllib.request.urlopen(req, timeout=300)
try:
while True:
chunk = resp.read(64 * 1024)
if not chunk:
break
yield chunk
finally:
resp.close()
@app.get("/api/proxy/audio")
def proxy_audio(video: str = Query(...)):
"""Proxy direct audio stream."""
stream_url, stream_type = _get_cached_stream(video)
if stream_type != "direct":
raise HTTPException(status_code=503, detail="Not a direct stream")
return StreamingResponse(
_stream_direct(stream_url),
media_type="audio/*",
headers={"Cache-Control": "no-cache"},
)
app.add_middleware( app.add_middleware(
@ -72,106 +178,104 @@ app.add_middleware(
@app.get("/api/channels") @app.get("/api/channels")
def list_channels() -> list[dict]: def list_channels() -> list[dict]:
"""List all channels. Uses YouTube API for live detection if available, otherwise yt-dlp fallback.""" """List all channels with thumbnails."""
results = [] return [
for channel in CHANNELS: {
if settings.YOUTUBE_API_KEY: "id": channel["id"],
video_id = find_live_video(channel["id"]) "name": channel["name"],
results.append( "handle": channel.get("handle", ""),
{ "description": channel.get("description", ""),
"id": channel["id"], "isLive": True,
"name": channel["name"], "videoId": None,
"handle": channel.get("handle", ""), "thumbnail": channel.get("thumbnail"),
"description": channel.get("description", ""), }
"isLive": video_id is not None, for channel in CHANNELS
"videoId": video_id, ]
}
)
else:
results.append(
{
"id": channel["id"],
"name": channel["name"],
"handle": channel.get("handle", ""),
"description": channel.get("description", ""),
"isLive": True,
"videoId": None,
}
)
return results
@app.get("/api/channels/{channel_id}/live") @app.get("/api/channels/{channel_id}/live")
def check_channel_live(channel_id: str) -> dict: async def check_channel_live(channel_id: str) -> dict:
"""Check if a specific channel is currently live.""" """Check if a specific channel is currently live."""
channel = next((c for c in CHANNELS if c["id"] == channel_id), None) channel = next((c for c in CHANNELS if c["id"] == channel_id), None)
if not channel: if not channel:
raise HTTPException(status_code=404, detail="Channel not found") raise HTTPException(status_code=404, detail="Channel not found")
video_id = ( video_id, thumbnail = await find_live_video(channel["id"], channel.get("handle", ""))
find_live_video(channel_id)
if settings.YOUTUBE_API_KEY
else find_latest_video(channel_id)
)
return { return {
"channelId": channel_id, "channelId": channel_id,
"name": channel["name"], "name": channel["name"],
"isLive": video_id is not None, "isLive": video_id is not None,
"videoId": video_id, "videoId": video_id,
"thumbnail": thumbnail,
} }
@app.get("/api/channel/{channel_id}/latest") @app.get("/api/channel/{channel_id}/latest")
def get_channel_latest(channel_id: str) -> dict: async def get_channel_latest(channel_id: str) -> dict:
"""Find the latest video for a channel using yt-dlp.""" """Find the latest video for a channel."""
channel = next((c for c in CHANNELS if c["id"] == channel_id), None) channel = next((c for c in CHANNELS if c["id"] == channel_id), None)
if not channel: if not channel:
raise HTTPException(status_code=404, detail="Channel not found") raise HTTPException(status_code=404, detail="Channel not found")
video_id = find_latest_video(channel_id) video_id, thumbnail = await find_live_video(channel["id"], channel.get("handle", ""))
if not video_id: if not video_id:
raise HTTPException(status_code=404, detail="No videos found for this channel") raise HTTPException(status_code=404, detail="No videos found for this channel")
return { return {
"channelId": channel_id, "channelId": channel_id,
"videoId": video_id, "videoId": video_id,
"thumbnail": thumbnail,
} }
@app.get("/api/stream/{video_id}") @app.get("/api/stream/{video_id}")
def get_stream(video_id: str) -> dict: def get_stream(video_id: str) -> dict:
"""Get HLS stream URL for a YouTube video.""" """Get proxied stream URL for a YouTube video."""
stream_info = extract_audio_stream(video_id) stream_info = extract_audio_stream(video_id)
if not stream_info: if not stream_info:
raise HTTPException( raise HTTPException(
status_code=503, detail="Unable to extract stream for this video" status_code=503, detail="Unable to extract stream for this video"
) )
is_valid = validate_stream(stream_info["url"], stream_info["streamType"]) # Cache the stream URL (server's IP-bound)
if not is_valid: _stream_cache[video_id] = (
raise HTTPException(status_code=503, detail="Stream URL validation failed") stream_info["url"],
stream_info["streamType"],
time.time() + CACHE_TTL
)
if stream_info["streamType"] == "hls":
stream_info["url"] = f"/api/proxy/hls?video={video_id}"
else:
stream_info["url"] = f"/api/proxy/audio?video={video_id}"
return stream_info return stream_info
@app.get("/api/now-playing") @app.get("/api/now-playing")
def now_playing() -> dict: async def now_playing() -> dict:
"""Get the current active live stream from any channel.""" """Get the current active live stream from any channel."""
for channel in CHANNELS: for channel in CHANNELS:
video_id = ( video_id, thumbnail = await find_live_video(channel["id"], channel.get("handle", ""))
find_live_video(channel["id"])
if settings.YOUTUBE_API_KEY
else find_latest_video(channel["id"])
)
if video_id: if video_id:
stream_info = extract_audio_stream(video_id) stream_info = extract_audio_stream(video_id)
if stream_info: if stream_info:
_stream_cache[video_id] = (
stream_info["url"],
stream_info["streamType"],
time.time() + CACHE_TTL
)
if stream_info["streamType"] == "hls":
stream_info["url"] = f"/api/proxy/hls?video={video_id}"
else:
stream_info["url"] = f"/api/proxy/audio?video={video_id}"
stream_info["channel"] = { stream_info["channel"] = {
"id": channel["id"], "id": channel["id"],
"name": channel["name"], "name": channel["name"],
"handle": channel.get("handle", ""), "handle": channel.get("handle", ""),
"description": channel.get("description", ""), "description": channel.get("description", ""),
} }
stream_info["thumbnail"] = thumbnail
return stream_info return stream_info
return {"channel": None, "videoId": None, "url": None} return {"channel": None, "videoId": None, "url": None}

View File

@ -1,83 +1,179 @@
import asyncio
import logging import logging
import httpx from playwright.async_api import async_playwright
from src.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_browser = None
def find_live_video(channel_id: str) -> str | None:
"""Find the current live video ID for a channel using YouTube Data API."""
if not settings.YOUTUBE_API_KEY:
logger.warning("YouTube API key not configured")
return None
params = { async def _get_browser():
"part": "id,snippet", global _browser
"channelId": channel_id, if _browser is None:
"eventType": "live", p = await async_playwright().start()
"maxResults": 1, _browser = await p.chromium.launch(headless=True, args=["--no-sandbox"])
"key": settings.YOUTUBE_API_KEY, return _browser
}
async def _scrape_channel(handle: str, channel_id: str) -> tuple[str | None, str | None]:
"""Navigate YouTube with playwright and find the live video. Returns (video_id, thumbnail_url)."""
urls_to_try = []
if handle and handle.startswith("@"):
urls_to_try.extend([
f"https://www.youtube.com/{handle}/live",
f"https://www.youtube.com/{handle}/streams",
f"https://www.youtube.com/{handle}/videos",
])
urls_to_try.extend([
f"https://www.youtube.com/channel/{channel_id}/live",
f"https://www.youtube.com/channel/{channel_id}/streams",
f"https://www.youtube.com/channel/{channel_id}/videos",
])
try: try:
response = httpx.get(settings.YOUTUBE_SEARCH_BASE, params=params, timeout=10) browser = await _get_browser()
response.raise_for_status()
data = response.json()
if data.get("items"): for url in urls_to_try:
video_id = data["items"][0]["id"]["videoId"] try:
title = data["items"][0]["snippet"]["title"] page = await browser.new_page()
logger.info( await page.goto(url, timeout=15000, wait_until="domcontentloaded")
"Found live video for channel %s: %s (%s)", channel_id, video_id, title await page.wait_for_timeout(3000)
)
return video_id
return None videos = await page.query_selector_all("ytd-video-renderer")
except httpx.HTTPStatusError as e: if not videos:
logger.error( videos = await page.query_selector_all("ytd-grid-video-renderer")
"YouTube API error for channel %s: %s", channel_id, e.response.text
)
return None
except httpx.RequestError as e:
logger.error("Request error for channel %s: %s", channel_id, e)
return None
for vid in videos:
try:
title_el = await vid.query_selector(".yt-simple-endpoint.style-scope.ytd-video-renderer a")
if not title_el:
title_el = await vid.query_selector("a#video-title")
def get_channel_info(channel_id: str) -> dict | None: video_id = None
"""Get channel details using YouTube Data API.""" if title_el:
if not settings.YOUTUBE_API_KEY: href = await title_el.get_attribute("href")
return None if href and "/watch?v=" in href:
video_id = href.split("/watch?v=")[1].split("&")[0]
elif href and "/live/" in href:
video_id = href.split("/live/")[1].split("?")[0]
params = { if not video_id:
"part": "snippet,statistics", continue
"id": channel_id,
"key": settings.YOUTUBE_API_KEY,
}
thumb = None
thumb_el = await vid.query_selector("img#img")
if thumb_el:
thumb = await thumb_el.get_attribute("src")
if not thumb:
thumb = await thumb_el.get_attribute("data-thumb")
logger.info(
"Found video for channel %s via playwright %s: %s",
channel_id, url, video_id,
)
await page.close()
return video_id, thumb
except Exception:
continue
await page.close()
except Exception as e:
logger.debug("Playwright failed for %s: %s", url, e)
except Exception as e:
logger.debug("Playwright browser failed: %s", e)
# Fallback to yt-dlp
try: try:
response = httpx.get(settings.YOUTUBE_CHANNELS_BASE, params=params, timeout=10) import yt_dlp
response.raise_for_status()
data = response.json()
if data.get("items"): ydl_opts = {
item = data["items"][0] "flat_playlist": True,
return { "playlistend": 5,
"channel_id": channel_id, "logger": logger,
"title": item["snippet"]["title"], }
"description": item["snippet"]["description"],
"thumbnail": item["snippet"]["thumbnails"]["default"]["url"], for url in urls_to_try:
"subscriber_count": item.get("statistics", {}).get( try:
"subscriberCount", 0 with yt_dlp.YoutubeDL(ydl_opts) as ydl:
), info = ydl.extract_info(url, download=False)
} entries = info.get("_entries", []) or info.get("entries", [])
return None for entry in entries:
except httpx.HTTPStatusError as e: video_id = entry.get("id")
logger.error( if video_id:
"YouTube API error for channel %s: %s", channel_id, e.response.text thumb = None
) thumbnails = entry.get("thumbnails", [])
return None if thumbnails:
except httpx.RequestError as e: thumb = sorted(thumbnails, key=lambda t: t.get("width", 0), reverse=True)[0].get("url")
logger.error("Request error for channel %s: %s", channel_id, e) logger.info(
return None "Found video for channel %s via yt-dlp %s: %s",
channel_id, url, video_id,
)
return video_id, thumb
except Exception as e:
logger.debug("yt-dlp failed for %s: %s", url, e)
except Exception as e:
logger.debug("yt-dlp fallback failed: %s", e)
return None, None
async def find_live_video(channel_id: str, handle: str = "") -> tuple[str | None, str | None]:
"""Find the current live video for a channel using playwright. Returns (video_id, thumbnail_url)."""
return await _scrape_channel(handle, channel_id)
async def get_channel_info(channel_id: str, handle: str = "") -> dict | None:
"""Get channel details by scraping YouTube with playwright."""
urls_to_try = []
if handle and handle.startswith("@"):
urls_to_try.append(f"https://www.youtube.com/{handle}")
urls_to_try.append(f"https://www.youtube.com/channel/{channel_id}")
browser = await _get_browser()
for url in urls_to_try:
try:
page = await browser.new_page()
await page.goto(url, timeout=15000, wait_until="domcontentloaded")
await page.wait_for_timeout(3000)
title_el = await page.query_selector("h1#text a")
if not title_el:
title_el = await page.query_selector("h1#text span")
title = await title_el.inner_text() if title_el else None
desc_el = await page.query_selector("#description-text")
if not desc_el:
desc_el = await page.query_selector("span#description span")
description = await desc_el.inner_text() if desc_el else None
thumb_el = await page.query_selector("#img")
thumbnail = await thumb_el.get_attribute("src") if thumb_el else None
subs_el = await page.query_selector("span#text")
subscriber_count = 0
if subs_el:
subs_text = await subs_el.inner_text()
if "subscribers" in subs_text:
import re
match = re.search(r"([\d,.]+)", subs_text)
if match:
subscriber_count = match.group(1).replace(",", "")
await page.close()
if title:
return {
"channel_id": channel_id,
"title": title,
"description": description,
"thumbnail": thumbnail,
"subscriber_count": subscriber_count,
}
except Exception as e:
logger.debug("Playwright failed for channel %s (%s): %s", channel_id, url, e)
return None

View File

@ -14,6 +14,7 @@ def extract_audio_stream(video_id: str) -> dict | None:
"quiet": True, "quiet": True,
"no_warnings": True, "no_warnings": True,
"extract_flat": False, "extract_flat": False,
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
} }
try: try:

View File

@ -7,9 +7,13 @@ logger = logging.getLogger(__name__)
def validate_stream(stream_url: str, stream_type: str) -> bool: def validate_stream(stream_url: str, stream_type: str) -> bool:
"""Validate that a stream URL is accessible and returns expected content.""" """Validate that a stream URL is accessible and returns expected content."""
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Referer": "https://www.youtube.com/",
}
try: try:
if stream_type == "hls": if stream_type == "hls":
response = httpx.get(stream_url, timeout=10, follow_redirects=True) response = httpx.get(stream_url, timeout=10, follow_redirects=True, headers=headers)
if response.status_code != 200: if response.status_code != 200:
logger.warning( logger.warning(
"HLS playlist returned status %d for %s", "HLS playlist returned status %d for %s",
@ -27,7 +31,7 @@ def validate_stream(stream_url: str, stream_type: str) -> bool:
return True return True
elif stream_type == "direct": elif stream_type == "direct":
response = httpx.head(stream_url, timeout=10, follow_redirects=True) response = httpx.head(stream_url, timeout=10, follow_redirects=True, headers=headers)
if response.status_code not in (200, 206): if response.status_code not in (200, 206):
logger.warning( logger.warning(
"Direct stream returned status %d for %s", "Direct stream returned status %d for %s",

View File

@ -1,16 +1,17 @@
from unittest.mock import MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from fastapi import HTTPException
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from src.main import app from src.main import app
def _mock_discovery_live(video_id: str = "dQw4w9WgXcQ"): def _mock_discovery_live(video_id: str = "dQw4w9WgXcQ", thumb: str = "http://thumb.png"):
return MagicMock(return_value=video_id) return AsyncMock(return_value=(video_id, thumb))
def _mock_discovery_none(): def _mock_discovery_none():
return MagicMock(return_value=None) return AsyncMock(return_value=(None, None))
def _mock_extractor_hls(video_id: str = "dQw4w9WgXcQ"): def _mock_extractor_hls(video_id: str = "dQw4w9WgXcQ"):
@ -41,46 +42,46 @@ def _mock_validator_false():
class TestListChannels: class TestListChannels:
def test_returns_all_channels(self) -> None: def test_returns_all_channels(self) -> None:
with patch("src.main.find_live_video", _mock_discovery_none()): client = TestClient(app)
client = TestClient(app) response = client.get("/api/channels")
response = client.get("/api/channels")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert len(data) == 20 assert len(data) == 99
assert all("id" in c for c in data) assert all("id" in c for c in data)
assert all("name" in c for c in data) assert all("name" in c for c in data)
assert all("isLive" in c for c in data) assert all("isLive" in c for c in data)
assert all(c["isLive"] is False for c in data) assert all(c["isLive"] is True for c in data)
assert all(c["videoId"] is None for c in data) assert all(c["videoId"] is None for c in data)
assert all("thumbnail" in c for c in data)
assert any(c["thumbnail"] is not None for c in data)
def test_marks_live_channels(self) -> None: def test_marks_live_channels(self) -> None:
with patch("src.main.find_live_video", _mock_discovery_live()): client = TestClient(app)
client = TestClient(app) response = client.get("/api/channels")
response = client.get("/api/channels")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert all(c["isLive"] for c in data) assert all(c["isLive"] for c in data)
assert all(c["videoId"] == "dQw4w9WgXcQ" for c in data)
class TestCheckChannelLive: class TestCheckChannelLive:
def test_returns_live_status(self) -> None: def test_returns_live_status(self) -> None:
with patch("src.main.find_live_video", _mock_discovery_live()): with patch("src.main.find_live_video", _mock_discovery_live()):
client = TestClient(app) client = TestClient(app)
response = client.get("/api/channels/UCSJ4g0vg1503/live") response = client.get("/api/channels/UCSJ4gkVC6NrvII8umztf0Ow/live")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["channelId"] == "UCSJ4g0vg1503" assert data["channelId"] == "UCSJ4gkVC6NrvII8umztf0Ow"
assert data["isLive"] is True assert data["isLive"] is True
assert data["videoId"] == "dQw4w9WgXcQ" assert data["videoId"] == "dQw4w9WgXcQ"
assert data["thumbnail"] == "http://thumb.png"
def test_returns_not_live(self) -> None: def test_returns_not_live(self) -> None:
with patch("src.main.find_live_video", _mock_discovery_none()): with patch("src.main.find_live_video", _mock_discovery_none()):
client = TestClient(app) client = TestClient(app)
response = client.get("/api/channels/UCSJ4g0vg1503/live") response = client.get("/api/channels/UCSJ4gkVC6NrvII8umztf0Ow/live")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -95,15 +96,14 @@ class TestCheckChannelLive:
class TestGetStream: class TestGetStream:
def test_returns_stream_url(self) -> None: def test_returns_proxied_stream_url(self) -> None:
with patch("src.main.extract_audio_stream", _mock_extractor_hls()): with patch("src.main.extract_audio_stream", _mock_extractor_hls()):
with patch("src.main.validate_stream", _mock_validator_true()): client = TestClient(app)
client = TestClient(app) response = client.get("/api/stream/dQw4w9WgXcQ")
response = client.get("/api/stream/dQw4w9WgXcQ")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["url"] == "https://manifest.hls.tv/pl.m3u8" assert data["url"].startswith("/api/proxy/hls?video=")
assert data["streamType"] == "hls" assert data["streamType"] == "hls"
def test_returns_503_when_no_stream(self) -> None: def test_returns_503_when_no_stream(self) -> None:
@ -113,14 +113,6 @@ class TestGetStream:
assert response.status_code == 503 assert response.status_code == 503
def test_returns_503_when_validation_fails(self) -> None:
with patch("src.main.extract_audio_stream", _mock_extractor_hls()):
with patch("src.main.validate_stream", _mock_validator_false()):
client = TestClient(app)
response = client.get("/api/stream/dQw4w9WgXcQ")
assert response.status_code == 503
class TestNowPlaying: class TestNowPlaying:
def test_returns_active_stream(self) -> None: def test_returns_active_stream(self) -> None:
@ -145,24 +137,109 @@ class TestNowPlaying:
assert data["videoId"] is None assert data["videoId"] is None
assert data["url"] is None assert data["url"] is None
def test_now_playing_returns_proxied_url(self) -> None:
with patch("src.main.find_live_video", _mock_discovery_live()):
with patch("src.main.extract_audio_stream", _mock_extractor_hls()):
client = TestClient(app)
response = client.get("/api/now-playing")
assert response.status_code == 200
data = response.json()
assert data["url"].startswith("/api/proxy/hls?video=")
class TestProxyStream:
def test_proxy_hls_returns_playlist(self) -> None:
"""Verify HLS proxy returns m3u8 playlist with rewritten segments."""
mock_playlist = "#EXTM3U\n#EXTINF:5.0\nseg0.ts\n#EXTINF:5.0\nseg1.ts"
with patch("src.main._get_cached_stream", return_value=("http://example.com/playlist.m3u8", "hls")):
with patch("src.main._fetch_playlist", return_value=mock_playlist):
client = TestClient(app)
response = client.get("/api/proxy/hls?video=test123")
assert response.status_code == 200
assert response.headers["content-type"] == "application/x-mpegURL"
body = response.text
assert "#EXTM3U" in body
assert "/api/proxy/segment?video=test123&idx=0" in body
assert "/api/proxy/segment?video=test123&idx=1" in body
def test_proxy_segment_returns_audio_data(self) -> None:
"""Verify segment proxy returns actual audio content."""
mock_segment = b"\x00\x01\x02\x03" * 1000
mock_playlist = "#EXTM3U\n#EXTINF:5.0\nhttp://example.com/seg0.ts"
with patch("src.main._get_cached_stream", return_value=("http://example.com/pl.m3u8", "hls")):
with patch("src.main._fetch_playlist", return_value=mock_playlist):
with patch("httpx.get", return_value=MagicMock(status_code=200, content=mock_segment)):
client = TestClient(app)
response = client.get("/api/proxy/segment?video=test123&idx=0")
assert response.status_code == 200
assert response.content == mock_segment
assert len(response.content) > 0
def test_proxy_hls_503_when_no_stream(self) -> None:
"""HLS proxy should return 503 when video stream can't be extracted."""
with patch("src.main._get_cached_stream", side_effect=HTTPException(status_code=503)):
client = TestClient(app)
response = client.get("/api/proxy/hls?video=test123")
assert response.status_code == 503
def test_proxy_hls_direct_stream_returns_m3u8(self) -> None:
"""HLS proxy returns synthetic m3u8 for direct streams."""
with patch("src.main._get_cached_stream", return_value=("http://example.com/audio.mp4", "direct")):
client = TestClient(app)
response = client.get("/api/proxy/hls?video=test123")
assert response.status_code == 200
assert response.headers["content-type"] == "application/x-mpegURL"
body = response.text
assert "#EXTM3U" in body
assert "/api/proxy/segment?video=test123&idx=0" in body
def test_proxy_audio_direct_stream(self) -> None:
"""Audio proxy streams direct audio content."""
mock_chunk = b"\x00\x01\x02\x03" * 1000
with patch("src.main._get_cached_stream", return_value=("http://example.com/audio.mp4", "direct")):
with patch("urllib.request.urlopen") as mock_urlopen:
mock_resp = MagicMock()
mock_resp.read.side_effect = [mock_chunk, b""]
mock_urlopen.return_value = mock_resp
client = TestClient(app)
response = client.get("/api/proxy/audio?video=test123")
assert response.status_code == 200
assert response.content == mock_chunk
def test_proxy_audio_rejects_hls(self) -> None:
"""Audio proxy should reject HLS streams."""
with patch("src.main._get_cached_stream", return_value=("http://example.com/playlist.m3u8", "hls")):
client = TestClient(app)
response = client.get("/api/proxy/audio?video=test123")
assert response.status_code == 503
class TestCORS: class TestCORS:
def test_allows_frontend_origin(self) -> None: def test_allows_frontend_origin(self) -> None:
client = TestClient(app) with patch("src.main.find_live_video", _mock_discovery_none()):
response = client.get( client = TestClient(app)
"/api/channels", response = client.get(
headers={"origin": "http://localhost:5173"}, "/api/channels",
) headers={"origin": "http://localhost:5173"},
)
assert response.status_code == 200 assert response.status_code == 200
assert "access-control-allow-origin" in response.headers assert "access-control-allow-origin" in response.headers
def test_allows_docker_frontend_origin(self) -> None: def test_allows_docker_frontend_origin(self) -> None:
client = TestClient(app) with patch("src.main.find_live_video", _mock_discovery_none()):
response = client.get( client = TestClient(app)
"/api/channels", response = client.get(
headers={"origin": "http://frontend:80"}, "/api/channels",
) headers={"origin": "http://frontend:80"},
)
assert response.status_code == 200 assert response.status_code == 200
assert "access-control-allow-origin" in response.headers assert "access-control-allow-origin" in response.headers

View File

@ -1,158 +1,115 @@
from unittest.mock import MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import httpx import pytest
from src.modules.discovery import find_live_video, get_channel_info from src.modules.discovery import find_live_video, get_channel_info
def _mock_settings_with_key():
mock_settings = MagicMock()
mock_settings.YOUTUBE_API_KEY = "fake-key"
mock_settings.YOUTUBE_SEARCH_BASE = "https://www.googleapis.com/youtube/v3/search"
mock_settings.YOUTUBE_CHANNELS_BASE = (
"https://www.googleapis.com/youtube/v3/channels"
)
return mock_settings
class TestFindLiveVideo: class TestFindLiveVideo:
def test_returns_video_id_when_live(self) -> None: @pytest.mark.asyncio
mock_response = MagicMock() async def test_returns_video_id_when_live(self) -> None:
mock_response.json.return_value = { mock_page = MagicMock()
"items": [ mock_video = MagicMock()
{ mock_title = MagicMock()
"id": {"videoId": "dQw4w9WgXcQ"}, mock_title.get_attribute = AsyncMock(return_value="/watch?v=dQw4w9WgXcQ")
"snippet": {"title": "Live Lofi Stream"}, mock_video.query_selector = AsyncMock(return_value=mock_title)
} mock_videos = [mock_video]
]
}
mock_response.raise_for_status.return_value = None
with patch("src.modules.discovery.httpx.get", return_value=mock_response): mock_page.query_selector_all = AsyncMock(return_value=mock_videos)
with patch("src.modules.discovery.settings", _mock_settings_with_key()): mock_page.close = AsyncMock()
result = find_live_video("UC_test_channel") mock_page.goto = AsyncMock()
mock_page.wait_for_timeout = AsyncMock()
assert result == "dQw4w9WgXcQ" mock_browser = MagicMock()
mock_browser.new_page = AsyncMock(return_value=mock_page)
def test_returns_none_when_no_live_video(self) -> None: with patch("src.modules.discovery._get_browser", new=AsyncMock(return_value=mock_browser)):
mock_response = MagicMock() result = await find_live_video("UC_test_channel", "@testchannel")
mock_response.json.return_value = {"items": []}
mock_response.raise_for_status.return_value = None
with patch("src.modules.discovery.httpx.get", return_value=mock_response): assert result[0] == "dQw4w9WgXcQ"
with patch("src.modules.discovery.settings", _mock_settings_with_key()):
result = find_live_video("UC_test_channel")
assert result is None @pytest.mark.asyncio
async def test_returns_none_when_no_video(self) -> None:
mock_page = MagicMock()
mock_page.query_selector_all = AsyncMock(return_value=[])
mock_page.close = AsyncMock()
mock_page.goto = AsyncMock()
mock_page.wait_for_timeout = AsyncMock()
def test_returns_none_when_no_api_key(self) -> None: mock_browser = MagicMock()
mock_settings = _mock_settings_with_key() mock_browser.new_page = AsyncMock(return_value=mock_page)
mock_settings.YOUTUBE_API_KEY = ""
with patch("src.modules.discovery.settings", mock_settings): with patch("src.modules.discovery._get_browser", new=AsyncMock(return_value=mock_browser)):
result = find_live_video("UC_test_channel") with patch("yt_dlp.YoutubeDL") as mock_ytdlp:
mock_ydl = MagicMock()
mock_ydl.extract_info.return_value = {"_entries": []}
mock_ytdlp.return_value.__enter__ = MagicMock(return_value=mock_ydl)
mock_ytdlp.return_value.__exit__ = MagicMock(return_value=False)
result = await find_live_video("UC_test_channel", "@testchannel")
assert result is None assert result[0] is None
def test_returns_none_on_http_error(self) -> None: @pytest.mark.asyncio
mock_response = MagicMock() async def test_returns_none_on_error(self) -> None:
mock_response.text = "API quota exceeded" mock_page = MagicMock()
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( mock_page.goto = AsyncMock(side_effect=Exception("Connection refused"))
"Forbidden", request=MagicMock(), response=mock_response
)
with patch("src.modules.discovery.httpx.get", return_value=mock_response): mock_browser = MagicMock()
with patch("src.modules.discovery.settings", _mock_settings_with_key()): mock_browser.new_page = AsyncMock(return_value=mock_page)
result = find_live_video("UC_test_channel")
assert result is None with patch("src.modules.discovery._get_browser", new=AsyncMock(return_value=mock_browser)):
with patch("yt_dlp.YoutubeDL") as mock_ytdlp:
mock_ytdlp.side_effect = Exception("yt-dlp error")
result = await find_live_video("UC_test_channel", "@testchannel")
def test_returns_none_on_request_error(self) -> None: assert result[0] is None
with patch(
"src.modules.discovery.httpx.get",
side_effect=httpx.RequestError("Connection refused"),
):
with patch("src.modules.discovery.settings", _mock_settings_with_key()):
result = find_live_video("UC_test_channel")
assert result is None @pytest.mark.asyncio
async def test_includes_thumbnail(self) -> None:
mock_page = MagicMock()
mock_video = MagicMock()
mock_title = MagicMock()
mock_title.get_attribute = AsyncMock(return_value="/watch?v=dQw4w9WgXcQ")
mock_thumb = MagicMock()
mock_thumb.get_attribute = AsyncMock(return_value="http://thumb.png")
mock_video.query_selector = AsyncMock(side_effect=[mock_title, mock_thumb])
mock_videos = [mock_video]
mock_page.query_selector_all = AsyncMock(return_value=mock_videos)
mock_page.close = AsyncMock()
mock_page.goto = AsyncMock()
mock_page.wait_for_timeout = AsyncMock()
mock_browser = MagicMock()
mock_browser.new_page = AsyncMock(return_value=mock_page)
with patch("src.modules.discovery._get_browser", new=AsyncMock(return_value=mock_browser)):
result = await find_live_video("UC_test_channel", "@testchannel")
assert result[0] == "dQw4w9WgXcQ"
assert result[1] == "http://thumb.png"
class TestGetChannelInfo: class TestGetChannelInfo:
def test_returns_channel_details(self) -> None: @pytest.mark.asyncio
mock_response = MagicMock() async def test_returns_none_when_no_title(self) -> None:
mock_response.json.return_value = { mock_page = MagicMock()
"items": [ mock_page.goto = AsyncMock()
{ mock_page.wait_for_timeout = AsyncMock()
"snippet": { mock_page.close = AsyncMock()
"title": "Lofi Girl", mock_page.query_selector = AsyncMock(return_value=None)
"description": "Beats to relax",
"thumbnails": {"default": {"url": "http://thumb.png"}},
},
"statistics": {"subscriberCount": 1000000},
}
]
}
mock_response.raise_for_status.return_value = None
with patch("src.modules.discovery.httpx.get", return_value=mock_response): with patch("src.modules.discovery._get_browser", new=AsyncMock(return_value=MagicMock(new_page=AsyncMock(return_value=mock_page)))):
with patch("src.modules.discovery.settings", _mock_settings_with_key()): result = await get_channel_info("UC_test_channel", "@testchannel")
result = get_channel_info("UC_test_channel")
assert result is not None
assert result["channel_id"] == "UC_test_channel"
assert result["title"] == "Lofi Girl"
assert result["description"] == "Beats to relax"
assert result["thumbnail"] == "http://thumb.png"
assert result["subscriber_count"] == 1000000
def test_returns_none_when_no_items(self) -> None:
mock_response = MagicMock()
mock_response.json.return_value = {"items": []}
mock_response.raise_for_status.return_value = None
with patch("src.modules.discovery.httpx.get", return_value=mock_response):
with patch("src.modules.discovery.settings", _mock_settings_with_key()):
result = get_channel_info("UC_test_channel")
assert result is None assert result is None
def test_returns_none_when_no_api_key(self) -> None: @pytest.mark.asyncio
mock_settings = _mock_settings_with_key() async def test_returns_none_on_error(self) -> None:
mock_settings.YOUTUBE_API_KEY = "" mock_page = MagicMock()
mock_page.goto = AsyncMock(side_effect=Exception("Timeout"))
with patch("src.modules.discovery.settings", mock_settings): with patch("src.modules.discovery._get_browser", new=AsyncMock(return_value=MagicMock(new_page=AsyncMock(return_value=mock_page)))):
result = get_channel_info("UC_test_channel") result = await get_channel_info("UC_test_channel", "@testchannel")
assert result is None assert result is None
def test_returns_none_on_error(self) -> None:
with patch(
"src.modules.discovery.httpx.get",
side_effect=httpx.RequestError("Timeout"),
):
with patch("src.modules.discovery.settings", _mock_settings_with_key()):
result = get_channel_info("UC_test_channel")
assert result is None
def test_handles_missing_statistics(self) -> None:
mock_response = MagicMock()
mock_response.json.return_value = {
"items": [
{
"snippet": {
"title": "Test Channel",
"description": "Desc",
"thumbnails": {"default": {"url": "http://thumb.png"}},
}
}
]
}
mock_response.raise_for_status.return_value = None
with patch("src.modules.discovery.httpx.get", return_value=mock_response):
with patch("src.modules.discovery.settings", _mock_settings_with_key()):
result = get_channel_info("UC_test_channel")
assert result["subscriber_count"] == 0