Compare commits
3 Commits
2156bb3226
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f439231e3c | ||
|
|
1ca4371ea0 | ||
|
|
1cef5fa1e8 |
@@ -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 {
|
||||||
@@ -24,50 +24,54 @@ android {
|
|||||||
keyPassword = "youtubeapp"
|
keyPassword = "youtubeapp"
|
||||||
enableV1Signing = true
|
enableV1Signing = true
|
||||||
enableV2Signing = true
|
enableV2Signing = true
|
||||||
|
enableV3Signing = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
getByName("release") {
|
debug {
|
||||||
signingConfig = signingConfigs.getByName("release")
|
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"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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
1
app/frontend/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Add project-specific ProGuard rules here.
|
||||||
@@ -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">
|
||||||
|
|
||||||
|
|||||||
@@ -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 =
|
||||||
"http://10.0.2.2:8000/"
|
if (BuildConfig.DEBUG && (Build.FINGERPRINT.contains("generic") || Build.FINGERPRINT.contains("sdk")))
|
||||||
else
|
"http://10.0.2.2:8000/"
|
||||||
"http://192.168.178.34: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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" }
|
||||||
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
videos/
|
videos/
|
||||||
|
cookies.txt
|
||||||
|
|||||||
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)
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from api.cookies_controller import router as cookiesRouter
|
||||||
from api.profile_controller import router as profilesRouter
|
from api.profile_controller import router as profilesRouter
|
||||||
from api.video_controller import router as videosRouter
|
from api.video_controller import router as videosRouter
|
||||||
from database.database import SessionLocal, createTables
|
from database.database import SessionLocal, createTables
|
||||||
@@ -18,6 +19,7 @@ app.add_middleware(
|
|||||||
|
|
||||||
app.include_router(videosRouter)
|
app.include_router(videosRouter)
|
||||||
app.include_router(profilesRouter)
|
app.include_router(profilesRouter)
|
||||||
|
app.include_router(cookiesRouter)
|
||||||
registerWebsocket(app)
|
registerWebsocket(app)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
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/
|
||||||
@@ -1,9 +1,20 @@
|
|||||||
const SERVER_BASE = "http://marha.local:8000";
|
const SERVER_BASE = "https://youtube.marha.de";
|
||||||
|
|
||||||
browser.runtime.onMessage.addListener(({ profileId, video }) => {
|
browser.runtime.onMessage.addListener((msg) => {
|
||||||
fetch(`${SERVER_BASE}/profiles/${profileId}/videos`, {
|
if (msg?.type === "sync-cookies") {
|
||||||
method: "POST",
|
return syncCookies();
|
||||||
headers: { "Content-Type": "application/json" },
|
}
|
||||||
body: JSON.stringify(video),
|
if (msg?.profileId && msg?.video) {
|
||||||
}).catch(() => {});
|
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) } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,27 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<style>
|
<style>
|
||||||
body { width: 200px; padding: 10px; font-family: sans-serif; font-size: 14px; }
|
body { width: 220px; padding: 10px; font-family: sans-serif; font-size: 14px; }
|
||||||
h3 { margin: 0 0 8px; }
|
h3 { margin: 0 0 8px; }
|
||||||
label { display: block; padding: 4px 0; cursor: pointer; }
|
label { display: block; padding: 4px 0; cursor: pointer; }
|
||||||
.error { color: red; font-size: 12px; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h3>Profil</h3>
|
<h3>Profil</h3>
|
||||||
<div id="profiles"></div>
|
<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>
|
<script src="popup.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
const SERVER_URL = "http://localhost:8000/profiles";
|
const SERVER_URL = "https://youtube.marha.de/profiles";
|
||||||
const container = document.getElementById("profiles");
|
const container = document.getElementById("profiles");
|
||||||
|
const statusEl = document.getElementById("cookieStatus");
|
||||||
|
const syncBtn = document.getElementById("syncBtn");
|
||||||
|
|
||||||
async function load() {
|
async function loadProfiles() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(SERVER_URL);
|
const res = await fetch(SERVER_URL);
|
||||||
const profiles = await res.json();
|
const profiles = await res.json();
|
||||||
@@ -27,4 +29,41 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
load();
|
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,12 +1,22 @@
|
|||||||
{
|
{
|
||||||
"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": [
|
||||||
{
|
{
|
||||||
@@ -15,7 +25,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"background": {
|
"background": {
|
||||||
"scripts": ["api/background.js"]
|
"scripts": ["api/syncCookies.js", "api/background.js"]
|
||||||
},
|
},
|
||||||
"browser_action": {
|
"browser_action": {
|
||||||
"default_popup": "config/popup.html",
|
"default_popup": "config/popup.html",
|
||||||
|
|||||||
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 + 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
|
||||||
|
|||||||
Reference in New Issue
Block a user