From 55244b5027a555c3fa0d338f6ed13a167f1c6b15 Mon Sep 17 00:00:00 2001 From: opencode Date: Sun, 5 Jul 2026 12:33:26 +0000 Subject: [PATCH] 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) --- android/app/proguard-rules.pro | 8 ++- android/app/src/main/AndroidManifest.xml | 2 +- .../java/com/lofiradio/data/models/Channel.kt | 8 +-- .../java/com/lofiradio/network/ServerApi.kt | 5 +- .../com/lofiradio/network/YouTubeExtractor.kt | 5 +- .../java/com/lofiradio/player/AudioPlayer.kt | 5 -- .../com/lofiradio/ui/ChannelListFragment.kt | 2 +- .../java/com/lofiradio/ui/LofiViewModel.kt | 47 +++++++++++----- .../java/com/lofiradio/ui/MainActivity.kt | 25 ++++++--- .../java/com/lofiradio/util/Preferences.kt | 3 +- docker/Dockerfile.backend | 2 + frontend/src/hooks/useAudioPlayer.ts | 12 +++- src/main.py | 55 +++++++++++-------- src/modules/stream_extractor.py | 2 +- tests/integration/test_api.py | 3 +- 15 files changed, 118 insertions(+), 66 deletions(-) diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 2cd6ebf..7aee25a 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -4,4 +4,10 @@ -dontwarn com.google.gson.** -dontwarn androidx.media3.** -dontwarn okhttp3.** --dontwarn coil3.** \ No newline at end of file +-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 { *; } \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 70857ce..ecbcd90 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,7 +9,7 @@ ? { 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}"))) } } diff --git a/android/app/src/main/java/com/lofiradio/network/YouTubeExtractor.kt b/android/app/src/main/java/com/lofiradio/network/YouTubeExtractor.kt index c265e66..bff491b 100644 --- a/android/app/src/main/java/com/lofiradio/network/YouTubeExtractor.kt +++ b/android/app/src/main/java/com/lofiradio/network/YouTubeExtractor.kt @@ -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>): 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 } diff --git a/android/app/src/main/java/com/lofiradio/player/AudioPlayer.kt b/android/app/src/main/java/com/lofiradio/player/AudioPlayer.kt index f5c0956..20fb494 100644 --- a/android/app/src/main/java/com/lofiradio/player/AudioPlayer.kt +++ b/android/app/src/main/java/com/lofiradio/player/AudioPlayer.kt @@ -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) } diff --git a/android/app/src/main/java/com/lofiradio/ui/ChannelListFragment.kt b/android/app/src/main/java/com/lofiradio/ui/ChannelListFragment.kt index 20f5ef3..88926a1 100644 --- a/android/app/src/main/java/com/lofiradio/ui/ChannelListFragment.kt +++ b/android/app/src/main/java/com/lofiradio/ui/ChannelListFragment.kt @@ -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() } diff --git a/android/app/src/main/java/com/lofiradio/ui/LofiViewModel.kt b/android/app/src/main/java/com/lofiradio/ui/LofiViewModel.kt index b1207e7..0386451 100644 --- a/android/app/src/main/java/com/lofiradio/ui/LofiViewModel.kt +++ b/android/app/src/main/java/com/lofiradio/ui/LofiViewModel.kt @@ -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() 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() { + } } diff --git a/android/app/src/main/java/com/lofiradio/ui/MainActivity.kt b/android/app/src/main/java/com/lofiradio/ui/MainActivity.kt index 828bdd4..a1b1441 100644 --- a/android/app/src/main/java/com/lofiradio/ui/MainActivity.kt +++ b/android/app/src/main/java/com/lofiradio/ui/MainActivity.kt @@ -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() } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/lofiradio/util/Preferences.kt b/android/app/src/main/java/com/lofiradio/util/Preferences.kt index 59d0d52..4585839 100644 --- a/android/app/src/main/java/com/lofiradio/util/Preferences.kt +++ b/android/app/src/main/java/com/lofiradio/util/Preferences.kt @@ -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) { diff --git a/docker/Dockerfile.backend b/docker/Dockerfile.backend index f301de2..ede9cf5 100644 --- a/docker/Dockerfile.backend +++ b/docker/Dockerfile.backend @@ -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 diff --git a/frontend/src/hooks/useAudioPlayer.ts b/frontend/src/hooks/useAudioPlayer.ts index 6c0b42d..90a7f77 100644 --- a/frontend/src/hooks/useAudioPlayer.ts +++ b/frontend/src/hooks/useAudioPlayer.ts @@ -5,6 +5,7 @@ import Hls from "hls.js" export function useAudioPlayer() { const audioRef = useRef(null) const hlsRef = useRef(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 diff --git a/src/main.py b/src/main.py index a96953b..e77789e 100644 --- a/src/main.py +++ b/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=["*"], ) diff --git a/src/modules/stream_extractor.py b/src/modules/stream_extractor.py index 9875489..9e587a7 100644 --- a/src/modules/stream_extractor.py +++ b/src/modules/stream_extractor.py @@ -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", } diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index c3e4d2d..115c16d 100644 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -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)