v3.9.2: Fix Android TV OOM crash + backend Content-Type headers

- Backend: Add Content-Type: application/json to all JSON API endpoints
- Android TV: Reduce HomeViewModel memory usage (load 4 categories only, limit 15 items each)
- Android TV: Prevent OOM kill on TV devices with limited RAM
- Updated APK, docker-compose, health endpoint to v3.9.2
This commit is contained in:
vndangkhoa 2026-03-01 11:16:34 +07:00
parent fbe89e14fd
commit 69308bf696
95 changed files with 7684 additions and 7709 deletions

View file

@ -7,34 +7,34 @@ COPY frontend-react/ .
RUN npm run build RUN npm run build
# Stage 2: Build Image (Backend) # Stage 2: Build Image (Backend)
FROM golang:1.24-alpine AS backend-builder FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS backend-builder
WORKDIR /app/backend WORKDIR /app/backend
# Install build dependencies
RUN apk add --no-cache gcc musl-dev ARG TARGETOS TARGETARCH
COPY backend/go.mod backend/go.sum ./ COPY backend/go.mod backend/go.sum ./
RUN go mod download RUN go mod download
COPY backend/ . COPY backend/ .
# Build static binary for Linux amd64 # Build static binary for Linux amd64
RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o server cmd/server/main.go RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-w -s" -o server cmd/server/main.go
# Stage 3: Final Image # Stage 3: Final Image
FROM alpine:latest FROM alpine:latest
WORKDIR /app WORKDIR /app
# Install runtime dependencies (sqlite + yt-dlp for video extraction fallback) # Install runtime dependencies
RUN apk add --no-cache sqlite ca-certificates tzdata python3 py3-pip && \ RUN apk add --no-cache sqlite ca-certificates tzdata python3 py3-pip
pip3 install --break-system-packages yt-dlp RUN pip3 install --break-system-packages --ignore-installed yt-dlp || true
# Copy backend binary # Copy backend binary
COPY --from=backend-builder /app/backend/server . COPY --from=backend-builder /app/backend/server .
# Copy frontend build to the expected static directory # Copy frontend build to the expected static directory
# The backend expects ../frontend-react/dist relative to itself, or we configure it.
# Let's align with the standard deployment structure: /app/server and /app/dist
COPY --from=frontend-builder /app/frontend/dist ./dist COPY --from=frontend-builder /app/frontend/dist ./dist
# Create data directory # Create data directory
RUN mkdir -p data RUN mkdir -p data

View file

