This commit is contained in:
Marek
2026-04-05 20:43:35 +02:00
parent f8de245e45
commit f269271c6e
16 changed files with 237 additions and 32 deletions

View File

@@ -6,5 +6,11 @@ data class Video(
val youtuber: String, val youtuber: String,
val thumbnail_url: String, val thumbnail_url: String,
val youtube_url: String, val youtube_url: String,
val is_downloaded: Boolean val is_downloaded: Boolean,
val profile_ids: List<Int> = emptyList()
)
data class Profile(
val id: Int,
val name: String
) )

View File

@@ -3,14 +3,18 @@ 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("videos")
suspend fun getAllVideos(): List<Video> suspend fun getAllVideos(@Query("profile_id") profileId: Int? = null): List<Video>
@GET("videos/downloaded") @GET("videos/downloaded")
suspend fun getDownloadedVideos(): List<Video> 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): Map<String, String>
@GET("profiles")
suspend fun getProfiles(): List<Profile>
} }

View File

@@ -2,14 +2,18 @@ package com.youtubeapp.data
class VideoRepository(private val api: VideoApi = ApiClient.api) { class VideoRepository(private val api: VideoApi = ApiClient.api) {
suspend fun getAllVideos(): Result<List<Video>> = runCatching { api.getAllVideos() } suspend fun getAllVideos(profileId: Int? = null): Result<List<Video>> =
runCatching { api.getAllVideos(profileId) }
suspend fun getDownloadedVideos(): Result<List<Video>> = runCatching { api.getDownloadedVideos() } suspend fun getDownloadedVideos(profileId: Int? = null): Result<List<Video>> =
runCatching { api.getDownloadedVideos(profileId) }
suspend fun triggerDownload(videoId: Int): Result<String> = runCatching { suspend fun triggerDownload(videoId: Int): Result<String> = runCatching {
val response = api.triggerDownload(videoId) val response = api.triggerDownload(videoId)
response["status"] ?: "unknown" response["status"] ?: "unknown"
} }
suspend fun getProfiles(): Result<List<Profile>> = runCatching { api.getProfiles() }
fun getStreamUrl(videoId: Int): String = "${ApiClient.BASE_URL}videos/$videoId/stream" fun getStreamUrl(videoId: Int): String = "${ApiClient.BASE_URL}videos/$videoId/stream"
} }

View File

