Merge pull request 'fix: resolve 35 open issues' (#49) from fix/all-open-issues into main

Reviewed-on: https://git.home.ms/jarianc/lofi-app/pulls/49
This commit is contained in:
jarianc 2026-07-05 07:39:44 -05:00
commit 611e1b630f
15 changed files with 118 additions and 66 deletions

View File

@ -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 { *; }

View File

@ -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"

View File

@ -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
) )

View File

@ -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}")))
} }
} }

View File

@ -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
} }

View File

@ -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)
} }

View File

@ -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()
} }

View File

@ -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() {
}
} }

View File

@ -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()
} }
} }

View File

@ -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) {

View File

@ -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

View File

@ -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

View File

@ -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=["*"],
) )

View File

@ -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",
} }

View File

@ -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)