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 okhttp3.**
|
||||
-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
|
||||
android:name=".LofiRadioApp"
|
||||
android:allowBackup="true"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
|
||||
@ -6,8 +6,8 @@ data class Channel(
|
||||
val handle: String,
|
||||
val description: String,
|
||||
val thumbnail: String?,
|
||||
var isLive: Boolean = false,
|
||||
var videoId: String? = null,
|
||||
var liveThumbnail: String? = null,
|
||||
var isFavorite: Boolean = false
|
||||
val isLive: Boolean = false,
|
||||
val videoId: String? = null,
|
||||
val liveThumbnail: String? = null,
|
||||
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>? {
|
||||
val response = get("$getServerUrl()/api/channels")
|
||||
return try {
|
||||
val json = com.google.gson.JsonParser.parseString(response).asJsonObject
|
||||
val channels = json.getAsJsonArray("channels")
|
||||
val json = com.google.gson.JsonParser.parseString(response)
|
||||
val channels = if (json.isJsonArray) json.getAsJsonArray() else json.getAsJsonObject().getAsJsonArray("channels")
|
||||
channels.map { item ->
|
||||
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) {
|
||||
continuation.resumeWith(Result.success(response.body?.string()))
|
||||
} else {
|
||||
response.body?.close()
|
||||
continuation.resumeWith(Result.failure(Exception("HTTP ${response.code}")))
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ object YouTubeExtractor {
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.connectionPool(okhttp3.ConnectionPool(10, 30, TimeUnit.SECONDS))
|
||||
.build()
|
||||
|
||||
private val gson = Gson()
|
||||
@ -176,12 +177,12 @@ object YouTubeExtractor {
|
||||
|
||||
private fun findHlsUrl(formats: List<Pair<String, String>>): String? {
|
||||
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 formats.firstOrNull { (url, mimeType) ->
|
||||
mimeType.contains("audio") || mimeType.contains("mp4")
|
||||
mimeType.contains("audio")
|
||||
}?.first
|
||||
}
|
||||
|
||||
|
||||
@ -81,11 +81,6 @@ class AudioPlayer(private val context: Context) : Player.Listener {
|
||||
Player.STATE_ENDED -> {
|
||||
onError("Stream ended")
|
||||
}
|
||||
Player.STATE_READY -> {
|
||||
if (!exoPlayer.isPlaying) {
|
||||
onError("Playback paused")
|
||||
}
|
||||
}
|
||||
}
|
||||
onStateChanged(playbackState)
|
||||
}
|
||||
|
||||
@ -74,7 +74,6 @@ class ChannelListFragment : Fragment() {
|
||||
private fun setupSwipeRefresh() {
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
viewModel.discoverAllChannels()
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -91,6 +90,7 @@ class ChannelListFragment : Fragment() {
|
||||
|
||||
viewModel.isDiscovering.observe(viewLifecycleOwner) {
|
||||
binding.fabDiscover.isEnabled = !it
|
||||
binding.swipeRefresh.isRefreshing = it
|
||||
if (it) {
|
||||
Toast.makeText(requireContext(), "Discovering live channels...", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
@ -107,9 +107,10 @@ class LofiViewModel(
|
||||
|
||||
fun toggleFavorite(channelId: String) {
|
||||
Preferences.toggleFavorite(context, channelId)
|
||||
val channel = _channelList.find { it.id == channelId }
|
||||
if (channel != null) {
|
||||
channel.isFavorite = Preferences.isFavorite(context, channelId)
|
||||
val idx = _channelList.indexOfFirst { it.id == channelId }
|
||||
if (idx != -1) {
|
||||
val fav = Preferences.isFavorite(context, channelId)
|
||||
_channelList[idx] = _channelList[idx].copy(isFavorite = fav)
|
||||
notifyChannelsChanged()
|
||||
}
|
||||
}
|
||||
@ -159,7 +160,9 @@ class LofiViewModel(
|
||||
_isLoading.value = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val stream = YouTubeExtractor.extractAudioStream(channel.videoId!!)
|
||||
val stream = withContext(Dispatchers.IO) {
|
||||
YouTubeExtractor.extractAudioStream(channel.videoId!!)
|
||||
}
|
||||
if (stream != null) {
|
||||
audioPlayer.play(stream.videoId, stream.url, stream.streamType)
|
||||
_currentChannel.value = channel
|
||||
@ -231,27 +234,42 @@ class LofiViewModel(
|
||||
viewModelScope.launch {
|
||||
val mode = Preferences.getStreamingMode(context)
|
||||
val apiKey = Preferences.getYouTubeApiKey(context)
|
||||
val updated = mutableListOf<Channel>()
|
||||
|
||||
for (channel in _channelList.toList()) {
|
||||
try {
|
||||
when (mode) {
|
||||
val newChannel = when (mode) {
|
||||
Preferences.StreamingMode.SERVER -> {
|
||||
val live = serverApi.checkChannelLive(channel.id)
|
||||
channel.isLive = live?.isLive ?: false
|
||||
channel.videoId = live?.videoId
|
||||
channel.liveThumbnail = live?.thumbnail
|
||||
withContext(Dispatchers.IO) {
|
||||
serverApi.checkChannelLive(channel.id)
|
||||
}
|
||||
}.let { live ->
|
||||
channel.copy(
|
||||
isLive = live?.isLive ?: false,
|
||||
videoId = live?.videoId,
|
||||
liveThumbnail = live?.thumbnail
|
||||
)
|
||||
}
|
||||
Preferences.StreamingMode.DIRECT -> {
|
||||
val stream = YouTubeExtractor.discoverVideo(channel.id, channel.handle, apiKey)
|
||||
channel.isLive = stream != null
|
||||
channel.videoId = stream?.videoId
|
||||
withContext(Dispatchers.IO) {
|
||||
YouTubeExtractor.discoverVideo(channel.id, channel.handle, apiKey)
|
||||
}
|
||||
}.let { stream ->
|
||||
channel.copy(
|
||||
isLive = stream != null,
|
||||
videoId = stream?.videoId
|
||||
)
|
||||
}
|
||||
}
|
||||
updated.add(newChannel)
|
||||
} catch (e: Exception) {
|
||||
Log.e("LofiViewModel", "Discovery error for ${channel.name}", e)
|
||||
updated.add(channel)
|
||||
}
|
||||
delay(500)
|
||||
}
|
||||
_channelList.clear()
|
||||
_channelList.addAll(updated)
|
||||
notifyChannelsChanged()
|
||||
_isDiscovering.value = false
|
||||
}
|
||||
@ -261,8 +279,11 @@ class LofiViewModel(
|
||||
return Preferences.getStreamingMode(context)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
fun releaseAudioPlayer() {
|
||||
audioPlayer.release()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -29,7 +29,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.fragment_container, ChannelListFragment())
|
||||
.add(R.id.fragment_container, ChannelListFragment(), "channels")
|
||||
.commit()
|
||||
binding.bottomNavigation.selectedItemId = R.id.navigation_channels
|
||||
}
|
||||
@ -41,15 +41,15 @@ class MainActivity : AppCompatActivity() {
|
||||
binding.bottomNavigation.setOnItemSelectedListener { item ->
|
||||
when (item.itemId) {
|
||||
R.id.navigation_channels -> {
|
||||
showFragment(ChannelListFragment())
|
||||
showFragment(ChannelListFragment(), "channels")
|
||||
true
|
||||
}
|
||||
R.id.navigation_player -> {
|
||||
showFragment(PlayerFragment())
|
||||
showFragment(PlayerFragment(), "player")
|
||||
true
|
||||
}
|
||||
R.id.navigation_settings -> {
|
||||
showFragment(SettingsFragment())
|
||||
showFragment(SettingsFragment(), "settings")
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
@ -57,10 +57,16 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun showFragment(fragment: androidx.fragment.app.Fragment) {
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.fragment_container, fragment)
|
||||
.commit()
|
||||
private fun showFragment(fragment: androidx.fragment.app.Fragment, tag: String) {
|
||||
val existing = supportFragmentManager.findFragmentByTag(tag)
|
||||
supportFragmentManager.beginTransaction().apply {
|
||||
if (existing != null) {
|
||||
hide(existing)
|
||||
show(existing)
|
||||
} else {
|
||||
add(R.id.fragment_container, fragment, tag)
|
||||
}
|
||||
}.commit()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
@ -73,5 +79,6 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
viewModel.releaseAudioPlayer()
|
||||
}
|
||||
}
|
||||
@ -30,8 +30,7 @@ object Preferences {
|
||||
|
||||
fun getServerUrl(context: Context): String {
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
return prefs.getString(KEY_SERVER_URL, "http://192.168.1.100:8000")
|
||||
?: "http://192.168.1.100:8000"
|
||||
return prefs.getString(KEY_SERVER_URL, "") ?: ""
|
||||
}
|
||||
|
||||
fun setServerUrl(context: Context, url: String) {
|
||||
|
||||
@ -44,6 +44,8 @@ WORKDIR /app
|
||||
COPY src/ ./src/
|
||||
|
||||
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 /build
|
||||
|
||||
@ -5,6 +5,7 @@ import Hls from "hls.js"
|
||||
export function useAudioPlayer() {
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const hlsRef = useRef<Hls | null>(null)
|
||||
const networkRetryCount = useRef(0)
|
||||
const onStateChange = useRef<((state: "playing" | "paused" | "stopped" | "error") => void) | null>(null)
|
||||
|
||||
const play = useCallback((streamUrl: string, streamType: "hls" | "direct" | null) => {
|
||||
@ -31,7 +32,15 @@ export function useAudioPlayer() {
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (data.fatal) {
|
||||
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 {
|
||||
hls.destroy()
|
||||
hlsRef.current = null
|
||||
@ -40,6 +49,7 @@ export function useAudioPlayer() {
|
||||
}
|
||||
})
|
||||
|
||||
networkRetryCount.current = 0
|
||||
hlsRef.current = hls
|
||||
} else {
|
||||
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.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
from functools import lru_cache
|
||||
|
||||
from src.channels import CHANNELS
|
||||
from src.config import settings
|
||||
@ -20,6 +21,15 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
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)
|
||||
_stream_cache: dict[str, tuple[str, str, float]] = {}
|
||||
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"]
|
||||
|
||||
|
||||
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:
|
||||
"""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/",
|
||||
})
|
||||
resp = _http_client.get(playlist_url, timeout=15)
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=502, detail="Failed to fetch playlist")
|
||||
return resp.text
|
||||
@ -60,11 +75,14 @@ def _rewrite_playlist(playlist_content: str, video_id: str) -> str:
|
||||
seg_idx = 0
|
||||
for line in lines:
|
||||
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}")
|
||||
seg_idx += 1
|
||||
else:
|
||||
result.append(line)
|
||||
return "\n".join(result)
|
||||
|
||||
|
||||
@ -82,6 +100,7 @@ def _extract_segments(playlist_content: str, playlist_url: str) -> list[str]:
|
||||
@app.get("/api/proxy/hls")
|
||||
def proxy_hls(video: str = Query(...)):
|
||||
"""Proxy HLS playlist - fetches fresh playlist and rewrites segments."""
|
||||
_evict_expired_cache()
|
||||
stream_url, stream_type = _get_cached_stream(video)
|
||||
if stream_type == "hls":
|
||||
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")
|
||||
|
||||
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/",
|
||||
})
|
||||
resp = _http_client.get(seg_url, timeout=30)
|
||||
if resp.status_code != 200:
|
||||
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):
|
||||
"""Stream direct audio from YouTube."""
|
||||
import urllib.request
|
||||
req = urllib.request.Request(url, headers={
|
||||
with httpx.stream("GET", url, timeout=300, 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
|
||||
}) as resp:
|
||||
for chunk in resp.iter_bytes(64 * 1024):
|
||||
yield chunk
|
||||
finally:
|
||||
resp.close()
|
||||
|
||||
|
||||
@app.get("/api/proxy/audio")
|
||||
def proxy_audio(video: str = Query(...)):
|
||||
"""Proxy direct audio stream."""
|
||||
_evict_expired_cache()
|
||||
stream_url, stream_type = _get_cached_stream(video)
|
||||
if stream_type != "direct":
|
||||
raise HTTPException(status_code=503, detail="Not a direct stream")
|
||||
@ -170,7 +179,7 @@ app.add_middleware(
|
||||
"http://localhost:5175",
|
||||
"http://frontend:80",
|
||||
],
|
||||
allow_methods=["GET"],
|
||||
allow_methods=["GET", "POST", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@ def extract_audio_stream(video_id: str) -> dict | None:
|
||||
"format": "bestaudio/best",
|
||||
"quiet": 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",
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.channels import CHANNELS
|
||||
from src.main import app
|
||||
|
||||
|
||||
@ -47,7 +48,7 @@ class TestListChannels:
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 99
|
||||
assert len(data) == len(CHANNELS)
|
||||
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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user