From b6635a107dc6fd37694709b599192ca9a14680c6 Mon Sep 17 00:00:00 2001 From: Marek Date: Sun, 5 Apr 2026 23:26:55 +0200 Subject: [PATCH] update --- CLAUDE.md | 35 ++++++---- .../youtubeapp/data/LocalStorageService.kt | 7 ++ .../main/java/com/youtubeapp/data/VideoApi.kt | 7 +- .../com/youtubeapp/data/VideoRepository.kt | 10 ++- .../youtubeapp/ui/screens/AllVideosScreen.kt | 2 +- .../youtubeapp/ui/screens/DownloadedScreen.kt | 2 +- .../ui/screens/VideoDetailScreen.kt | 2 +- .../youtubeapp/ui/viewmodel/VideoViewModel.kt | 48 ++++++++++++- architecture.md | 6 +- backend/main.py | 28 +++++++- backend/requirements.txt | 2 +- .../routes/__pycache__/videos.cpython-312.pyc | Bin 6546 -> 7998 bytes backend/routes/videos.py | 57 ++++++++++----- backend/schemas.py | 5 ++ .../download_service.cpython-312.pyc | Bin 977 -> 990 bytes .../__pycache__/video_service.cpython-312.pyc | Bin 4645 -> 5273 bytes backend/services/download_service.py | 2 +- backend/services/stream_service.py | 65 ++++++++++++------ backend/services/video_service.py | 20 +++--- commication.md | 3 +- features.md | 3 +- 21 files changed, 229 insertions(+), 75 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b721b5f..aa8e1be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,41 +31,48 @@ Drei Komponenten: ### Server (`backend/`) - **Python, FastAPI, SQLAlchemy, SQLite** (`videos/youtubeapp.db`) -- **yt-dlp + ffmpeg** fuer Video-Download und Live-Streaming +- **yt-dlp + ffmpeg** fuer Video-Download und Streaming +- **WebSocket** (`/ws`) — benachrichtigt verbundene Clients bei neuen Videos - Dockerisiert: `docker compose up --build -d` im `backend/` Verzeichnis - Laeuft auf `http://localhost:8000` - Download-Service speichert Videos unter `/videos/{id}.mp4` -- Stream-Service: heruntergeladene Videos von Datei, sonst Live-Stream via yt-dlp + ffmpeg (fragmentiertes MP4) +- Stream-Service: heruntergeladene Videos von Datei, sonst progressiver Download via yt-dlp mit gleichzeitigem Streaming - Dedup: beim Batch-Import wird bestehender Eintrag mit gleicher Video-ID geloescht und neu eingefuegt - Sortierung: nach ID absteigend (erstes Video im Batch bekommt hoechste ID) - Profile: fest in DB definiert, Videos ueber Many-to-Many zugeordnet +- Nach lokalem Download wird die Server-Datei geloescht (file_path auf null) ### App (`app/`) - **Kotlin, Jetpack Compose**, Android/Android TV - Gradle-Projekt, Modul `frontend` -- Screens: AllVideos (Grid), Downloaded, VideoDetail, VideoPlayer +- Screens: AllVideos (Grid), Downloaded (lokal verfuegbare Videos), VideoDetail, VideoPlayer - Retrofit fuer API-Calls, Coil fuer Thumbnails, ExoPlayer fuer Streaming -- Navigation mit TopBar (Profil-Auswahl) und Bottom Bar, Dark Theme +- OkHttp WebSocket-Client — automatisches Neuladen bei neuen Videos +- Navigation mit TopBar (Profil-Auswahl, Aufraeumen-Icon) und Bottom Bar, Dark Theme - Profil-Auswahl wird in SharedPreferences persistiert, filtert Videos nach Profil - Lokaler Download: Videos werden auf dem Geraet gespeichert, lokal bevorzugt abgespielt +- Aufraeumen: loescht alle nicht lokal gespeicherten Videos des Profils (sendet lokale IDs als Ausnahme) - Server-IP konfigurierbar in `ApiClient.kt` (aktuell `192.168.178.92`) - Emulator: Android Studio → Device Manager → Pixel 6a, API 35 ## API Endpoints - `GET /profiles` — alle Profile abrufen -- `POST /videos` — Video-Batch von Extension empfangen (Dedup, Reverse-Insert, Profil-Zuordnung) +- `POST /videos` — Video-Batch von Extension empfangen (Dedup, Reverse-Insert, Profil-Zuordnung, WebSocket-Benachrichtigung) - `GET /videos` — alle Videos abrufen (optional `?profile_id=X`, sortiert nach ID absteigend) - `GET /videos/downloaded` — heruntergeladene Videos abrufen (optional `?profile_id=X`) +- `DELETE /videos?profile_id=X&exclude_ids=` — Videos des Profils loeschen (ausser lokal gespeicherte) - `POST /videos/{id}/download` — Download auf Server triggern -- `GET /videos/{id}/stream` — Video streamen (von Datei oder Live via yt-dlp/ffmpeg) +- `GET /videos/{id}/stream` — Video streamen (von Datei oder progressiver Download via yt-dlp) - `GET /videos/{id}/file` — Video-Datei zum Download auf Client ausliefern +- `DELETE /videos/{id}/file` — Server-Datei loeschen (nach lokalem Download) +- `WS /ws` — WebSocket, sendet Profile-IDs bei neuen Videos ## Projektstruktur ``` backend/ - main.py — FastAPI App, CORS, Startup, Seed-Profile + main.py — FastAPI App, CORS, Startup, Seed-Profile, WebSocket database.py — SQLAlchemy Engine, Session, Base models.py — Video, Profile, video_profiles (Many-to-Many) schemas.py — Pydantic Schemas (VideoCreate, VideoResponse, ProfileResponse) @@ -73,14 +80,14 @@ backend/ services/ video_service.py — CRUD-Operationen, Dedup, Profil-Filter download_service.py — yt-dlp Download - stream_service.py — Live-Streaming via yt-dlp + ffmpeg + stream_service.py — Progressiver Download + Streaming via yt-dlp Dockerfile — Python 3.12 + ffmpeg docker-compose.yml — Service-Definition, Port 8000, Volume /videos .dockerignore — videos/, __pycache__/ .gitignore — videos/, __pycache__/ browser_extension/ - manifest.json — Manifest V2, Permissions, browser_action + manifest.json — Manifest V2, Permissions, browser_action, storage content.js — DOM-Extraktion + IntersectionObserver + Batch-Versand mit Profil background.js — Batch-POST an Server popup.html — Profil-Auswahl UI @@ -93,9 +100,12 @@ app/ data/ — Video, Profile, ApiClient, VideoApi, VideoRepository, LocalStorageService ui/screens/ — AllVideos, Downloaded, VideoDetail, VideoPlayer ui/components/ — VideoCard - ui/viewmodel/ — VideoViewModel + ui/viewmodel/ — VideoViewModel (inkl. WebSocket-Client) ui/navigation/ — AppNavigation, Routes ui/theme/ — Theme (Dark) + frontend/src/main/res/ + layout/player_view.xml — PlayerView mit TextureView fuer TV-Kompatibilitaet + drawable/tv_banner.png — Android TV Launcher-Banner ``` ## Entscheidungen @@ -103,8 +113,9 @@ app/ - Kein Jellyfin — erfuellt nicht die Anforderung, Videos vor dem Download aufzulisten - Kein PostgreSQL/MySQL — SQLite reicht fuer den Prototyp - Profile fest in DB — kein UI zum Erstellen/Loeschen, werden direkt in der Datenbank verwaltet -- Videos werden auf dem Server gespeichert, Client speichert nur bei explizitem Download +- Server-Datei wird nach lokalem Download geloescht — spart Speicherplatz auf dem Server - DOM-Extraktion statt ytInitialData-Parsing — funktioniert auch bei SPA-Navigation und Scrollen - IntersectionObserver statt blindem Scan — nur sichtbare Videos erfassen -- Live-Streaming via yt-dlp/ffmpeg statt synchronem Download vor dem Streamen +- Progressiver Download via yt-dlp mit gleichzeitigem Streaming statt komplettem Download vor dem Abspielen +- WebSocket statt Polling — effiziente Echtzeit-Aktualisierung der Videoliste - Sprache der Dokumentation: Deutsch diff --git a/app/frontend/src/main/java/com/youtubeapp/data/LocalStorageService.kt b/app/frontend/src/main/java/com/youtubeapp/data/LocalStorageService.kt index 88c3d05..5be8431 100644 --- a/app/frontend/src/main/java/com/youtubeapp/data/LocalStorageService.kt +++ b/app/frontend/src/main/java/com/youtubeapp/data/LocalStorageService.kt @@ -24,6 +24,13 @@ class LocalStorageService(private val context: Context) { fun deleteLocalFile(videoId: Int): Boolean = videoFile(videoId).delete() + fun getLocalVideoIds(): List { + return videosDir().listFiles() + ?.filter { it.extension == "mp4" } + ?.mapNotNull { it.nameWithoutExtension.toIntOrNull() } + ?: emptyList() + } + fun downloadAndSave(videoId: Int): File { val url = "${ApiClient.BASE_URL}videos/$videoId/file" val file = videoFile(videoId) diff --git a/app/frontend/src/main/java/com/youtubeapp/data/VideoApi.kt b/app/frontend/src/main/java/com/youtubeapp/data/VideoApi.kt index fa23baf..eda95c0 100644 --- a/app/frontend/src/main/java/com/youtubeapp/data/VideoApi.kt +++ b/app/frontend/src/main/java/com/youtubeapp/data/VideoApi.kt @@ -15,8 +15,11 @@ interface VideoApi { @POST("videos/{id}/download") suspend fun triggerDownload(@Path("id") id: Int): Map - @retrofit2.http.DELETE("videos") - suspend fun deleteNotDownloaded(@Query("profile_id") profileId: Int): Map + @POST("videos/cleanup") + suspend fun cleanupVideos(@retrofit2.http.Body body: Map): Map + + @retrofit2.http.DELETE("videos/{id}/file") + suspend fun deleteServerFile(@Path("id") id: Int): Map @GET("profiles") suspend fun getProfiles(): List diff --git a/app/frontend/src/main/java/com/youtubeapp/data/VideoRepository.kt b/app/frontend/src/main/java/com/youtubeapp/data/VideoRepository.kt index 82d8fd3..d84fdbc 100644 --- a/app/frontend/src/main/java/com/youtubeapp/data/VideoRepository.kt +++ b/app/frontend/src/main/java/com/youtubeapp/data/VideoRepository.kt @@ -13,8 +13,14 @@ class VideoRepository(private val api: VideoApi = ApiClient.api) { response["status"] ?: "unknown" } - suspend fun deleteNotDownloaded(profileId: Int): Result = runCatching { - val response = api.deleteNotDownloaded(profileId) + suspend fun deleteServerFile(videoId: Int): Result = runCatching { + val response = api.deleteServerFile(videoId) + response["status"] ?: "unknown" + } + + suspend fun cleanupVideos(profileId: Int, excludeIds: List): Result = runCatching { + val body = mapOf("profile_id" to profileId, "exclude_ids" to excludeIds) + val response = api.cleanupVideos(body) response["deleted"] ?: 0 } diff --git a/app/frontend/src/main/java/com/youtubeapp/ui/screens/AllVideosScreen.kt b/app/frontend/src/main/java/com/youtubeapp/ui/screens/AllVideosScreen.kt index 95befbb..2119801 100644 --- a/app/frontend/src/main/java/com/youtubeapp/ui/screens/AllVideosScreen.kt +++ b/app/frontend/src/main/java/com/youtubeapp/ui/screens/AllVideosScreen.kt @@ -47,7 +47,7 @@ fun AllVideosScreen(viewModel: VideoViewModel, onVideoClick: (Int) -> Unit) { } else -> { LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 250.dp), + columns = GridCells.Adaptive(minSize = 160.dp), contentPadding = PaddingValues(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), diff --git a/app/frontend/src/main/java/com/youtubeapp/ui/screens/DownloadedScreen.kt b/app/frontend/src/main/java/com/youtubeapp/ui/screens/DownloadedScreen.kt index 589c8a2..b7af243 100644 --- a/app/frontend/src/main/java/com/youtubeapp/ui/screens/DownloadedScreen.kt +++ b/app/frontend/src/main/java/com/youtubeapp/ui/screens/DownloadedScreen.kt @@ -52,7 +52,7 @@ fun DownloadedScreen(viewModel: VideoViewModel, onVideoClick: (Int) -> Unit) { } else -> { LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 250.dp), + columns = GridCells.Adaptive(minSize = 160.dp), contentPadding = PaddingValues(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), diff --git a/app/frontend/src/main/java/com/youtubeapp/ui/screens/VideoDetailScreen.kt b/app/frontend/src/main/java/com/youtubeapp/ui/screens/VideoDetailScreen.kt index 05e73aa..5203a32 100644 --- a/app/frontend/src/main/java/com/youtubeapp/ui/screens/VideoDetailScreen.kt +++ b/app/frontend/src/main/java/com/youtubeapp/ui/screens/VideoDetailScreen.kt @@ -48,7 +48,7 @@ fun VideoDetailScreen( ) { val state by viewModel.state.collectAsState() val video = viewModel.getVideoById(videoId) - val isLocal = viewModel.isLocallyAvailable(videoId) + val isLocal = remember(state.downloadStatus) { viewModel.isLocallyAvailable(videoId) } val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(state.downloadStatus) { diff --git a/app/frontend/src/main/java/com/youtubeapp/ui/viewmodel/VideoViewModel.kt b/app/frontend/src/main/java/com/youtubeapp/ui/viewmodel/VideoViewModel.kt index 5d5b117..6e8bf32 100644 --- a/app/frontend/src/main/java/com/youtubeapp/ui/viewmodel/VideoViewModel.kt +++ b/app/frontend/src/main/java/com/youtubeapp/ui/viewmodel/VideoViewModel.kt @@ -1,8 +1,10 @@ package com.youtubeapp.ui.viewmodel import android.content.Context +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.youtubeapp.data.ApiClient import com.youtubeapp.data.LocalStorageService import com.youtubeapp.data.Profile import com.youtubeapp.data.Video @@ -12,6 +14,11 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener data class VideoUiState( val allVideos: List