This commit is contained in:
Marek Lenczewski
2026-04-15 21:53:37 +02:00
parent 2156bb3226
commit 1cef5fa1e8
18 changed files with 154 additions and 71 deletions

View File

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

View File

@@ -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 {
@@ -24,50 +24,54 @@ android {
keyPassword = "youtubeapp" keyPassword = "youtubeapp"
enableV1Signing = true enableV1Signing = true
enableV2Signing = true enableV2Signing = true
enableV3Signing = true
} }
} }
buildTypes { buildTypes {
getByName("release") { debug {
buildConfigField("String", "BASE_URL", "\"http://192.168.178.34:8000/\"")
}
release {
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
buildConfigField("String", "BASE_URL", "\"https://youtube.marha.de/\"")
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
} }
} }
buildFeatures {
compose = true
}
kotlinOptions {
jvmTarget = "17"
}
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
View File

@@ -0,0 +1 @@
# Add project-specific ProGuard rules here.

View File

@@ -10,6 +10,7 @@
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">

View File

@@ -1,18 +1,27 @@
package com.youtubeapp.data package com.youtubeapp.data
import android.os.Build 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 {
val BASE_URL: String = if (Build.FINGERPRINT.contains("generic") || Build.FINGERPRINT.contains("sdk")) val BASE_URL: String =
if (BuildConfig.DEBUG && (Build.FINGERPRINT.contains("generic") || Build.FINGERPRINT.contains("sdk")))
"http://10.0.2.2:8000/" "http://10.0.2.2:8000/"
else else
"http://192.168.178.34:8000/" 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)

View File

@@ -9,7 +9,7 @@ interface VideoApi {
suspend fun getAllVideos(@Path("profileId") profileId: Int): List<Video> suspend fun getAllVideos(@Path("profileId") profileId: Int): 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("profiles/{profileId}/videos/cleanup") @POST("profiles/{profileId}/videos/cleanup")
suspend fun cleanupVideos( suspend fun cleanupVideos(

View File

@@ -5,8 +5,8 @@ class VideoRepository(private val api: VideoApi = ApiClient.api) {
suspend fun getAllVideos(profileId: Int): Result<List<Video>> = suspend fun getAllVideos(profileId: Int): Result<List<Video>> =
runCatching { api.getAllVideos(profileId) } runCatching { api.getAllVideos(profileId) }
suspend fun triggerDownload(videoId: Int): Result<String> = runCatching { suspend fun triggerDownload(videoId: Int, maxHeight: Int = 1080): Result<String> = runCatching {
val response = api.triggerDownload(videoId) val response = api.triggerDownload(videoId, maxHeight)
response["status"] ?: "unknown" response["status"] ?: "unknown"
} }
@@ -23,5 +23,6 @@ class VideoRepository(private val api: VideoApi = ApiClient.api) {
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"
} }

View File

@@ -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() {
@@ -122,10 +124,11 @@ class VideoViewModel : ViewModel() {
} }
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,
@@ -200,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)
} }
} }
} }

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>

View 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" }

1
backend/.gitignore vendored
View File

@@ -1,3 +1,4 @@
__pycache__/ __pycache__/
*.pyc *.pyc
videos/ videos/
cookies.txt

View File

@@ -12,25 +12,25 @@ router = APIRouter(prefix="/videos")
@router.post("/{videoId}/download") @router.post("/{videoId}/download")
def download(videoId: int, db: DbSession): def download(videoId: int, db: DbSession, maxHeight: int = 1080):
video = Video.getById(db, videoId) video = Video.getById(db, videoId)
if not video: if not video:
raise HTTPException(404, "Video nicht gefunden") raise HTTPException(404, "Video nicht gefunden")
if video.filePath: if video.filePath:
return {"status": "already_downloaded"} return {"status": "already_downloaded"}
downloadAsync(videoId, video.youtubeUrl) downloadAsync(videoId, video.youtubeUrl, maxHeight)
return {"status": "download_started"} return {"status": "download_started"}
@router.get("/{videoId}/stream") @router.get("/{videoId}/stream")
def stream(videoId: int, db: DbSession): def stream(videoId: int, db: DbSession, maxHeight: int = 1080):
video = Video.getById(db, videoId) video = Video.getById(db, videoId)
if not video: if not video:
raise HTTPException(404, "Video nicht gefunden") raise HTTPException(404, "Video nicht gefunden")
if not video.filePath: if not video.filePath:
return StreamingResponse(streamAndSave(videoId, video.youtubeUrl), media_type="video/mp4") return StreamingResponse(streamAndSave(videoId, video.youtubeUrl, maxHeight), media_type="video/mp4")
path = Path(video.filePath) path = Path(video.filePath)
if not path.exists(): if not path.exists():

