Three more code paths were bypassing the _meta_lock guard and firing raw yt-dlp processes concurrently with active downloads: - Popular fetch Phase 1 (flat-playlist channel crawl): changed from ytdlp._run to ytdlp._meta_run so it waits for active downloads - download_subs_only: changed from _run to _meta_run - fetch_video_comments: returns empty list immediately if a download is active (avoids blocking a 90s call indefinitely) - Diagnostic test endpoint (settings): switched to _meta_run Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
273 lines
9.0 KiB
Python
273 lines
9.0 KiB
Python
from pathlib import Path
|
|
from typing import Optional
|
|
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ..auth_utils import get_current_user
|
|
from ..config import settings
|
|
from ..database import get_db
|
|
from ..models import User, UserSettings
|
|
from ..services import ytdlp
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
VALID_BROWSERS = {"", "chrome", "chromium", "firefox", "brave", "edge", "opera", "safari"}
|
|
VALID_REGIONS = {"US", "SE", "GB", "DE", "JP", "FR", "CA", "AU", "BR", "IN", "KR", "MX"}
|
|
|
|
|
|
class SettingsOut(BaseModel):
|
|
preferred_quality: str
|
|
max_concurrent_downloads: int
|
|
hide_watched_from_feed: bool
|
|
mark_watched_at_percent: int
|
|
auto_download_on_sync: bool
|
|
cookies_browser: str = ""
|
|
cookies_file: str = ""
|
|
theater_mode: bool = False
|
|
discovery_regions: str = "US,SE"
|
|
calm_mode: bool = False
|
|
hide_subscriber_counts: bool = False
|
|
autoplay_enabled: bool = False
|
|
feed_weight_recency: float = 5.0
|
|
feed_weight_affinity: float = 5.0
|
|
feed_weight_channel: float = 5.0
|
|
use_oauth2: bool = False
|
|
sync_interval_hours: int = 0
|
|
subtitle_langs: str = ""
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class SettingsPatch(BaseModel):
|
|
preferred_quality: Optional[str] = None
|
|
max_concurrent_downloads: Optional[int] = Field(None, ge=1, le=5)
|
|
hide_watched_from_feed: Optional[bool] = None
|
|
mark_watched_at_percent: Optional[int] = Field(None, ge=50, le=100)
|
|
auto_download_on_sync: Optional[bool] = None
|
|
cookies_browser: Optional[str] = None
|
|
cookies_file: Optional[str] = None
|
|
theater_mode: Optional[bool] = None
|
|
discovery_regions: Optional[str] = None
|
|
calm_mode: Optional[bool] = None
|
|
hide_subscriber_counts: Optional[bool] = None
|
|
autoplay_enabled: Optional[bool] = None
|
|
feed_weight_recency: Optional[float] = Field(None, ge=0.0, le=10.0)
|
|
feed_weight_affinity: Optional[float] = Field(None, ge=0.0, le=10.0)
|
|
feed_weight_channel: Optional[float] = Field(None, ge=0.0, le=10.0)
|
|
use_oauth2: Optional[bool] = None
|
|
sync_interval_hours: Optional[int] = Field(None, ge=0, le=168)
|
|
subtitle_langs: Optional[str] = None
|
|
|
|
|
|
def _get_or_create(db: Session, user_id: int) -> UserSettings:
|
|
s = db.query(UserSettings).filter_by(user_id=user_id).first()
|
|
if not s:
|
|
s = UserSettings(user_id=user_id)
|
|
db.add(s)
|
|
db.commit()
|
|
db.refresh(s)
|
|
return s
|
|
|
|
|
|
@router.get("", response_model=SettingsOut)
|
|
def get_settings(
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
return _get_or_create(db, current_user.id)
|
|
|
|
|
|
@router.patch("", response_model=SettingsOut)
|
|
def update_settings(
|
|
body: SettingsPatch,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
s = _get_or_create(db, current_user.id)
|
|
|
|
if body.preferred_quality is not None and body.preferred_quality in ytdlp.QUALITY_FORMATS:
|
|
s.preferred_quality = body.preferred_quality
|
|
if body.max_concurrent_downloads is not None:
|
|
s.max_concurrent_downloads = body.max_concurrent_downloads
|
|
ytdlp.set_max_concurrent(body.max_concurrent_downloads)
|
|
if body.hide_watched_from_feed is not None:
|
|
s.hide_watched_from_feed = body.hide_watched_from_feed
|
|
if body.mark_watched_at_percent is not None:
|
|
s.mark_watched_at_percent = body.mark_watched_at_percent
|
|
if body.auto_download_on_sync is not None:
|
|
s.auto_download_on_sync = body.auto_download_on_sync
|
|
if body.cookies_browser is not None and body.cookies_browser in VALID_BROWSERS:
|
|
s.cookies_browser = body.cookies_browser
|
|
ytdlp.set_cookies_browser(body.cookies_browser)
|
|
if body.cookies_file is not None:
|
|
s.cookies_file = body.cookies_file.strip()
|
|
ytdlp.set_cookies_file(body.cookies_file)
|
|
if body.theater_mode is not None:
|
|
s.theater_mode = body.theater_mode
|
|
if body.discovery_regions is not None:
|
|
# Validate: comma-separated list of known region codes
|
|
codes = [r.strip().upper() for r in body.discovery_regions.split(",") if r.strip()]
|
|
valid = [c for c in codes if c in VALID_REGIONS]
|
|
if valid:
|
|
s.discovery_regions = ",".join(valid)
|
|
if body.calm_mode is not None:
|
|
s.calm_mode = body.calm_mode
|
|
if body.hide_subscriber_counts is not None:
|
|
s.hide_subscriber_counts = body.hide_subscriber_counts
|
|
if body.autoplay_enabled is not None:
|
|
s.autoplay_enabled = body.autoplay_enabled
|
|
if body.feed_weight_recency is not None:
|
|
s.feed_weight_recency = body.feed_weight_recency
|
|
if body.feed_weight_affinity is not None:
|
|
s.feed_weight_affinity = body.feed_weight_affinity
|
|
if body.feed_weight_channel is not None:
|
|
s.feed_weight_channel = body.feed_weight_channel
|
|
if body.use_oauth2 is not None:
|
|
s.use_oauth2 = body.use_oauth2
|
|
ytdlp.set_oauth2(body.use_oauth2)
|
|
if body.sync_interval_hours is not None:
|
|
s.sync_interval_hours = body.sync_interval_hours
|
|
if body.subtitle_langs is not None:
|
|
s.subtitle_langs = body.subtitle_langs.strip()
|
|
|
|
db.commit()
|
|
db.refresh(s)
|
|
return s
|
|
|
|
|
|
def _cookies_path() -> Path:
|
|
db_file = settings.database_url.replace("sqlite:///", "")
|
|
return Path(db_file).parent / "cookies.txt"
|
|
|
|
|
|
@router.post("/cookies-file", response_model=SettingsOut)
|
|
async def upload_cookies_file(
|
|
file: UploadFile = File(...),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
if not file.filename or not file.filename.endswith(".txt"):
|
|
raise HTTPException(status_code=400, detail="Upload a .txt cookies file")
|
|
content = await file.read()
|
|
if len(content) > 5 * 1024 * 1024:
|
|
raise HTTPException(status_code=400, detail="File too large (max 5 MB)")
|
|
path = _cookies_path()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(content)
|
|
s = _get_or_create(db, current_user.id)
|
|
s.cookies_file = str(path)
|
|
ytdlp.set_cookies_file(str(path))
|
|
db.commit()
|
|
db.refresh(s)
|
|
return s
|
|
|
|
|
|
@router.delete("/cookies-file", response_model=SettingsOut)
|
|
def delete_cookies_file(
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
path = _cookies_path()
|
|
if path.exists():
|
|
path.unlink()
|
|
s = _get_or_create(db, current_user.id)
|
|
s.cookies_file = ""
|
|
ytdlp.set_cookies_file("")
|
|
db.commit()
|
|
db.refresh(s)
|
|
return s
|
|
|
|
|
|
@router.post("/oauth2-init")
|
|
def oauth2_init(
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Start a yt-dlp OAuth2 device-auth flow. Returns the device URL and code immediately."""
|
|
state = ytdlp.start_oauth2_flow()
|
|
return state
|
|
|
|
|
|
@router.get("/oauth2-status")
|
|
def oauth2_status(
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Poll the current OAuth2 flow status."""
|
|
return ytdlp.get_oauth2_status()
|
|
|
|
|
|
@router.post("/oauth2-enable", response_model=SettingsOut)
|
|
def oauth2_enable(
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Mark OAuth2 as the active auth method after a successful flow."""
|
|
s = _get_or_create(db, current_user.id)
|
|
s.use_oauth2 = True
|
|
ytdlp.set_oauth2(True)
|
|
db.commit()
|
|
db.refresh(s)
|
|
return s
|
|
|
|
|
|
@router.post("/oauth2-disable", response_model=SettingsOut)
|
|
def oauth2_disable(
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Disable OAuth2 auth, falling back to cookies."""
|
|
s = _get_or_create(db, current_user.id)
|
|
s.use_oauth2 = False
|
|
ytdlp.set_oauth2(False)
|
|
db.commit()
|
|
db.refresh(s)
|
|
return s
|
|
|
|
|
|
@router.get("/ytdlp-test")
|
|
def ytdlp_test(
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Run a quick yt-dlp metadata fetch and environment check for diagnostics."""
|
|
import subprocess, shutil
|
|
cookie_args = ytdlp._cookie_args()
|
|
|
|
node_path = shutil.which("node") or shutil.which("nodejs")
|
|
node_version = None
|
|
if node_path:
|
|
try:
|
|
nv = subprocess.run([node_path, "--version"], capture_output=True, text=True, timeout=5)
|
|
node_version = nv.stdout.strip()
|
|
except Exception:
|
|
pass
|
|
|
|
yt_version = None
|
|
try:
|
|
yv = subprocess.run(["yt-dlp", "--version"], capture_output=True, text=True, timeout=5)
|
|
yt_version = yv.stdout.strip()
|
|
except Exception:
|
|
pass
|
|
|
|
test_stdout, test_stderr, test_code = ytdlp._meta_run(
|
|
[
|
|
"yt-dlp",
|
|
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
"--dump-json", "--no-download", "--no-playlist",
|
|
"--extractor-args", "youtube:player_client=web",
|
|
*cookie_args,
|
|
],
|
|
timeout=30,
|
|
)
|
|
return {
|
|
"node_path": node_path,
|
|
"node_version": node_version,
|
|
"yt_dlp_version": yt_version,
|
|
"cookie_args": cookie_args,
|
|
"returncode": test_code,
|
|
"stdout_lines": test_stdout.splitlines()[:5],
|
|
"stderr_tail": test_stderr.splitlines()[-20:],
|
|
"success": test_code == 0,
|
|
}
|