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 thumbnail_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.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface VideoApi {
@GET("videos")
suspend fun getAllVideos(): List<Video>
suspend fun getAllVideos(@Query("profile_id") profileId: Int? = null): List<Video>
@GET("videos/downloaded")
suspend fun getDownloadedVideos(): List<Video>
suspend fun getDownloadedVideos(@Query("profile_id") profileId: Int? = null): List<Video>
@POST("videos/{id}/download")
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) {
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 {
val response = api.triggerDownload(videoId)
response["status"] ?: "unknown"
}
suspend fun getProfiles(): Result<List<Profile>> = runCatching { api.getProfiles() }
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.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Person
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.IconButton
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.platform.LocalContext
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.viewmodel.VideoViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppNavigation() {
val navController = rememberNavController()
val viewModel: VideoViewModel = viewModel()
val context = LocalContext.current
viewModel.init(context)
val state by viewModel.state.collectAsState()
val navBackStackEntry by navController.currentBackStackEntryAsState()
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(
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 = {
if (showBottomBar) {
if (showBars) {
NavigationBar {
NavigationBarItem(
selected = currentRoute == Route.AllVideos.route,

View File

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

View File

@@ -1,8 +1,9 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from database import create_tables
from routes.videos import router as videos_router
from database import SessionLocal, create_tables
from models import Profile
from routes.videos import profiles_router, router as videos_router
app = FastAPI()
@@ -14,11 +15,17 @@ app.add_middleware(
)
app.include_router(videos_router)
app.include_router(profiles_router)
@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("/")

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
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"
@@ -12,3 +27,4 @@ class Video(Base):
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")

View File

@@ -1,12 +1,13 @@
import threading
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 sqlalchemy.orm import Session
from database import get_db
from schemas import VideoCreate, VideoResponse
from schemas import ProfileResponse, VideoCreate, VideoResponse
from services import video_service
from services.download_service import download_video
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])
def get_all_videos(db: Session = Depends(get_db)):
videos = video_service.get_all_videos(db)
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(db: Session = Depends(get_db)):
videos = video_service.get_downloaded_videos(db)
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]
@@ -88,3 +89,11 @@ def download_file(video_id: int, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail="Videodatei nicht gefunden")
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
thumbnail_url: str
youtube_url: str
profile_id: int | None = None
class VideoResponse(BaseModel):
@@ -15,6 +16,7 @@ class VideoResponse(BaseModel):
thumbnail_url: str
youtube_url: str
is_downloaded: bool
profile_ids: list[int]
class Config:
from_attributes = True
@@ -28,4 +30,13 @@ class VideoResponse(BaseModel):
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 ProfileResponse(BaseModel):
id: int
name: str
class Config:
from_attributes = True

View File

@@ -1,23 +1,35 @@
from sqlalchemy.orm import Session
from models import Video
from models import Profile, Video, video_profiles
from schemas import VideoCreate
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.commit()
db.refresh(video)
return video
def get_all_videos(db: Session) -> list[Video]:
return db.query(Video).order_by(Video.id.desc()).all()
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) -> list[Video]:
return db.query(Video).filter(Video.file_path.isnot(None)).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:
@@ -34,3 +46,7 @@ def update_file_path(db: Session, video_id: int, path: str):
if video:
video.file_path = path
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 sendTimer = null;
function queueVideos(videos) {
async function queueVideos(videos) {
pendingVideos.push(...videos);
if (!sendTimer) {
sendTimer = setTimeout(() => {
sendTimer = setTimeout(async () => {
if (pendingVideos.length > 0) {
console.log(`[YT-Erfasser] ${pendingVideos.length} Videos senden`);
browser.runtime.sendMessage(pendingVideos);
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;

View File

@@ -5,7 +5,8 @@
"description": "Erfasst YouTube-Videos und sendet sie an den Server",
"permissions": [
"*://www.youtube.com/*",
"http://localhost:8000/*"
"http://localhost:8000/*",
"storage"
],
"content_scripts": [
{
@@ -15,5 +16,9 @@
],
"background": {
"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();