2026-06-11 04:15:25 +02:00
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
2026-06-11 23:07:09 +02:00
|
|
|
from sqlalchemy import and_, case, func, select, update
|
2026-06-11 01:22:07 +02:00
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
2026-06-11 04:15:25 +02:00
|
|
|
from app import quota, state
|
2026-06-11 01:22:07 +02:00
|
|
|
from app.auth import current_user
|
|
|
|
|
from app.db import get_db
|
|
|
|
|
from app.models import Channel, Subscription, User, Video
|
2026-06-11 23:07:09 +02:00
|
|
|
from app.sync.runner import (
|
|
|
|
|
estimate_deep_backfill,
|
|
|
|
|
run_enrich,
|
|
|
|
|
run_recent_backfill,
|
|
|
|
|
run_rss_poll,
|
|
|
|
|
)
|
2026-06-11 01:22:07 +02:00
|
|
|
from app.sync.subscriptions import import_subscriptions
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
|
|
|
|
|
|
|
|
|
|
feat: M2 (part 2) — RSS poller, backfill, enrichment, scheduler
- Free per-channel RSS reader for quota-less fresh-video detection
- Recent-first backfill (configurable: 100 videos / 1 year) plus resumable deep
backfill from the uploads playlist
- Enrichment via videos.list: duration, view/like counts, category, topics,
language, Shorts heuristic and livestream/premiere classification
- Reusable sync runners + APScheduler jobs (rss / enrich / backfill), all
quota-aware with a reserve so backfill never starves fresh enrichment
- Manual triggers: POST /api/sync/{rss,backfill,enrich}
- Exact insert counting via RETURNING with in-batch de-duplication
2026-06-11 01:36:41 +02:00
|
|
|
def _user_channels(db: Session, user: User) -> list[Channel]:
|
|
|
|
|
return (
|
|
|
|
|
db.execute(
|
|
|
|
|
select(Channel)
|
|
|
|
|
.join(Subscription, Subscription.channel_id == Channel.id)
|
|
|
|
|
.where(Subscription.user_id == user.id)
|
|
|
|
|
)
|
|
|
|
|
.scalars()
|
|
|
|
|
.all()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 01:22:07 +02:00
|
|
|
@router.post("/subscriptions")
|
|
|
|
|
def sync_subscriptions(
|
|
|
|
|
user: User = Depends(current_user), db: Session = Depends(get_db)
|
|
|
|
|
) -> dict:
|
|
|
|
|
before = quota.units_used_today(db)
|
|
|
|
|
result = import_subscriptions(db, user)
|
|
|
|
|
result["quota_used_estimate"] = quota.units_used_today(db) - before
|
|
|
|
|
result["quota_remaining_today"] = quota.remaining_today(db)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
feat: M2 (part 2) — RSS poller, backfill, enrichment, scheduler
- Free per-channel RSS reader for quota-less fresh-video detection
- Recent-first backfill (configurable: 100 videos / 1 year) plus resumable deep
backfill from the uploads playlist
- Enrichment via videos.list: duration, view/like counts, category, topics,
language, Shorts heuristic and livestream/premiere classification
- Reusable sync runners + APScheduler jobs (rss / enrich / backfill), all
quota-aware with a reserve so backfill never starves fresh enrichment
- Manual triggers: POST /api/sync/{rss,backfill,enrich}
- Exact insert counting via RETURNING with in-batch de-duplication
2026-06-11 01:36:41 +02:00
|
|
|
@router.post("/rss")
|
|
|
|
|
def sync_rss(
|
|
|
|
|
user: User = Depends(current_user), db: Session = Depends(get_db)
|
|
|
|
|
) -> dict:
|
|
|
|
|
new = run_rss_poll(db, _user_channels(db, user))
|
|
|
|
|
return {"new_videos": new}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/backfill")
|
|
|
|
|
def sync_backfill(
|
|
|
|
|
user: User = Depends(current_user),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
max_channels: int = 25,
|
|
|
|
|
) -> dict:
|
|
|
|
|
before = quota.units_used_today(db)
|
|
|
|
|
result = run_recent_backfill(db, _user_channels(db, user), max_channels=max_channels)
|
|
|
|
|
result["quota_used_estimate"] = quota.units_used_today(db) - before
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/enrich")
|
|
|
|
|
def sync_enrich(
|
|
|
|
|
user: User = Depends(current_user), db: Session = Depends(get_db)
|
|
|
|
|
) -> dict:
|
|
|
|
|
before = quota.units_used_today(db)
|
|
|
|
|
enriched = run_enrich(db)
|
|
|
|
|
return {
|
|
|
|
|
"enriched": enriched,
|
|
|
|
|
"quota_used_estimate": quota.units_used_today(db) - before,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 01:22:07 +02:00
|
|
|
@router.get("/status")
|
|
|
|
|
def sync_status(
|
|
|
|
|
user: User = Depends(current_user), db: Session = Depends(get_db)
|
|
|
|
|
) -> dict:
|
|
|
|
|
sub_count = db.scalar(
|
|
|
|
|
select(func.count())
|
|
|
|
|
.select_from(Subscription)
|
|
|
|
|
.where(Subscription.user_id == user.id)
|
|
|
|
|
)
|
|
|
|
|
return {
|
|
|
|
|
"subscriptions": sub_count,
|
|
|
|
|
"channels_total": db.scalar(select(func.count()).select_from(Channel)),
|
2026-06-11 04:15:25 +02:00
|
|
|
"channels_backfilling": db.scalar(
|
|
|
|
|
select(func.count()).select_from(Channel).where(Channel.backfill_done.is_(False))
|
|
|
|
|
),
|
2026-06-11 01:22:07 +02:00
|
|
|
"videos_total": db.scalar(select(func.count()).select_from(Video)),
|
2026-06-11 04:15:25 +02:00
|
|
|
"pending_enrich": db.scalar(
|
|
|
|
|
select(func.count()).select_from(Video).where(Video.enriched_at.is_(None))
|
|
|
|
|
),
|
2026-06-11 01:22:07 +02:00
|
|
|
"quota_used_today": quota.units_used_today(db),
|
|
|
|
|
"quota_remaining_today": quota.remaining_today(db),
|
2026-06-11 04:15:25 +02:00
|
|
|
"paused": state.is_sync_paused(db),
|
|
|
|
|
"is_admin": user.role == "admin",
|
2026-06-11 01:22:07 +02:00
|
|
|
}
|
2026-06-11 04:15:25 +02:00
|
|
|
|
|
|
|
|
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
@router.get("/my-status")
|
|
|
|
|
def my_status(
|
|
|
|
|
user: User = Depends(current_user), db: Session = Depends(get_db)
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Per-user sync progress: how far the channels *this* user subscribes to have synced."""
|
|
|
|
|
sub_join = and_(
|
|
|
|
|
Subscription.channel_id == Channel.id, Subscription.user_id == user.id
|
|
|
|
|
)
|
|
|
|
|
total, details_synced, recent_synced, deep_done = db.execute(
|
|
|
|
|
select(
|
|
|
|
|
func.count(Channel.id),
|
|
|
|
|
func.count(Channel.details_synced_at),
|
|
|
|
|
func.count(Channel.recent_synced_at),
|
|
|
|
|
func.sum(case((Channel.backfill_done.is_(True), 1), else_=0)),
|
|
|
|
|
)
|
|
|
|
|
.select_from(Channel)
|
|
|
|
|
.join(Subscription, sub_join)
|
|
|
|
|
).one()
|
|
|
|
|
total = total or 0
|
|
|
|
|
deep_done = int(deep_done or 0)
|
2026-06-11 23:07:09 +02:00
|
|
|
|
|
|
|
|
# Per-user full-history (deep) backfill: channels this user has opted in, and an ETA
|
|
|
|
|
# for the still-pending ones (quota-bound estimate).
|
|
|
|
|
deep_requested_channels = (
|
|
|
|
|
db.execute(
|
|
|
|
|
select(Channel).join(
|
|
|
|
|
Subscription,
|
|
|
|
|
and_(sub_join, Subscription.deep_requested.is_(True)),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.scalars()
|
|
|
|
|
.all()
|
|
|
|
|
)
|
|
|
|
|
deep_eta = estimate_deep_backfill(db, deep_requested_channels)
|
|
|
|
|
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
my_videos = db.scalar(
|
|
|
|
|
select(func.count(Video.id)).join(
|
|
|
|
|
Subscription,
|
|
|
|
|
and_(
|
|
|
|
|
Subscription.channel_id == Video.channel_id,
|
|
|
|
|
Subscription.user_id == user.id,
|
|
|
|
|
Subscription.hidden.is_(False),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return {
|
|
|
|
|
"channels_total": total,
|
|
|
|
|
"channels_details_synced": details_synced,
|
|
|
|
|
"channels_recent_synced": recent_synced,
|
|
|
|
|
"channels_deep_done": deep_done,
|
|
|
|
|
"channels_recent_pending": total - recent_synced,
|
|
|
|
|
"channels_deep_pending": total - deep_done,
|
2026-06-11 23:07:09 +02:00
|
|
|
"channels_deep_requested": len(deep_requested_channels),
|
|
|
|
|
"deep_pending_count": deep_eta["channels_pending"],
|
|
|
|
|
"deep_eta_seconds": deep_eta["eta_seconds"],
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
"my_videos": my_videos or 0,
|
|
|
|
|
"quota_used_today": quota.units_used_today(db),
|
|
|
|
|
"quota_remaining_today": quota.remaining_today(db),
|
|
|
|
|
"paused": state.is_sync_paused(db),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 23:07:09 +02:00
|
|
|
@router.post("/deep-all")
|
|
|
|
|
def deep_all(
|
|
|
|
|
user: User = Depends(current_user),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
on: bool = True,
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Opt every one of the user's channels into (or out of) full-history backfill."""
|
|
|
|
|
result = db.execute(
|
|
|
|
|
update(Subscription)
|
|
|
|
|
.where(Subscription.user_id == user.id)
|
|
|
|
|
.values(deep_requested=on)
|
|
|
|
|
)
|
|
|
|
|
db.commit()
|
|
|
|
|
return {"updated": result.rowcount, "deep_requested": on}
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 04:15:25 +02:00
|
|
|
@router.post("/pause")
|
|
|
|
|
def pause_sync(
|
|
|
|
|
user: User = Depends(current_user), db: Session = Depends(get_db)
|
|
|
|
|
) -> dict:
|
|
|
|
|
if user.role != "admin":
|
|
|
|
|
raise HTTPException(status_code=403, detail="Admin only")
|
|
|
|
|
state.set_sync_paused(db, True)
|
|
|
|
|
return {"paused": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/resume")
|
|
|
|
|
def resume_sync(
|
|
|
|
|
user: User = Depends(current_user), db: Session = Depends(get_db)
|
|
|
|
|
) -> dict:
|
|
|
|
|
if user.role != "admin":
|
|
|
|
|
raise HTTPException(status_code=403, detail="Admin only")
|
|
|
|
|
state.set_sync_paused(db, False)
|
|
|
|
|
return {"paused": False}
|