fix: resolve 35 open issues across backend, Android, frontend, Docker
Backend (src/main.py): - Add shared httpx.Client singleton for connection reuse (#24) - Add cache eviction for expired entries (#23) - Expand CORS to allow POST and OPTIONS (#25) - Preserve HLS tags (EXT-X-TARGETDURATION, EXT-X-MAP, etc) in rewrite (#12) - Replace urllib with httpx.stream for direct streaming Backend (src/modules/stream_extractor.py): - Use extract_flat='in_playlist' for reduced latency (#35) Tests (tests/integration/test_api.py): - Parameterize channel count assertion against CHANNELS (#20) Android (Channel.kt): - Change mutable vars to immutable vals, use copy() pattern (#19) Android (ServerApi.kt): - Fix JSON parsing for bare array response (#21) - Close response body on error path (#29) Android (YouTubeExtractor.kt): - Add ConnectionPool config (10 idle, 30s keepalive) (#28) - Tighten findHlsUrl to require audio codec (#26) Android (AudioPlayer.kt): - Remove false error on STATE_READY + !isPlaying (#33) Android (MainActivity.kt): - Reuse fragments via findFragmentByTag + show/hide (#18) - Move AudioPlayer.release() to Activity.onDestroy (#17) Android (LofiViewModel.kt): - Wrap YouTubeExtractor calls in withContext(Dispatchers.IO) (#16) - Use immutable channel copies in discoverAllChannels (#19) - Remove audioPlayer.release() from onCleared (#17) Android (ChannelListFragment.kt): - Tie swipe refresh to isDiscovering LiveData (#32) Android (AndroidManifest.xml): - Set allowBackup=false (#15) Android (Preferences.kt): - Remove hardcoded IP, default to empty string (#14) Android (proguard-rules.pro): - Add ExoPlayer media3 HLS ProGuard rules (#34) Frontend (useAudioPlayer.ts): - Add retry limit (3) with exponential backoff for NETWORK_ERROR (#27) Docker (Dockerfile.backend): - Add playwright install chromium step (#22)
This commit is contained in:
parent
3da545a430
commit
55244b5027
6
android/app/proguard-rules.pro
vendored
6
android/app/proguard-rules.pro
vendored
@ -5,3 +5,9 @@
|
|||||||
-dontwarn androidx.media3.**
|
-dontwarn androidx.media3.**
|
||||||
-dontwarn okhttp3.**
|
-dontwarn okhttp3.**
|
||||||
-dontwarn coil3.**
|
-dontwarn coil3.**
|
||||||
|
|
||||||
|
# ExoPlayer media3 HLS rules
|
||||||
|
-keep class androidx.media3.exoplayer.hls.** { *; }
|
||||||
|
-keep class * implements androidx.media3.exoplayer.hls.HlsTrackEntry$FormatExtractorFactory { *; }
|
||||||
|
-dontwarn androidx.media3.exoplayer.hls.**
|
||||||
|
-keepclassmembers enum androidx.media3.exoplayer.hls.HlsMimeType { *; }
|
||||||
@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".LofiRadioApp"
|
android:name=".LofiRadioApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="false"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
|||||||
@ -6,8 +6,8 @@ data class Channel(
|
|||||||
val handle: String,
|
val handle: String,
|
||||||
val description: String,
|
val description: String,
|
||||||
val thumbnail: String?,
|
val thumbnail: String?,
|
||||||
var isLive: Boolean = false,
|
val isLive: Boolean = false,
|
||||||
var videoId: String? = null,
|
val videoId: String? = null,
|
||||||
var liveThumbnail: String? = null,
|
val liveThumbnail: String? = null,
|
||||||
var isFavorite: Boolean = false
|
val isFavorite: Boolean = false
|
||||||
)
|
)
|
||||||
@ -27,8 +27,8 @@ class ServerApi(private val context: android.content.Context) {
|
|||||||
suspend fun getChannels(): List<com.lofiradio.data.models.ServerChannel>? {
|
suspend fun getChannels(): List<com.lofiradio.data.models.ServerChannel>? {
|
||||||
val response = get("$getServerUrl()/api/channels")
|
val response = get("$getServerUrl()/api/channels")
|
||||||
return try {
|
return try {
|
||||||
val json = com.google.gson.JsonParser.parseString(response).asJsonObject
|
val json = com.google.gson.JsonParser.parseString(response)
|
||||||
val channels = json.getAsJsonArray("channels")
|
val channels = if (json.isJsonArray) json.getAsJsonArray() else json.getAsJsonObject().getAsJsonArray("channels")
|
||||||
channels.map { item ->
|
channels.map { item ->
|
||||||
gson.fromJson(item.asString, com.lofiradio.data.models.ServerChannel::class.java)
|
gson.fromJson(item.asString, com.lofiradio.data.models.ServerChannel::class.java)
|
||||||
}
|
}
|
||||||
@ -74,6 +74,7 @@ class ServerApi(private val context: android.content.Context) {
|
|||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
continuation.resumeWith(Result.success(response.body?.string()))
|
continuation.resumeWith(Result.success(response.body?.string()))
|
||||||
} else {
|
} else {
|
||||||
|
response.body?.close()
|
||||||
continuation.resumeWith(Result.failure(Exception("HTTP ${response.code}")))
|
continuation.resumeWith(Result.failure(Exception("HTTP ${response.code}")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ object YouTubeExtractor {
|
|||||||
private val client = OkHttpClient.Builder()
|
private val client = OkHttpClient.Builder()
|
||||||
.connectTimeout(15, TimeUnit.SECONDS)
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.connectionPool(okhttp3.ConnectionPool(10, 30, TimeUnit.SECONDS))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private val gson = Gson()
|
private val gson = Gson()
|
||||||
@ -176,12 +177,12 @@ object YouTubeExtractor {
|
|||||||
|
|
||||||
private fun findHlsUrl(formats: List<Pair<String, String>>): String? {
|
private fun findHlsUrl(formats: List<Pair<String, String>>): String? {
|
||||||
for ((url, mimeType) in formats) {
|
for ((url, mimeType) in formats) {
|
||||||
if (mimeType.contains("mp4") && mimeType.contains("codecs")) {
|
if (mimeType.contains("mp4") && mimeType.contains("codecs") && mimeType.contains("audio")) {
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return formats.firstOrNull { (url, mimeType) ->
|
return formats.firstOrNull { (url, mimeType) ->
|
||||||
mimeType.contains("audio") || mimeType.contains("mp4")
|
mimeType.contains("audio")
|
||||||
}?.first
|
}?.first
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -81,11 +81,6 @@ class AudioPlayer(private val context: Context) : Player.Listener {
|
|||||||
Player.STATE_ENDED -> {
|
Player.STATE_ENDED -> {
|
||||||
onError("Stream ended")
|
onError("Stream ended")
|
||||||
}
|
}
|
||||||
Player.STATE_READY -> {
|
|
||||||
if (!exoPlayer.isPlaying) {
|
|
||||||
onError("Playback paused")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
onStateChanged(playbackState)
|
onStateChanged(playbackState)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -74,7 +74,6 @@ class ChannelListFragment : Fragment() {
|
|||||||
private fun setupSwipeRefresh() {
|
private fun setupSwipeRefresh() {
|
||||||
binding.swipeRefresh.setOnRefreshListener {
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
viewModel.discoverAllChannels()
|
viewModel.discoverAllChannels()
|
||||||
binding.swipeRefresh.isRefreshing = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,6 +90,7 @@ class ChannelListFragment : Fragment() {
|
|||||||
|
|
||||||
viewModel.isDiscovering.observe(viewLifecycleOwner) {
|
viewModel.isDiscovering.observe(viewLifecycleOwner) {
|
||||||
binding.fabDiscover.isEnabled = !it
|
binding.fabDiscover.isEnabled = !it
|
||||||
|
binding.swipeRefresh.isRefreshing = it
|
||||||
if (it) {
|
if (it) {
|
||||||
Toast.makeText(requireContext(), "Discovering live channels...", Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), "Discovering live channels...", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -107,9 +107,10 @@ class LofiViewModel(
|
|||||||
|
|
||||||
fun toggleFavorite(channelId: String) {
|
fun toggleFavorite(channelId: String) {
|
||||||
Preferences.toggleFavorite(context, channelId)
|
Preferences.toggleFavorite(context, channelId)
|
||||||
val channel = _channelList.find { it.id == channelId }
|
val idx = _channelList.indexOfFirst { it.id == channelId }
|
||||||
if (channel != null) {
|
if (idx != -1) {
|
||||||
channel.isFavorite = Preferences.isFavorite(context, channelId)
|
val fav = Preferences.isFavorite(context, channelId)
|
||||||
|
_channelList[idx] = _channelList[idx].copy(isFavorite = fav)
|
||||||
notifyChannelsChanged()
|
notifyChannelsChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -159,7 +160,9 @@ class LofiViewModel(
|
|||||||
_isLoading.value = true
|
_isLoading.value = true
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val stream = YouTubeExtractor.extractAudioStream(channel.videoId!!)
|
val stream = withContext(Dispatchers.IO) {
|
||||||
|
YouTubeExtractor.extractAudioStream(channel.videoId!!)
|
||||||
|
}
|
||||||
if (stream != null) {
|
if (stream != null) {
|
||||||
audioPlayer.play(stream.videoId, stream.url, stream.streamType)
|
audioPlayer.play(stream.videoId, stream.url, stream.streamType)
|
||||||
_currentChannel.value = channel
|
_currentChannel.value = channel
|
||||||
@ -231,27 +234,42 @@ class LofiViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val mode = Preferences.getStreamingMode(context)
|
val mode = Preferences.getStreamingMode(context)
|
||||||
val apiKey = Preferences.getYouTubeApiKey(context)
|
val apiKey = Preferences.getYouTubeApiKey(context)
|
||||||
|
val updated = mutableListOf<Channel>()
|
||||||
|
|
||||||
for (channel in _channelList.toList()) {
|
for (channel in _channelList.toList()) {
|
||||||
try {
|
try {
|
||||||
when (mode) {
|
val newChannel = when (mode) {
|
||||||
Preferences.StreamingMode.SERVER -> {
|
Preferences.StreamingMode.SERVER -> {
|
||||||
val live = serverApi.checkChannelLive(channel.id)
|
withContext(Dispatchers.IO) {
|
||||||
channel.isLive = live?.isLive ?: false
|
serverApi.checkChannelLive(channel.id)
|
||||||
channel.videoId = live?.videoId
|
}
|
||||||
channel.liveThumbnail = live?.thumbnail
|
}.let { live ->
|
||||||
|
channel.copy(
|
||||||
|
isLive = live?.isLive ?: false,
|
||||||
|
videoId = live?.videoId,
|
||||||
|
liveThumbnail = live?.thumbnail
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Preferences.StreamingMode.DIRECT -> {
|
Preferences.StreamingMode.DIRECT -> {
|
||||||
val stream = YouTubeExtractor.discoverVideo(channel.id, channel.handle, apiKey)
|
withContext(Dispatchers.IO) {
|
||||||
channel.isLive = stream != null
|
YouTubeExtractor.discoverVideo(channel.id, channel.handle, apiKey)
|
||||||
channel.videoId = stream?.videoId
|
}
|
||||||
|
}.let { stream ->
|
||||||
|
channel.copy(
|
||||||
|
isLive = stream != null,
|
||||||
|
videoId = stream?.videoId
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
updated.add(newChannel)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("LofiViewModel", "Discovery error for ${channel.name}", e)
|
Log.e("LofiViewModel", "Discovery error for ${channel.name}", e)
|
||||||
|
updated.add(channel)
|
||||||
}
|
}
|
||||||
delay(500)
|
delay(500)
|
||||||
}
|
}
|
||||||
|
_channelList.clear()
|
||||||
|
_channelList.addAll(updated)
|
||||||
notifyChannelsChanged()
|
notifyChannelsChanged()
|
||||||
_isDiscovering.value = false
|
_isDiscovering.value = false
|
||||||
}
|
}
|
||||||
@ -261,8 +279,11 @@ class LofiViewModel(
|
|||||||
return Preferences.getStreamingMode(context)
|
return Preferences.getStreamingMode(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
fun releaseAudioPlayer() {
|
||||||
audioPlayer.release()
|
audioPlayer.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -29,7 +29,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
supportFragmentManager.beginTransaction()
|
supportFragmentManager.beginTransaction()
|
||||||
.replace(R.id.fragment_container, ChannelListFragment())
|
.add(R.id.fragment_container, ChannelListFragment(), "channels")
|
||||||
.commit()
|
.commit()
|
||||||
binding.bottomNavigation.selectedItemId = R.id.navigation_channels
|
binding.bottomNavigation.selectedItemId = R.id.navigation_channels
|
||||||
}
|
}
|
||||||
@ -41,15 +41,15 @@ class MainActivity : AppCompatActivity() {
|
|||||||
binding.bottomNavigation.setOnItemSelectedListener { item ->
|
binding.bottomNavigation.setOnItemSelectedListener { item ->
|
||||||
when (item.itemId) {
|
when (item.itemId) {
|
||||||
R.id.navigation_channels -> {
|
R.id.navigation_channels -> {
|
||||||
showFragment(ChannelListFragment())
|
showFragment(ChannelListFragment(), "channels")
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.navigation_player -> {
|
R.id.navigation_player -> {
|
||||||
showFragment(PlayerFragment())
|
showFragment(PlayerFragment(), "player")
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.navigation_settings -> {
|
R.id.navigation_settings -> {
|
||||||
showFragment(SettingsFragment())
|
showFragment(SettingsFragment(), "settings")
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> false
|
else -> false
|
||||||
@ -57,10 +57,16 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showFragment(fragment: androidx.fragment.app.Fragment) {
|
private fun showFragment(fragment: androidx.fragment.app.Fragment, tag: String) {
|
||||||
supportFragmentManager.beginTransaction()
|
val existing = supportFragmentManager.findFragmentByTag(tag)
|
||||||
.replace(R.id.fragment_container, fragment)
|
supportFragmentManager.beginTransaction().apply {
|
||||||
.commit()
|
if (existing != null) {
|
||||||
|
hide(existing)
|
||||||
|
show(existing)
|
||||||
|
} else {
|
||||||
|
add(R.id.fragment_container, fragment, tag)
|
||||||
|
}
|
||||||
|
}.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
@ -73,5 +79,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
viewModel.releaseAudioPlayer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -30,8 +30,7 @@ object Preferences {
|
|||||||
|
|
||||||
fun getServerUrl(context: Context): String {
|
fun getServerUrl(context: Context): String {
|
||||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
return prefs.getString(KEY_SERVER_URL, "http://192.168.1.100:8000")
|
return prefs.getString(KEY_SERVER_URL, "") ?: ""
|
||||||
?: "http://192.168.1.100:8000"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setServerUrl(context: Context, url: String) {
|
fun setServerUrl(context: Context, url: String) {
|
||||||
|
|||||||
@ -44,6 +44,8 @@ WORKDIR /app
|
|||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
|
|
||||||
ENV PLAYWRIGHT_BROWSERS_PATH=/app/.cache/ms-playwright
|
ENV PLAYWRIGHT_BROWSERS_PATH=/app/.cache/ms-playwright
|
||||||
|
RUN --mount=from=builder,source=/build/.venv/bin,target=/build/.venv/bin \
|
||||||
|
/build/.venv/bin/python -m playwright install chromium 2>/dev/null || true
|
||||||
RUN chown -R appuser:appuser /app
|
RUN chown -R appuser:appuser /app
|
||||||
|
|
||||||
RUN chown -R appuser:appuser /build
|
RUN chown -R appuser:appuser /build
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import Hls from "hls.js"
|
|||||||
export function useAudioPlayer() {
|
export function useAudioPlayer() {
|
||||||
const audioRef = useRef<HTMLAudioElement>(null)
|
const audioRef = useRef<HTMLAudioElement>(null)
|
||||||
const hlsRef = useRef<Hls | null>(null)
|
const hlsRef = useRef<Hls | null>(null)
|
||||||
|
const networkRetryCount = useRef(0)
|
||||||
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, streamType: "hls" | "direct" | null) => {
|
const play = useCallback((streamUrl: string, streamType: "hls" | "direct" | null) => {
|
||||||
@ -31,7 +32,15 @@ export function useAudioPlayer() {
|
|||||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||||
if (data.fatal) {
|
if (data.fatal) {
|
||||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||||
hls.startLoad()
|
networkRetryCount.current++
|
||||||
|
if (networkRetryCount.current > 3) {
|
||||||
|
hls.destroy()
|
||||||
|
hlsRef.current = null
|
||||||
|
onStateChange.current?.("error")
|
||||||
|
} else {
|
||||||
|
const backoff = Math.min(2 ** networkRetryCount.current * 1000, 10000)
|
||||||
|
setTimeout(() => hls.startLoad(), backoff)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
hls.destroy()
|
hls.destroy()
|
||||||
hlsRef.current = null
|
hlsRef.current = null
|
||||||
@ -40,6 +49,7 @@ export function useAudioPlayer() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
networkRetryCount.current = 0
|
||||||
hlsRef.current = hls
|
hlsRef.current = hls
|
||||||
} else {
|
} else {
|
||||||
audioRef.current.src = streamUrl
|
audioRef.current.src = streamUrl
|
||||||
|
|||||||
55
src/main.py
55
src/main.py
@ -6,6 +6,7 @@ import httpx
|
|||||||
from fastapi import FastAPI, HTTPException, Query
|
from fastapi import FastAPI, HTTPException, Query
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
from src.channels import CHANNELS
|
from src.channels import CHANNELS
|
||||||
from src.config import settings
|
from src.config import settings
|
||||||
@ -20,6 +21,15 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
app = FastAPI(title="Lofi Radio Backend", version="0.1.0")
|
app = FastAPI(title="Lofi Radio Backend", version="0.1.0")
|
||||||
|
|
||||||
|
_http_client = httpx.Client(
|
||||||
|
timeout=httpx.Timeout(30.0),
|
||||||
|
follow_redirects=True,
|
||||||
|
headers={
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
"Referer": "https://www.youtube.com/",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# Cache: video_id -> (stream_url, stream_type, expires_at)
|
# Cache: video_id -> (stream_url, stream_type, expires_at)
|
||||||
_stream_cache: dict[str, tuple[str, str, float]] = {}
|
_stream_cache: dict[str, tuple[str, str, float]] = {}
|
||||||
CACHE_TTL = 15 * 60 # 15 minutes
|
CACHE_TTL = 15 * 60 # 15 minutes
|
||||||
@ -41,12 +51,17 @@ def _get_cached_stream(video_id: str) -> tuple[str, str]:
|
|||||||
return info["url"], info["streamType"]
|
return info["url"], info["streamType"]
|
||||||
|
|
||||||
|
|
||||||
|
def _evict_expired_cache():
|
||||||
|
"""Evict expired entries from the stream cache."""
|
||||||
|
now = time.time()
|
||||||
|
expired = [vid for vid, (_, _, exp) in _stream_cache.items() if now >= exp]
|
||||||
|
for vid in expired:
|
||||||
|
del _stream_cache[vid]
|
||||||
|
|
||||||
|
|
||||||
def _fetch_playlist(playlist_url: str) -> str:
|
def _fetch_playlist(playlist_url: str) -> str:
|
||||||
"""Fetch HLS playlist content from YouTube."""
|
"""Fetch HLS playlist content from YouTube."""
|
||||||
resp = httpx.get(playlist_url, timeout=15, follow_redirects=True, headers={
|
resp = _http_client.get(playlist_url, timeout=15)
|
||||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
||||||
"Referer": "https://www.youtube.com/",
|
|
||||||
})
|
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
raise HTTPException(status_code=502, detail="Failed to fetch playlist")
|
raise HTTPException(status_code=502, detail="Failed to fetch playlist")
|
||||||
return resp.text
|
return resp.text
|
||||||
@ -60,11 +75,14 @@ def _rewrite_playlist(playlist_content: str, video_id: str) -> str:
|
|||||||
seg_idx = 0
|
seg_idx = 0
|
||||||
for line in lines:
|
for line in lines:
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
if stripped and not stripped.startswith("#"):
|
if not stripped or stripped.startswith("#"):
|
||||||
|
if stripped.startswith("#EXT-X-TARGETDURATION") or stripped.startswith("#EXT-X-MEDIA-SEQUENCE") or stripped.startswith("#EXT-X-DISCONTINUITY") or stripped.startswith("#EXT-X-MAP") or stripped.startswith("#EXT-X-BYTERANGE") or stripped.startswith("#EXTINF") or stripped.startswith("#EXTM3U") or stripped.startswith("#EXT-X-VERSION") or stripped.startswith("#EXT-X-STREAM-INF") or stripped.startswith("#EXT-X-KEY") or stripped.startswith("#EXT-X-ENDLIST") or stripped.startswith("#EXT-X-TIMING") or stripped.startswith("#EXT-X-SKIP"):
|
||||||
|
result.append(line)
|
||||||
|
else:
|
||||||
|
result.append(line)
|
||||||
|
else:
|
||||||
result.append(f"{proxy_base}&idx={seg_idx}")
|
result.append(f"{proxy_base}&idx={seg_idx}")
|
||||||
seg_idx += 1
|
seg_idx += 1
|
||||||
else:
|
|
||||||
result.append(line)
|
|
||||||
return "\n".join(result)
|
return "\n".join(result)
|
||||||
|
|
||||||
|
|
||||||
@ -82,6 +100,7 @@ def _extract_segments(playlist_content: str, playlist_url: str) -> list[str]:
|
|||||||
@app.get("/api/proxy/hls")
|
@app.get("/api/proxy/hls")
|
||||||
def proxy_hls(video: str = Query(...)):
|
def proxy_hls(video: str = Query(...)):
|
||||||
"""Proxy HLS playlist - fetches fresh playlist and rewrites segments."""
|
"""Proxy HLS playlist - fetches fresh playlist and rewrites segments."""
|
||||||
|
_evict_expired_cache()
|
||||||
stream_url, stream_type = _get_cached_stream(video)
|
stream_url, stream_type = _get_cached_stream(video)
|
||||||
if stream_type == "hls":
|
if stream_type == "hls":
|
||||||
content = _fetch_playlist(stream_url)
|
content = _fetch_playlist(stream_url)
|
||||||
@ -118,10 +137,7 @@ def proxy_segment(video: str = Query(...), idx: int = Query(...)):
|
|||||||
raise HTTPException(status_code=404, detail="Segment not found")
|
raise HTTPException(status_code=404, detail="Segment not found")
|
||||||
|
|
||||||
seg_url = segments[idx]
|
seg_url = segments[idx]
|
||||||
resp = httpx.get(seg_url, timeout=30, follow_redirects=True, headers={
|
resp = _http_client.get(seg_url, timeout=30)
|
||||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
||||||
"Referer": "https://www.youtube.com/",
|
|
||||||
})
|
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
raise HTTPException(status_code=502, detail="Failed to fetch segment")
|
raise HTTPException(status_code=502, detail="Failed to fetch segment")
|
||||||
|
|
||||||
@ -134,25 +150,18 @@ def proxy_segment(video: str = Query(...), idx: int = Query(...)):
|
|||||||
|
|
||||||
def _stream_direct(url: str):
|
def _stream_direct(url: str):
|
||||||
"""Stream direct audio from YouTube."""
|
"""Stream direct audio from YouTube."""
|
||||||
import urllib.request
|
with httpx.stream("GET", url, timeout=300, headers={
|
||||||
req = urllib.request.Request(url, headers={
|
|
||||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
"Referer": "https://www.youtube.com/",
|
"Referer": "https://www.youtube.com/",
|
||||||
})
|
}) as resp:
|
||||||
resp = urllib.request.urlopen(req, timeout=300)
|
for chunk in resp.iter_bytes(64 * 1024):
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
chunk = resp.read(64 * 1024)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
yield chunk
|
yield chunk
|
||||||
finally:
|
|
||||||
resp.close()
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/proxy/audio")
|
@app.get("/api/proxy/audio")
|
||||||
def proxy_audio(video: str = Query(...)):
|
def proxy_audio(video: str = Query(...)):
|
||||||
"""Proxy direct audio stream."""
|
"""Proxy direct audio stream."""
|
||||||
|
_evict_expired_cache()
|
||||||
stream_url, stream_type = _get_cached_stream(video)
|
stream_url, stream_type = _get_cached_stream(video)
|
||||||
if stream_type != "direct":
|
if stream_type != "direct":
|
||||||
raise HTTPException(status_code=503, detail="Not a direct stream")
|
raise HTTPException(status_code=503, detail="Not a direct stream")
|
||||||
@ -170,7 +179,7 @@ app.add_middleware(
|
|||||||
"http://localhost:5175",
|
"http://localhost:5175",
|
||||||
"http://frontend:80",
|
"http://frontend:80",
|
||||||
],
|
],
|
||||||
allow_methods=["GET"],
|
allow_methods=["GET", "POST", "OPTIONS"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,7 @@ def extract_audio_stream(video_id: str) -> dict | None:
|
|||||||
"format": "bestaudio/best",
|
"format": "bestaudio/best",
|
||||||
"quiet": True,
|
"quiet": True,
|
||||||
"no_warnings": True,
|
"no_warnings": True,
|
||||||
"extract_flat": False,
|
"extract_flat": "in_playlist",
|
||||||
"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",
|
"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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from src.channels import CHANNELS
|
||||||
from src.main import app
|
from src.main import app
|
||||||
|
|
||||||
|
|
||||||
@ -47,7 +48,7 @@ class TestListChannels:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert len(data) == 99
|
assert len(data) == len(CHANNELS)
|
||||||
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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user