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() {