Files
youclonedl/backend/routers/settings.py
Mattias Tall b58dc26bd4 Switch to android_vr player client — no Node.js required
android_vr provides pre-signed format URLs that bypass YouTube's
n-challenge and signature JS requirements entirely. Tested: 23 video
formats available without any JavaScript runtime installed.

Reverts Node.js Dockerfile addition (which failed to build anyway).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 10:10:58 +02:00

265 lines
8.7 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
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
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)
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
result = subprocess.run(
[
"yt-dlp",
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"--dump-json", "--no-download", "--no-playlist",
"--extractor-args", "youtube:player_client=android_vr",
*cookie_args,
],
capture_output=True, text=True, timeout=30,
)
return {
"node_path": node_path,
"node_version": node_version,
"yt_dlp_version": yt_version,
"cookie_args": cookie_args,
"returncode": result.returncode,
"stdout_lines": result.stdout.splitlines()[:5],
"stderr_tail": result.stderr.splitlines()[-20:],
"success": result.returncode == 0,
}