update
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
@@ -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")
|
||||
|
||||
Binary file not shown.
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
16
browser_extension/popup.html
Normal file
16
browser_extension/popup.html
Normal 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>
|
||||
30
browser_extension/popup.js
Normal file
30
browser_extension/popup.js
Normal 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();
|
||||
Reference in New Issue
Block a user