@@ -3,14 +3,25 @@ package com.youtubeapp.ui.navigation
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.material.icons.filled.VideoLibrary
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -27,20 +38,60 @@ import com.youtubeapp.ui.screens.VideoDetailScreen
import com.youtubeapp.ui.screens.VideoPlayerScreen import com.youtubeapp.ui.screens.VideoPlayerScreen
import com.youtubeapp.ui.viewmodel.VideoViewModel import com.youtubeapp.ui.viewmodel.VideoViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AppNavigation() { fun AppNavigation() {
val navController = rememberNavController() val navController = rememberNavController()
val viewModel: VideoViewModel = viewModel() val viewModel: VideoViewModel = viewModel()
val context = LocalContext.current val context = LocalContext.current
viewModel.init(context) viewModel.init(context)
val state by viewModel.state.collectAsState()
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route val currentRoute = navBackStackEntry?.destination?.route
val showBottomBar = currentRoute in listOf(Route.AllVideos.route, Route.Downloaded.route) val showBars = currentRoute in listOf(Route.AllVideos.route, Route.Downloaded.route)
var showProfileMenu by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.loadProfiles()
}
val selectedProfileName = state.profiles.find { it.id == state.selectedProfileId }?.name ?: "Profil"
Scaffold( Scaffold(
topBar = {
if (showBars) {
TopAppBar(
title = { Text("YouTube App") },
actions = {
IconButton(onClick = { showProfileMenu = true }) {
Icon(Icons.Default.Person, contentDescription = "Profil")
}
DropdownMenu(
expanded = showProfileMenu,
onDismissRequest = { showProfileMenu = false }
) {
for (profile in state.profiles) {
DropdownMenuItem(
text = {
Text(
if (profile.id == state.selectedProfileId) "${profile.name}"
else profile.name
)
},
onClick = {
viewModel.selectProfile(profile.id)
showProfileMenu = false
}
)
}
}
}
)
}
},
bottomBar = { bottomBar = {
if (showBottomBar) { if (showBars) {
NavigationBar { NavigationBar {
NavigationBarItem( NavigationBarItem(
selected = currentRoute == Route.AllVideos.route, selected = currentRoute == Route.AllVideos.route,

View File

@@ -4,6 +4,7 @@ import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.youtubeapp.data.LocalStorageService import com.youtubeapp.data.LocalStorageService
import com.youtubeapp.data.Profile
import com.youtubeapp.data.Video import com.youtubeapp.data.Video
import com.youtubeapp.data.VideoRepository import com.youtubeapp.data.VideoRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -15,6 +16,8 @@ import kotlinx.coroutines.launch
data class VideoUiState( data class VideoUiState(
val allVideos: List<Video> = emptyList(), val allVideos: List<Video> = emptyList(),
val downloadedVideos: List<Video> = emptyList(), val downloadedVideos: List<Video> = emptyList(),
val profiles: List<Profile> = emptyList(),
val selectedProfileId: Int? = null,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isDownloading: Boolean = false, val isDownloading: Boolean = false,
val error: String? = null, val error: String? = null,
@@ -24,6 +27,7 @@ data class VideoUiState(
class VideoViewModel : ViewModel() { class VideoViewModel : ViewModel() {
private val repository = VideoRepository() private val repository = VideoRepository()
private var localStorage: LocalStorageService? = null private var localStorage: LocalStorageService? = null
private var prefs: android.content.SharedPreferences? = null
private val _state = MutableStateFlow(VideoUiState()) private val _state = MutableStateFlow(VideoUiState())
val state: StateFlow<VideoUiState> = _state val state: StateFlow<VideoUiState> = _state
@@ -31,12 +35,38 @@ class VideoViewModel : ViewModel() {
if (localStorage == null) { if (localStorage == null) {
localStorage = LocalStorageService(context.applicationContext) localStorage = LocalStorageService(context.applicationContext)
} }
if (prefs == null) {
prefs = context.applicationContext.getSharedPreferences("youtubeapp", Context.MODE_PRIVATE)
val savedId = prefs?.getInt("profile_id", -1) ?: -1
if (savedId > 0) {
_state.value = _state.value.copy(selectedProfileId = savedId)
}
}
}
fun loadProfiles() {
viewModelScope.launch {
repository.getProfiles()
.onSuccess { profiles ->
_state.value = _state.value.copy(profiles = profiles)
if (_state.value.selectedProfileId == null && profiles.isNotEmpty()) {
selectProfile(profiles.first().id)
}
}
}
}
fun selectProfile(profileId: Int) {
_state.value = _state.value.copy(selectedProfileId = profileId)
prefs?.edit()?.putInt("profile_id", profileId)?.apply()
loadAllVideos()
loadDownloadedVideos()
} }
fun loadAllVideos() { fun loadAllVideos() {
viewModelScope.launch { viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null) _state.value = _state.value.copy(isLoading = true, error = null)
repository.getAllVideos() repository.getAllVideos(profileId = _state.value.selectedProfileId)
.onSuccess { videos -> .onSuccess { videos ->
_state.value = _state.value.copy(allVideos = videos, isLoading = false) _state.value = _state.value.copy(allVideos = videos, isLoading = false)
} }
@@ -49,7 +79,7 @@ class VideoViewModel : ViewModel() {
fun loadDownloadedVideos() { fun loadDownloadedVideos() {
viewModelScope.launch { viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null) _state.value = _state.value.copy(isLoading = true, error = null)
repository.getDownloadedVideos() repository.getDownloadedVideos(profileId = _state.value.selectedProfileId)
.onSuccess { videos -> .onSuccess { videos ->
_state.value = _state.value.copy(downloadedVideos = videos, isLoading = false) _state.value = _state.value.copy(downloadedVideos = videos, isLoading = false)
} }
@@ -63,7 +93,6 @@ class VideoViewModel : ViewModel() {
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 {
// 1. Server-Download triggern
val result = repository.triggerDownload(videoId) val result = repository.triggerDownload(videoId)
if (result.isFailure) { if (result.isFailure) {
_state.value = _state.value.copy( _state.value = _state.value.copy(
@@ -73,7 +102,6 @@ class VideoViewModel : ViewModel() {
return@launch return@launch
} }
// 2. Warten bis Server-Download fertig
val status = result.getOrNull() val status = result.getOrNull()
if (status == "download_started") { if (status == "download_started") {
while (true) { while (true) {
@@ -84,7 +112,6 @@ class VideoViewModel : ViewModel() {
} }
} }
// 3. Lokal speichern
localStorage?.downloadAndSave(videoId) localStorage?.downloadAndSave(videoId)
_state.value = _state.value.copy( _state.value = _state.value.copy(
isDownloading = false, isDownloading = false,

View File

@@ -1,8 +1,9 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from database import create_tables from database import SessionLocal, create_tables
from routes.videos import router as videos_router from models import Profile
from routes.videos import profiles_router, router as videos_router
app = FastAPI() app = FastAPI()
@@ -14,11 +15,17 @@ app.add_middleware(
) )
app.include_router(videos_router) app.include_router(videos_router)
app.include_router(profiles_router)
@app.on_event("startup") @app.on_event("startup")
def startup(): def startup():
create_tables() create_tables()
db = SessionLocal()
if db.query(Profile).count() == 0:
db.add(Profile(name="Standard"))
db.commit()
db.close()
@app.get("/") @app.get("/")

View File

@@ -1,7 +1,22 @@
from sqlalchemy import Column, Integer, String from sqlalchemy import Column, ForeignKey, Integer, String, Table
from sqlalchemy.orm import relationship
from database import Base 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): class Video(Base):
__tablename__ = "videos" __tablename__ = "videos"
@@ -12,3 +27,4 @@ class Video(Base):
thumbnail_url = Column(String, nullable=False) thumbnail_url = Column(String, nullable=False)
youtube_url = Column(String, nullable=False) youtube_url = Column(String, nullable=False)
file_path = Column(String, nullable=True) file_path = Column(String, nullable=True)
profiles = relationship("Profile", secondary=video_profiles, backref="videos")

View File

@@ -1,12 +1,13 @@
import threading import threading
from pathlib import Path from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from schemas import VideoCreate, VideoResponse from schemas import ProfileResponse, VideoCreate, VideoResponse
from services import video_service from services import video_service
from services.download_service import download_video from services.download_service import download_video
from services.stream_service import stream_video_live from services.stream_service import stream_video_live
@@ -27,14 +28,14 @@ def create_videos(videos_data: list[VideoCreate], db: Session = Depends(get_db))
@router.get("", response_model=list[VideoResponse]) @router.get("", response_model=list[VideoResponse])
def get_all_videos(db: Session = Depends(get_db)): def get_all_videos(profile_id: Optional[int] = Query(None), db: Session = Depends(get_db)):
videos = video_service.get_all_videos(db) videos = video_service.get_all_videos(db, profile_id=profile_id)
return [VideoResponse.from_model(v) for v in videos] return [VideoResponse.from_model(v) for v in videos]
@router.get("/downloaded", response_model=list[VideoResponse]) @router.get("/downloaded", response_model=list[VideoResponse])
def get_downloaded_videos(db: Session = Depends(get_db)): def get_downloaded_videos(profile_id: Optional[int] = Query(None), db: Session = Depends(get_db)):
videos = video_service.get_downloaded_videos(db) videos = video_service.get_downloaded_videos(db, profile_id=profile_id)
return [VideoResponse.from_model(v) for v in videos] return [VideoResponse.from_model(v) for v in videos]
@@ -88,3 +89,11 @@ def download_file(video_id: int, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail="Videodatei nicht gefunden") raise HTTPException(status_code=404, detail="Videodatei nicht gefunden")
return FileResponse(path, media_type="video/mp4", filename=f"{video.title}.mp4") return FileResponse(path, media_type="video/mp4", filename=f"{video.title}.mp4")
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)

View File

@@ -6,6 +6,7 @@ class VideoCreate(BaseModel):
youtuber: str youtuber: str
thumbnail_url: str thumbnail_url: str
youtube_url: str youtube_url: str
profile_id: int | None = None
class VideoResponse(BaseModel): class VideoResponse(BaseModel):
@@ -15,6 +16,7 @@ class VideoResponse(BaseModel):
thumbnail_url: str thumbnail_url: str
youtube_url: str youtube_url: str
is_downloaded: bool is_downloaded: bool
profile_ids: list[int]
class Config: class Config:
from_attributes = True from_attributes = True
@@ -28,4 +30,13 @@ class VideoResponse(BaseModel):
thumbnail_url=video.thumbnail_url, thumbnail_url=video.thumbnail_url,
youtube_url=video.youtube_url, youtube_url=video.youtube_url,
is_downloaded=video.file_path is not None, is_downloaded=video.file_path is not None,
profile_ids=[p.id for p in video.profiles],
) )
class ProfileResponse(BaseModel):
id: int
name: str
class Config:
from_attributes = True

View File

@@ -1,23 +1,35 @@
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from models import Video from models import Profile, Video, video_profiles
from schemas import VideoCreate from schemas import VideoCreate
def create_video(db: Session, video_data: VideoCreate) -> Video: def create_video(db: Session, video_data: VideoCreate) -> Video:
video = Video(**video_data.model_dump()) profile_id = video_data.profile_id
data = video_data.model_dump(exclude={"profile_id"})
video = Video(**data)
if profile_id:
profile = db.query(Profile).filter(Profile.id == profile_id).first()
if profile:
video.profiles.append(profile)
db.add(video) db.add(video)
db.commit() db.commit()
db.refresh(video) db.refresh(video)
return video return video
def get_all_videos(db: Session) -> list[Video]: def get_all_videos(db: Session, profile_id: int | None = None) -> list[Video]:
return db.query(Video).order_by(Video.id.desc()).all() 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) -> list[Video]: def get_downloaded_videos(db: Session, profile_id: int | None = None) -> list[Video]:
return db.query(Video).filter(Video.file_path.isnot(None)).order_by(Video.id.desc()).all() 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: def get_video(db: Session, video_id: int) -> Video | None:
@@ -34,3 +46,7 @@ def update_file_path(db: Session, video_id: int, path: str):
if video: if video:
video.file_path = path video.file_path = path
db.commit() db.commit()
def get_all_profiles(db: Session) -> list[Profile]:
return db.query(Profile).all()

View File

@@ -40,13 +40,16 @@ function collectVideos(elements) {
let pendingVideos = []; let pendingVideos = [];
let sendTimer = null; let sendTimer = null;
function queueVideos(videos) { async function queueVideos(videos) {
pendingVideos.push(...videos); pendingVideos.push(...videos);
if (!sendTimer) { if (!sendTimer) {
sendTimer = setTimeout(() => { sendTimer = setTimeout(async () => {
if (pendingVideos.length > 0) { if (pendingVideos.length > 0) {
console.log(`[YT-Erfasser] ${pendingVideos.length} Videos senden`); const stored = await browser.storage.local.get("profileId");
browser.runtime.sendMessage(pendingVideos); 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 = []; pendingVideos = [];
sendTimer = null; sendTimer = null;

View File

@@ -5,7 +5,8 @@
"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://localhost:8000/*",
"storage"
], ],
"content_scripts": [ "content_scripts": [
{ {
@@ -15,5 +16,9 @@
], ],
"background": { "background": {
"scripts": ["background.js"] "scripts": ["background.js"]
},
"browser_action": {
"default_popup": "popup.html",
"default_title": "Profil auswählen"
} }
} }

View File

@@ -0,0 +1,16 @@
<!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>

View File

@@ -0,0 +1,30 @@
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();