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:
parent
fbe89e14fd
commit
69308bf696
95 changed files with 7684 additions and 7709 deletions
18
Dockerfile
18
Dockerfile
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
0
android-tv/gradlew
vendored
Normal file → Executable 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Binary file not shown.
Loading…
Reference in a new issue