Everythying fixed
This commit is contained in:
parent
ceed10aef4
commit
c510943f61
@ -8,3 +8,5 @@ additionaldocs/
|
||||
prompt.md
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
@ -1,6 +1,7 @@
|
||||
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/*
|
||||
|
||||
WORKDIR /build
|
||||
@ -14,12 +15,41 @@ SHELL ["/bin/sh", "-c"]
|
||||
|
||||
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
|
||||
|
||||
WORKDIR /app
|
||||
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
|
||||
@ -9,11 +9,24 @@ server {
|
||||
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 {
|
||||
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_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
}
|
||||
5519
frontend/package-lock.json
generated
Normal file
5519
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -4,7 +4,7 @@ import { useAudioPlayer } from "../hooks/useAudioPlayer"
|
||||
import { useAudioStore } from "../store/audioStore"
|
||||
|
||||
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 mountedRef = useRef(true)
|
||||
@ -34,9 +34,9 @@ export function AudioPlayer() {
|
||||
|
||||
useEffect(() => {
|
||||
if (currentChannel && streamUrl) {
|
||||
play(streamUrl)
|
||||
play(streamUrl, streamType)
|
||||
}
|
||||
}, [currentChannel, streamUrl, play])
|
||||
}, [currentChannel, streamUrl, streamType, play])
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
@ -49,9 +49,9 @@ export function AudioPlayer() {
|
||||
pause()
|
||||
useAudioStore.getState().setIsPlaying(false)
|
||||
} else if (streamUrl) {
|
||||
play(streamUrl)
|
||||
play(streamUrl, streamType)
|
||||
}
|
||||
}, [isPlaying, streamUrl, pause, play])
|
||||
}, [isPlaying, streamUrl, streamType, pause, play])
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
stop()
|
||||
|
||||
@ -43,26 +43,44 @@ export function ChannelList() {
|
||||
}`}
|
||||
onClick={() => playChannel(channel)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lofi-text">{channel.name}</h3>
|
||||
{channel.handle && (
|
||||
<p className="text-xs text-lofi-muted">{channel.handle}</p>
|
||||
)}
|
||||
<div className="flex items-start gap-3 mb-2">
|
||||
{channel.thumbnail ? (
|
||||
<img
|
||||
src={channel.thumbnail}
|
||||
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>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (isFav) {
|
||||
removeFavorite(channel.id)
|
||||
} else {
|
||||
addFavorite(channel.id)
|
||||
}
|
||||
}}
|
||||
className="ml-2 text-lg"
|
||||
>
|
||||
{isFav ? "\u2665" : "\u2666"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-lofi-muted mb-3 line-clamp-2">{channel.description}</p>
|
||||
|
||||
@ -7,7 +7,7 @@ export function useAudioPlayer() {
|
||||
const hlsRef = useRef<Hls | 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 (hlsRef.current) {
|
||||
@ -15,7 +15,7 @@ export function useAudioPlayer() {
|
||||
hlsRef.current = null
|
||||
}
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
if (streamType === "hls" && Hls.isSupported()) {
|
||||
const hls = new Hls({
|
||||
maxBufferLength: 30,
|
||||
maxMaxBufferLength: 60,
|
||||
@ -41,10 +41,12 @@ export function useAudioPlayer() {
|
||||
})
|
||||
|
||||
hlsRef.current = hls
|
||||
} else if (audioRef.current.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
} else {
|
||||
audioRef.current.src = streamUrl
|
||||
audioRef.current.play().then(() => {
|
||||
onStateChange.current?.("playing")
|
||||
}).catch(() => {
|
||||
onStateChange.current?.("error")
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
@ -7,6 +7,7 @@ interface AudioState {
|
||||
channels: Channel[]
|
||||
currentChannel: Channel | null
|
||||
streamUrl: string | null
|
||||
streamType: "hls" | "direct" | null
|
||||
isPlaying: boolean
|
||||
volume: number
|
||||
isLoading: boolean
|
||||
@ -34,6 +35,7 @@ export const useAudioStore = create<AudioState>((set, get) => ({
|
||||
channels: [],
|
||||
currentChannel: null,
|
||||
streamUrl: null,
|
||||
streamType: null,
|
||||
isPlaying: false,
|
||||
volume: 0.7,
|
||||
isLoading: false,
|
||||
@ -41,7 +43,7 @@ export const useAudioStore = create<AudioState>((set, get) => ({
|
||||
favorites: [],
|
||||
|
||||
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 }),
|
||||
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
||||
setVolume: (volume) => set({ volume }),
|
||||
@ -65,8 +67,11 @@ export const useAudioStore = create<AudioState>((set, get) => ({
|
||||
const latest = await fetchChannelLatest(channel.id)
|
||||
videoId = latest.videoId
|
||||
}
|
||||
if (!videoId) {
|
||||
throw new Error("No video found for this channel")
|
||||
}
|
||||
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) {
|
||||
set({ error: e instanceof Error ? e.message : "Failed to load stream", isLoading: false })
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ export interface Channel {
|
||||
description: string
|
||||
isLive: boolean
|
||||
videoId: string | null
|
||||
thumbnail: string | null
|
||||
}
|
||||
|
||||
export interface StreamInfo {
|
||||
@ -15,6 +16,7 @@ export interface StreamInfo {
|
||||
channel: string
|
||||
duration: number | null
|
||||
isLive: boolean
|
||||
thumbnail?: string | null
|
||||
}
|
||||
|
||||
export interface NowPlaying {
|
||||
|
||||
@ -9,6 +9,7 @@ dependencies = [
|
||||
"yt-dlp>=2024.0.0",
|
||||
"httpx>=0.27.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"playwright>=1.40.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
723
src/channels.py
723
src/channels.py
@ -1,122 +1,695 @@
|
||||
CHANNELS = [
|
||||
{
|
||||
"id": "UCSJ4g0vg1503",
|
||||
"id": "UCSJ4gkVC6NrvII8umztf0Ow",
|
||||
"name": "Lofi Girl",
|
||||
"handle": "@LofiGirl",
|
||||
"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",
|
||||
"handle": "@ChillhopMusic",
|
||||
"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",
|
||||
"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",
|
||||
"id": "UCFLPmPdKzkwubQjtMX46r7Q",
|
||||
"name": "Sleepyfish",
|
||||
"handle": "@Sleepyfish",
|
||||
"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",
|
||||
"name": "Lofi Cafe",
|
||||
"handle": "@loficafe",
|
||||
"description": "Cozy cafe vibes with smooth lofi hip hop",
|
||||
"id": "UCN4y5bU7xuKlc86wfzaDk6w",
|
||||
"name": "Gym Lofi",
|
||||
"handle": "@GymLofi",
|
||||
"description": "Lofi beats for your workout sessions",
|
||||
"thumbnail": "https://yt3.googleusercontent.com/0wL0QE3VcI19jZM-MWAS8JOSaKfF3L7bhkSuQHuTAW3IzptC9AS4JSVPaPjdE4dpa7ELLltOjw=s900-c-k-c0x00ffffff-no-rj",
|
||||
},
|
||||
{
|
||||
"id": "UCkGPVPhVBJ-2nYCSZNQ5dJw",
|
||||
"name": "Relax & Beat",
|
||||
"handle": "@RelaxBeat",
|
||||
"description": "Relaxing beats for unwinding after a long day",
|
||||
"id": "UCBTKBe2IUs9SURddQOT6mpA",
|
||||
"name": "Study Lofi",
|
||||
"handle": "@StudyLofi",
|
||||
"description": "Focus beats for studying and concentration",
|
||||
"thumbnail": "https://yt3.googleusercontent.com/XvuMl2pI3VZUUZL8N79H9doTN5N_kMTnkbfZewJ0ljR9yv_2oa1FScL0SyoKNaO2omxzoQYm1-A=s900-c-k-c0x00ffffff-no-rj",
|
||||
},
|
||||
{
|
||||
"id": "UC7ChRm7eV6PVd9RfJF7xV1g",
|
||||
"name": "Lofi Fantasy",
|
||||
"handle": "@lofifantasy",
|
||||
"description": "Fantasy-themed lofi beats with magical vibes",
|
||||
"id": "UCnV2UaGCuZzepjpQNVGXHAA",
|
||||
"name": "Lofi Fruits",
|
||||
"handle": "@LofiFruits",
|
||||
"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",
|
||||
"name": "Midnight Lofi",
|
||||
"handle": "@midnightlofi",
|
||||
"description": "Late-night lofi beats for midnight thinkers",
|
||||
"id": "UCfj4xwi09E5lWnBay8KYkAA",
|
||||
"name": "Lofi Tokyo Dreams",
|
||||
"handle": "@LofiTokyoDreamsOfficial",
|
||||
"description": "Tokyo-inspired lofi dreams and cityscapes",
|
||||
"thumbnail": "https://yt3.googleusercontent.com/xvvRyUe7JlR48VBlk_djsKIFtAc9E1dX9gMeRO2XWdgm4JdMMBgv6jQ4aUD1sangY1OKmSvzCw=s900-c-k-c0x00ffffff-no-rj"
|
||||
},
|
||||
{
|
||||
"id": "UCvR5MjDqJXqN8F5KqYz3LpA",
|
||||
"name": "Lofi Dreams",
|
||||
"handle": "@lofidreams",
|
||||
"description": "Dreamy lofi soundscapes for your imagination",
|
||||
"id": "UCZ8g_Awlv5bj0q0oC8rHwOw",
|
||||
"name": "Lemon Lofi Vibes",
|
||||
"handle": "@LemonLofiVibes",
|
||||
"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",
|
||||
"name": "Lofi Beats",
|
||||
"handle": "@lofibeats",
|
||||
"description": "Classic lofi hip hop beats collection",
|
||||
"id": "UCChP8U3LI7M3jAPsbKe0b1w",
|
||||
"name": "Little Lofi Cute",
|
||||
"handle": "@LittleLofiCute",
|
||||
"description": "Cute and cozy lofi beats for relaxation",
|
||||
"thumbnail": "https://yt3.googleusercontent.com/XY5I62ZTW4KGHIKYSX3dzfW6WZ1f0khRSAmHGQ-tOL2Q8oqkuJXJuLLEQzXPfsrRj9PTtiGx=s900-c-k-c0x00ffffff-no-rj"
|
||||
},
|
||||
{
|
||||
"id": "UCwQ8FmT7pJxK5vN9RqG3hLg",
|
||||
"name": "Lofi Vibes",
|
||||
"handle": "@lofivibes",
|
||||
"description": "Good vibes only with smooth lofi music",
|
||||
"id": "UCtT5o5ovUH19FEPzYDvw7KQ",
|
||||
"name": "Afro Lofi",
|
||||
"handle": "@afrolofi",
|
||||
"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",
|
||||
"name": "Lofi Zone",
|
||||
"handle": "@lofizone",
|
||||
"description": "Your personal lofi zone for relaxation",
|
||||
"id": "UCGlBgoZTfFmwJ2OwR6nKJWA",
|
||||
"name": "Retro Rhythm",
|
||||
"handle": "@retrorhythm",
|
||||
"description": "Retro rhythms and vintage lofi beats",
|
||||
"thumbnail": "https://yt3.googleusercontent.com/b2g1x73TgG1CRZEWedsjfnuUFlVbRZO4GB728MZ2XByxyN-bNvSpak8Hxgxi6TODCbGoo7s6Tg=s900-c-k-c0x00ffffff-no-rj"
|
||||
},
|
||||
{
|
||||
"id": "UCmP7GqT9rJxL3vN8SqI4hKg",
|
||||
"name": "Lofi Studio",
|
||||
"handle": "@lofistudio",
|
||||
"description": "Fresh lofi productions from independent artists",
|
||||
"id": "UC0fiLCwTmAukotCXYnqfj0A",
|
||||
"name": "The Bootleg Boy",
|
||||
"handle": "@thebootlegboy",
|
||||
"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",
|
||||
"name": "Lofi Radio",
|
||||
"handle": "@lofiradio",
|
||||
"description": "24/7 lofi radio with curated playlists",
|
||||
"id": "UC9OIZ77MhlVoi4IxLFXl-nQ",
|
||||
"name": "Tokyo Tones",
|
||||
"handle": "@TokyoTones",
|
||||
"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",
|
||||
"name": "Chillstep",
|
||||
"handle": "@chillstep",
|
||||
"description": "Chillstep and lofi fusion for deep relaxation",
|
||||
"id": "UCA0-hrRPtkPh0VZDZM5b4vA",
|
||||
"name": "Kuri Lofi",
|
||||
"handle": "@KuriLofi",
|
||||
"description": "Warm lofi beats with a Japanese touch",
|
||||
"thumbnail": "https://yt3.googleusercontent.com/l7EeE_DFIDWSwid_ylKbDiRR5LUFXLdj8a2P7VdaaaXoBnHmTrynyKxWEzvvvYF5rKSbgAt1=s900-c-k-c0x00ffffff-no-rj"
|
||||
},
|
||||
{
|
||||
"id": "UCtS2JpW4uJxO6xQ1VsL7kNg",
|
||||
"name": "Ambient LoFi",
|
||||
"id": "UCVWGstdY3EBG5jLy77VBgvg",
|
||||
"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",
|
||||
"description": "Ambient lofi soundscapes for meditation",
|
||||
"thumbnail": "https://yt3.googleusercontent.com/BgpSgxV_wGR-TKQNtCJX4kxugFVUUJdjE5djhqga-HJNbsD0ZCCgZKBxCmJZ2R0XRTX7N-JO=s900-c-k-c0x00ffffff-no-rj",
|
||||
},
|
||||
{
|
||||
"id": "UCuT3KpX5vJxP7yR2WtM8lOg",
|
||||
"name": "College Mystery",
|
||||
"handle": "@collegemystery",
|
||||
"description": "Mysterious lofi beats for college nights",
|
||||
"id": "UC4sRUMkLcE4cImnKPoUo-Hg",
|
||||
"name": "Relaxing Ambient Escape",
|
||||
"handle": "@RelaxingAmbientEscape",
|
||||
"description": "Relaxing ambient escape for peace",
|
||||
"thumbnail": "https://yt3.googleusercontent.com/pVjrEubSQfJu0xlxqmjdtataNAm0Z6I6G6QGmUGRsozrXbjCFlEXWh9Jn29jUEm-U3mzjFI0nA=s900-c-k-c0x00ffffff-no-rj"
|
||||
},
|
||||
{
|
||||
"id": "UCvU4LpY6wJxQ8zS3XuN9mPg",
|
||||
"name": "Lofi Hip Hop",
|
||||
"handle": "@lofihiphop",
|
||||
"description": "The original lofi hip hop experience",
|
||||
"id": "UCF02M7hAKgRNq7jmMAYh2bg",
|
||||
"name": "Futurescapes",
|
||||
"handle": "@Futurescapes",
|
||||
"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"
|
||||
},
|
||||
]
|
||||
266
src/main.py
266
src/main.py
@ -1,13 +1,17 @@
|
||||
import asyncio
|
||||
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.responses import StreamingResponse
|
||||
|
||||
from src.channels import CHANNELS
|
||||
from src.config import settings
|
||||
from src.modules.discovery import find_live_video
|
||||
from src.modules.stream_extractor import extract_audio_stream
|
||||
from src.modules.validator import validate_stream
|
||||
|
||||
logging.basicConfig(
|
||||
level=settings.LOG_LEVEL.upper(),
|
||||
@ -17,45 +21,147 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
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)
|
||||
handle = channel.get("handle", "") if channel else ""
|
||||
urls_to_try = []
|
||||
def _get_cached_stream(video_id: str) -> tuple[str, str]:
|
||||
"""Get or refresh cached stream for a video. Returns (url, stream_type)."""
|
||||
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("@"):
|
||||
urls_to_try.append(f"https://www.youtube.com/{handle}")
|
||||
urls_to_try.append(f"https://www.youtube.com/channel/{channel_id}")
|
||||
info = extract_audio_stream(video_id)
|
||||
if not info:
|
||||
raise HTTPException(status_code=503, detail="Unable to extract stream")
|
||||
|
||||
ydl_opts = {
|
||||
"flat_playlist": True,
|
||||
"playlistend": 1,
|
||||
"logger": logger,
|
||||
}
|
||||
_stream_cache[video_id] = (info["url"], info["streamType"], now + CACHE_TTL)
|
||||
return info["url"], info["streamType"]
|
||||
|
||||
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)
|
||||
return None
|
||||
def _fetch_playlist(playlist_url: str) -> str:
|
||||
"""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(
|
||||
@ -72,106 +178,104 @@ app.add_middleware(
|
||||
|
||||
@app.get("/api/channels")
|
||||
def list_channels() -> list[dict]:
|
||||
"""List all channels. Uses YouTube API for live detection if available, otherwise yt-dlp fallback."""
|
||||
results = []
|
||||
for channel in CHANNELS:
|
||||
if settings.YOUTUBE_API_KEY:
|
||||
video_id = find_live_video(channel["id"])
|
||||
results.append(
|
||||
{
|
||||
"id": channel["id"],
|
||||
"name": channel["name"],
|
||||
"handle": channel.get("handle", ""),
|
||||
"description": channel.get("description", ""),
|
||||
"isLive": video_id is not None,
|
||||
"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
|
||||
"""List all channels with thumbnails."""
|
||||
return [
|
||||
{
|
||||
"id": channel["id"],
|
||||
"name": channel["name"],
|
||||
"handle": channel.get("handle", ""),
|
||||
"description": channel.get("description", ""),
|
||||
"isLive": True,
|
||||
"videoId": None,
|
||||
"thumbnail": channel.get("thumbnail"),
|
||||
}
|
||||
for channel in CHANNELS
|
||||
]
|
||||
|
||||
|
||||
@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."""
|
||||
channel = next((c for c in CHANNELS if c["id"] == channel_id), None)
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
|
||||
video_id = (
|
||||
find_live_video(channel_id)
|
||||
if settings.YOUTUBE_API_KEY
|
||||
else find_latest_video(channel_id)
|
||||
)
|
||||
video_id, thumbnail = await find_live_video(channel["id"], channel.get("handle", ""))
|
||||
return {
|
||||
"channelId": channel_id,
|
||||
"name": channel["name"],
|
||||
"isLive": video_id is not None,
|
||||
"videoId": video_id,
|
||||
"thumbnail": thumbnail,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/channel/{channel_id}/latest")
|
||||
def get_channel_latest(channel_id: str) -> dict:
|
||||
"""Find the latest video for a channel using yt-dlp."""
|
||||
async def get_channel_latest(channel_id: str) -> dict:
|
||||
"""Find the latest video for a channel."""
|
||||
channel = next((c for c in CHANNELS if c["id"] == channel_id), None)
|
||||
if not channel:
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="No videos found for this channel")
|
||||
|
||||
return {
|
||||
"channelId": channel_id,
|
||||
"videoId": video_id,
|
||||
"thumbnail": thumbnail,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/stream/{video_id}")
|
||||
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)
|
||||
if not stream_info:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Unable to extract stream for this video"
|
||||
)
|
||||
|
||||
is_valid = validate_stream(stream_info["url"], stream_info["streamType"])
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=503, detail="Stream URL validation failed")
|
||||
# Cache the stream URL (server's IP-bound)
|
||||
_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}"
|
||||
|
||||
return stream_info
|
||||
|
||||
|
||||
@app.get("/api/now-playing")
|
||||
def now_playing() -> dict:
|
||||
async def now_playing() -> dict:
|
||||
"""Get the current active live stream from any channel."""
|
||||
for channel in CHANNELS:
|
||||
video_id = (
|
||||
find_live_video(channel["id"])
|
||||
if settings.YOUTUBE_API_KEY
|
||||
else find_latest_video(channel["id"])
|
||||
)
|
||||
video_id, thumbnail = await find_live_video(channel["id"], channel.get("handle", ""))
|
||||
if video_id:
|
||||
stream_info = extract_audio_stream(video_id)
|
||||
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"] = {
|
||||
"id": channel["id"],
|
||||
"name": channel["name"],
|
||||
"handle": channel.get("handle", ""),
|
||||
"description": channel.get("description", ""),
|
||||
}
|
||||
stream_info["thumbnail"] = thumbnail
|
||||
return stream_info
|
||||
|
||||
return {"channel": None, "videoId": None, "url": None}
|
||||
|
||||
@ -1,83 +1,179 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from src.config import settings
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
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 = {
|
||||
"part": "id,snippet",
|
||||
"channelId": channel_id,
|
||||
"eventType": "live",
|
||||
"maxResults": 1,
|
||||
"key": settings.YOUTUBE_API_KEY,
|
||||
}
|
||||
async def _get_browser():
|
||||
global _browser
|
||||
if _browser is None:
|
||||
p = await async_playwright().start()
|
||||
_browser = await p.chromium.launch(headless=True, args=["--no-sandbox"])
|
||||
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:
|
||||
response = httpx.get(settings.YOUTUBE_SEARCH_BASE, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
browser = await _get_browser()
|
||||
|
||||
if data.get("items"):
|
||||
video_id = data["items"][0]["id"]["videoId"]
|
||||
title = data["items"][0]["snippet"]["title"]
|
||||
logger.info(
|
||||
"Found live video for channel %s: %s (%s)", channel_id, video_id, title
|
||||
)
|
||||
return video_id
|
||||
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)
|
||||
|
||||
return None
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(
|
||||
"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
|
||||
videos = await page.query_selector_all("ytd-video-renderer")
|
||||
if not videos:
|
||||
videos = await page.query_selector_all("ytd-grid-video-renderer")
|
||||
|
||||
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:
|
||||
"""Get channel details using YouTube Data API."""
|
||||
if not settings.YOUTUBE_API_KEY:
|
||||
return None
|
||||
video_id = None
|
||||
if title_el:
|
||||
href = await title_el.get_attribute("href")
|
||||
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 = {
|
||||
"part": "snippet,statistics",
|
||||
"id": channel_id,
|
||||
"key": settings.YOUTUBE_API_KEY,
|
||||
}
|
||||
if not video_id:
|
||||
continue
|
||||
|
||||
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:
|
||||
response = httpx.get(settings.YOUTUBE_CHANNELS_BASE, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
import yt_dlp
|
||||
|
||||
if data.get("items"):
|
||||
item = data["items"][0]
|
||||
return {
|
||||
"channel_id": channel_id,
|
||||
"title": item["snippet"]["title"],
|
||||
"description": item["snippet"]["description"],
|
||||
"thumbnail": item["snippet"]["thumbnails"]["default"]["url"],
|
||||
"subscriber_count": item.get("statistics", {}).get(
|
||||
"subscriberCount", 0
|
||||
),
|
||||
}
|
||||
return None
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(
|
||||
"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
|
||||
ydl_opts = {
|
||||
"flat_playlist": True,
|
||||
"playlistend": 5,
|
||||
"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", [])
|
||||
for entry in entries:
|
||||
video_id = entry.get("id")
|
||||
if video_id:
|
||||
thumb = None
|
||||
thumbnails = entry.get("thumbnails", [])
|
||||
if thumbnails:
|
||||
thumb = sorted(thumbnails, key=lambda t: t.get("width", 0), reverse=True)[0].get("url")
|
||||
logger.info(
|
||||
"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
|
||||
@ -14,6 +14,7 @@ def extract_audio_stream(video_id: str) -> dict | None:
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"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:
|
||||
|
||||
@ -7,9 +7,13 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def validate_stream(stream_url: str, stream_type: str) -> bool:
|
||||
"""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:
|
||||
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:
|
||||
logger.warning(
|
||||
"HLS playlist returned status %d for %s",
|
||||
@ -27,7 +31,7 @@ def validate_stream(stream_url: str, stream_type: str) -> bool:
|
||||
return True
|
||||
|
||||
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):
|
||||
logger.warning(
|
||||
"Direct stream returned status %d for %s",
|
||||
|
||||
@ -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 src.main import app
|
||||
|
||||
|
||||
def _mock_discovery_live(video_id: str = "dQw4w9WgXcQ"):
|
||||
return MagicMock(return_value=video_id)
|
||||
def _mock_discovery_live(video_id: str = "dQw4w9WgXcQ", thumb: str = "http://thumb.png"):
|
||||
return AsyncMock(return_value=(video_id, thumb))
|
||||
|
||||
|
||||
def _mock_discovery_none():
|
||||
return MagicMock(return_value=None)
|
||||
return AsyncMock(return_value=(None, None))
|
||||
|
||||
|
||||
def _mock_extractor_hls(video_id: str = "dQw4w9WgXcQ"):
|
||||
@ -41,46 +42,46 @@ def _mock_validator_false():
|
||||
|
||||
class TestListChannels:
|
||||
def test_returns_all_channels(self) -> None:
|
||||
with patch("src.main.find_live_video", _mock_discovery_none()):
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/channels")
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/channels")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 20
|
||||
assert len(data) == 99
|
||||
assert all("id" 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(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("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:
|
||||
with patch("src.main.find_live_video", _mock_discovery_live()):
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/channels")
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/channels")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert all(c["isLive"] for c in data)
|
||||
assert all(c["videoId"] == "dQw4w9WgXcQ" for c in data)
|
||||
|
||||
|
||||
class TestCheckChannelLive:
|
||||
def test_returns_live_status(self) -> None:
|
||||
with patch("src.main.find_live_video", _mock_discovery_live()):
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/channels/UCSJ4g0vg1503/live")
|
||||
response = client.get("/api/channels/UCSJ4gkVC6NrvII8umztf0Ow/live")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["channelId"] == "UCSJ4g0vg1503"
|
||||
assert data["channelId"] == "UCSJ4gkVC6NrvII8umztf0Ow"
|
||||
assert data["isLive"] is True
|
||||
assert data["videoId"] == "dQw4w9WgXcQ"
|
||||
assert data["thumbnail"] == "http://thumb.png"
|
||||
|
||||
def test_returns_not_live(self) -> None:
|
||||
with patch("src.main.find_live_video", _mock_discovery_none()):
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/channels/UCSJ4g0vg1503/live")
|
||||
response = client.get("/api/channels/UCSJ4gkVC6NrvII8umztf0Ow/live")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@ -95,15 +96,14 @@ class TestCheckChannelLive:
|
||||
|
||||
|
||||
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.validate_stream", _mock_validator_true()):
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/stream/dQw4w9WgXcQ")
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/stream/dQw4w9WgXcQ")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["url"] == "https://manifest.hls.tv/pl.m3u8"
|
||||
assert data["url"].startswith("/api/proxy/hls?video=")
|
||||
assert data["streamType"] == "hls"
|
||||
|
||||
def test_returns_503_when_no_stream(self) -> None:
|
||||
@ -113,14 +113,6 @@ class TestGetStream:
|
||||
|
||||
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:
|
||||
def test_returns_active_stream(self) -> None:
|
||||
@ -145,24 +137,109 @@ class TestNowPlaying:
|
||||
assert data["videoId"] 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:
|
||||
def test_allows_frontend_origin(self) -> None:
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/channels",
|
||||
headers={"origin": "http://localhost:5173"},
|
||||
)
|
||||
with patch("src.main.find_live_video", _mock_discovery_none()):
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/channels",
|
||||
headers={"origin": "http://localhost:5173"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "access-control-allow-origin" in response.headers
|
||||
|
||||
def test_allows_docker_frontend_origin(self) -> None:
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/channels",
|
||||
headers={"origin": "http://frontend:80"},
|
||||
)
|
||||
with patch("src.main.find_live_video", _mock_discovery_none()):
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/channels",
|
||||
headers={"origin": "http://frontend:80"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "access-control-allow-origin" in response.headers
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
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:
|
||||
def test_returns_video_id_when_live(self) -> None:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"items": [
|
||||
{
|
||||
"id": {"videoId": "dQw4w9WgXcQ"},
|
||||
"snippet": {"title": "Live Lofi Stream"},
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_video_id_when_live(self) -> None:
|
||||
mock_page = MagicMock()
|
||||
mock_video = MagicMock()
|
||||
mock_title = MagicMock()
|
||||
mock_title.get_attribute = AsyncMock(return_value="/watch?v=dQw4w9WgXcQ")
|
||||
mock_video.query_selector = AsyncMock(return_value=mock_title)
|
||||
mock_videos = [mock_video]
|
||||
|
||||
with patch("src.modules.discovery.httpx.get", return_value=mock_response):
|
||||
with patch("src.modules.discovery.settings", _mock_settings_with_key()):
|
||||
result = find_live_video("UC_test_channel")
|
||||
mock_page.query_selector_all = AsyncMock(return_value=mock_videos)
|
||||
mock_page.close = AsyncMock()
|
||||
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:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"items": []}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
with patch("src.modules.discovery._get_browser", new=AsyncMock(return_value=mock_browser)):
|
||||
result = await find_live_video("UC_test_channel", "@testchannel")
|
||||
|
||||
with patch("src.modules.discovery.httpx.get", return_value=mock_response):
|
||||
with patch("src.modules.discovery.settings", _mock_settings_with_key()):
|
||||
result = find_live_video("UC_test_channel")
|
||||
assert result[0] == "dQw4w9WgXcQ"
|
||||
|
||||
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_settings = _mock_settings_with_key()
|
||||
mock_settings.YOUTUBE_API_KEY = ""
|
||||
mock_browser = MagicMock()
|
||||
mock_browser.new_page = AsyncMock(return_value=mock_page)
|
||||
|
||||
with patch("src.modules.discovery.settings", mock_settings):
|
||||
result = find_live_video("UC_test_channel")
|
||||
with patch("src.modules.discovery._get_browser", new=AsyncMock(return_value=mock_browser)):
|
||||
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:
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = "API quota exceeded"
|
||||
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"Forbidden", request=MagicMock(), response=mock_response
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_on_error(self) -> None:
|
||||
mock_page = MagicMock()
|
||||
mock_page.goto = AsyncMock(side_effect=Exception("Connection refused"))
|
||||
|
||||
with patch("src.modules.discovery.httpx.get", return_value=mock_response):
|
||||
with patch("src.modules.discovery.settings", _mock_settings_with_key()):
|
||||
result = find_live_video("UC_test_channel")
|
||||
mock_browser = MagicMock()
|
||||
mock_browser.new_page = AsyncMock(return_value=mock_page)
|
||||
|
||||
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:
|
||||
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[0] is None
|
||||
|
||||
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:
|
||||
def test_returns_channel_details(self) -> None:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"items": [
|
||||
{
|
||||
"snippet": {
|
||||
"title": "Lofi Girl",
|
||||
"description": "Beats to relax",
|
||||
"thumbnails": {"default": {"url": "http://thumb.png"}},
|
||||
},
|
||||
"statistics": {"subscriberCount": 1000000},
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_when_no_title(self) -> None:
|
||||
mock_page = MagicMock()
|
||||
mock_page.goto = AsyncMock()
|
||||
mock_page.wait_for_timeout = AsyncMock()
|
||||
mock_page.close = AsyncMock()
|
||||
mock_page.query_selector = AsyncMock(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 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")
|
||||
with patch("src.modules.discovery._get_browser", new=AsyncMock(return_value=MagicMock(new_page=AsyncMock(return_value=mock_page)))):
|
||||
result = await get_channel_info("UC_test_channel", "@testchannel")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_when_no_api_key(self) -> None:
|
||||
mock_settings = _mock_settings_with_key()
|
||||
mock_settings.YOUTUBE_API_KEY = ""
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_on_error(self) -> None:
|
||||
mock_page = MagicMock()
|
||||
mock_page.goto = AsyncMock(side_effect=Exception("Timeout"))
|
||||
|
||||
with patch("src.modules.discovery.settings", mock_settings):
|
||||
result = get_channel_info("UC_test_channel")
|
||||
with patch("src.modules.discovery._get_browser", new=AsyncMock(return_value=MagicMock(new_page=AsyncMock(return_value=mock_page)))):
|
||||
result = await get_channel_info("UC_test_channel", "@testchannel")
|
||||
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user