View File

@@ -9,17 +9,20 @@ VIDEOS_DIR = "/videos"
MIN_VALID_SIZE = 1024 * 100 # 100 KB MIN_VALID_SIZE = 1024 * 100 # 100 KB
def downloadAsync(videoId: int, youtubeUrl: str): def downloadAsync(videoId: int, youtubeUrl: str, maxHeight: int = 1080):
threading.Thread(target=downloadVideo, args=(videoId, youtubeUrl)).start() threading.Thread(target=downloadVideo, args=(videoId, youtubeUrl, maxHeight)).start()
def downloadVideo(videoId: int, youtubeUrl: str): def downloadVideo(videoId: int, youtubeUrl: str, maxHeight: int = 1080):
outputPath = f"{VIDEOS_DIR}/{videoId}.mp4" outputPath = f"{VIDEOS_DIR}/{videoId}.mp4"
formatFilter = f"bestvideo[ext=mp4][vcodec^=avc][height<={maxHeight}]+bestaudio[ext=m4a]/best[ext=mp4]"
subprocess.run( 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,
"-o", outputPath, "-o", outputPath,
"--merge-output-format", "mp4", "--merge-output-format", "mp4",
"--force-overwrites", "--force-overwrites",

View File

@@ -7,10 +7,10 @@ VIDEOS_DIR = "/videos"
CHUNK_SIZE = 64 * 1024 CHUNK_SIZE = 64 * 1024
def streamAndSave(videoId: int, youtubeUrl: str): def streamAndSave(videoId: int, youtubeUrl: str, maxHeight: int = 1080):
from model.video import Video # Lazy-Import gegen Zirkular from model.video import Video # Lazy-Import gegen Zirkular
outputPath = f"{VIDEOS_DIR}/{videoId}.mp4" outputPath = f"{VIDEOS_DIR}/{videoId}.mp4"
yield from streamVideoLive(videoId, youtubeUrl) yield from streamVideoLive(videoId, youtubeUrl, maxHeight)
if Path(outputPath).exists(): if Path(outputPath).exists():
db = SessionLocal() db = SessionLocal()
try: try:
@@ -19,11 +19,14 @@ def streamAndSave(videoId: int, youtubeUrl: str):
db.close() db.close()
def _getStreamUrls(youtubeUrl: str): 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",
youtubeUrl, youtubeUrl,
], ],
@@ -40,10 +43,10 @@ def _getStreamUrls(youtubeUrl: str):
return None, None return None, None
def streamVideoLive(videoId: int, youtubeUrl: str): def streamVideoLive(videoId: int, youtubeUrl: str, maxHeight: int = 1080):
outputPath = f"{VIDEOS_DIR}/{videoId}.mp4" outputPath = f"{VIDEOS_DIR}/{videoId}.mp4"
videoUrl, audioUrl = _getStreamUrls(youtubeUrl) videoUrl, audioUrl = _getStreamUrls(youtubeUrl, maxHeight)
if not videoUrl: if not videoUrl:
return return

View File

@@ -1,4 +1,4 @@
const SERVER_URL = "http://localhost:8000/profiles"; const SERVER_URL = "http://marha.local:8000/profiles";
const container = document.getElementById("profiles"); const container = document.getElementById("profiles");
async function load() { async function load() {

View File

@@ -5,7 +5,7 @@
"description": "Erfasst YouTube-Videos und sendet sie an den Server", "description": "Erfasst YouTube-Videos und sendet sie an den Server",
"permissions": [ "permissions": [
"*://www.youtube.com/*", "*://www.youtube.com/*",
"http://localhost:8000/*", "http://marha.local:8000/*",
"storage" "storage"
], ],
"content_scripts": [ "content_scripts": [

View File

@@ -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 + Deno: 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