This commit is contained in:
Marek Lenczewski
2026-04-07 17:55:30 +02:00
parent 8f15f51bce
commit ca988345e9
19 changed files with 201 additions and 203 deletions

View File

@@ -4,10 +4,10 @@ data class Video(
val id: Int, val id: Int,
val title: String, val title: String,
val youtuber: String, val youtuber: String,
val thumbnail_url: String, val thumbnailUrl: String,
val youtube_url: String, val youtubeUrl: String,
val is_downloaded: Boolean, val isDownloaded: Boolean,
val profile_ids: List<Int> = emptyList() val profileIds: List<Int> = emptyList()
) )
data class Profile( data class Profile(

View File

@@ -7,10 +7,10 @@ import retrofit2.http.Query
interface VideoApi { interface VideoApi {
@GET("videos") @GET("videos")
suspend fun getAllVideos(@Query("profile_id") profileId: Int? = null): List<Video> suspend fun getAllVideos(@Query("profileId") profileId: Int? = null): List<Video>
@GET("videos/downloaded") @GET("videos/downloaded")
suspend fun getDownloadedVideos(@Query("profile_id") profileId: Int? = null): List<Video> suspend fun getDownloadedVideos(@Query("profileId") 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>

View File

@@ -19,7 +19,7 @@ class VideoRepository(private val api: VideoApi = ApiClient.api) {
} }
suspend fun cleanupVideos(profileId: Int, excludeIds: List<Int>): Result<Int> = runCatching { suspend fun cleanupVideos(profileId: Int, excludeIds: List<Int>): Result<Int> = runCatching {
val body = mapOf("profile_id" to profileId, "exclude_ids" to excludeIds) val body = mapOf("profileId" to profileId, "excludeIds" to excludeIds)
val response = api.cleanupVideos(body) val response = api.cleanupVideos(body)
response["deleted"] ?: 0 response["deleted"] ?: 0
} }

View File

@@ -40,7 +40,7 @@ fun VideoCard(video: Video, onClick: () -> Unit) {
) { ) {
Column { Column {
AsyncImage( AsyncImage(
model = video.thumbnail_url, model = video.thumbnailUrl,
contentDescription = video.title, contentDescription = video.title,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier

View File

@@ -83,7 +83,7 @@ fun VideoDetailScreen(
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
AsyncImage( AsyncImage(
model = video.thumbnail_url, model = video.thumbnailUrl,
contentDescription = video.title, contentDescription = video.title,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
@@ -121,7 +121,7 @@ private fun VideoInfo(
Text( Text(
text = when { text = when {
isLocal -> "Lokal gespeichert" isLocal -> "Lokal gespeichert"
video.is_downloaded -> "Auf Server heruntergeladen" video.isDownloaded -> "Auf Server heruntergeladen"
else -> "Noch nicht heruntergeladen" else -> "Noch nicht heruntergeladen"
}, },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,

View File

@@ -140,7 +140,7 @@ class VideoViewModel : ViewModel() {
delay(2000) delay(2000)
val videosResult = repository.getAllVideos() val videosResult = repository.getAllVideos()
val video = videosResult.getOrNull()?.find { it.id == videoId } val video = videosResult.getOrNull()?.find { it.id == videoId }
if (video?.is_downloaded == true) break if (video?.isDownloaded == true) break
attempts++ attempts++
} }
if (attempts >= 150) throw Exception("Download fehlgeschlagen") if (attempts >= 150) throw Exception("Download fehlgeschlagen")

View File

@@ -2,12 +2,12 @@ from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from api.schemas import ProfileResponse from api.schemas import ProfileResponse
from database.database import get_db from database.database import getDb
from model.profile import Profile from model.profile import Profile
router = APIRouter(prefix="/profiles", tags=["profiles"]) router = APIRouter(prefix="/profiles", tags=["profiles"])
@router.get("", response_model=list[ProfileResponse]) @router.get("", response_model=list[ProfileResponse])
def get_profiles(db: Session = Depends(get_db)): def getAll(db: Session = Depends(getDb)):
return Profile.get_all(db) return Profile.getAll(db)

View File

@@ -1,47 +1,45 @@
from pydantic import BaseModel from pydantic import BaseModel, ConfigDict
class VideoCreate(BaseModel): class VideoCreate(BaseModel):
title: str title: str
youtuber: str youtuber: str
thumbnail_url: str thumbnailUrl: str
youtube_url: str youtubeUrl: str
profile_id: int | None = None profileId: int | None = None
class VideoResponse(BaseModel): class VideoResponse(BaseModel):
id: int id: int
title: str title: str
youtuber: str youtuber: str
thumbnail_url: str thumbnailUrl: str
youtube_url: str youtubeUrl: str
is_downloaded: bool isDownloaded: bool
profile_ids: list[int] profileIds: list[int]
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
@classmethod @classmethod
def from_model(cls, video): def fromModel(cls, video):
return cls( return cls(
id=video.id, id=video.id,
title=video.title, title=video.title,
youtuber=video.youtuber, youtuber=video.youtuber,
thumbnail_url=video.thumbnail_url, thumbnailUrl=video.thumbnailUrl,
youtube_url=video.youtube_url, youtubeUrl=video.youtubeUrl,
is_downloaded=video.file_path is not None, isDownloaded=video.filePath is not None,
profile_ids=[p.id for p in video.profiles], profileIds=[p.id for p in video.profiles],
) )
class CleanupRequest(BaseModel): class CleanupRequest(BaseModel):
profile_id: int profileId: int
exclude_ids: list[int] = [] excludeIds: list[int] = []
class ProfileResponse(BaseModel): class ProfileResponse(BaseModel):
id: int id: int
name: str name: str
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True

View File

@@ -7,115 +7,115 @@ from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from api.schemas import CleanupRequest, VideoCreate, VideoResponse from api.schemas import CleanupRequest, VideoCreate, VideoResponse
from database.database import SessionLocal, get_db from database.database import SessionLocal, getDb
from download.download_service import download_video from download.download_service import downloadVideo
from model.video import Video from model.video import Video
from notify.notify_clients import notify_clients from notify.notify_clients import notifyClients
from stream.stream_service import stream_video_live from stream.stream_service import streamVideoLive
router = APIRouter(prefix="/videos", tags=["videos"]) router = APIRouter(prefix="/videos", tags=["videos"])
@router.post("", response_model=list[VideoResponse]) @router.post("", status_code=204)
async def create_videos(videos_data: list[VideoCreate], db: Session = Depends(get_db)): async def create(videoData: VideoCreate, db: Session = Depends(getDb)):
created_ids = [] title = videoData.title
profile_ids = set() youtuber = videoData.youtuber
for video_data in reversed(videos_data): thumbnailUrl = videoData.thumbnailUrl
video_id_match = video_data.youtube_url.split("v=")[-1].split("&")[0] youtubeUrl = videoData.youtubeUrl
Video.delete_by_youtube_id(db, video_id_match) profileId = videoData.profileId
data = video_data.model_dump(exclude={"profile_id"})
video = Video.create_from_dict(db, data, video_data.profile_id)
created_ids.append(video.id)
profile_ids.add(video_data.profile_id or 1)
videos = [Video.get_by_id(db, vid) for vid in created_ids]
if profile_ids: Video.deleteIfExists(db, youtubeUrl, profileId)
await notify_clients(list(profile_ids)) Video.create(db, title, youtuber, thumbnailUrl, youtubeUrl, profileId)
await notifyClients(profileId)
return [VideoResponse.from_model(v) for v in videos if v]
@router.get("", response_model=list[VideoResponse]) @router.get("", response_model=list[VideoResponse])
def get_all_videos(profile_id: Optional[int] = Query(None), db: Session = Depends(get_db)): def getAll(
videos = Video.get_all(db, profile_id=profile_id) profileId: Optional[int] = Query(None),
return [VideoResponse.from_model(v) for v in videos] db: Session = Depends(getDb),
):
videos = Video.getAll(db, profileId=profileId)
return [VideoResponse.fromModel(v) for v in videos]
@router.get("/downloaded", response_model=list[VideoResponse]) @router.get("/downloaded", response_model=list[VideoResponse])
def get_downloaded_videos(profile_id: Optional[int] = Query(None), db: Session = Depends(get_db)): def getDownloaded(
videos = Video.get_downloaded(db, profile_id=profile_id) profileId: Optional[int] = Query(None),
return [VideoResponse.from_model(v) for v in videos] db: Session = Depends(getDb),
):
videos = Video.getDownloaded(db, profileId=profileId)
return [VideoResponse.fromModel(v) for v in videos]
@router.post("/cleanup") @router.post("/cleanup")
def cleanup_videos(request: CleanupRequest, db: Session = Depends(get_db)): def cleanup(request: CleanupRequest, db: Session = Depends(getDb)):
count = Video.delete_not_downloaded(db, request.profile_id, request.exclude_ids or None) count = Video.deleteNotDownloaded(db, request.profileId, request.excludeIds or None)
return {"deleted": count} return {"deleted": count}
@router.post("/{video_id}/download") @router.post("/{videoId}/download")
def trigger_download(video_id: int, db: Session = Depends(get_db)): def download(videoId: int, db: Session = Depends(getDb)):
video = Video.get_by_id(db, video_id) video = Video.getById(db, videoId)
if not video: if not video:
raise HTTPException(status_code=404, detail="Video nicht gefunden") raise HTTPException(status_code=404, detail="Video nicht gefunden")
if video.file_path: if video.filePath:
return {"status": "already_downloaded"} return {"status": "already_downloaded"}
thread = threading.Thread(target=download_video, args=(video.id, video.youtube_url)) thread = threading.Thread(target=downloadVideo, args=(video.id, video.youtubeUrl))
thread.start() thread.start()
return {"status": "download_started"} return {"status": "download_started"}
@router.get("/{video_id}/stream") @router.get("/{videoId}/stream")
def stream_video(video_id: int, db: Session = Depends(get_db)): def stream(videoId: int, db: Session = Depends(getDb)):
video = Video.get_by_id(db, video_id) video = Video.getById(db, videoId)
if not video: if not video:
raise HTTPException(status_code=404, detail="Video nicht gefunden") raise HTTPException(status_code=404, detail="Video nicht gefunden")
if not video.file_path: if not video.filePath:
def stream_and_save(): def streamAndSave():
output_path = f"/videos/{video_id}.mp4" outputPath = f"/videos/{videoId}.mp4"
yield from stream_video_live(video_id, video.youtube_url) yield from streamVideoLive(videoId, video.youtubeUrl)
if Path(output_path).exists(): if Path(outputPath).exists():
sdb = SessionLocal() sdb = SessionLocal()
try: try:
Video.update_file_path(sdb, video_id, output_path) Video.updateFilePath(sdb, videoId, outputPath)
finally: finally:
sdb.close() sdb.close()
return StreamingResponse(stream_and_save(), media_type="video/mp4") return StreamingResponse(streamAndSave(), media_type="video/mp4")
path = Path(video.file_path) path = Path(video.filePath)
if not path.exists(): if not path.exists():
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") return FileResponse(path, media_type="video/mp4")
@router.get("/{video_id}/file") @router.get("/{videoId}/file")
def download_file(video_id: int, db: Session = Depends(get_db)): def getFile(videoId: int, db: Session = Depends(getDb)):
video = Video.get_by_id(db, video_id) video = Video.getById(db, videoId)
if not video: if not video:
raise HTTPException(status_code=404, detail="Video nicht gefunden") raise HTTPException(status_code=404, detail="Video nicht gefunden")
if not video.file_path: if not video.filePath:
raise HTTPException(status_code=404, detail="Video noch nicht heruntergeladen") raise HTTPException(status_code=404, detail="Video noch nicht heruntergeladen")
path = Path(video.file_path) path = Path(video.filePath)
if not path.exists(): if not path.exists():
Video.update_file_path(db, video_id, None) Video.updateFilePath(db, videoId, None)
raise HTTPException(status_code=404, detail="Video noch nicht heruntergeladen") raise HTTPException(status_code=404, detail="Video noch nicht heruntergeladen")
return FileResponse(path, media_type="video/mp4", filename=f"{video.title}.mp4") return FileResponse(path, media_type="video/mp4", filename=f"{video.title}.mp4")
@router.delete("/{video_id}/file") @router.delete("/{videoId}/file")
def delete_server_file(video_id: int, db: Session = Depends(get_db)): def deleteFile(videoId: int, db: Session = Depends(getDb)):
video = Video.get_by_id(db, video_id) video = Video.getById(db, videoId)
if not video: if not video:
raise HTTPException(status_code=404, detail="Video nicht gefunden") raise HTTPException(status_code=404, detail="Video nicht gefunden")
if video.file_path: if video.filePath:
path = Path(video.file_path) path = Path(video.filePath)
if path.exists(): if path.exists():
path.unlink() path.unlink()
Video.update_file_path(db, video_id, None) Video.updateFilePath(db, videoId, None)
return {"status": "deleted"} return {"status": "deleted"}

View File

@@ -1,11 +1,11 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from api.profile_controller import router as profiles_router from api.profile_controller import router as profilesRouter
from api.video_controller import router as videos_router from api.video_controller import router as videosRouter
from database.database import SessionLocal, create_tables from database.database import SessionLocal, createTables
from model.profile import Profile from model.profile import Profile
from notify.notify_clients import register_websocket from notify.notify_clients import registerWebsocket
app = FastAPI() app = FastAPI()
@@ -16,14 +16,14 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
app.include_router(videos_router) app.include_router(videosRouter)
app.include_router(profiles_router) app.include_router(profilesRouter)
register_websocket(app) registerWebsocket(app)
@app.on_event("startup") @app.on_event("startup")
def startup(): def startup():
create_tables() createTables()
db = SessionLocal() db = SessionLocal()
if db.query(Profile).count() == 0: if db.query(Profile).count() == 0:
db.add(Profile(name="Standard")) db.add(Profile(name="Standard"))

View File

@@ -8,11 +8,11 @@ SessionLocal = sessionmaker(bind=engine)
Base = declarative_base() Base = declarative_base()
def create_tables(): def createTables():
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
def get_db(): def getDb():
db = SessionLocal() db = SessionLocal()
try: try:
yield db yield db

View File

@@ -8,28 +8,28 @@ VIDEOS_DIR = "/videos"
MIN_VALID_SIZE = 1024 * 100 # 100 KB MIN_VALID_SIZE = 1024 * 100 # 100 KB
def download_video(video_id: int, youtube_url: str): def downloadVideo(videoId: int, youtubeUrl: str):
output_path = f"{VIDEOS_DIR}/{video_id}.mp4" outputPath = f"{VIDEOS_DIR}/{videoId}.mp4"
subprocess.run( subprocess.run(
[ [
"yt-dlp", "yt-dlp",
"-f", "bestvideo[ext=mp4][vcodec^=avc]+bestaudio[ext=m4a]/best[ext=mp4]", "-f", "bestvideo[ext=mp4][vcodec^=avc]+bestaudio[ext=m4a]/best[ext=mp4]",
"-o", output_path, "-o", outputPath,
"--merge-output-format", "mp4", "--merge-output-format", "mp4",
"--force-overwrites", "--force-overwrites",
"--no-continue", "--no-continue",
youtube_url, youtubeUrl,
], ],
check=True, check=True,
) )
path = Path(output_path) path = Path(outputPath)
if not path.exists() or path.stat().st_size < MIN_VALID_SIZE: if not path.exists() or path.stat().st_size < MIN_VALID_SIZE:
return return
db = SessionLocal() db = SessionLocal()
try: try:
Video.update_file_path(db, video_id, output_path) Video.updateFilePath(db, videoId, outputPath)
finally: finally:
db.close() db.close()

View File

@@ -11,5 +11,5 @@ class Profile(Base):
name = Column(String, nullable=False, unique=True) name = Column(String, nullable=False, unique=True)
@classmethod @classmethod
def get_all(cls, db: Session) -> list["Profile"]: def getAll(cls, db: Session) -> list["Profile"]:
return db.query(cls).all() return db.query(cls).all()

View File

@@ -2,7 +2,7 @@ from sqlalchemy import Column, ForeignKey, Integer, Table
from database.database import Base from database.database import Base
video_profiles = Table( videoProfiles = Table(
"video_profiles", "video_profiles",
Base.metadata, Base.metadata,
Column("video_id", Integer, ForeignKey("videos.id", ondelete="CASCADE")), Column("video_id", Integer, ForeignKey("videos.id", ondelete="CASCADE")),

View File

@@ -3,7 +3,7 @@ from sqlalchemy.orm import Session, relationship
from database.database import Base from database.database import Base
from model.profile import Profile from model.profile import Profile
from model.profile_video import video_profiles from model.profile_video import videoProfiles
class Video(Base): class Video(Base):
@@ -12,17 +12,42 @@ class Video(Base):
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String, nullable=False) title = Column(String, nullable=False)
youtuber = Column(String, nullable=False) youtuber = Column(String, nullable=False)
thumbnail_url = Column(String, nullable=False) thumbnailUrl = Column("thumbnail_url", String, nullable=False)
youtube_url = Column(String, nullable=False) youtubeUrl = Column("youtube_url", String, nullable=False)
file_path = Column(String, nullable=True) filePath = Column("file_path", String, nullable=True)
profiles = relationship("Profile", secondary=video_profiles, backref="videos") profiles = relationship("Profile", secondary=videoProfiles, backref="videos")
@classmethod @classmethod
def create_from_dict(cls, db: Session, data: dict, profile_id: int | None) -> "Video": def deleteIfExists(cls, db: Session, youtubeUrl: str, profileId: int | None):
video = cls(**data) if not profileId:
if not profile_id: profileId = 1
profile_id = 1 videos = db.query(cls).filter(
profile = db.query(Profile).filter(Profile.id == profile_id).first() cls.youtubeUrl == youtubeUrl,
cls.profiles.any(Profile.id == profileId),
).all()
for video in videos:
db.delete(video)
db.commit()
@classmethod
def create(
cls,
db: Session,
title: str,
youtuber: str,
thumbnailUrl: str,
youtubeUrl: str,
profileId: int | None,
) -> "Video":
if not profileId:
profileId = 1
video = cls(
title=title,
youtuber=youtuber,
thumbnailUrl=thumbnailUrl,
youtubeUrl=youtubeUrl,
)
profile = db.query(Profile).filter(Profile.id == profileId).first()
if profile: if profile:
video.profiles.append(profile) video.profiles.append(profile)
db.add(video) db.add(video)
@@ -31,47 +56,42 @@ class Video(Base):
return video return video
@classmethod @classmethod
def get_all(cls, db: Session, profile_id: int | None = None) -> list["Video"]: def getAll(cls, db: Session, profileId: int | None = None) -> list["Video"]:
query = db.query(cls) query = db.query(cls)
if profile_id: if profileId:
query = query.filter(cls.profiles.any(Profile.id == profile_id)) query = query.filter(cls.profiles.any(Profile.id == profileId))
return query.order_by(cls.id.desc()).all() return query.order_by(cls.id.desc()).all()
@classmethod @classmethod
def get_downloaded(cls, db: Session, profile_id: int | None = None) -> list["Video"]: def getDownloaded(cls, db: Session, profileId: int | None = None) -> list["Video"]:
query = db.query(cls).filter(cls.file_path.isnot(None)) query = db.query(cls).filter(cls.filePath.isnot(None))
if profile_id: if profileId:
query = query.filter(cls.profiles.any(Profile.id == profile_id)) query = query.filter(cls.profiles.any(Profile.id == profileId))
return query.order_by(cls.id.desc()).all() return query.order_by(cls.id.desc()).all()
@classmethod @classmethod
def get_by_id(cls, db: Session, video_id: int) -> "Video | None": def getById(cls, db: Session, videoId: int) -> "Video | None":
return db.query(cls).filter(cls.id == video_id).first() return db.query(cls).filter(cls.id == videoId).first()
@classmethod @classmethod
def delete_by_youtube_id(cls, db: Session, youtube_id: str): def updateFilePath(cls, db: Session, videoId: int, path: str | None):
db.query(cls).filter(cls.youtube_url.contains(youtube_id)).delete(synchronize_session=False) video = cls.getById(db, videoId)
db.commit()
@classmethod
def update_file_path(cls, db: Session, video_id: int, path: str | None):
video = cls.get_by_id(db, video_id)
if video: if video:
video.file_path = path video.filePath = path
db.commit() db.commit()
@classmethod @classmethod
def delete_not_downloaded(cls, db: Session, profile_id: int, exclude_ids: list[int] | None = None) -> int: def deleteNotDownloaded(cls, db: Session, profileId: int, excludeIds: list[int] | None = None) -> int:
query = db.query(cls).filter( query = db.query(cls).filter(
cls.profiles.any(Profile.id == profile_id), cls.profiles.any(Profile.id == profileId),
) )
if exclude_ids: if excludeIds:
query = query.filter(cls.id.notin_(exclude_ids)) query = query.filter(cls.id.notin_(excludeIds))
videos = query.all() videos = query.all()
video_ids = [v.id for v in videos] videoIds = [v.id for v in videos]
if not video_ids: if not videoIds:
return 0 return 0
db.execute(video_profiles.delete().where(video_profiles.c.video_id.in_(video_ids))) db.execute(videoProfiles.delete().where(videoProfiles.c.video_id.in_(videoIds)))
db.query(cls).filter(cls.id.in_(video_ids)).delete(synchronize_session=False) db.query(cls).filter(cls.id.in_(videoIds)).delete(synchronize_session=False)
db.commit() db.commit()
return len(video_ids) return len(videoIds)

View File

@@ -1,24 +1,26 @@
from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi import FastAPI, WebSocket, WebSocketDisconnect
connected_clients: set[WebSocket] = set() connectedClients: set[WebSocket] = set()
async def notify_clients(profile_ids: list[int]): async def notifyClients(profileId: int | None):
message = ",".join(str(pid) for pid in profile_ids) if not profileId:
for client in list(connected_clients): profileId = 1
message = str(profileId)
for client in list(connectedClients):
try: try:
await client.send_text(message) await client.send_text(message)
except Exception: except Exception:
connected_clients.discard(client) connectedClients.discard(client)
def register_websocket(app: FastAPI): def registerWebsocket(app: FastAPI):
@app.websocket("/ws") @app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket): async def websocketEndpoint(websocket: WebSocket):
await websocket.accept() await websocket.accept()
connected_clients.add(websocket) connectedClients.add(websocket)
try: try:
while True: while True:
await websocket.receive_text() await websocket.receive_text()
except WebSocketDisconnect: except WebSocketDisconnect:
connected_clients.discard(websocket) connectedClients.discard(websocket)

View File

@@ -5,13 +5,13 @@ VIDEOS_DIR = "/videos"
CHUNK_SIZE = 64 * 1024 CHUNK_SIZE = 64 * 1024
def _get_stream_urls(youtube_url: str): def _getStreamUrls(youtubeUrl: str):
result = subprocess.run( result = subprocess.run(
[ [
"yt-dlp", "yt-dlp",
"-f", "bestvideo[ext=mp4][vcodec^=avc]+bestaudio[ext=m4a]/best[ext=mp4]", "-f", "bestvideo[ext=mp4][vcodec^=avc]+bestaudio[ext=m4a]/best[ext=mp4]",
"--print", "urls", "--print", "urls",
youtube_url, youtubeUrl,
], ],
capture_output=True, text=True, timeout=30, capture_output=True, text=True, timeout=30,
) )
@@ -26,24 +26,24 @@ def _get_stream_urls(youtube_url: str):
return None, None return None, None
def stream_video_live(video_id: int, youtube_url: str): def streamVideoLive(videoId: int, youtubeUrl: str):
output_path = f"{VIDEOS_DIR}/{video_id}.mp4" outputPath = f"{VIDEOS_DIR}/{videoId}.mp4"
video_url, audio_url = _get_stream_urls(youtube_url) videoUrl, audioUrl = _getStreamUrls(youtubeUrl)
if not video_url: if not videoUrl:
return return
if audio_url: if audioUrl:
cmd = [ cmd = [
"ffmpeg", "ffmpeg",
"-reconnect", "1", "-reconnect", "1",
"-reconnect_streamed", "1", "-reconnect_streamed", "1",
"-reconnect_delay_max", "5", "-reconnect_delay_max", "5",
"-i", video_url, "-i", videoUrl,
"-reconnect", "1", "-reconnect", "1",
"-reconnect_streamed", "1", "-reconnect_streamed", "1",
"-reconnect_delay_max", "5", "-reconnect_delay_max", "5",
"-i", audio_url, "-i", audioUrl,
"-c:v", "copy", "-c:v", "copy",
"-c:a", "aac", "-c:a", "aac",
"-movflags", "frag_keyframe+empty_moov+default_base_moof", "-movflags", "frag_keyframe+empty_moov+default_base_moof",
@@ -56,7 +56,7 @@ def stream_video_live(video_id: int, youtube_url: str):
"-reconnect", "1", "-reconnect", "1",
"-reconnect_streamed", "1", "-reconnect_streamed", "1",
"-reconnect_delay_max", "5", "-reconnect_delay_max", "5",
"-i", video_url, "-i", videoUrl,
"-c", "copy", "-c", "copy",
"-movflags", "frag_keyframe+empty_moov+default_base_moof", "-movflags", "frag_keyframe+empty_moov+default_base_moof",
"-f", "mp4", "-f", "mp4",
@@ -70,7 +70,7 @@ def stream_video_live(video_id: int, youtube_url: str):
) )
try: try:
with open(output_path, "wb") as f: with open(outputPath, "wb") as f:
while True: while True:
chunk = process.stdout.read(CHUNK_SIZE) chunk = process.stdout.read(CHUNK_SIZE)
if not chunk: if not chunk:
@@ -86,6 +86,6 @@ def stream_video_live(video_id: int, youtube_url: str):
if process.stdout: if process.stdout:
process.stdout.close() process.stdout.close()
path = Path(output_path) path = Path(outputPath)
if process.returncode != 0 and path.exists(): if process.returncode != 0 and path.exists():
path.unlink() path.unlink()

View File

@@ -1,9 +1,9 @@
const SERVER_URL = "http://marha.local:8000/videos"; const SERVER_URL = "http://marha.local:8000/videos";
browser.runtime.onMessage.addListener((videos) => { browser.runtime.onMessage.addListener((video) => {
fetch(SERVER_URL, { fetch(SERVER_URL, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(videos), body: JSON.stringify(video),
}).catch(() => {}); }).catch(() => {});
}); });

View File

@@ -18,51 +18,29 @@ function extractVideoFromCard(element) {
return { return {
title, title,
youtuber, youtuber,
thumbnail_url: thumbnail || `https://img.youtube.com/vi/${match[1]}/hqdefault.jpg`, thumbnailUrl: thumbnail || `https://img.youtube.com/vi/${match[1]}/hqdefault.jpg`,
youtube_url: `https://www.youtube.com/watch?v=${match[1]}`, youtubeUrl: `https://www.youtube.com/watch?v=${match[1]}`,
}; };
} }
function collectVideos(elements) { async function sendVideo(video) {
const videos = []; const stored = await browser.storage.local.get("profileId");
for (const el of elements) { const profileId = stored.profileId || null;
const video = extractVideoFromCard(el); const payload = { ...video, profileId };
if (!video) continue; console.log(`[YT-Erfasser] Video senden (Profil: ${profileId})`, payload.title);
if (sentUrls.has(video.youtube_url)) continue; browser.runtime.sendMessage(payload);
sentUrls.add(video.youtube_url);
videos.push(video);
}
return videos;
}
// --- Debounced Batch-Versand ---
let pendingVideos = [];
let sendTimer = null;
async function queueVideos(videos) {
pendingVideos.push(...videos);
if (!sendTimer) {
sendTimer = setTimeout(async () => {
if (pendingVideos.length > 0) {
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;
}, 250);
}
} }
// --- IntersectionObserver: nur sichtbare Cards erfassen --- // --- IntersectionObserver: nur sichtbare Cards erfassen ---
const visibilityObserver = new IntersectionObserver((entries) => { const visibilityObserver = new IntersectionObserver((entries) => {
const cards = entries.filter((e) => e.isIntersecting).map((e) => e.target); for (const entry of entries) {
if (cards.length > 0) { if (!entry.isIntersecting) continue;
queueVideos(collectVideos(cards)); const video = extractVideoFromCard(entry.target);
if (!video) continue;
if (sentUrls.has(video.youtubeUrl)) continue;
sentUrls.add(video.youtubeUrl);
sendVideo(video);
} }
}, { threshold: 0.5 }); }, { threshold: 0.5 });