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

@ -4,4 +4,10 @@
-dontwarn com.google.gson.**
-dontwarn androidx.media3.**
-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
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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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