diff --git a/backend/main.py b/backend/main.py index 8870c93..7fc27d0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -71,6 +71,8 @@ def on_startup(): "ALTER TABLE user_settings ADD COLUMN feed_weight_affinity REAL DEFAULT 5.0", "ALTER TABLE user_settings ADD COLUMN feed_weight_channel REAL DEFAULT 5.0", "ALTER TABLE user_settings ADD COLUMN use_oauth2 INTEGER DEFAULT 0", + "ALTER TABLE user_settings ADD COLUMN sync_interval_hours INTEGER DEFAULT 0", + "ALTER TABLE user_settings ADD COLUMN subtitle_langs TEXT DEFAULT ''", """CREATE TABLE IF NOT EXISTS search_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -151,9 +153,47 @@ def on_startup(): # Backfill descriptions for videos that don't have them yet (runs in background) import threading - from .routers.channels import _enrich_missing_task + from .routers.channels import _enrich_missing_task, _index_channels_batch threading.Thread(target=_enrich_missing_task, args=(3,), daemon=True).start() + def _auto_sync_daemon(): + import time + from datetime import datetime, timedelta + from sqlalchemy import text as _text + while True: + time.sleep(3600) + try: + db = SessionLocal() + try: + users_due = db.execute( + _text("SELECT user_id, sync_interval_hours FROM user_settings WHERE sync_interval_hours > 0") + ).mappings().all() + for row in users_due: + uid = row["user_id"] + cutoff = datetime.utcnow() - timedelta(hours=row["sync_interval_hours"]) + ch_ids = [ + r["id"] for r in db.execute( + _text(""" + SELECT c.id FROM channels c + JOIN user_channels uc ON c.id = uc.channel_id + WHERE uc.user_id = :uid AND uc.status = 'followed' + AND (c.crawled_at IS NULL OR c.crawled_at < :cutoff) + ORDER BY COALESCE(c.crawled_at, '1970-01-01') ASC + """), + {"uid": uid, "cutoff": cutoff}, + ).mappings().all() + ] + if ch_ids: + threading.Thread( + target=_index_channels_batch, args=(ch_ids, uid), daemon=True + ).start() + finally: + db.close() + except Exception: + pass + + threading.Thread(target=_auto_sync_daemon, daemon=True).start() + @app.get("/api/health") def health(): diff --git a/backend/models.py b/backend/models.py index cc995af..0813059 100644 --- a/backend/models.py +++ b/backend/models.py @@ -122,6 +122,8 @@ class UserSettings(Base): feed_weight_affinity = Column(Float, default=5.0) # 0–10 feed_weight_channel = Column(Float, default=5.0) # 0–10 use_oauth2 = Column(Boolean, default=False) + sync_interval_hours = Column(Integer, default=0) # 0 = disabled, 6/12/24 = auto-sync interval + subtitle_langs = Column(String, default="") # "" = disabled, "en", "en,sv", etc. class DiscoveryQueue(Base): diff --git a/backend/routers/channels.py b/backend/routers/channels.py index 91aefe4..e202a30 100644 --- a/backend/routers/channels.py +++ b/backend/routers/channels.py @@ -147,6 +147,7 @@ def _index_channel_task(channel_id: int, user_id: int): if channel_auto: quality = user_settings.preferred_quality if user_settings else "best" + subtitle_langs = (user_settings.subtitle_langs or "") if user_settings else "" from ..routers.downloads import _on_progress, _on_complete, _on_error for yt_id, vid_id in new_video_ids: existing_dl = db.query(Download).filter_by( @@ -159,7 +160,7 @@ def _index_channel_task(channel_id: int, user_id: int): import threading t = threading.Thread( target=ytdlp.start_download, - args=(yt_id, dl.id, _on_progress, _on_complete, _on_error, quality), + args=(yt_id, dl.id, _on_progress, _on_complete, _on_error, quality, subtitle_langs), daemon=True, ) t.start() diff --git a/backend/routers/downloads.py b/backend/routers/downloads.py index 5d8f5d8..cc927fb 100644 --- a/backend/routers/downloads.py +++ b/backend/routers/downloads.py @@ -127,6 +127,7 @@ def create_download( user_settings = db.query(UserSettings).filter_by(user_id=current_user.id).first() default_quality = user_settings.preferred_quality if user_settings else "best" quality = body.quality if body.quality in ytdlp.QUALITY_FORMATS else default_quality + subtitle_langs = (user_settings.subtitle_langs or "") if user_settings else "" _DL_SELECT = """ SELECT d.id, d.status, d.progress_percent, d.resolution, @@ -155,7 +156,7 @@ def create_download( ytdlp.start_download, video.youtube_video_id, dl.id, _on_progress, _on_complete, _on_error, - quality, + quality, subtitle_langs, ) row = db.execute(text(_DL_SELECT), {"id": dl.id}).mappings().first() @@ -190,6 +191,11 @@ def _get_quality(db, user_id: int) -> str: return s.preferred_quality if s else "best" +def _get_subtitle_langs(db, user_id: int) -> str: + s = db.query(UserSettings).filter_by(user_id=user_id).first() + return (s.subtitle_langs or "") if s else "" + + @router.post("/channel/{channel_id}", status_code=202) def download_channel_videos( channel_id: int, @@ -198,6 +204,7 @@ def download_channel_videos( current_user: User = Depends(get_current_user), ): quality = _get_quality(db, current_user.id) + subtitle_langs = _get_subtitle_langs(db, current_user.id) rows = db.execute( text(""" SELECT v.id, v.youtube_video_id @@ -216,7 +223,7 @@ def download_channel_videos( db.flush() background_tasks.add_task( ytdlp.start_download, row["youtube_video_id"], dl.id, - _on_progress, _on_complete, _on_error, quality, + _on_progress, _on_complete, _on_error, quality, subtitle_langs, ) count += 1 db.commit() @@ -230,6 +237,7 @@ def download_following_videos( current_user: User = Depends(get_current_user), ): quality = _get_quality(db, current_user.id) + subtitle_langs = _get_subtitle_langs(db, current_user.id) rows = db.execute( text(""" SELECT v.id, v.youtube_video_id diff --git a/backend/routers/settings.py b/backend/routers/settings.py index a0dcbc2..4a60446 100644 --- a/backend/routers/settings.py +++ b/backend/routers/settings.py @@ -34,6 +34,8 @@ class SettingsOut(BaseModel): 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} @@ -55,6 +57,8 @@ class SettingsPatch(BaseModel): 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: @@ -123,6 +127,10 @@ def update_settings( 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) diff --git a/backend/routers/stats.py b/backend/routers/stats.py index fdbe428..10e733a 100644 --- a/backend/routers/stats.py +++ b/backend/routers/stats.py @@ -1,8 +1,12 @@ +import os +import shutil + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from sqlalchemy import text from ..auth_utils import get_current_user +from ..config import settings from ..database import get_db from ..models import User, UserTagAffinity @@ -120,6 +124,15 @@ def get_stats( {"uid": uid}, ).mappings().first() + try: + disk = shutil.disk_usage(settings.download_path) + download_bytes = sum( + e.stat().st_size for e in os.scandir(settings.download_path) if e.is_file() + ) + except Exception: + disk = None + download_bytes = 0 + return { "total_watched": totals["total_watched"] or 0, "total_watch_seconds": totals["total_watch_seconds"] or 0, @@ -141,6 +154,12 @@ def get_stats( "total_liked": liked_count["n"] or 0, "top_categories": [dict(r) for r in top_categories], "taste_profile": [dict(r) for r in taste_profile], + "disk": { + "total_bytes": disk.total if disk else None, + "free_bytes": disk.free if disk else None, + "used_bytes": disk.used if disk else None, + "download_bytes": download_bytes, + }, } diff --git a/backend/services/ytdlp.py b/backend/services/ytdlp.py index 2e8ad29..e07fb9f 100644 --- a/backend/services/ytdlp.py +++ b/backend/services/ytdlp.py @@ -621,17 +621,20 @@ def start_download( on_complete: Any, on_error: Any, quality: str = "best", + subtitle_langs: str = "", ) -> None: """Start yt-dlp download in a background thread. - Uses a single progressive MP4 format so the file is playable as it downloads. --no-part writes directly to the final filename (no .part rename at the end). """ url = f"https://www.youtube.com/watch?v={video_id}" - # Predictable output path — lets the player start before download finishes output_template = str(Path(settings.download_path) / f"{video_id}.%(ext)s") fmt = QUALITY_FORMATS.get(quality, QUALITY_FORMATS["best"]) + subtitle_args = ( + ["--write-subs", "--write-auto-subs", "--sub-langs", subtitle_langs, "--convert-subs", "srt"] + if subtitle_langs else [] + ) def _run_download(): with _SEMAPHORE: @@ -645,6 +648,7 @@ def start_download( "--no-part", "--no-mtime", "-o", output_template, "--newline", "--progress", "--no-colors", + *subtitle_args, *cookie_args, ], stdout=subprocess.PIPE, diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 86182e1..053e27d 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -650,6 +650,25 @@ export default function SettingsPage() { + {/* Sync */} +
+ + + +
+ {/* Download quality */}
set({ auto_download_on_sync: v })} /> + + set({ subtitle_langs: e.target.value })} + placeholder="en, sv, …" + className="bg-zinc-800 text-zinc-200 text-sm rounded-lg px-3 py-1.5 border border-zinc-700 focus:outline-none focus:border-accent w-36" + /> +
{/* Feed */} diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index 6d46c8c..f177864 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -11,6 +11,14 @@ function fmt(seconds) { return `${h}h ${m}m`; } +function fmtBytes(bytes) { + if (!bytes) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let i = 0, v = bytes; + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } + return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} + function StatCard({ label, value, sub }) { return (
@@ -170,6 +178,29 @@ export default function Stats() { )}
+ {/* Disk usage */} + {data.disk?.total_bytes && ( +
+

Disk usage

+
+
+ Downloads + {fmtBytes(data.disk.download_bytes)} +
+
+
+
+
+ {fmtBytes(data.disk.used_bytes)} used + {fmtBytes(data.disk.free_bytes)} free · {fmtBytes(data.disk.total_bytes)} total +
+
+
+ )} + {/* Taste profile */} {topTags.length > 0 && (