Compare commits
13 Commits
8ecef00d0a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f439231e3c | ||
|
|
1ca4371ea0 | ||
|
|
1cef5fa1e8 | ||
|
|
2156bb3226 | ||
|
|
a0c8ecaf27 | ||
|
|
375a9cd386 | ||
|
|
ca988345e9 | ||
|
|
8f15f51bce | ||
|
|
52c4e5f33d | ||
|
|
eb71c148d5 | ||
|
|
8e1d482ef2 | ||
|
|
f48f0aac08 | ||
|
|
2f37d1b31f |
139
CLAUDE.md
139
CLAUDE.md
@@ -9,6 +9,7 @@ Selbst-gehostete Anwendung: YouTube-Videos per Browser Extension erfassen, auf e
|
|||||||
- `features.md` — Konkrete Features und Benutzerinteraktionen
|
- `features.md` — Konkrete Features und Benutzerinteraktionen
|
||||||
- `architecture.md` — Detaillierter Systemaufbau (Endpoints, Services, Models, Screens)
|
- `architecture.md` — Detaillierter Systemaufbau (Endpoints, Services, Models, Screens)
|
||||||
- `szenarios.md` — Benutzer-Szenarien
|
- `szenarios.md` — Benutzer-Szenarien
|
||||||
|
- `modules.md` — Modul-Aufteilung pro Komponente
|
||||||
|
|
||||||
## Architektur
|
## Architektur
|
||||||
|
|
||||||
@@ -16,31 +17,45 @@ Drei Komponenten:
|
|||||||
|
|
||||||
### Browser Extension (`browser_extension/`)
|
### Browser Extension (`browser_extension/`)
|
||||||
- **Manifest V2**, Firefox-kompatibel (`browser.*` API)
|
- **Manifest V2**, Firefox-kompatibel (`browser.*` API)
|
||||||
- `content.js` — extrahiert Videodaten direkt aus dem YouTube-DOM:
|
- Modulare Ordnerstruktur (`tracking/`, `api/`, `config/`); `manifest.json` bleibt im Root
|
||||||
|
- `tracking/content.js` — extrahiert Videodaten direkt aus dem YouTube-DOM:
|
||||||
- `ytd-rich-item-renderer` (Homepage, Abos, Kanalseiten)
|
- `ytd-rich-item-renderer` (Homepage, Abos, Kanalseiten)
|
||||||
- `ytd-video-renderer` (Suchergebnisse)
|
- `ytd-video-renderer` (Suchergebnisse)
|
||||||
- IntersectionObserver (threshold 50%) — nur sichtbare Cards erfassen
|
- IntersectionObserver (threshold 50%) — nur sichtbare Cards erfassen
|
||||||
- MutationObserver registriert neue Cards beim IntersectionObserver
|
- MutationObserver registriert neue Cards beim IntersectionObserver
|
||||||
- `yt-navigate-finish` Event-Listener fuer SPA-Navigation
|
- `yt-navigate-finish` Event-Listener fuer SPA-Navigation
|
||||||
- Deduplizierung ueber `sentUrls` Set, wird bei Navigation geleert
|
- Deduplizierung ueber `sentUrls` Set, wird bei Navigation geleert
|
||||||
- Batch-Versand: sammelt sichtbare Videos mit Profil-ID, sendet als Array
|
- **Einzelversand**: jedes sichtbare Video sendet sofort einen separaten POST (kein Batching mehr)
|
||||||
- Selektoren ohne Klassen: nur Tags (`h3`, `img`) und Attribute (`href`, `src`)
|
- Selektoren ohne Klassen: nur Tags (`h3`, `img`) und Attribute (`href`, `src`)
|
||||||
- `background.js` — empfaengt Batch vom Content Script, sendet POST an Server
|
- `api/background.js` — empfaengt `{profileId, video}` vom Content Script, baut URL `/profiles/{profileId}/videos` und sendet POST
|
||||||
- `popup.html/popup.js` — Profil-Auswahl (holt Profile vom Server, speichert in browser.storage.local)
|
- `config/popup.html` + `config/popup.js` — Profil-Auswahl (holt Profile vom Server, speichert in browser.storage.local)
|
||||||
- Laden via `about:debugging#/runtime/this-firefox` → "Temporaeres Add-on laden" → `manifest.json`
|
- Laden via `about:debugging#/runtime/this-firefox` → "Temporaeres Add-on laden" → `manifest.json`
|
||||||
|
|
||||||
### Server (`backend/`)
|
### Server (`backend/`)
|
||||||
- **Python, FastAPI, SQLAlchemy, SQLite** (`videos/youtubeapp.db`)
|
- **Python, FastAPI, SQLAlchemy, SQLite** (`videos/youtubeapp.db`)
|
||||||
- **yt-dlp + ffmpeg** fuer Video-Download und Streaming
|
- **yt-dlp + ffmpeg + Deno** fuer Video-Download und Streaming
|
||||||
- **WebSocket** (`/ws`) — benachrichtigt verbundene Clients bei neuen Videos
|
- **WebSocket** (`/ws`) — benachrichtigt verbundene Clients bei neuen Videos (sendet einzelne `profileId`)
|
||||||
- Dockerisiert: `docker compose up --build -d` im `backend/` Verzeichnis
|
- Dockerisiert: `docker compose up --build -d` im `backend/` Verzeichnis
|
||||||
- Laeuft auf `http://localhost:8000`
|
- Laeuft auf `http://localhost:8000`
|
||||||
- Download-Service speichert Videos unter `/videos/{id}.mp4`
|
- Modulare Ordnerstruktur (`database/`, `model/`, `api/`, `download/`, `stream/`, `notify/`, `base/`) mit `__init__.py`-Markern
|
||||||
- Stream-Service: heruntergeladene Videos von Datei, sonst progressiver Download via yt-dlp mit gleichzeitigem Streaming
|
- Entrypoint: `base.app:app` (im Dockerfile als `uvicorn`-Target)
|
||||||
- Dedup: beim Batch-Import wird bestehender Eintrag mit gleicher Video-ID geloescht und neu eingefuegt
|
- **Active Record Pattern**: DB-Methoden leben als Klassenmethoden auf den Models, nicht in einem Service-Layer
|
||||||
- Sortierung: nach ID absteigend (erstes Video im Batch bekommt hoechste ID)
|
- Download-Service: `downloadVideo` ruft yt-dlp mit `--force-overwrites --no-continue`, prueft Datei-Existenz und Mindestgroesse, dann `Video.updateFilePath`. `downloadAsync` startet das im Hintergrund-Thread
|
||||||
- Profile: fest in DB definiert, Videos ueber Many-to-Many zugeordnet
|
- Stream-Service: `streamAndSave` ist ein Generator, der `streamVideoLive` (ffmpeg Live-Muxing) umhuellt und am Ende `Video.updateFilePath` setzt
|
||||||
- Nach lokalem Download wird die Server-Datei geloescht (file_path auf null)
|
- **Profil-scoped Dedup**: pro Profil eine eigene Video-Zeile. `Video.deleteIfExists(db, youtubeUrl, profileId)` loescht nur die Zeile dieses Profils, `Video.create(...)` fuegt eine neue ein. Profile sind unabhaengig.
|
||||||
|
- Sortierung: nach ID absteigend
|
||||||
|
- Profile: fest in DB definiert, Videos ueber Many-to-Many zugeordnet (de facto 1:1 pro Zeile durch das per-Profil-Dedup)
|
||||||
|
- Nach lokalem Download wird die Server-Datei geloescht (`Video.deleteServerFile`)
|
||||||
|
|
||||||
|
#### Naming-Konventionen im Backend
|
||||||
|
- **camelCase** fuer Variablen, Funktionen, Methoden, Pydantic-Felder, Klassenattribute (auch in `Video`-SQLAlchemy-Model — DB-Spaltennamen werden via `Column("snake_case", ...)` angegeben)
|
||||||
|
- **PascalCase** fuer Klassen
|
||||||
|
- **UPPER_SNAKE_CASE** fuer Konstanten (`VIDEOS_DIR`, `CHUNK_SIZE`, `DATABASE_URL`)
|
||||||
|
- **snake_case** bleibt nur fuer DB-Tabellen-/Spaltennamen im SQL und Python-Standardbibliothek
|
||||||
|
- Verstoesst gegen PEP 8, ist vom User explizit so gewuenscht (Hintergrund Symfony/Spring Boot)
|
||||||
|
|
||||||
|
#### DB-Session per FastAPI
|
||||||
|
- `DbSession = Annotated[Session, Depends(getDb)]` in `database/database.py` definiert; Routen schreiben nur `db: DbSession`
|
||||||
|
|
||||||
### App (`app/`)
|
### App (`app/`)
|
||||||
- **Kotlin, Jetpack Compose**, Android/Android TV
|
- **Kotlin, Jetpack Compose**, Android/Android TV
|
||||||
@@ -50,59 +65,73 @@ Drei Komponenten:
|
|||||||
- OkHttp WebSocket-Client — automatisches Neuladen bei neuen Videos
|
- OkHttp WebSocket-Client — automatisches Neuladen bei neuen Videos
|
||||||
- Navigation mit TopBar (Profil-Auswahl, Aufraeumen-Icon) und Bottom Bar, Dark Theme
|
- Navigation mit TopBar (Profil-Auswahl, Aufraeumen-Icon) und Bottom Bar, Dark Theme
|
||||||
- Profil-Auswahl wird in SharedPreferences persistiert, filtert Videos nach Profil
|
- Profil-Auswahl wird in SharedPreferences persistiert, filtert Videos nach Profil
|
||||||
- Lokaler Download: Videos werden auf dem Geraet gespeichert, lokal bevorzugt abgespielt
|
- Lokaler Download: Videos und Metadaten werden auf dem Geraet gespeichert, lokal bevorzugt abgespielt, offline verfuegbar
|
||||||
|
- Heruntergeladen-Tab liest aus lokalem Storage, **nicht** vom Server (kein `/downloaded`-Endpoint mehr)
|
||||||
- Aufraeumen: loescht alle nicht lokal gespeicherten Videos des Profils (sendet lokale IDs als Ausnahme)
|
- 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`)
|
- Server-IP konfigurierbar in `ApiClient.kt` (Emulator: `10.0.2.2`, echtes Geraet: `192.168.178.34`)
|
||||||
- Emulator: Android Studio → Device Manager → Pixel 6a, API 35
|
- Emulator: Android Studio → Device Manager → Pixel 7a, API 36
|
||||||
|
- **camelCase** Felder durchgaengig (`thumbnailUrl`, `youtubeUrl`, `isDownloaded`, `profileIds`)
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
|
Profil-bezogene Routen liegen in `api/profile_controller.py`, video-Aktionen in `api/video_controller.py`.
|
||||||
|
|
||||||
- `GET /profiles` — alle Profile abrufen
|
- `GET /profiles` — alle Profile abrufen
|
||||||
- `POST /videos` — Video-Batch von Extension empfangen (Dedup, Reverse-Insert, Profil-Zuordnung, WebSocket-Benachrichtigung)
|
- `POST /profiles/{profileId}/videos` — Einzelnes Video von Extension empfangen (Dedup pro Profil, WebSocket-Benachrichtigung). Antwort: `204 No Content`
|
||||||
- `GET /videos` — alle Videos abrufen (optional `?profile_id=X`, sortiert nach ID absteigend)
|
- `GET /profiles/{profileId}/videos` — Videos eines Profils, sortiert nach ID absteigend
|
||||||
- `GET /videos/downloaded` — heruntergeladene Videos abrufen (optional `?profile_id=X`)
|
- `POST /profiles/{profileId}/videos/cleanup` — Videos des Profils loeschen (Body: `{"excludeIds": [...]}` fuer lokal gespeicherte Ausnahmen)
|
||||||
- `DELETE /videos?profile_id=X&exclude_ids=` — Videos des Profils loeschen (ausser lokal gespeicherte)
|
- `POST /videos/{id}/download` — Download auf Server triggern (Background-Thread via `downloadAsync`)
|
||||||
- `POST /videos/{id}/download` — Download auf Server triggern
|
- `GET /videos/{id}/stream` — Video streamen (von Datei oder Live-Muxing via `streamAndSave`)
|
||||||
- `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 (`Video.getValidPath` korrigiert verwaiste DB-Eintraege)
|
||||||
- `GET /videos/{id}/file` — Video-Datei zum Download auf Client ausliefern
|
- `DELETE /videos/{id}/file` — Server-Datei loeschen (`Video.deleteServerFile`)
|
||||||
- `DELETE /videos/{id}/file` — Server-Datei loeschen (nach lokalem Download)
|
- `WS /ws` — WebSocket, sendet eine einzelne `profileId` als Text bei neuen Videos
|
||||||
- `WS /ws` — WebSocket, sendet Profile-IDs bei neuen Videos
|
|
||||||
|
JSON-Wire-Format ist durchgaengig **camelCase** (Backend, Extension, App).
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/
|
backend/
|
||||||
main.py — FastAPI App, CORS, Startup, Seed-Profile, WebSocket
|
base/
|
||||||
database.py — SQLAlchemy Engine, Session, Base
|
app.py — FastAPI App, CORS, Startup, Seed-Profile, Router-Includes
|
||||||
models.py — Video, Profile, video_profiles (Many-to-Many)
|
database/
|
||||||
schemas.py — Pydantic Schemas (VideoCreate, VideoResponse, ProfileResponse)
|
database.py — SQLAlchemy Engine, SessionLocal, Base, getDb, DbSession-Alias
|
||||||
routes/videos.py — Video- und Profil-Routen
|
model/
|
||||||
services/
|
profile.py — Profile-Klasse, getAll() liefert list[dict]
|
||||||
video_service.py — CRUD-Operationen, Dedup, Profil-Filter
|
video.py — Video-Klasse mit Properties (isDownloaded, profileIds) und Klassenmethoden (deleteIfExists, create, getAll, getById, updateFilePath, getValidPath, deleteServerFile, deleteNotDownloaded)
|
||||||
download_service.py — yt-dlp Download
|
profile_video.py — videoProfiles M:N-Tabelle
|
||||||
stream_service.py — Progressiver Download + Streaming via yt-dlp
|
api/
|
||||||
Dockerfile — Python 3.12 + ffmpeg
|
schemas.py — Pydantic Schemas (VideoCreate, VideoResponse, CleanupRequest)
|
||||||
docker-compose.yml — Service-Definition, Port 8000, Volume /videos
|
profile_controller.py — /profiles und profil-scoped Video-Routen
|
||||||
.dockerignore — videos/, __pycache__/
|
video_controller.py — Video-Aktionen (download, stream, file, deleteFile)
|
||||||
.gitignore — videos/, __pycache__/
|
download/
|
||||||
|
download_service.py — yt-dlp Download (downloadVideo, downloadAsync)
|
||||||
|
stream/
|
||||||
|
stream_service.py — ffmpeg Live-Muxing (streamVideoLive, streamAndSave)
|
||||||
|
notify/
|
||||||
|
notify_clients.py — WebSocket-Endpoint, connectedClients, notifyClients(profileId), registerWebsocket
|
||||||
|
Dockerfile — Python 3.12 + ffmpeg + Deno, CMD `uvicorn base.app:app`
|
||||||
|
docker-compose.yml — Service-Definition, Port 8000, Volume /videos
|
||||||
|
.dockerignore — videos/, __pycache__/
|
||||||
|
.gitignore — videos/, __pycache__/
|
||||||
|
|
||||||
browser_extension/
|
browser_extension/
|
||||||
manifest.json — Manifest V2, Permissions, browser_action, storage
|
manifest.json — Manifest V2, Permissions, browser_action, storage; verweist auf tracking/, api/, config/
|
||||||
content.js — DOM-Extraktion + IntersectionObserver + Batch-Versand mit Profil
|
tracking/content.js — DOM-Extraktion + IntersectionObserver + Einzel-Send mit Profil
|
||||||
background.js — Batch-POST an Server
|
api/background.js — Einzel-POST an /profiles/{profileId}/videos
|
||||||
popup.html — Profil-Auswahl UI
|
config/popup.html — Profil-Auswahl UI
|
||||||
popup.js — Profile laden, Auswahl speichern
|
config/popup.js — Profile laden, Auswahl speichern
|
||||||
|
|
||||||
app/
|
app/
|
||||||
.gitignore — .gradle/, build/, .idea/, local.properties
|
.gitignore — .gradle/, build/, .idea/, local.properties
|
||||||
frontend/src/main/java/com/youtubeapp/
|
frontend/src/main/java/com/youtubeapp/
|
||||||
MainActivity.kt — Einstiegspunkt
|
MainActivity.kt — Einstiegspunkt
|
||||||
data/ — Video, Profile, ApiClient, VideoApi, VideoRepository, LocalStorageService
|
data/ — Video, Profile, ApiClient, VideoApi, VideoRepository, LocalStorageService
|
||||||
ui/screens/ — AllVideos, Downloaded, VideoDetail, VideoPlayer
|
ui/screens/ — AllVideos, Downloaded, VideoDetail, VideoPlayer
|
||||||
ui/components/ — VideoCard
|
ui/components/ — VideoCard
|
||||||
ui/viewmodel/ — VideoViewModel (inkl. WebSocket-Client)
|
ui/viewmodel/ — VideoViewModel (inkl. WebSocket-Client)
|
||||||
ui/navigation/ — AppNavigation, Routes
|
ui/navigation/ — AppNavigation, Routes
|
||||||
ui/theme/ — Theme (Dark)
|
ui/theme/ — Theme (Dark)
|
||||||
frontend/src/main/res/
|
frontend/src/main/res/
|
||||||
layout/player_view.xml — PlayerView mit TextureView fuer TV-Kompatibilitaet
|
layout/player_view.xml — PlayerView mit TextureView fuer TV-Kompatibilitaet
|
||||||
drawable/tv_banner.png — Android TV Launcher-Banner
|
drawable/tv_banner.png — Android TV Launcher-Banner
|
||||||
@@ -116,6 +145,14 @@ app/
|
|||||||
- Server-Datei wird nach lokalem Download geloescht — spart Speicherplatz auf dem Server
|
- Server-Datei wird nach lokalem Download geloescht — spart Speicherplatz auf dem Server
|
||||||
- DOM-Extraktion statt ytInitialData-Parsing — funktioniert auch bei SPA-Navigation und Scrollen
|
- DOM-Extraktion statt ytInitialData-Parsing — funktioniert auch bei SPA-Navigation und Scrollen
|
||||||
- IntersectionObserver statt blindem Scan — nur sichtbare Videos erfassen
|
- IntersectionObserver statt blindem Scan — nur sichtbare Videos erfassen
|
||||||
- Progressiver Download via yt-dlp mit gleichzeitigem Streaming statt komplettem Download vor dem Abspielen
|
- ffmpeg Live-Muxing statt komplettem Download vor dem Abspielen
|
||||||
|
- Deno als JavaScript-Runtime fuer yt-dlp — YouTube erfordert JS-Ausfuehrung zur URL-Extraktion
|
||||||
|
- Videos ohne Profilzuweisung werden automatisch dem Standardprofil zugeordnet (Fallback in `notifyClients` und `Video.create`)
|
||||||
|
- Per-Profil-Dedup statt globalem Dedup — verhindert, dass Profile sich gegenseitig Videos loeschen
|
||||||
|
- Einzelversand statt Batches — einfachere Logik, geringfuegig schlechtere Sortier-Garantie wird in Kauf genommen
|
||||||
- WebSocket statt Polling — effiziente Echtzeit-Aktualisierung der Videoliste
|
- WebSocket statt Polling — effiziente Echtzeit-Aktualisierung der Videoliste
|
||||||
|
- REST Nested Resources — `profileId` in der URL statt als Query/Body
|
||||||
|
- Active Record statt Service-Layer — DB-Methoden direkt am Model
|
||||||
|
- camelCase statt snake_case im Python-Code — bewusster Verstoss gegen PEP 8 zugunsten der Lesbarkeit fuer den User (Hintergrund Symfony/Spring Boot)
|
||||||
- Sprache der Dokumentation: Deutsch
|
- Sprache der Dokumentation: Deutsch
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.application") version "8.7.3" apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
alias(libs.plugins.android.application)
|
||||||
id("org.jetbrains.kotlin.android")
|
alias(libs.plugins.kotlin.android)
|
||||||
id("org.jetbrains.kotlin.plugin.compose")
|
alias(libs.plugins.kotlin.compose)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -16,41 +16,62 @@ android {
|
|||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
signingConfigs {
|
||||||
compose = true
|
create("release") {
|
||||||
|
storeFile = file("../release-key.jks")
|
||||||
|
storePassword = "youtubeapp"
|
||||||
|
keyAlias = "youtubeapp"
|
||||||
|
keyPassword = "youtubeapp"
|
||||||
|
enableV1Signing = true
|
||||||
|
enableV2Signing = true
|
||||||
|
enableV3Signing = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
buildTypes {
|
||||||
jvmTarget = "17"
|
debug {
|
||||||
|
buildConfigField("String", "BASE_URL", "\"http://192.168.178.34:8000/\"")
|
||||||
|
}
|
||||||
|
release {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
buildConfigField("String", "BASE_URL", "\"https://youtube.marha.de/\"")
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(platform("androidx.compose:compose-bom:2024.12.01"))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation("androidx.compose.ui:ui")
|
implementation(libs.androidx.ui)
|
||||||
implementation("androidx.compose.material3:material3")
|
implementation(libs.androidx.material3)
|
||||||
implementation("androidx.compose.material:material-icons-extended")
|
implementation(libs.androidx.compose.material.icons.extended)
|
||||||
implementation("androidx.activity:activity-compose:1.9.3")
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation("androidx.navigation:navigation-compose:2.8.5")
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
// Networking
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
implementation("com.squareup.retrofit2:retrofit:2.11.0")
|
implementation(libs.retrofit)
|
||||||
implementation("com.squareup.retrofit2:converter-gson:2.11.0")
|
implementation(libs.retrofit.converter.gson)
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
implementation(libs.okhttp)
|
||||||
|
implementation(libs.okhttp.logging.interceptor)
|
||||||
// Image Loading
|
implementation(libs.coil.compose)
|
||||||
implementation("io.coil-kt:coil-compose:2.7.0")
|
implementation(libs.media3.exoplayer)
|
||||||
|
implementation(libs.media3.ui)
|
||||||
// Video Player
|
|
||||||
implementation("androidx.media3:media3-exoplayer:1.5.1")
|
|
||||||
implementation("androidx.media3:media3-ui:1.5.1")
|
|
||||||
|
|
||||||
// ViewModel
|
|
||||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
|
||||||
}
|
}
|
||||||
|
|||||||
1
app/frontend/proguard-rules.pro
vendored
Normal file
1
app/frontend/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Add project-specific ProGuard rules here.
|
||||||
@@ -10,12 +10,14 @@
|
|||||||
android:banner="@drawable/tv_banner"
|
android:banner="@drawable/tv_banner"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:label="YouTube App"
|
android:label="YouTube App"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
android:exported="true"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
package com.youtubeapp.data
|
package com.youtubeapp.data
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import com.youtubeapp.BuildConfig
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
|
|
||||||
object ApiClient {
|
object ApiClient {
|
||||||
// Server-IP hier anpassen
|
val BASE_URL: String =
|
||||||
const val BASE_URL = "http://marha.local:8000/"
|
if (BuildConfig.DEBUG && (Build.FINGERPRINT.contains("generic") || Build.FINGERPRINT.contains("sdk")))
|
||||||
|
"http://10.0.2.2:8000/"
|
||||||
|
else
|
||||||
|
BuildConfig.BASE_URL
|
||||||
|
|
||||||
|
private val okHttp: OkHttpClient = OkHttpClient.Builder()
|
||||||
|
.addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC })
|
||||||
|
.build()
|
||||||
|
|
||||||
val api: VideoApi by lazy {
|
val api: VideoApi by lazy {
|
||||||
Retrofit.Builder()
|
Retrofit.Builder()
|
||||||
.baseUrl(BASE_URL)
|
.baseUrl(BASE_URL)
|
||||||
|
.client(okHttp)
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
.build()
|
.build()
|
||||||
.create(VideoApi::class.java)
|
.create(VideoApi::class.java)
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package com.youtubeapp.data
|
package com.youtubeapp.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import com.google.gson.Gson
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
|
|
||||||
class LocalStorageService(private val context: Context) {
|
class LocalStorageService(private val context: Context) {
|
||||||
|
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
private fun videosDir(): File {
|
private fun videosDir(): File {
|
||||||
val dir = File(context.filesDir, "videos")
|
val dir = File(context.filesDir, "videos")
|
||||||
if (!dir.exists()) dir.mkdirs()
|
if (!dir.exists()) dir.mkdirs()
|
||||||
@@ -15,6 +18,8 @@ class LocalStorageService(private val context: Context) {
|
|||||||
|
|
||||||
private fun videoFile(videoId: Int): File = File(videosDir(), "$videoId.mp4")
|
private fun videoFile(videoId: Int): File = File(videosDir(), "$videoId.mp4")
|
||||||
|
|
||||||
|
private fun metadataFile(videoId: Int): File = File(videosDir(), "$videoId.json")
|
||||||
|
|
||||||
fun isLocallyAvailable(videoId: Int): Boolean = videoFile(videoId).exists()
|
fun isLocallyAvailable(videoId: Int): Boolean = videoFile(videoId).exists()
|
||||||
|
|
||||||
fun getLocalFile(videoId: Int): File? {
|
fun getLocalFile(videoId: Int): File? {
|
||||||
@@ -22,7 +27,10 @@ class LocalStorageService(private val context: Context) {
|
|||||||
return if (file.exists()) file else null
|
return if (file.exists()) file else null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteLocalFile(videoId: Int): Boolean = videoFile(videoId).delete()
|
fun deleteLocalFile(videoId: Int): Boolean {
|
||||||
|
metadataFile(videoId).delete()
|
||||||
|
return videoFile(videoId).delete()
|
||||||
|
}
|
||||||
|
|
||||||
fun getLocalVideoIds(): List<Int> {
|
fun getLocalVideoIds(): List<Int> {
|
||||||
return videosDir().listFiles()
|
return videosDir().listFiles()
|
||||||
@@ -31,6 +39,24 @@ class LocalStorageService(private val context: Context) {
|
|||||||
?: emptyList()
|
?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun saveMetadata(video: Video) {
|
||||||
|
metadataFile(video.id).writeText(gson.toJson(video))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLocalVideos(): List<Video> {
|
||||||
|
return videosDir().listFiles()
|
||||||
|
?.filter { it.extension == "json" }
|
||||||
|
?.mapNotNull { file ->
|
||||||
|
try {
|
||||||
|
val video = gson.fromJson(file.readText(), Video::class.java)
|
||||||
|
if (videoFile(video.id).exists()) video else null
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
fun downloadAndSave(videoId: Int): File {
|
fun downloadAndSave(videoId: Int): File {
|
||||||
val url = "${ApiClient.BASE_URL}videos/$videoId/file"
|
val url = "${ApiClient.BASE_URL}videos/$videoId/file"
|
||||||
val file = videoFile(videoId)
|
val file = videoFile(videoId)
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ data class Video(
|
|||||||
val id: Int,
|
val id: Int,
|
||||||
val title: String,
|
val title: String,
|
||||||
val youtuber: String,
|
val youtuber: String,
|
||||||
val thumbnail_url: String,
|
val thumbnailUrl: String,
|
||||||
val youtube_url: String,
|
val youtubeUrl: String,
|
||||||
val is_downloaded: Boolean,
|
val isDownloaded: Boolean,
|
||||||
val profile_ids: List<Int> = emptyList()
|
val profileIds: List<Int> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Profile(
|
data class Profile(
|
||||||
|
|||||||
@@ -3,20 +3,19 @@ package com.youtubeapp.data
|
|||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
|
||||||
|
|
||||||
interface VideoApi {
|
interface VideoApi {
|
||||||
@GET("videos")
|
@GET("profiles/{profileId}/videos")
|
||||||
suspend fun getAllVideos(@Query("profile_id") profileId: Int? = null): List<Video>
|
suspend fun getAllVideos(@Path("profileId") profileId: Int): List<Video>
|
||||||
|
|
||||||
@GET("videos/downloaded")
|
|
||||||
suspend fun getDownloadedVideos(@Query("profile_id") profileId: Int? = null): List<Video>
|
|
||||||
|
|
||||||
@POST("videos/{id}/download")
|
@POST("videos/{id}/download")
|
||||||
suspend fun triggerDownload(@Path("id") id: Int): Map<String, String>
|
suspend fun triggerDownload(@Path("id") id: Int, @retrofit2.http.Query("maxHeight") maxHeight: Int): Map<String, String>
|
||||||
|
|
||||||
@POST("videos/cleanup")
|
@POST("profiles/{profileId}/videos/cleanup")
|
||||||
suspend fun cleanupVideos(@retrofit2.http.Body body: Map<String, @JvmSuppressWildcards Any>): Map<String, Int>
|
suspend fun cleanupVideos(
|
||||||
|
@Path("profileId") profileId: Int,
|
||||||
|
@retrofit2.http.Body body: Map<String, @JvmSuppressWildcards Any>,
|
||||||
|
): Map<String, Int>
|
||||||
|
|
||||||
@retrofit2.http.DELETE("videos/{id}/file")
|
@retrofit2.http.DELETE("videos/{id}/file")
|
||||||
suspend fun deleteServerFile(@Path("id") id: Int): Map<String, String>
|
suspend fun deleteServerFile(@Path("id") id: Int): Map<String, String>
|
||||||
|
|||||||
@@ -2,14 +2,11 @@ package com.youtubeapp.data
|
|||||||
|
|
||||||
class VideoRepository(private val api: VideoApi = ApiClient.api) {
|
class VideoRepository(private val api: VideoApi = ApiClient.api) {
|
||||||
|
|
||||||
suspend fun getAllVideos(profileId: Int? = null): Result<List<Video>> =
|
suspend fun getAllVideos(profileId: Int): Result<List<Video>> =
|
||||||
runCatching { api.getAllVideos(profileId) }
|
runCatching { api.getAllVideos(profileId) }
|
||||||
|
|
||||||
suspend fun getDownloadedVideos(profileId: Int? = null): Result<List<Video>> =
|
suspend fun triggerDownload(videoId: Int, maxHeight: Int = 1080): Result<String> = runCatching {
|
||||||
runCatching { api.getDownloadedVideos(profileId) }
|
val response = api.triggerDownload(videoId, maxHeight)
|
||||||
|
|
||||||
suspend fun triggerDownload(videoId: Int): Result<String> = runCatching {
|
|
||||||
val response = api.triggerDownload(videoId)
|
|
||||||
response["status"] ?: "unknown"
|
response["status"] ?: "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,12 +16,13 @@ class VideoRepository(private val api: VideoApi = ApiClient.api) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun cleanupVideos(profileId: Int, excludeIds: List<Int>): Result<Int> = runCatching {
|
suspend fun cleanupVideos(profileId: Int, excludeIds: List<Int>): Result<Int> = runCatching {
|
||||||
val body = mapOf("profile_id" to profileId, "exclude_ids" to excludeIds)
|
val body = mapOf("excludeIds" to excludeIds)
|
||||||
val response = api.cleanupVideos(body)
|
val response = api.cleanupVideos(profileId, body)
|
||||||
response["deleted"] ?: 0
|
response["deleted"] ?: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getProfiles(): Result<List<Profile>> = runCatching { api.getProfiles() }
|
suspend fun getProfiles(): Result<List<Profile>> = runCatching { api.getProfiles() }
|
||||||
|
|
||||||
fun getStreamUrl(videoId: Int): String = "${ApiClient.BASE_URL}videos/$videoId/stream"
|
fun getStreamUrl(videoId: Int, maxHeight: Int = 1080): String =
|
||||||
|
"${ApiClient.BASE_URL}videos/$videoId/stream?maxHeight=$maxHeight"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ fun VideoCard(video: Video, onClick: () -> Unit) {
|
|||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = video.thumbnail_url,
|
model = video.thumbnailUrl,
|
||||||
contentDescription = video.title,
|
contentDescription = video.title,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ fun VideoDetailScreen(
|
|||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = video.thumbnail_url,
|
model = video.thumbnailUrl,
|
||||||
contentDescription = video.title,
|
contentDescription = video.title,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -121,7 +121,7 @@ private fun VideoInfo(
|
|||||||
Text(
|
Text(
|
||||||
text = when {
|
text = when {
|
||||||
isLocal -> "Lokal gespeichert"
|
isLocal -> "Lokal gespeichert"
|
||||||
video.is_downloaded -> "Auf Server heruntergeladen"
|
video.isDownloaded -> "Auf Server heruntergeladen"
|
||||||
else -> "Noch nicht heruntergeladen"
|
else -> "Noch nicht heruntergeladen"
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ class VideoViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun connectWebSocket() {
|
private fun connectWebSocket() {
|
||||||
val wsUrl = ApiClient.BASE_URL.replace("http://", "ws://") + "ws"
|
val wsUrl = ApiClient.BASE_URL
|
||||||
|
.replace("https://", "wss://")
|
||||||
|
.replace("http://", "ws://") + "ws"
|
||||||
val request = Request.Builder().url(wsUrl).build()
|
val request = Request.Builder().url(wsUrl).build()
|
||||||
val client = OkHttpClient()
|
val client = OkHttpClient()
|
||||||
webSocket = client.newWebSocket(request, object : WebSocketListener() {
|
webSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||||
@@ -103,37 +105,30 @@ class VideoViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun loadAllVideos() {
|
fun loadAllVideos() {
|
||||||
|
val profileId = _state.value.selectedProfileId ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||||
repository.getAllVideos(profileId = _state.value.selectedProfileId)
|
repository.getAllVideos(profileId)
|
||||||
.onSuccess { videos ->
|
.onSuccess { videos ->
|
||||||
_state.value = _state.value.copy(allVideos = videos, isLoading = false)
|
_state.value = _state.value.copy(allVideos = videos, isLoading = false)
|
||||||
}
|
}
|
||||||
.onFailure { e ->
|
.onFailure {
|
||||||
_state.value = _state.value.copy(error = e.message, isLoading = false)
|
_state.value = _state.value.copy(error = "Server nicht erreichbar", isLoading = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadDownloadedVideos() {
|
fun loadDownloadedVideos() {
|
||||||
viewModelScope.launch {
|
val videos = localStorage?.getLocalVideos() ?: emptyList()
|
||||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
_state.value = _state.value.copy(downloadedVideos = videos)
|
||||||
repository.getAllVideos(profileId = _state.value.selectedProfileId)
|
|
||||||
.onSuccess { videos ->
|
|
||||||
val local = videos.filter { localStorage?.isLocallyAvailable(it.id) == true }
|
|
||||||
_state.value = _state.value.copy(downloadedVideos = local, isLoading = false)
|
|
||||||
}
|
|
||||||
.onFailure { e ->
|
|
||||||
_state.value = _state.value.copy(error = e.message, isLoading = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun triggerDownload(videoId: Int) {
|
fun triggerDownload(videoId: Int) {
|
||||||
|
val maxHeight = if (android.os.Build.VERSION.SDK_INT < 29) 720 else 1080
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
_state.value = _state.value.copy(isDownloading = true, downloadStatus = null)
|
_state.value = _state.value.copy(isDownloading = true, downloadStatus = null)
|
||||||
try {
|
try {
|
||||||
val result = repository.triggerDownload(videoId)
|
val result = repository.triggerDownload(videoId, maxHeight)
|
||||||
if (result.isFailure) {
|
if (result.isFailure) {
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
isDownloading = false,
|
isDownloading = false,
|
||||||
@@ -144,16 +139,25 @@ class VideoViewModel : ViewModel() {
|
|||||||
|
|
||||||
val status = result.getOrNull()
|
val status = result.getOrNull()
|
||||||
if (status == "download_started") {
|
if (status == "download_started") {
|
||||||
while (true) {
|
val profileId = _state.value.selectedProfileId ?: 1
|
||||||
|
var attempts = 0
|
||||||
|
while (attempts < 150) {
|
||||||
delay(2000)
|
delay(2000)
|
||||||
val videosResult = repository.getAllVideos()
|
val videosResult = repository.getAllVideos(profileId)
|
||||||
val video = videosResult.getOrNull()?.find { it.id == videoId }
|
val video = videosResult.getOrNull()?.find { it.id == videoId }
|
||||||
if (video?.is_downloaded == true) break
|
if (video?.isDownloaded == true) break
|
||||||
|
attempts++
|
||||||
}
|
}
|
||||||
|
if (attempts >= 150) throw Exception("Download fehlgeschlagen")
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage?.downloadAndSave(videoId)
|
localStorage?.downloadAndSave(videoId)
|
||||||
|
val video = _state.value.allVideos.find { it.id == videoId }
|
||||||
|
if (video != null) {
|
||||||
|
localStorage?.saveMetadata(video)
|
||||||
|
}
|
||||||
repository.deleteServerFile(videoId)
|
repository.deleteServerFile(videoId)
|
||||||
|
loadDownloadedVideos()
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
isDownloading = false,
|
isDownloading = false,
|
||||||
downloadStatus = "Lokal gespeichert"
|
downloadStatus = "Lokal gespeichert"
|
||||||
@@ -187,6 +191,7 @@ class VideoViewModel : ViewModel() {
|
|||||||
|
|
||||||
fun getVideoById(videoId: Int): Video? {
|
fun getVideoById(videoId: Int): Video? {
|
||||||
return _state.value.allVideos.find { it.id == videoId }
|
return _state.value.allVideos.find { it.id == videoId }
|
||||||
|
?: _state.value.downloadedVideos.find { it.id == videoId }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isLocallyAvailable(videoId: Int): Boolean {
|
fun isLocallyAvailable(videoId: Int): Boolean {
|
||||||
@@ -198,7 +203,8 @@ class VideoViewModel : ViewModel() {
|
|||||||
return if (localFile != null) {
|
return if (localFile != null) {
|
||||||
android.net.Uri.fromFile(localFile).toString()
|
android.net.Uri.fromFile(localFile).toString()
|
||||||
} else {
|
} else {
|
||||||
repository.getStreamUrl(videoId)
|
val maxHeight = if (android.os.Build.VERSION.SDK_INT < 29) 720 else 1080
|
||||||
|
repository.getStreamUrl(videoId, maxHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="true" />
|
||||||
|
</network-security-config>
|
||||||
34
app/gradle/libs.versions.toml
Normal file
34
app/gradle/libs.versions.toml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "8.7.3"
|
||||||
|
kotlin = "2.1.0"
|
||||||
|
activityCompose = "1.9.3"
|
||||||
|
composeBom = "2024.12.01"
|
||||||
|
navigationCompose = "2.8.5"
|
||||||
|
lifecycleViewmodelCompose = "2.8.7"
|
||||||
|
lifecycleRuntimeCompose = "2.8.7"
|
||||||
|
retrofit = "2.11.0"
|
||||||
|
okhttp = "4.12.0"
|
||||||
|
coil = "2.7.0"
|
||||||
|
media3 = "1.5.1"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
|
||||||
|
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" }
|
||||||
|
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
|
||||||
|
retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
|
||||||
|
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||||
|
okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
|
||||||
|
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||||
|
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
|
||||||
|
media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
BIN
app/release-key.jks
Normal file
BIN
app/release-key.jks
Normal file
Binary file not shown.
@@ -11,13 +11,13 @@
|
|||||||
- DELETE /videos?profile_id=X&exclude_ids= — Videos des Profils loeschen (ausser lokal gespeicherte)
|
- DELETE /videos?profile_id=X&exclude_ids= — Videos des Profils loeschen (ausser lokal gespeicherte)
|
||||||
- POST /videos/{id}/download — Download triggern
|
- POST /videos/{id}/download — Download triggern
|
||||||
- GET /videos/{id}/stream — Video streamen
|
- GET /videos/{id}/stream — Video streamen
|
||||||
- GET /videos/{id}/file — Video-Datei zum Download ausliefern
|
- GET /videos/{id}/file — Video-Datei zum Download ausliefern, setzt Download-Status zurueck wenn Datei fehlt
|
||||||
- DELETE /videos/{id}/file — Server-Datei loeschen (nach lokalem Download)
|
- DELETE /videos/{id}/file — Server-Datei loeschen (nach lokalem Download)
|
||||||
- WS /ws — WebSocket, benachrichtigt Clients bei neuen Videos
|
- WS /ws — WebSocket, benachrichtigt Clients bei neuen Videos
|
||||||
## Services
|
## Services
|
||||||
- VideoService — Videos speichern, abrufen, Status verwalten
|
- VideoService — Videos speichern, abrufen, Status verwalten
|
||||||
- DownloadService — yt-dlp aufrufen, Video herunterladen
|
- DownloadService — yt-dlp aufrufen, Video herunterladen
|
||||||
- StreamService — Live-Streaming via yt-dlp + ffmpeg
|
- StreamService — ffmpeg Live-Muxing von Video+Audio, gleichzeitiges Streaming und Speichern
|
||||||
## Model
|
## Model
|
||||||
- Video — id, title, youtuber, thumbnail_url, youtube_url, file_path
|
- Video — id, title, youtuber, thumbnail_url, youtube_url, file_path
|
||||||
- Profile — id, name
|
- Profile — id, name
|
||||||
@@ -27,11 +27,11 @@
|
|||||||
- AllVideosScreen — alle Videos als Cards
|
- AllVideosScreen — alle Videos als Cards
|
||||||
- DownloadedVideosScreen — heruntergeladene Videos als Cards
|
- DownloadedVideosScreen — heruntergeladene Videos als Cards
|
||||||
- VideoDetailScreen — Starten, Download, Zurück, Löschen
|
- VideoDetailScreen — Starten, Download, Zurück, Löschen
|
||||||
- VideoPlayerScreen — Player mit Standard-Controls
|
- VideoPlayerScreen — Player mit Standard-Controls, Rotation behaelt Video-Position bei
|
||||||
## ViewModel
|
## ViewModel
|
||||||
- VideoViewModel — Video-State verwalten, API-Aufrufe triggern, lokal bevorzugen sonst streamen
|
- VideoViewModel — Video-State verwalten, API-Aufrufe triggern, lokal bevorzugen sonst streamen
|
||||||
## Services
|
## Services
|
||||||
- LocalStorageService — Videos lokal speichern, pruefen, loeschen
|
- LocalStorageService — Videos und Metadaten lokal speichern, pruefen, loeschen (offline verfuegbar)
|
||||||
## API
|
## API
|
||||||
- ServerApi — kommuniziert mit FastAPI (GET /profiles, GET /videos, POST /download, GET /stream, GET /videos/{id}/file, DELETE /videos/{id}/file, DELETE /videos)
|
- ServerApi — kommuniziert mit FastAPI (GET /profiles, GET /videos, POST /download, GET /stream, GET /videos/{id}/file, DELETE /videos/{id}/file, DELETE /videos)
|
||||||
## Model
|
## Model
|
||||||
|
|||||||
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
videos/
|
videos/
|
||||||
|
cookies.txt
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y ffmpeg curl unzip && rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -9,4 +10,4 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
CMD ["uvicorn", "base.app:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
|
|||||||
16
backend/api/cookies_controller.py
Normal file
16
backend/api/cookies_controller.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request, status
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
COOKIES_PATH = Path("/app/cookies.txt")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cookies", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def uploadCookies(request: Request):
|
||||||
|
body = (await request.body()).decode("utf-8", errors="replace")
|
||||||
|
if not body.startswith("# Netscape"):
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Kein Netscape-Cookie-File")
|
||||||
|
tmp = COOKIES_PATH.with_suffix(".tmp")
|
||||||
|
tmp.write_text(body, encoding="utf-8")
|
||||||
|
tmp.replace(COOKIES_PATH)
|
||||||
38
backend/api/profile_controller.py
Normal file
38
backend/api/profile_controller.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from api.schemas import CleanupRequest, VideoCreate, VideoResponse
|
||||||
|
from database.database import DbSession
|
||||||
|
from model.profile import Profile
|
||||||
|
from model.video import Video
|
||||||
|
from notify.notify_clients import notifyClients
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profiles")
|
||||||
|
def getAll(db: DbSession):
|
||||||
|
return Profile.getAll(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profiles/{profileId}/videos", status_code=204)
|
||||||
|
async def createVideo(profileId: int, videoData: VideoCreate, db: DbSession):
|
||||||
|
title = videoData.title
|
||||||
|
youtuber = videoData.youtuber
|
||||||
|
thumbnailUrl = videoData.thumbnailUrl
|
||||||
|
youtubeUrl = videoData.youtubeUrl
|
||||||
|
|
||||||
|
Video.deleteIfExists(db, youtubeUrl, profileId)
|
||||||
|
Video.create(db, title, youtuber, thumbnailUrl, youtubeUrl, profileId)
|
||||||
|
await notifyClients(profileId)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profiles/{profileId}/videos", response_model=list[VideoResponse])
|
||||||
|
def getVideos(profileId: int, db: DbSession):
|
||||||
|
return Video.getAll(db, profileId=profileId)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profiles/{profileId}/videos/cleanup")
|
||||||
|
def cleanupVideos(profileId: int, request: CleanupRequest, db: DbSession):
|
||||||
|
excludeIds = request.excludeIds
|
||||||
|
count = Video.deleteNotDownloaded(db, profileId, excludeIds)
|
||||||
|
return {"deleted": count}
|
||||||
24
backend/api/schemas.py
Normal file
24
backend/api/schemas.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class VideoCreate(BaseModel):
|
||||||
|
title: str
|
||||||
|
youtuber: str
|
||||||
|
thumbnailUrl: str
|
||||||
|
youtubeUrl: str
|
||||||
|
|
||||||
|
|
||||||
|
class VideoResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
youtuber: str
|
||||||
|
thumbnailUrl: str
|
||||||
|
youtubeUrl: str
|
||||||
|
isDownloaded: bool
|
||||||
|
profileIds: list[int]
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CleanupRequest(BaseModel):
|
||||||
|
excludeIds: list[int] = []
|
||||||
53
backend/api/video_controller.py
Normal file
53
backend/api/video_controller.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
|
|
||||||
|
from database.database import DbSession
|
||||||
|
from download.download_service import downloadAsync
|
||||||
|
from model.video import Video
|
||||||
|
from stream.stream_service import streamAndSave
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/videos")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{videoId}/download")
|
||||||
|
def download(videoId: int, db: DbSession, maxHeight: int = 1080):
|
||||||
|
video = Video.getById(db, videoId)
|
||||||
|
if not video:
|
||||||
|
raise HTTPException(404, "Video nicht gefunden")
|
||||||
|
if video.filePath:
|
||||||
|
return {"status": "already_downloaded"}
|
||||||
|
|
||||||
|
downloadAsync(videoId, video.youtubeUrl, maxHeight)
|
||||||
|
return {"status": "download_started"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{videoId}/stream")
|
||||||
|
def stream(videoId: int, db: DbSession, maxHeight: int = 1080):
|
||||||
|
video = Video.getById(db, videoId)
|
||||||
|
if not video:
|
||||||
|
raise HTTPException(404, "Video nicht gefunden")
|
||||||
|
|
||||||
|
if not video.filePath:
|
||||||
|
return StreamingResponse(streamAndSave(videoId, video.youtubeUrl, maxHeight), media_type="video/mp4")
|
||||||
|
|
||||||
|
path = Path(video.filePath)
|
||||||
|
if not path.exists():
|
||||||
|
raise HTTPException(404, "Videodatei nicht gefunden")
|
||||||
|
return FileResponse(path, media_type="video/mp4")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{videoId}/file")
|
||||||
|
def getFile(videoId: int, db: DbSession):
|
||||||
|
path, video = Video.getValidPath(db, videoId)
|
||||||
|
if not path:
|
||||||
|
raise HTTPException(404, "Video noch nicht heruntergeladen")
|
||||||
|
return FileResponse(path, media_type="video/mp4", filename=f"{video.title}.mp4")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{videoId}/file")
|
||||||
|
def deleteFile(videoId: int, db: DbSession):
|
||||||
|
if not Video.deleteServerFile(db, videoId):
|
||||||
|
raise HTTPException(404, "Video nicht gefunden")
|
||||||
|
return {"status": "deleted"}
|
||||||
38
backend/base/app.py
Normal file
38
backend/base/app.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from api.cookies_controller import router as cookiesRouter
|
||||||
|
from api.profile_controller import router as profilesRouter
|
||||||
|
from api.video_controller import router as videosRouter
|
||||||
|
from database.database import SessionLocal, createTables
|
||||||
|
from model.profile import Profile
|
||||||
|
from notify.notify_clients import registerWebsocket
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(videosRouter)
|
||||||
|
app.include_router(profilesRouter)
|
||||||
|
app.include_router(cookiesRouter)
|
||||||
|
registerWebsocket(app)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def startup():
|
||||||
|
createTables()
|
||||||
|
db = SessionLocal()
|
||||||
|
if db.query(Profile).count() == 0:
|
||||||
|
db.add(Profile(name="Standard"))
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def root():
|
||||||
|
return {"status": "running"}
|
||||||
0
backend/database/__init__.py
Normal file
0
backend/database/__init__.py
Normal file
@@ -1,5 +1,8 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
from sqlalchemy.orm import Session, sessionmaker, declarative_base
|
||||||
|
|
||||||
DATABASE_URL = "sqlite:///videos/youtubeapp.db"
|
DATABASE_URL = "sqlite:///videos/youtubeapp.db"
|
||||||
|
|
||||||
@@ -8,13 +11,16 @@ SessionLocal = sessionmaker(bind=engine)
|
|||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
def create_tables():
|
def createTables():
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def getDb():
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
yield db
|
yield db
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
DbSession = Annotated[Session, Depends(getDb)]
|
||||||
0
backend/download/__init__.py
Normal file
0
backend/download/__init__.py
Normal file
43
backend/download/download_service.py
Normal file
43
backend/download/download_service.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from database.database import SessionLocal
|
||||||
|
from model.video import Video
|
||||||
|
|
||||||
|
VIDEOS_DIR = "/videos"
|
||||||
|
MIN_VALID_SIZE = 1024 * 100 # 100 KB
|
||||||
|
|
||||||
|
|
||||||
|
def downloadAsync(videoId: int, youtubeUrl: str, maxHeight: int = 1080):
|
||||||
|
threading.Thread(target=downloadVideo, args=(videoId, youtubeUrl, maxHeight)).start()
|
||||||
|
|
||||||
|
|
||||||
|
def downloadVideo(videoId: int, youtubeUrl: str, maxHeight: int = 1080):
|
||||||
|
outputPath = f"{VIDEOS_DIR}/{videoId}.mp4"
|
||||||
|
formatFilter = f"bestvideo[ext=mp4][vcodec^=avc][height<={maxHeight}]+bestaudio[ext=m4a]/best[ext=mp4]"
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"yt-dlp",
|
||||||
|
"--cookies", "/app/cookies.txt",
|
||||||
|
"--remote-components", "ejs:github",
|
||||||
|
"-f", formatFilter,
|
||||||
|
"-o", outputPath,
|
||||||
|
"--merge-output-format", "mp4",
|
||||||
|
"--force-overwrites",
|
||||||
|
"--no-continue",
|
||||||
|
youtubeUrl,
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
path = Path(outputPath)
|
||||||
|
if not path.exists() or path.stat().st_size < MIN_VALID_SIZE:
|
||||||
|
return
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
Video.updateFilePath(db, videoId, outputPath)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
|
|
||||||
from database import SessionLocal, create_tables
|
|
||||||
from models import Profile
|
|
||||||
from routes.videos import profiles_router, router as videos_router
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"],
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
app.include_router(videos_router)
|
|
||||||
app.include_router(profiles_router)
|
|
||||||
|
|
||||||
# --- WebSocket ---
|
|
||||||
|
|
||||||
connected_clients: set[WebSocket] = set()
|
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws")
|
|
||||||
async def websocket_endpoint(websocket: WebSocket):
|
|
||||||
await websocket.accept()
|
|
||||||
connected_clients.add(websocket)
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
await websocket.receive_text()
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
connected_clients.discard(websocket)
|
|
||||||
|
|
||||||
|
|
||||||
async def notify_clients(profile_ids: list[int]):
|
|
||||||
message = ",".join(str(pid) for pid in profile_ids)
|
|
||||||
for client in list(connected_clients):
|
|
||||||
try:
|
|
||||||
await client.send_text(message)
|
|
||||||
except Exception:
|
|
||||||
connected_clients.discard(client)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Startup ---
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
|
||||||
def startup():
|
|
||||||
create_tables()
|
|
||||||
db = SessionLocal()
|
|
||||||
if db.query(Profile).count() == 0:
|
|
||||||
db.add(Profile(name="Standard"))
|
|
||||||
db.commit()
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
|
||||||
def root():
|
|
||||||
return {"status": "running"}
|
|
||||||
0
backend/model/__init__.py
Normal file
0
backend/model/__init__.py
Normal file
16
backend/model/profile.py
Normal file
16
backend/model/profile.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from database.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Profile(Base):
|
||||||
|
__tablename__ = "profiles"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name = Column(String, nullable=False, unique=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def getAll(cls, db: Session) -> list[dict]:
|
||||||
|
profiles = db.query(cls).all()
|
||||||
|
return [{"id": p.id, "name": p.name} for p in profiles]
|
||||||
10
backend/model/profile_video.py
Normal file
10
backend/model/profile_video.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from sqlalchemy import Column, ForeignKey, Integer, Table
|
||||||
|
|
||||||
|
from database.database import Base
|
||||||
|
|
||||||
|
videoProfiles = Table(
|
||||||
|
"video_profiles",
|
||||||
|
Base.metadata,
|
||||||
|
Column("video_id", Integer, ForeignKey("videos.id", ondelete="CASCADE")),
|
||||||
|
Column("profile_id", Integer, ForeignKey("profiles.id", ondelete="CASCADE")),
|
||||||
|
)
|
||||||
123
backend/model/video.py
Normal file
123
backend/model/video.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String
|
||||||
|
from sqlalchemy.orm import Session, relationship
|
||||||
|
|
||||||
|
from database.database import Base
|
||||||
|
from model.profile import Profile
|
||||||
|
from model.profile_video import videoProfiles
|
||||||
|
|
||||||
|
|
||||||
|
class Video(Base):
|
||||||
|
__tablename__ = "videos"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
title = Column(String, nullable=False)
|
||||||
|
youtuber = Column(String, nullable=False)
|
||||||
|
thumbnailUrl = Column("thumbnail_url", String, nullable=False)
|
||||||
|
youtubeUrl = Column("youtube_url", String, nullable=False)
|
||||||
|
filePath = Column("file_path", String, nullable=True)
|
||||||
|
profiles = relationship("Profile", secondary=videoProfiles, backref="videos")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def isDownloaded(self) -> bool:
|
||||||
|
return self.filePath is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def profileIds(self) -> list[int]:
|
||||||
|
return [p.id for p in self.profiles]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def deleteIfExists(cls, db: Session, youtubeUrl: str, profileId: int | None):
|
||||||
|
if not profileId:
|
||||||
|
profileId = 1
|
||||||
|
videos = db.query(cls).filter(
|
||||||
|
cls.youtubeUrl == youtubeUrl,
|
||||||
|
cls.profiles.any(Profile.id == profileId),
|
||||||
|
).all()
|
||||||
|
for video in videos:
|
||||||
|
db.delete(video)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
db: Session,
|
||||||
|
title: str,
|
||||||
|
youtuber: str,
|
||||||
|
thumbnailUrl: str,
|
||||||
|
youtubeUrl: str,
|
||||||
|
profileId: int | None,
|
||||||
|
) -> "Video":
|
||||||
|
if not profileId:
|
||||||
|
profileId = 1
|
||||||
|
video = cls(
|
||||||
|
title=title,
|
||||||
|
youtuber=youtuber,
|
||||||
|
thumbnailUrl=thumbnailUrl,
|
||||||
|
youtubeUrl=youtubeUrl,
|
||||||
|
)
|
||||||
|
profile = db.query(Profile).filter(Profile.id == profileId).first()
|
||||||
|
if profile:
|
||||||
|
video.profiles.append(profile)
|
||||||
|
db.add(video)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(video)
|
||||||
|
return video
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def getAll(cls, db: Session, profileId: int | None = None) -> list["Video"]:
|
||||||
|
query = db.query(cls)
|
||||||
|
if profileId:
|
||||||
|
query = query.filter(cls.profiles.any(Profile.id == profileId))
|
||||||
|
return query.order_by(cls.id.desc()).all()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def getById(cls, db: Session, videoId: int) -> "Video | None":
|
||||||
|
return db.query(cls).filter(cls.id == videoId).first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def updateFilePath(cls, db: Session, videoId: int, path: str | None):
|
||||||
|
video = cls.getById(db, videoId)
|
||||||
|
if video:
|
||||||
|
video.filePath = path
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def getValidPath(cls, db: Session, videoId: int):
|
||||||
|
video = cls.getById(db, videoId)
|
||||||
|
if not video or not video.filePath:
|
||||||
|
return None, None
|
||||||
|
path = Path(video.filePath)
|
||||||
|
if not path.exists():
|
||||||
|
cls.updateFilePath(db, videoId, None)
|
||||||
|
return None, None
|
||||||
|
return path, video
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def deleteServerFile(cls, db: Session, videoId: int) -> bool:
|
||||||
|
video = cls.getById(db, videoId)
|
||||||
|
if not video:
|
||||||
|
return False
|
||||||
|
if video.filePath:
|
||||||
|
path = Path(video.filePath)
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
cls.updateFilePath(db, videoId, None)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def deleteNotDownloaded(cls, db: Session, profileId: int, excludeIds: list[int] | None = None) -> int:
|
||||||
|
query = db.query(cls).filter(
|
||||||
|
cls.profiles.any(Profile.id == profileId),
|
||||||
|
)
|
||||||
|
if excludeIds:
|
||||||
|
query = query.filter(cls.id.notin_(excludeIds))
|
||||||
|
videos = query.all()
|
||||||
|
videoIds = [v.id for v in videos]
|
||||||
|
if not videoIds:
|
||||||
|
return 0
|
||||||
|
db.execute(videoProfiles.delete().where(videoProfiles.c.video_id.in_(videoIds)))
|
||||||
|
db.query(cls).filter(cls.id.in_(videoIds)).delete(synchronize_session=False)
|
||||||
|
db.commit()
|
||||||
|
return len(videoIds)
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
from sqlalchemy import Column, ForeignKey, Integer, String, Table
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
|
|
||||||
from database import Base
|
|
||||||
|
|
||||||
video_profiles = Table(
|
|
||||||
"video_profiles",
|
|
||||||
Base.metadata,
|
|
||||||
Column("video_id", Integer, ForeignKey("videos.id", ondelete="CASCADE")),
|
|
||||||
Column("profile_id", Integer, ForeignKey("profiles.id", ondelete="CASCADE")),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Profile(Base):
|
|
||||||
__tablename__ = "profiles"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
name = Column(String, nullable=False, unique=True)
|
|
||||||
|
|
||||||
|
|
||||||
class Video(Base):
|
|
||||||
__tablename__ = "videos"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
title = Column(String, nullable=False)
|
|
||||||
youtuber = Column(String, nullable=False)
|
|
||||||
thumbnail_url = Column(String, nullable=False)
|
|
||||||
youtube_url = Column(String, nullable=False)
|
|
||||||
file_path = Column(String, nullable=True)
|
|
||||||
profiles = relationship("Profile", secondary=video_profiles, backref="videos")
|
|
||||||
0
backend/notify/__init__.py
Normal file
0
backend/notify/__init__.py
Normal file
26
backend/notify/notify_clients.py
Normal file
26
backend/notify/notify_clients.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
|
connectedClients: set[WebSocket] = set()
|
||||||
|
|
||||||
|
|
||||||
|
async def notifyClients(profileId: int | None):
|
||||||
|
if not profileId:
|
||||||
|
profileId = 1
|
||||||
|
message = str(profileId)
|
||||||
|
for client in list(connectedClients):
|
||||||
|
try:
|
||||||
|
await client.send_text(message)
|
||||||
|
except Exception:
|
||||||
|
connectedClients.discard(client)
|
||||||
|
|
||||||
|
|
||||||
|
def registerWebsocket(app: FastAPI):
|
||||||
|
@app.websocket("/ws")
|
||||||
|
async def websocketEndpoint(websocket: WebSocket):
|
||||||
|
await websocket.accept()
|
||||||
|
connectedClients.add(websocket)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await websocket.receive_text()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
connectedClients.discard(websocket)
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1,131 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import threading
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
|
||||||
from fastapi.responses import FileResponse, StreamingResponse
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from database import get_db
|
|
||||||
from schemas import CleanupRequest, ProfileResponse, VideoCreate, VideoResponse
|
|
||||||
from services import video_service
|
|
||||||
from services.download_service import download_video
|
|
||||||
from services.stream_service import stream_video_live
|
|
||||||
from services.video_service import update_file_path
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/videos", tags=["videos"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=list[VideoResponse])
|
|
||||||
async def create_videos(videos_data: list[VideoCreate], db: Session = Depends(get_db)):
|
|
||||||
created_ids = []
|
|
||||||
profile_ids = set()
|
|
||||||
for video_data in reversed(videos_data):
|
|
||||||
video_id_match = video_data.youtube_url.split("v=")[-1].split("&")[0]
|
|
||||||
video_service.delete_by_youtube_id(db, video_id_match)
|
|
||||||
video = video_service.create_video(db, video_data)
|
|
||||||
created_ids.append(video.id)
|
|
||||||
if video_data.profile_id:
|
|
||||||
profile_ids.add(video_data.profile_id)
|
|
||||||
videos = [video_service.get_video(db, vid) for vid in created_ids]
|
|
||||||
|
|
||||||
if profile_ids:
|
|
||||||
from main import notify_clients
|
|
||||||
await notify_clients(list(profile_ids))
|
|
||||||
|
|
||||||
return [VideoResponse.from_model(v) for v in videos if v]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[VideoResponse])
|
|
||||||
def get_all_videos(profile_id: Optional[int] = Query(None), db: Session = Depends(get_db)):
|
|
||||||
videos = video_service.get_all_videos(db, profile_id=profile_id)
|
|
||||||
return [VideoResponse.from_model(v) for v in videos]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/downloaded", response_model=list[VideoResponse])
|
|
||||||
def get_downloaded_videos(profile_id: Optional[int] = Query(None), db: Session = Depends(get_db)):
|
|
||||||
videos = video_service.get_downloaded_videos(db, profile_id=profile_id)
|
|
||||||
return [VideoResponse.from_model(v) for v in videos]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/cleanup")
|
|
||||||
def cleanup_videos(request: CleanupRequest, db: Session = Depends(get_db)):
|
|
||||||
count = video_service.delete_not_downloaded(db, request.profile_id, request.exclude_ids or None)
|
|
||||||
return {"deleted": count}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{video_id}/download")
|
|
||||||
def trigger_download(video_id: int, db: Session = Depends(get_db)):
|
|
||||||
video = video_service.get_video(db, video_id)
|
|
||||||
if not video:
|
|
||||||
raise HTTPException(status_code=404, detail="Video nicht gefunden")
|
|
||||||
if video.file_path:
|
|
||||||
return {"status": "already_downloaded"}
|
|
||||||
|
|
||||||
thread = threading.Thread(target=download_video, args=(video.id, video.youtube_url))
|
|
||||||
thread.start()
|
|
||||||
return {"status": "download_started"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{video_id}/stream")
|
|
||||||
def stream_video(video_id: int, db: Session = Depends(get_db)):
|
|
||||||
video = video_service.get_video(db, video_id)
|
|
||||||
if not video:
|
|
||||||
raise HTTPException(status_code=404, detail="Video nicht gefunden")
|
|
||||||
|
|
||||||
if not video.file_path:
|
|
||||||
def stream_and_save():
|
|
||||||
output_path = f"/videos/{video_id}.mp4"
|
|
||||||
yield from stream_video_live(video_id, video.youtube_url)
|
|
||||||
if Path(output_path).exists():
|
|
||||||
sdb = __import__("database").SessionLocal()
|
|
||||||
try:
|
|
||||||
update_file_path(sdb, video_id, output_path)
|
|
||||||
finally:
|
|
||||||
sdb.close()
|
|
||||||
|
|
||||||
return StreamingResponse(stream_and_save(), media_type="video/mp4")
|
|
||||||
|
|
||||||
path = Path(video.file_path)
|
|
||||||
if not path.exists():
|
|
||||||
raise HTTPException(status_code=404, detail="Videodatei nicht gefunden")
|
|
||||||
|
|
||||||
return FileResponse(path, media_type="video/mp4")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{video_id}/file")
|
|
||||||
def download_file(video_id: int, db: Session = Depends(get_db)):
|
|
||||||
video = video_service.get_video(db, video_id)
|
|
||||||
if not video:
|
|
||||||
raise HTTPException(status_code=404, detail="Video nicht gefunden")
|
|
||||||
if not video.file_path:
|
|
||||||
raise HTTPException(status_code=404, detail="Video noch nicht heruntergeladen")
|
|
||||||
|
|
||||||
path = Path(video.file_path)
|
|
||||||
if not path.exists():
|
|
||||||
video_service.update_file_path(db, video_id, None)
|
|
||||||
raise HTTPException(status_code=404, detail="Video noch nicht heruntergeladen")
|
|
||||||
|
|
||||||
return FileResponse(path, media_type="video/mp4", filename=f"{video.title}.mp4")
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{video_id}/file")
|
|
||||||
def delete_server_file(video_id: int, db: Session = Depends(get_db)):
|
|
||||||
video = video_service.get_video(db, video_id)
|
|
||||||
if not video:
|
|
||||||
raise HTTPException(status_code=404, detail="Video nicht gefunden")
|
|
||||||
if video.file_path:
|
|
||||||
path = Path(video.file_path)
|
|
||||||
if path.exists():
|
|
||||||
path.unlink()
|
|
||||||
video_service.update_file_path(db, video_id, None)
|
|
||||||
return {"status": "deleted"}
|
|
||||||
|
|
||||||
|
|
||||||
profiles_router = APIRouter(prefix="/profiles", tags=["profiles"])
|
|
||||||
|
|
||||||
|
|
||||||
@profiles_router.get("", response_model=list[ProfileResponse])
|
|
||||||
def get_profiles(db: Session = Depends(get_db)):
|
|
||||||
return video_service.get_all_profiles(db)
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class VideoCreate(BaseModel):
|
|
||||||
title: str
|
|
||||||
youtuber: str
|
|
||||||
thumbnail_url: str
|
|
||||||
youtube_url: str
|
|
||||||
profile_id: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class VideoResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
title: str
|
|
||||||
youtuber: str
|
|
||||||
thumbnail_url: str
|
|
||||||
youtube_url: str
|
|
||||||
is_downloaded: bool
|
|
||||||
profile_ids: list[int]
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_model(cls, video):
|
|
||||||
return cls(
|
|
||||||
id=video.id,
|
|
||||||
title=video.title,
|
|
||||||
youtuber=video.youtuber,
|
|
||||||
thumbnail_url=video.thumbnail_url,
|
|
||||||
youtube_url=video.youtube_url,
|
|
||||||
is_downloaded=video.file_path is not None,
|
|
||||||
profile_ids=[p.id for p in video.profiles],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CleanupRequest(BaseModel):
|
|
||||||
profile_id: int
|
|
||||||
exclude_ids: list[int] = []
|
|
||||||
|
|
||||||
|
|
||||||
class ProfileResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
name: str
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,27 +0,0 @@
|
|||||||
import subprocess
|
|
||||||
|
|
||||||
from database import SessionLocal
|
|
||||||
from services.video_service import get_video, update_file_path
|
|
||||||
|
|
||||||
VIDEOS_DIR = "/videos"
|
|
||||||
|
|
||||||
|
|
||||||
def download_video(video_id: int, youtube_url: str):
|
|
||||||
output_path = f"{VIDEOS_DIR}/{video_id}.mp4"
|
|
||||||
|
|
||||||
subprocess.run(
|
|
||||||
[
|
|
||||||
"yt-dlp",
|
|
||||||
"-f", "bestvideo[ext=mp4][vcodec^=avc]+bestaudio[ext=m4a]/best[ext=mp4]",
|
|
||||||
"-o", output_path,
|
|
||||||
"--merge-output-format", "mp4",
|
|
||||||
youtube_url,
|
|
||||||
],
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
update_file_path(db, video_id, output_path)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from models import Profile, Video, video_profiles
|
|
||||||
from schemas import VideoCreate
|
|
||||||
|
|
||||||
|
|
||||||
def create_video(db: Session, video_data: VideoCreate) -> Video:
|
|
||||||
profile_id = video_data.profile_id
|
|
||||||
data = video_data.model_dump(exclude={"profile_id"})
|
|
||||||
video = Video(**data)
|
|
||||||
if not profile_id:
|
|
||||||
profile_id = 1
|
|
||||||
profile = db.query(Profile).filter(Profile.id == profile_id).first()
|
|
||||||
if profile:
|
|
||||||
video.profiles.append(profile)
|
|
||||||
db.add(video)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(video)
|
|
||||||
return video
|
|
||||||
|
|
||||||
|
|
||||||
def get_all_videos(db: Session, profile_id: int | None = None) -> list[Video]:
|
|
||||||
query = db.query(Video)
|
|
||||||
if profile_id:
|
|
||||||
query = query.filter(Video.profiles.any(Profile.id == profile_id))
|
|
||||||
return query.order_by(Video.id.desc()).all()
|
|
||||||
|
|
||||||
|
|
||||||
def get_downloaded_videos(db: Session, profile_id: int | None = None) -> list[Video]:
|
|
||||||
query = db.query(Video).filter(Video.file_path.isnot(None))
|
|
||||||
if profile_id:
|
|
||||||
query = query.filter(Video.profiles.any(Profile.id == profile_id))
|
|
||||||
return query.order_by(Video.id.desc()).all()
|
|
||||||
|
|
||||||
|
|
||||||
def get_video(db: Session, video_id: int) -> Video | None:
|
|
||||||
return db.query(Video).filter(Video.id == video_id).first()
|
|
||||||
|
|
||||||
|
|
||||||
def delete_by_youtube_id(db: Session, youtube_id: str):
|
|
||||||
db.query(Video).filter(Video.youtube_url.contains(youtube_id)).delete(synchronize_session=False)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def update_file_path(db: Session, video_id: int, path: str):
|
|
||||||
video = get_video(db, video_id)
|
|
||||||
if video:
|
|
||||||
video.file_path = path
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def delete_not_downloaded(db: Session, profile_id: int, exclude_ids: list[int] | None = None) -> int:
|
|
||||||
query = db.query(Video).filter(
|
|
||||||
Video.profiles.any(Profile.id == profile_id),
|
|
||||||
)
|
|
||||||
if exclude_ids:
|
|
||||||
query = query.filter(Video.id.notin_(exclude_ids))
|
|
||||||
videos = query.all()
|
|
||||||
video_ids = [v.id for v in videos]
|
|
||||||
if not video_ids:
|
|
||||||
return 0
|
|
||||||
db.execute(video_profiles.delete().where(video_profiles.c.video_id.in_(video_ids)))
|
|
||||||
db.query(Video).filter(Video.id.in_(video_ids)).delete(synchronize_session=False)
|
|
||||||
db.commit()
|
|
||||||
return len(video_ids)
|
|
||||||
|
|
||||||
|
|
||||||
def get_all_profiles(db: Session) -> list[Profile]:
|
|
||||||
return db.query(Profile).all()
|
|
||||||
0
backend/stream/__init__.py
Normal file
0
backend/stream/__init__.py
Normal file
@@ -1,17 +1,34 @@
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from database.database import SessionLocal
|
||||||
|
|
||||||
VIDEOS_DIR = "/videos"
|
VIDEOS_DIR = "/videos"
|
||||||
CHUNK_SIZE = 64 * 1024
|
CHUNK_SIZE = 64 * 1024
|
||||||
|
|
||||||
|
|
||||||
def _get_stream_urls(youtube_url: str):
|
def streamAndSave(videoId: int, youtubeUrl: str, maxHeight: int = 1080):
|
||||||
|
from model.video import Video # Lazy-Import gegen Zirkular
|
||||||
|
outputPath = f"{VIDEOS_DIR}/{videoId}.mp4"
|
||||||
|
yield from streamVideoLive(videoId, youtubeUrl, maxHeight)
|
||||||
|
if Path(outputPath).exists():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
Video.updateFilePath(db, videoId, outputPath)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _getStreamUrls(youtubeUrl: str, maxHeight: int = 1080):
|
||||||
|
formatFilter = f"bestvideo[ext=mp4][vcodec^=avc][height<={maxHeight}]+bestaudio[ext=m4a]/best[ext=mp4]"
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[
|
[
|
||||||
"yt-dlp",
|
"yt-dlp",
|
||||||
"-f", "bestvideo[ext=mp4][vcodec^=avc]+bestaudio[ext=m4a]/best[ext=mp4]",
|
"--cookies", "/app/cookies.txt",
|
||||||
|
"--remote-components", "ejs:github",
|
||||||
|
"-f", formatFilter,
|
||||||
"--print", "urls",
|
"--print", "urls",
|
||||||
youtube_url,
|
youtubeUrl,
|
||||||
],
|
],
|
||||||
capture_output=True, text=True, timeout=30,
|
capture_output=True, text=True, timeout=30,
|
||||||
)
|
)
|
||||||
@@ -26,24 +43,24 @@ def _get_stream_urls(youtube_url: str):
|
|||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
def stream_video_live(video_id: int, youtube_url: str):
|
def streamVideoLive(videoId: int, youtubeUrl: str, maxHeight: int = 1080):
|
||||||
output_path = f"{VIDEOS_DIR}/{video_id}.mp4"
|
outputPath = f"{VIDEOS_DIR}/{videoId}.mp4"
|
||||||
|
|
||||||
video_url, audio_url = _get_stream_urls(youtube_url)
|
videoUrl, audioUrl = _getStreamUrls(youtubeUrl, maxHeight)
|
||||||
if not video_url:
|
if not videoUrl:
|
||||||
return
|
return
|
||||||
|
|
||||||
if audio_url:
|
if audioUrl:
|
||||||
cmd = [
|
cmd = [
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-reconnect", "1",
|
"-reconnect", "1",
|
||||||
"-reconnect_streamed", "1",
|
"-reconnect_streamed", "1",
|
||||||
"-reconnect_delay_max", "5",
|
"-reconnect_delay_max", "5",
|
||||||
"-i", video_url,
|
"-i", videoUrl,
|
||||||
"-reconnect", "1",
|
"-reconnect", "1",
|
||||||
"-reconnect_streamed", "1",
|
"-reconnect_streamed", "1",
|
||||||
"-reconnect_delay_max", "5",
|
"-reconnect_delay_max", "5",
|
||||||
"-i", audio_url,
|
"-i", audioUrl,
|
||||||
"-c:v", "copy",
|
"-c:v", "copy",
|
||||||
"-c:a", "aac",
|
"-c:a", "aac",
|
||||||
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
||||||
@@ -56,7 +73,7 @@ def stream_video_live(video_id: int, youtube_url: str):
|
|||||||
"-reconnect", "1",
|
"-reconnect", "1",
|
||||||
"-reconnect_streamed", "1",
|
"-reconnect_streamed", "1",
|
||||||
"-reconnect_delay_max", "5",
|
"-reconnect_delay_max", "5",
|
||||||
"-i", video_url,
|
"-i", videoUrl,
|
||||||
"-c", "copy",
|
"-c", "copy",
|
||||||
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
||||||
"-f", "mp4",
|
"-f", "mp4",
|
||||||
@@ -70,7 +87,7 @@ def stream_video_live(video_id: int, youtube_url: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(output_path, "wb") as f:
|
with open(outputPath, "wb") as f:
|
||||||
while True:
|
while True:
|
||||||
chunk = process.stdout.read(CHUNK_SIZE)
|
chunk = process.stdout.read(CHUNK_SIZE)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
@@ -86,6 +103,6 @@ def stream_video_live(video_id: int, youtube_url: str):
|
|||||||
if process.stdout:
|
if process.stdout:
|
||||||
process.stdout.close()
|
process.stdout.close()
|
||||||
|
|
||||||
path = Path(output_path)
|
path = Path(outputPath)
|
||||||
if process.returncode != 0 and path.exists():
|
if process.returncode != 0 and path.exists():
|
||||||
path.unlink()
|
path.unlink()
|
||||||
1
browser_extension/.amo-upload-uuid
Normal file
1
browser_extension/.amo-upload-uuid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"uploadUuid":"d90f6b10216044b49f278d2ac7e340cd","channel":"unlisted","xpiCrcHash":"7b6c7cd66d1833f7ddc9b05bf378315c44aedd55645515c77d4c4b3e41d450a1"}
|
||||||
1
browser_extension/.gitignore
vendored
Normal file
1
browser_extension/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
web-ext-artifacts/
|
||||||
20
browser_extension/api/background.js
Normal file
20
browser_extension/api/background.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const SERVER_BASE = "https://youtube.marha.de";
|
||||||
|
|
||||||
|
browser.runtime.onMessage.addListener((msg) => {
|
||||||
|
if (msg?.type === "sync-cookies") {
|
||||||
|
return syncCookies();
|
||||||
|
}
|
||||||
|
if (msg?.profileId && msg?.video) {
|
||||||
|
fetch(`${SERVER_BASE}/profiles/${msg.profileId}/videos`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(msg.video),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
syncCookies();
|
||||||
|
browser.alarms.create("cookieSync", { periodInMinutes: 1440 });
|
||||||
|
browser.alarms.onAlarm.addListener((a) => {
|
||||||
|
if (a.name === "cookieSync") syncCookies();
|
||||||
|
});
|
||||||
37
browser_extension/api/syncCookies.js
Normal file
37
browser_extension/api/syncCookies.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
const COOKIE_SYNC_URL = "https://youtube.marha.de/cookies";
|
||||||
|
|
||||||
|
function toNetscape(cookies) {
|
||||||
|
const lines = ["# Netscape HTTP Cookie File"];
|
||||||
|
for (const c of cookies) {
|
||||||
|
const domain = c.httpOnly ? `#HttpOnly_${c.domain}` : c.domain;
|
||||||
|
const includeSubdomain = c.domain.startsWith(".") ? "TRUE" : "FALSE";
|
||||||
|
const secure = c.secure ? "TRUE" : "FALSE";
|
||||||
|
const expiration = Math.floor(c.expirationDate || 0);
|
||||||
|
lines.push([domain, includeSubdomain, c.path, secure, expiration, c.name, c.value].join("\t"));
|
||||||
|
}
|
||||||
|
return lines.join("\n") + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncCookies() {
|
||||||
|
const when = new Date().toISOString();
|
||||||
|
try {
|
||||||
|
const cookies = await browser.cookies.getAll({ domain: ".youtube.com" });
|
||||||
|
if (cookies.length === 0) {
|
||||||
|
await browser.storage.local.set({ lastCookieSync: { when, ok: false, error: "keine YouTube-Cookies gefunden" } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = toNetscape(cookies);
|
||||||
|
const res = await fetch(COOKIE_SYNC_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "text/plain" },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
await browser.storage.local.set({ lastCookieSync: { when, ok: false, error: `HTTP ${res.status}` } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await browser.storage.local.set({ lastCookieSync: { when, ok: true, count: cookies.length } });
|
||||||
|
} catch (e) {
|
||||||
|
await browser.storage.local.set({ lastCookieSync: { when, ok: false, error: String(e) } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
const SERVER_URL = "http://marha.local:8000/videos";
|
|
||||||
|
|
||||||
browser.runtime.onMessage.addListener((videos) => {
|
|
||||||
fetch(SERVER_URL, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(videos),
|
|
||||||
}).catch(() => {});
|
|
||||||
});
|
|
||||||
28
browser_extension/config/popup.html
Normal file
28
browser_extension/config/popup.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { width: 220px; padding: 10px; font-family: sans-serif; font-size: 14px; }
|
||||||
|
h3 { margin: 0 0 8px; }
|
||||||
|
label { display: block; padding: 4px 0; cursor: pointer; }
|
||||||
|
.error { color: red; font-size: 12px; }
|
||||||
|
.section { margin-top: 12px; padding-top: 10px; border-top: 1px solid #ddd; }
|
||||||
|
.status { font-size: 12px; color: #555; margin-bottom: 6px; }
|
||||||
|
.status.ok { color: #2a7; }
|
||||||
|
.status.fail { color: #c33; }
|
||||||
|
button { width: 100%; padding: 6px; font-size: 13px; cursor: pointer; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h3>Profil</h3>
|
||||||
|
<div id="profiles"></div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Cookie-Sync</h3>
|
||||||
|
<div id="cookieStatus" class="status">noch nicht synchronisiert</div>
|
||||||
|
<button id="syncBtn">Jetzt synchronisieren</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
69
browser_extension/config/popup.js
Normal file
69
browser_extension/config/popup.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
const SERVER_URL = "https://youtube.marha.de/profiles";
|
||||||
|
const container = document.getElementById("profiles");
|
||||||
|
const statusEl = document.getElementById("cookieStatus");
|
||||||
|
const syncBtn = document.getElementById("syncBtn");
|
||||||
|
|
||||||
|
async function loadProfiles() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(SERVER_URL);
|
||||||
|
const profiles = await res.json();
|
||||||
|
const stored = await browser.storage.local.get("profileId");
|
||||||
|
const selectedId = stored.profileId || null;
|
||||||
|
|
||||||
|
for (const profile of profiles) {
|
||||||
|
const label = document.createElement("label");
|
||||||
|
const radio = document.createElement("input");
|
||||||
|
radio.type = "radio";
|
||||||
|
radio.name = "profile";
|
||||||
|
radio.value = profile.id;
|
||||||
|
radio.checked = profile.id === selectedId;
|
||||||
|
radio.addEventListener("change", () => {
|
||||||
|
browser.storage.local.set({ profileId: profile.id });
|
||||||
|
});
|
||||||
|
label.appendChild(radio);
|
||||||
|
label.appendChild(document.createTextNode(" " + profile.name));
|
||||||
|
container.appendChild(label);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
container.innerHTML = '<span class="error">Server nicht erreichbar</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAgo(iso) {
|
||||||
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return "gerade eben";
|
||||||
|
if (mins < 60) return `vor ${mins} min`;
|
||||||
|
const hrs = Math.floor(mins / 60);
|
||||||
|
if (hrs < 24) return `vor ${hrs} h`;
|
||||||
|
const days = Math.floor(hrs / 24);
|
||||||
|
return `vor ${days} Tagen`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshStatus() {
|
||||||
|
const { lastCookieSync } = await browser.storage.local.get("lastCookieSync");
|
||||||
|
if (!lastCookieSync) {
|
||||||
|
statusEl.className = "status";
|
||||||
|
statusEl.textContent = "noch nicht synchronisiert";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lastCookieSync.ok) {
|
||||||
|
statusEl.className = "status ok";
|
||||||
|
statusEl.textContent = `OK (${lastCookieSync.count} Cookies, ${formatAgo(lastCookieSync.when)})`;
|
||||||
|
} else {
|
||||||
|
statusEl.className = "status fail";
|
||||||
|
statusEl.textContent = `Fehler: ${lastCookieSync.error}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncBtn.addEventListener("click", async () => {
|
||||||
|
syncBtn.disabled = true;
|
||||||
|
syncBtn.textContent = "Syncing...";
|
||||||
|
await browser.runtime.sendMessage({ type: "sync-cookies" });
|
||||||
|
await refreshStatus();
|
||||||
|
syncBtn.disabled = false;
|
||||||
|
syncBtn.textContent = "Jetzt synchronisieren";
|
||||||
|
});
|
||||||
|
|
||||||
|
loadProfiles();
|
||||||
|
refreshStatus();
|
||||||
@@ -1,24 +1,34 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 2,
|
"manifest_version": 2,
|
||||||
"name": "YouTube Video Erfasser",
|
"name": "YouTube Video Erfasser",
|
||||||
"version": "1.0",
|
"version": "1.1.1",
|
||||||
"description": "Erfasst YouTube-Videos und sendet sie an den Server",
|
"description": "Erfasst YouTube-Videos und sendet sie an den Server",
|
||||||
|
"browser_specific_settings": {
|
||||||
|
"gecko": {
|
||||||
|
"id": "youtubeapp@marha.de",
|
||||||
|
"data_collection_permissions": {
|
||||||
|
"required": ["none"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"*://www.youtube.com/*",
|
"*://*.youtube.com/*",
|
||||||
"http://localhost:8000/*",
|
"https://youtube.marha.de/*",
|
||||||
"storage"
|
"storage",
|
||||||
|
"cookies",
|
||||||
|
"alarms"
|
||||||
],
|
],
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
"matches": ["*://www.youtube.com/*"],
|
"matches": ["*://www.youtube.com/*"],
|
||||||
"js": ["content.js"]
|
"js": ["tracking/content.js"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"background": {
|
"background": {
|
||||||
"scripts": ["background.js"]
|
"scripts": ["api/syncCookies.js", "api/background.js"]
|
||||||
},
|
},
|
||||||
"browser_action": {
|
"browser_action": {
|
||||||
"default_popup": "popup.html",
|
"default_popup": "config/popup.html",
|
||||||
"default_title": "Profil auswählen"
|
"default_title": "Profil auswählen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<style>
|
|
||||||
body { width: 200px; padding: 10px; font-family: sans-serif; font-size: 14px; }
|
|
||||||
h3 { margin: 0 0 8px; }
|
|
||||||
label { display: block; padding: 4px 0; cursor: pointer; }
|
|
||||||
.error { color: red; font-size: 12px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h3>Profil</h3>
|
|
||||||
<div id="profiles"></div>
|
|
||||||
<script src="popup.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
const SERVER_URL = "http://localhost:8000/profiles";
|
|
||||||
const container = document.getElementById("profiles");
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
const res = await fetch(SERVER_URL);
|
|
||||||
const profiles = await res.json();
|
|
||||||
const stored = await browser.storage.local.get("profileId");
|
|
||||||
const selectedId = stored.profileId || null;
|
|
||||||
|
|
||||||
for (const profile of profiles) {
|
|
||||||
const label = document.createElement("label");
|
|
||||||
const radio = document.createElement("input");
|
|
||||||
radio.type = "radio";
|
|
||||||
radio.name = "profile";
|
|
||||||
radio.value = profile.id;
|
|
||||||
radio.checked = profile.id === selectedId;
|
|
||||||
radio.addEventListener("change", () => {
|
|
||||||
browser.storage.local.set({ profileId: profile.id });
|
|
||||||
});
|
|
||||||
label.appendChild(radio);
|
|
||||||
label.appendChild(document.createTextNode(" " + profile.name));
|
|
||||||
container.appendChild(label);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
container.innerHTML = '<span class="error">Server nicht erreichbar</span>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
load();
|
|
||||||
@@ -18,51 +18,28 @@ function extractVideoFromCard(element) {
|
|||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
youtuber,
|
youtuber,
|
||||||
thumbnail_url: thumbnail || `https://img.youtube.com/vi/${match[1]}/hqdefault.jpg`,
|
thumbnailUrl: thumbnail || `https://img.youtube.com/vi/${match[1]}/hqdefault.jpg`,
|
||||||
youtube_url: `https://www.youtube.com/watch?v=${match[1]}`,
|
youtubeUrl: `https://www.youtube.com/watch?v=${match[1]}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectVideos(elements) {
|
async function sendVideo(video) {
|
||||||
const videos = [];
|
const stored = await browser.storage.local.get("profileId");
|
||||||
for (const el of elements) {
|
const profileId = stored.profileId || 1;
|
||||||
const video = extractVideoFromCard(el);
|
console.log(`[YT-Erfasser] Video senden (Profil: ${profileId})`, video.title);
|
||||||
if (!video) continue;
|
browser.runtime.sendMessage({ profileId, video });
|
||||||
if (sentUrls.has(video.youtube_url)) continue;
|
|
||||||
sentUrls.add(video.youtube_url);
|
|
||||||
videos.push(video);
|
|
||||||
}
|
|
||||||
return videos;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Debounced Batch-Versand ---
|
|
||||||
|
|
||||||
let pendingVideos = [];
|
|
||||||
let sendTimer = null;
|
|
||||||
|
|
||||||
async function queueVideos(videos) {
|
|
||||||
pendingVideos.push(...videos);
|
|
||||||
if (!sendTimer) {
|
|
||||||
sendTimer = setTimeout(async () => {
|
|
||||||
if (pendingVideos.length > 0) {
|
|
||||||
const stored = await browser.storage.local.get("profileId");
|
|
||||||
const profileId = stored.profileId || null;
|
|
||||||
const batch = pendingVideos.map((v) => ({ ...v, profile_id: profileId }));
|
|
||||||
console.log(`[YT-Erfasser] ${batch.length} Videos senden (Profil: ${profileId})`);
|
|
||||||
browser.runtime.sendMessage(batch);
|
|
||||||
}
|
|
||||||
pendingVideos = [];
|
|
||||||
sendTimer = null;
|
|
||||||
}, 250);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- IntersectionObserver: nur sichtbare Cards erfassen ---
|
// --- IntersectionObserver: nur sichtbare Cards erfassen ---
|
||||||
|
|
||||||
const visibilityObserver = new IntersectionObserver((entries) => {
|
const visibilityObserver = new IntersectionObserver((entries) => {
|
||||||
const cards = entries.filter((e) => e.isIntersecting).map((e) => e.target);
|
for (const entry of entries) {
|
||||||
if (cards.length > 0) {
|
if (!entry.isIntersecting) continue;
|
||||||
queueVideos(collectVideos(cards));
|
const video = extractVideoFromCard(entry.target);
|
||||||
|
if (!video) continue;
|
||||||
|
if (sentUrls.has(video.youtubeUrl)) continue;
|
||||||
|
sentUrls.add(video.youtubeUrl);
|
||||||
|
sendVideo(video);
|
||||||
}
|
}
|
||||||
}, { threshold: 0.5 });
|
}, { threshold: 0.5 });
|
||||||
|
|
||||||
6
database.md
Normal file
6
database.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Backend
|
||||||
|
Profile(id, name) - Profiledata
|
||||||
|
Video(id, title, youtuber, imgUrl, videoUrl, profile) - Videodata for profiles
|
||||||
|
# App
|
||||||
|
Profile(id, name) - Profiledata, sync with backend
|
||||||
|
Video(id, title, youtuber, imgPath, videoPath, profile) - Videodata for downloaded videos
|
||||||
40
features.md
40
features.md
@@ -1,16 +1,38 @@
|
|||||||
# Aufgaben
|
# Aufgaben
|
||||||
|
|
||||||
## Browser
|
## Browser
|
||||||
|
|
||||||
- Sichtbare Youtube Videos werden erfasst
|
- Sichtbare Youtube Videos werden erfasst
|
||||||
- Videodaten (Titel, Youtuber, Bild, Url) werden nach dem erfassen gruppiert an den Server gesendet
|
- Videodaten (Titel, Youtuber, Bild, Url) werden nach dem erfassen gruppiert an den Server gesendet
|
||||||
- Einstellung: Profil auswählen
|
- Einstellung: Profil auswählen
|
||||||
|
|
||||||
## App
|
## App
|
||||||
- Ansicht: Navigation mit Alle Videos, Heruntergeladen
|
|
||||||
|
- Startansicht:
|
||||||
|
- Navigation unten: Alle Videos, Heruntergeladen
|
||||||
|
- Icons oben rechts: Mülleimer (Löschen), Benutzer (Profile)
|
||||||
- Alle Videos: Videos als Cards auflisten (Untereinander: Bild, Youtuber, Titel)
|
- Alle Videos: Videos als Cards auflisten (Untereinander: Bild, Youtuber, Titel)
|
||||||
- Heruntergeladen: Heruntergeladene Videos als Cards auflisten (Untereinander: Bild, Youtuber, Titel)
|
- Klick auf ein Video zeigt die Videoübersicht
|
||||||
- Klick auf Card zeigt die Videoübersicht (Starten, Download, Zurück)
|
- Neue Videoeinträge in der DB aktualisieren direkt die Videoliste
|
||||||
- Klick auf Startet startet den Stream über den Server mit den Standard Video-Controls und einem Zurück-Button
|
- Funktioniert nur Online und wenn Server verfügbar
|
||||||
- Klick auf Download lädt das Video herunter und wird lokal auf dem Client gespeichert
|
- Heruntergeladen: Heruntergeladene (lokale) Videos als Cards auflisten (Untereinander: Bild, Youtuber, Titel)
|
||||||
- Klick auf Icon zeigt verfügbare Profile
|
- Videodaten und Videodatei werden lokal gespeichert (funktionieren Offline)
|
||||||
- Das ausgewählte Profil wird persistiert und bestimmt welche Videos angezeigt werden
|
- Mülleimer Icon: Alle Videodaten von nicht heruntergeladenen Videos zum aktuellen Profil löschen
|
||||||
- Klick auf Icon löscht alle nicht heruntergeladenen Videos vom aktuellen Profil
|
- Benutzer Icon: Verfügbare Profile anzeigen
|
||||||
- Bei neuen Videoeinträgen in der DB werden die Videos für das zugehörige Profil in der App aktualisiert
|
- Klick auf ein Profile setzt dieses als das aktuelle Profil
|
||||||
|
- Es werden nur Videos zu dem Profil angezeigt
|
||||||
|
- Videos ohne Profilzuweisung werden automatisch dem Standardprofil zugeordnet
|
||||||
|
- Videoübersicht:
|
||||||
|
- Oben links: Zurück-Button
|
||||||
|
- Unter Zurück-Button: Thumbnail
|
||||||
|
- Unten: Abspielen und Download Buttons
|
||||||
|
- Abspielen:
|
||||||
|
- "Zurück"-Button oben links
|
||||||
|
- Standard Videos Controls
|
||||||
|
- Startet einen Stream über den Server
|
||||||
|
- Download:
|
||||||
|
- Video auf dem Server herunterladen, Video lokal speichern, Video auf dem Server löschen
|
||||||
|
- Ladeanimation währenddessen
|
||||||
|
- Beim Abspielen wird das heruntergeladene Video priorisiert
|
||||||
|
- Fehlerbehandlung
|
||||||
|
- Server nicht erreichbar: Fehlermeldung "Server nicht erreichbar" anzeigen unter Alle Videos
|
||||||
|
|||||||
32
modules.md
Normal file
32
modules.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Browser Extension
|
||||||
|
## Tracking Module
|
||||||
|
- content.js - Scan visable youtube videos
|
||||||
|
## Api Module
|
||||||
|
- background.js - Send scanned videos to server
|
||||||
|
## Config Module
|
||||||
|
- popup.js - Select Profile
|
||||||
|
- popup.html - Template for popup
|
||||||
|
## Base Module
|
||||||
|
- manifest.json - Register extension
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
## Database Module
|
||||||
|
- database.py - Setup database
|
||||||
|
## Model Module
|
||||||
|
- profile.py - add profile columns and model methods
|
||||||
|
- video.py - add video columns and model methods
|
||||||
|
- profile_video.py - table for profile-video relation
|
||||||
|
## Api Module
|
||||||
|
- video_controller.py - Video routes
|
||||||
|
- profile_controller.py - Profile routes
|
||||||
|
- schemas.py - Validate API
|
||||||
|
## Download Module
|
||||||
|
- download_service.py - Handle downloads
|
||||||
|
## Stream Module
|
||||||
|
- stream_service.py - Handle streaming
|
||||||
|
## Notify Module
|
||||||
|
- notify_clients.py - Websocket, update video list
|
||||||
|
## Base Module
|
||||||
|
- app.py - App start
|
||||||
|
|
||||||
|
# App
|
||||||
28
systems.md
28
systems.md
@@ -1,8 +1,26 @@
|
|||||||
# Browser Extension
|
# Browser Extension
|
||||||
- Javascript: Daten angezeigter Videos an Server senden
|
- Javascript: basis
|
||||||
|
- HTML: Profile Template
|
||||||
# Server
|
# Server
|
||||||
- FastAPI: Videodaten empfangen
|
- python: basis
|
||||||
- yt-dlp + ffmpeg: Video herunterladen, Videos streamen
|
- FastAPI: Routing, Websocket
|
||||||
- SQLite: Daten persistieren
|
- SQLAlchemy: Datenbank ORM
|
||||||
|
- Pydantic: Dto
|
||||||
|
- yt-dlp: download
|
||||||
|
- ffmpeg: muxing, streaming
|
||||||
|
- yt-dlp + Deno: url extrahieren
|
||||||
|
- SQLite: Datenbank
|
||||||
|
- Docker: deployment
|
||||||
|
- uvicorn: webserver
|
||||||
# App
|
# App
|
||||||
- Kotlin: Videos auflisten, Download triggern, Videos abspielen
|
- kotlin: basis
|
||||||
|
- Gradle: Build
|
||||||
|
- Jetpack Compose: UI
|
||||||
|
- Material3: UI-Komponenten
|
||||||
|
- Retrofit: REST-Calls
|
||||||
|
- Gson: JSON-Serialisierung
|
||||||
|
- AndroidX ViewModel: State-Management
|
||||||
|
- ExoPlayer: wiedergabe
|
||||||
|
- Coil: Thumbnail-Loading
|
||||||
|
- OkHttp: WebSocket-Client
|
||||||
|
- SharedPreferences: Profil-Auswahl persistieren
|
||||||
|
|||||||
Reference in New Issue
Block a user