@ -49,9 +49,9 @@ fun StreamFlowTvApp() {
try { try {
currentTheme = userRepo.theme.first() currentTheme = userRepo.theme.first()
val serverUrl = userRepo.serverUrl.first() val serverUrl = userRepo.serverUrl.first()
/*if (serverUrl.isNotBlank()) { if (serverUrl.isNotBlank()) {
ApiClient.baseUrl = serverUrl ApiClient.baseUrl = serverUrl
}*/ }
Log.d("StreamFlowTvApp", "Settings loaded: theme=$currentTheme, url=$serverUrl") Log.d("StreamFlowTvApp", "Settings loaded: theme=$currentTheme, url=$serverUrl")
} catch (e: Exception) { } catch (e: Exception) {
Log.e("StreamFlowTvApp", "Error loading settings", e) Log.e("StreamFlowTvApp", "Error loading settings", e)

View file

@ -14,7 +14,7 @@ object ApiClient {
// Default base URL for testing // Default base URL for testing
// Change this to your production API when ready // Change this to your production API when ready
// var baseUrl: String = "https://nf.khoavo.myds.me" // var baseUrl: String = "https://nf.khoavo.myds.me"
private var _baseUrl: String = "http://10.0.2.2:3478/" private var _baseUrl: String = "http://10.0.2.2:8000/"
var baseUrl: String var baseUrl: String
get() = _baseUrl get() = _baseUrl

View file

@ -72,54 +72,20 @@ class HomeViewModel : ViewModel() {
val allFlattened = java.util.Collections.synchronizedList(mutableListOf<Movie>()) val allFlattened = java.util.Collections.synchronizedList(mutableListOf<Movie>())
kotlinx.coroutines.coroutineScope { kotlinx.coroutines.coroutineScope {
// 1. Initial categories // Load main categories only (to avoid OOM on TV devices)
val categoryTasks = categories.map { (slug, name) -> val categoryTasks = categories.map { (slug, name) ->
async { async {
try { try {
val response = repository.getHomeVideos(slug) val response = repository.getHomeVideos(slug)
allMovies[name] = response.items allMovies[name] = response.items.take(15)
allFlattened.addAll(response.items) allFlattened.addAll(response.items.take(15))
response.items response.items
} catch (_: Exception) { emptyList<Movie>() } } catch (_: Exception) { emptyList<Movie>() }
} }
} }
// 2. Fetch Genres & Countries metadata in parallel // Wait for categories
val genresDeferred = async { try { repository.getGenres().take(8) } catch (_: Exception) { emptyList() } }
val countriesDeferred = async { try { repository.getCountries().take(5) } catch (_: Exception) { emptyList() } }
val genres = genresDeferred.await()
val countries = countriesDeferred.await()
// 3. Fetch Genre and Country content in parallel
val genreTasks = genres.map { genre ->
async {
try {
val response = repository.getHomeVideos(genre.slug)
if (response.items.isNotEmpty()) {
allMovies["Genre: ${genre.name}"] = response.items
allFlattened.addAll(response.items)
}
} catch (_: Exception) { }
}
}
val countryTasks = countries.map { country ->
async {
try {
val response = repository.getHomeVideos(country.slug)
if (response.items.isNotEmpty()) {
allMovies["Country: ${country.name}"] = response.items
allFlattened.addAll(response.items)
}
} catch (_: Exception) { }
}
}
// Wait for everything
categoryTasks.awaitAll() categoryTasks.awaitAll()
genreTasks.awaitAll()
countryTasks.awaitAll()
} }
val heroItems = allMovies[categories.first().second]?.take(5) ?: emptyList() val heroItems = allMovies[categories.first().second]?.take(5) ?: emptyList()

0
android-tv/gradlew vendored Normal file → Executable file
View file

View file

@ -75,6 +75,7 @@ func (h *Handler) GetHomeVideos(w http.ResponseWriter, r *http.Request) {
return p.GetMoviesByCategory(category, page) return p.GetMoviesByCategory(category, page)
}) })
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(movies) json.NewEncoder(w).Encode(movies)
} }
@ -94,6 +95,7 @@ func (h *Handler) SearchVideos(w http.ResponseWriter, r *http.Request) {
return p.Search(query, page) return p.Search(query, page)
}) })
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(movies) json.NewEncoder(w).Encode(movies)
} }
@ -211,6 +213,7 @@ func (h *Handler) ExtractVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(info) json.NewEncoder(w).Encode(info)
} }
@ -306,6 +309,7 @@ func (h *Handler) GetMovieDetail(w http.ResponseWriter, r *http.Request) {
primaryMovie.Episodes = uniqueEps primaryMovie.Episodes = uniqueEps
} }
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(primaryMovie) json.NewEncoder(w).Encode(primaryMovie)
} }
@ -316,6 +320,7 @@ func (h *Handler) GetGenres(w http.ResponseWriter, r *http.Request) {
}); ok { }); ok {
genres, err := gp.GetGenres() genres, err := gp.GetGenres()
if err == nil { if err == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(genres) json.NewEncoder(w).Encode(genres)
return return
} }
@ -331,6 +336,7 @@ func (h *Handler) GetCountries(w http.ResponseWriter, r *http.Request) {
}); ok { }); ok {
countries, err := cp.GetCountries() countries, err := cp.GetCountries()
if err == nil { if err == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(countries) json.NewEncoder(w).Encode(countries)
return return
} }

View file

@ -61,11 +61,13 @@ func (s *ImageService) GetProxiedImage(url string, width int) ([]byte, string, e
resp, err := s.client.Do(req) resp, err := s.client.Do(req)
if err != nil { if err != nil {
fmt.Printf("GetProxiedImage fetch error: %v\n", err)
return nil, "", err return nil, "", err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
fmt.Printf("GetProxiedImage status error: %d for url: %s\n", resp.StatusCode, url)
return nil, "", fmt.Errorf("image fetch failed: %d", resp.StatusCode) return nil, "", fmt.Errorf("image fetch failed: %d", resp.StatusCode)
} }
@ -84,6 +86,7 @@ func (s *ImageService) GetProxiedImage(url string, width int) ([]byte, string, e
} }
if err != nil { if err != nil {
fmt.Printf("GetProxiedImage decode error: %v for content-type: %s and url: %s\n", err, contentType, url)
return nil, "", fmt.Errorf("decode error: %v", err) return nil, "", fmt.Errorf("decode error: %v", err)
} }

View file

@ -2,7 +2,7 @@ version: '3.8'
services: services:
streamflow: streamflow:
image: git.khoavo.myds.me/vndangkhoa/kv-streamflow:v3.9.1 image: git.khoavo.myds.me/vndangkhoa/kv-streamflow:v3.9.2
container_name: streamflow container_name: streamflow
platform: linux/amd64 platform: linux/amd64
ports: ports: