merge: M4 reader UI
This commit is contained in:
commit
93e1d7afc2
43 changed files with 2326 additions and 27 deletions
28
Dockerfile
Normal file
28
Dockerfile
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Stage 1: build the frontend SPA
|
||||||
|
FROM node:20-alpine AS frontend
|
||||||
|
WORKDIR /fe
|
||||||
|
COPY frontend/package.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: backend runtime, serving the built SPA
|
||||||
|
FROM python:3.12-slim
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY backend/requirements.txt .
|
||||||
|
RUN pip install -r requirements.txt
|
||||||
|
|
||||||
|
COPY backend/ .
|
||||||
|
COPY --from=frontend /fe/dist ./app/static_spa
|
||||||
|
|
||||||
|
RUN adduser --disabled-password --gecos "" appuser && chown -R appuser /app
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["sh", "entrypoint.sh"]
|
||||||
|
|
@ -18,6 +18,12 @@ once and stored locally, so filtering/searching/sorting are instant and don't bu
|
||||||
> - **M3** (auto-tagging): system tags for channel language (offline detection) and topic
|
> - **M3** (auto-tagging): system tags for channel language (offline detection) and topic
|
||||||
> (from YouTube topics + dominant category), regenerated automatically; user tags are
|
> (from YouTube topics + dominant category), regenerated automatically; user tags are
|
||||||
> never overwritten.
|
> never overwritten.
|
||||||
|
> - **M4** (reader UI): React + Vite SPA with four color schemes (dark/light) and adjustable
|
||||||
|
> text size; grid/list feed scoped to your subscriptions, with faceted tag filters,
|
||||||
|
> content-type toggles (Normal/Shorts/Live), date range, search, sort, watch/save/hide
|
||||||
|
> state and per-channel filtering; clicking a video opens youtube.com. Accurate Shorts
|
||||||
|
> detection via the /shorts probe, a sync-status indicator with admin pause/resume, a
|
||||||
|
> filtered video count, and structured timestamped logging.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|
|
||||||
52
backend/alembic/versions/0004_feed_state_prefs.py
Normal file
52
backend/alembic/versions/0004_feed_state_prefs.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
"""user preferences + per-user video watch state
|
||||||
|
|
||||||
|
Revision ID: 0004_feed_state_prefs
|
||||||
|
Revises: 0003_tags
|
||||||
|
Create Date: 2026-06-11
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "0004_feed_state_prefs"
|
||||||
|
down_revision: Union[str, None] = "0003_tags"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("users", sa.Column("preferences", sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"video_states",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"video_id",
|
||||||
|
sa.String(length=16),
|
||||||
|
sa.ForeignKey("videos.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("status", sa.String(length=12), nullable=False, server_default="new"),
|
||||||
|
sa.Column("watched_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("user_id", "video_id", name="uq_user_video"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_video_states_user_id", "video_states", ["user_id"])
|
||||||
|
op.create_index("ix_video_states_video_id", "video_states", ["video_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("video_states")
|
||||||
|
op.drop_column("users", "preferences")
|
||||||
28
backend/alembic/versions/0005_shorts_probed.py
Normal file
28
backend/alembic/versions/0005_shorts_probed.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""add videos.shorts_probed
|
||||||
|
|
||||||
|
Revision ID: 0005_shorts_probed
|
||||||
|
Revises: 0004_feed_state_prefs
|
||||||
|
Create Date: 2026-06-11
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "0005_shorts_probed"
|
||||||
|
down_revision: Union[str, None] = "0004_feed_state_prefs"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"videos",
|
||||||
|
sa.Column("shorts_probed", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_videos_shorts_probed", "videos", ["shorts_probed"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_videos_shorts_probed", table_name="videos")
|
||||||
|
op.drop_column("videos", "shorts_probed")
|
||||||
28
backend/alembic/versions/0006_app_state.py
Normal file
28
backend/alembic/versions/0006_app_state.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""app_state (admin-controlled global flags)
|
||||||
|
|
||||||
|
Revision ID: 0006_app_state
|
||||||
|
Revises: 0005_shorts_probed
|
||||||
|
Create Date: 2026-06-11
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "0006_app_state"
|
||||||
|
down_revision: Union[str, None] = "0005_shorts_probed"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"app_state",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("sync_paused", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
)
|
||||||
|
op.execute("INSERT INTO app_state (id, sync_paused) VALUES (1, false)")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("app_state")
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from authlib.integrations.starlette_client import OAuth, OAuthError
|
from authlib.integrations.starlette_client import OAuth, OAuthError
|
||||||
|
|
@ -14,6 +15,8 @@ from app.security import encrypt
|
||||||
# (unsubscribe, playlist export). openid/email/profile give us the account identity.
|
# (unsubscribe, playlist export). openid/email/profile give us the account identity.
|
||||||
SCOPES = "openid email profile https://www.googleapis.com/auth/youtube"
|
SCOPES = "openid email profile https://www.googleapis.com/auth/youtube"
|
||||||
|
|
||||||
|
log = logging.getLogger("subfeed.auth")
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
oauth = OAuth()
|
oauth = OAuth()
|
||||||
|
|
@ -28,13 +31,14 @@ oauth.register(
|
||||||
|
|
||||||
@router.get("/login")
|
@router.get("/login")
|
||||||
async def login(request: Request):
|
async def login(request: Request):
|
||||||
# access_type=offline + prompt=consent must be on the authorization URL so Google
|
# access_type=offline ensures a refresh_token on first authorization. We avoid
|
||||||
# returns a refresh_token (required for background sync).
|
# prompt=consent so returning users get a quick sign-in; the stored refresh token
|
||||||
|
# is kept when Google doesn't re-issue one (see the callback).
|
||||||
return await oauth.google.authorize_redirect(
|
return await oauth.google.authorize_redirect(
|
||||||
request,
|
request,
|
||||||
settings.oauth_redirect_url,
|
settings.oauth_redirect_url,
|
||||||
access_type="offline",
|
access_type="offline",
|
||||||
prompt="consent",
|
prompt="select_account",
|
||||||
include_granted_scopes="true",
|
include_granted_scopes="true",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -52,6 +56,7 @@ async def callback(request: Request, db: Session = Depends(get_db)):
|
||||||
|
|
||||||
email = (userinfo.get("email") or "").lower()
|
email = (userinfo.get("email") or "").lower()
|
||||||
if not email or email not in settings.allowed_email_set:
|
if not email or email not in settings.allowed_email_set:
|
||||||
|
log.warning("Login denied (not on invite list): %s", email or "<no email>")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403, detail="This Google account is not on the invite list."
|
status_code=403, detail="This Google account is not on the invite list."
|
||||||
)
|
)
|
||||||
|
|
@ -80,6 +85,7 @@ async def callback(request: Request, db: Session = Depends(get_db)):
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
request.session["user_id"] = user.id
|
request.session["user_id"] = user.id
|
||||||
|
log.info("Login: %s (id=%s, role=%s)", email, user.id, user.role)
|
||||||
return RedirectResponse(url="/")
|
return RedirectResponse(url="/")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,11 @@ class Settings(BaseSettings):
|
||||||
backfill_recent_max_videos: int = 100
|
backfill_recent_max_videos: int = 100
|
||||||
backfill_recent_max_days: int = 365
|
backfill_recent_max_days: int = 365
|
||||||
|
|
||||||
# Videos at or below this duration (seconds) are treated as Shorts.
|
# Shorts are confirmed by probing youtube.com/shorts/<id>. Only videos at or below
|
||||||
shorts_max_seconds: int = 60
|
# this duration (and not livestreams) are probed; longer videos are never Shorts.
|
||||||
|
shorts_probe_max_seconds: int = 180
|
||||||
|
shorts_probe_batch: int = 150
|
||||||
|
shorts_probe_interval_minutes: int = 2
|
||||||
# videos.list accepts up to 50 ids per call.
|
# videos.list accepts up to 50 ids per call.
|
||||||
enrich_batch_size: int = 50
|
enrich_batch_size: int = 50
|
||||||
|
|
||||||
|
|
@ -52,6 +55,7 @@ class Settings(BaseSettings):
|
||||||
# Number of recent video titles sampled per channel for language detection.
|
# Number of recent video titles sampled per channel for language detection.
|
||||||
autotag_title_sample: int = 40
|
autotag_title_sample: int = 40
|
||||||
autotag_interval_minutes: int = 30
|
autotag_interval_minutes: int = 30
|
||||||
|
subscriptions_resync_minutes: int = 360
|
||||||
# live_status values hidden from the feed by default. Completed-stream VODs
|
# live_status values hidden from the feed by default. Completed-stream VODs
|
||||||
# ("was_live") are real watchable content and stay visible.
|
# ("was_live") are real watchable content and stay visible.
|
||||||
feed_default_hidden_live: str = "live,upcoming"
|
feed_default_hidden_live: str = "live,upcoming"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,24 @@
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, HTTPException
|
||||||
|
|
||||||
|
# When started via uvicorn --log-config the "subfeed" logger is already configured
|
||||||
|
# (see log_config.json). This block is a timestamped fallback for other entrypoints
|
||||||
|
# (tests, scripts) so our logs are never silently dropped.
|
||||||
|
_subfeed_logger = logging.getLogger("subfeed")
|
||||||
|
if not _subfeed_logger.handlers:
|
||||||
|
_handler = logging.StreamHandler(sys.stdout)
|
||||||
|
_handler.setFormatter(
|
||||||
|
logging.Formatter("%(asctime)s %(levelname)-5s [%(name)s] %(message)s")
|
||||||
|
)
|
||||||
|
_subfeed_logger.addHandler(_handler)
|
||||||
|
_subfeed_logger.setLevel(logging.INFO)
|
||||||
|
_subfeed_logger.propagate = False
|
||||||
|
|
||||||
|
log = logging.getLogger("subfeed.app")
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
@ -9,16 +26,18 @@ from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from app import auth
|
from app import auth
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.routes import health, sync, tags
|
from app.routes import feed, health, me, sync, tags
|
||||||
from app.scheduler import shutdown_scheduler, start_scheduler
|
from app.scheduler import shutdown_scheduler, start_scheduler
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
log.info("Subfeed starting up")
|
||||||
start_scheduler()
|
start_scheduler()
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
log.info("Subfeed shutting down")
|
||||||
shutdown_scheduler()
|
shutdown_scheduler()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -44,11 +63,26 @@ app.include_router(health.router)
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(sync.router)
|
app.include_router(sync.router)
|
||||||
app.include_router(tags.router)
|
app.include_router(tags.router)
|
||||||
|
app.include_router(feed.router)
|
||||||
|
app.include_router(me.router)
|
||||||
|
|
||||||
STATIC_DIR = Path(__file__).parent / "static"
|
# The built SPA (populated by the Docker frontend build stage).
|
||||||
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
|
STATIC_DIR = Path(__file__).parent / "static_spa"
|
||||||
|
app.mount(
|
||||||
|
"/assets",
|
||||||
|
StaticFiles(directory=STATIC_DIR / "assets", check_dir=False),
|
||||||
|
name="assets",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def index() -> FileResponse:
|
async def index() -> FileResponse:
|
||||||
return FileResponse(STATIC_DIR / "index.html")
|
return FileResponse(STATIC_DIR / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/{full_path:path}")
|
||||||
|
async def spa_fallback(full_path: str) -> FileResponse:
|
||||||
|
# Client-side routes fall back to index.html; real API/asset paths are matched above.
|
||||||
|
if full_path.startswith(("api/", "auth/", "healthz", "assets/")):
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
return FileResponse(STATIC_DIR / "index.html")
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ class User(Base):
|
||||||
display_name: Mapped[str | None] = mapped_column(String(255))
|
display_name: Mapped[str | None] = mapped_column(String(255))
|
||||||
avatar_url: Mapped[str | None] = mapped_column(String(1024))
|
avatar_url: Mapped[str | None] = mapped_column(String(1024))
|
||||||
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
|
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
|
||||||
|
# Free-form UI preferences (theme, color scheme, font scale, default filters…).
|
||||||
|
preferences: Mapped[dict | None] = mapped_column(JSON)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now()
|
DateTime(timezone=True), server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|
@ -144,6 +146,10 @@ class Video(Base):
|
||||||
is_short: Mapped[bool] = mapped_column(
|
is_short: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=False, server_default="false", index=True
|
Boolean, default=False, server_default="false", index=True
|
||||||
)
|
)
|
||||||
|
# Whether the youtube.com/shorts/<id> probe has run for this video.
|
||||||
|
shorts_probed: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False, server_default="false", index=True
|
||||||
|
)
|
||||||
# none | live | upcoming | premiere | was_live
|
# none | live | upcoming | premiere | was_live
|
||||||
live_status: Mapped[str] = mapped_column(
|
live_status: Mapped[str] = mapped_column(
|
||||||
String(16), default="none", server_default="none", index=True
|
String(16), default="none", server_default="none", index=True
|
||||||
|
|
@ -201,6 +207,28 @@ class ChannelTag(Base):
|
||||||
confidence: Mapped[float | None] = mapped_column(Float)
|
confidence: Mapped[float | None] = mapped_column(Float)
|
||||||
|
|
||||||
|
|
||||||
|
class VideoState(Base):
|
||||||
|
"""Per-user watch state for a video. Rows exist only for non-default states; the
|
||||||
|
absence of a row means 'new/unseen'."""
|
||||||
|
|
||||||
|
__tablename__ = "video_states"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "video_id", name="uq_user_video"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
video_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("videos.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
# new | watched | saved | hidden
|
||||||
|
status: Mapped[str] = mapped_column(String(12), default="new", server_default="new")
|
||||||
|
watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ApiQuotaUsage(Base):
|
class ApiQuotaUsage(Base):
|
||||||
"""Tracks YouTube Data API units spent per Pacific-time day (the quota resets at
|
"""Tracks YouTube Data API units spent per Pacific-time day (the quota resets at
|
||||||
midnight Pacific). The whole app shares one daily budget."""
|
midnight Pacific). The whole app shares one daily budget."""
|
||||||
|
|
@ -209,3 +237,14 @@ class ApiQuotaUsage(Base):
|
||||||
|
|
||||||
day: Mapped[date] = mapped_column(Date, primary_key=True)
|
day: Mapped[date] = mapped_column(Date, primary_key=True)
|
||||||
units_used: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
units_used: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
|
||||||
|
|
||||||
|
class AppState(Base):
|
||||||
|
"""Single-row global app state (admin-controlled)."""
|
||||||
|
|
||||||
|
__tablename__ = "app_state"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, default=1)
|
||||||
|
sync_paused: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False, server_default="false"
|
||||||
|
)
|
||||||
|
|
|
||||||
282
backend/app/routes/feed.py
Normal file
282
backend/app/routes/feed.py
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import Select, and_, false, func, or_, select
|
||||||
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
|
from app.auth import current_user
|
||||||
|
from app.db import get_db
|
||||||
|
from app.models import Channel, ChannelTag, Subscription, Tag, User, Video, VideoState
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["feed"])
|
||||||
|
|
||||||
|
VALID_STATES = {"new", "watched", "saved", "hidden"}
|
||||||
|
HIDDEN_LIVE = ("live", "upcoming")
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_url(channel_id: str, handle: str | None) -> str:
|
||||||
|
if handle and handle.startswith("@"):
|
||||||
|
return f"https://www.youtube.com/{handle}"
|
||||||
|
return f"https://www.youtube.com/channel/{channel_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(row) -> dict:
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"title": row.title,
|
||||||
|
"channel_id": row.channel_id,
|
||||||
|
"channel_title": row.channel_title,
|
||||||
|
"channel_thumbnail": row.channel_thumbnail,
|
||||||
|
"channel_url": _channel_url(row.channel_id, row.channel_handle),
|
||||||
|
"published_at": row.published_at.isoformat() if row.published_at else None,
|
||||||
|
"thumbnail_url": row.thumbnail_url,
|
||||||
|
"duration_seconds": row.duration_seconds,
|
||||||
|
"view_count": row.view_count,
|
||||||
|
"is_short": row.is_short,
|
||||||
|
"live_status": row.live_status,
|
||||||
|
"status": row.status or "new",
|
||||||
|
"watch_url": f"https://www.youtube.com/watch?v={row.id}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _filtered_query(
|
||||||
|
db: Session,
|
||||||
|
user: User,
|
||||||
|
*,
|
||||||
|
tags: list[int],
|
||||||
|
tag_mode: str,
|
||||||
|
channel_id: str | None,
|
||||||
|
q: str | None,
|
||||||
|
min_duration: int | None,
|
||||||
|
max_duration: int | None,
|
||||||
|
max_age_days: int | None,
|
||||||
|
published_after: date | None,
|
||||||
|
published_before: date | None,
|
||||||
|
show_normal: bool,
|
||||||
|
include_shorts: bool,
|
||||||
|
include_live: bool,
|
||||||
|
show: str,
|
||||||
|
) -> tuple[Select, object]:
|
||||||
|
"""Build the feed query (joins + all WHERE filters), shared by /feed and /feed/count.
|
||||||
|
Returns the column-bearing select plus the watch-status expression for sorting."""
|
||||||
|
state = aliased(VideoState)
|
||||||
|
status_expr = func.coalesce(state.status, "new")
|
||||||
|
|
||||||
|
query = (
|
||||||
|
select(
|
||||||
|
Video.id,
|
||||||
|
Video.title,
|
||||||
|
Video.channel_id,
|
||||||
|
Channel.title.label("channel_title"),
|
||||||
|
Channel.thumbnail_url.label("channel_thumbnail"),
|
||||||
|
Channel.handle.label("channel_handle"),
|
||||||
|
Video.published_at,
|
||||||
|
Video.thumbnail_url,
|
||||||
|
Video.duration_seconds,
|
||||||
|
Video.view_count,
|
||||||
|
Video.is_short,
|
||||||
|
Video.live_status,
|
||||||
|
status_expr.label("status"),
|
||||||
|
)
|
||||||
|
.join(Channel, Channel.id == Video.channel_id)
|
||||||
|
# Only channels this user is subscribed to (and hasn't hidden).
|
||||||
|
.join(
|
||||||
|
Subscription,
|
||||||
|
and_(
|
||||||
|
Subscription.channel_id == Video.channel_id,
|
||||||
|
Subscription.user_id == user.id,
|
||||||
|
Subscription.hidden.is_(False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id))
|
||||||
|
)
|
||||||
|
|
||||||
|
if channel_id:
|
||||||
|
query = query.where(Video.channel_id == channel_id)
|
||||||
|
if min_duration is not None:
|
||||||
|
query = query.where(Video.duration_seconds >= min_duration)
|
||||||
|
if max_duration is not None:
|
||||||
|
query = query.where(Video.duration_seconds <= max_duration)
|
||||||
|
if max_age_days is not None:
|
||||||
|
cutoff = datetime.now(timezone.utc).timestamp() - max_age_days * 86400
|
||||||
|
query = query.where(
|
||||||
|
Video.published_at >= datetime.fromtimestamp(cutoff, tz=timezone.utc)
|
||||||
|
)
|
||||||
|
if published_after is not None:
|
||||||
|
start = datetime.combine(
|
||||||
|
published_after, datetime.min.time(), tzinfo=timezone.utc
|
||||||
|
)
|
||||||
|
query = query.where(Video.published_at >= start)
|
||||||
|
if published_before is not None:
|
||||||
|
end = datetime.combine(
|
||||||
|
published_before, datetime.min.time(), tzinfo=timezone.utc
|
||||||
|
) + timedelta(days=1)
|
||||||
|
query = query.where(Video.published_at < end)
|
||||||
|
if q:
|
||||||
|
like = f"%{q}%"
|
||||||
|
query = query.where(or_(Video.title.ilike(like), Channel.title.ilike(like)))
|
||||||
|
|
||||||
|
if tags:
|
||||||
|
# AND across tag categories (e.g. language AND topic narrows), OR within a
|
||||||
|
# category; the any/all toggle controls multiple topic tags.
|
||||||
|
cat_rows = db.execute(select(Tag.id, Tag.category).where(Tag.id.in_(tags))).all()
|
||||||
|
by_category: dict[str, list[int]] = {}
|
||||||
|
for tag_id, category in cat_rows:
|
||||||
|
by_category.setdefault(category, []).append(tag_id)
|
||||||
|
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
||||||
|
for category, ids in by_category.items():
|
||||||
|
if category == "topic" and tag_mode == "and" and len(ids) > 1:
|
||||||
|
sub = (
|
||||||
|
select(ChannelTag.channel_id)
|
||||||
|
.where(ChannelTag.tag_id.in_(ids), visible)
|
||||||
|
.group_by(ChannelTag.channel_id)
|
||||||
|
.having(func.count(func.distinct(ChannelTag.tag_id)) == len(set(ids)))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sub = select(ChannelTag.channel_id).where(
|
||||||
|
ChannelTag.tag_id.in_(ids), visible
|
||||||
|
)
|
||||||
|
query = query.where(Video.channel_id.in_(sub))
|
||||||
|
|
||||||
|
# Content type: Normal / Shorts / Live·Upcoming as a union of enabled types.
|
||||||
|
# Explicit Watched/Saved/Hidden views show every type so nothing goes missing.
|
||||||
|
explicit_view = show in ("watched", "saved", "hidden")
|
||||||
|
if not explicit_view:
|
||||||
|
type_clauses = []
|
||||||
|
if show_normal:
|
||||||
|
type_clauses.append(
|
||||||
|
and_(Video.is_short.is_(False), Video.live_status.notin_(HIDDEN_LIVE))
|
||||||
|
)
|
||||||
|
if include_shorts:
|
||||||
|
type_clauses.append(Video.is_short.is_(True))
|
||||||
|
if include_live:
|
||||||
|
type_clauses.append(Video.live_status.in_(HIDDEN_LIVE))
|
||||||
|
query = query.where(or_(*type_clauses) if type_clauses else false())
|
||||||
|
|
||||||
|
if show == "unwatched":
|
||||||
|
query = query.where(status_expr.notin_(("watched", "hidden")))
|
||||||
|
elif show == "watched":
|
||||||
|
query = query.where(status_expr == "watched")
|
||||||
|
elif show == "saved":
|
||||||
|
query = query.where(status_expr == "saved")
|
||||||
|
elif show == "hidden":
|
||||||
|
query = query.where(status_expr == "hidden")
|
||||||
|
else: # all
|
||||||
|
query = query.where(status_expr != "hidden")
|
||||||
|
|
||||||
|
return query, status_expr
|
||||||
|
|
||||||
|
|
||||||
|
# Shared query parameters for /feed and /feed/count.
|
||||||
|
def _feed_params(
|
||||||
|
tags: list[int] = Query(default=[]),
|
||||||
|
tag_mode: str = "or",
|
||||||
|
channel_id: str | None = None,
|
||||||
|
q: str | None = None,
|
||||||
|
min_duration: int | None = None,
|
||||||
|
max_duration: int | None = None,
|
||||||
|
max_age_days: int | None = None,
|
||||||
|
published_after: date | None = None,
|
||||||
|
published_before: date | None = None,
|
||||||
|
show_normal: bool = True,
|
||||||
|
include_shorts: bool = False,
|
||||||
|
include_live: bool = False,
|
||||||
|
show: str = "unwatched",
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"tags": tags,
|
||||||
|
"tag_mode": tag_mode,
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"q": q,
|
||||||
|
"min_duration": min_duration,
|
||||||
|
"max_duration": max_duration,
|
||||||
|
"max_age_days": max_age_days,
|
||||||
|
"published_after": published_after,
|
||||||
|
"published_before": published_before,
|
||||||
|
"show_normal": show_normal,
|
||||||
|
"include_shorts": include_shorts,
|
||||||
|
"include_live": include_live,
|
||||||
|
"show": show,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
SORTS = {
|
||||||
|
"newest": Video.published_at.desc().nulls_last(),
|
||||||
|
"oldest": Video.published_at.asc().nulls_last(),
|
||||||
|
"views": Video.view_count.desc().nulls_last(),
|
||||||
|
"duration_desc": Video.duration_seconds.desc().nulls_last(),
|
||||||
|
"duration_asc": Video.duration_seconds.asc().nulls_last(),
|
||||||
|
"title": func.lower(Video.title).asc().nulls_last(),
|
||||||
|
"subscribers": Channel.subscriber_count.desc().nulls_last(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feed")
|
||||||
|
def get_feed(
|
||||||
|
params: dict = Depends(_feed_params),
|
||||||
|
sort: str = "newest",
|
||||||
|
seed: int = 0,
|
||||||
|
limit: int = Query(default=60, le=200),
|
||||||
|
offset: int = 0,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
query, _status = _filtered_query(db, user, **params)
|
||||||
|
order = SORTS.get(sort)
|
||||||
|
if order is None and sort == "shuffle":
|
||||||
|
order = func.md5(func.concat(Video.id, str(seed)))
|
||||||
|
query = query.order_by(order if order is not None else SORTS["newest"])
|
||||||
|
|
||||||
|
rows = db.execute(query.offset(offset).limit(limit + 1)).all()
|
||||||
|
has_more = len(rows) > limit
|
||||||
|
return {
|
||||||
|
"items": [_serialize(r) for r in rows[:limit]],
|
||||||
|
"has_more": has_more,
|
||||||
|
"offset": offset,
|
||||||
|
"limit": limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feed/count")
|
||||||
|
def get_feed_count(
|
||||||
|
params: dict = Depends(_feed_params),
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
query, _status = _filtered_query(db, user, **params)
|
||||||
|
total = db.scalar(select(func.count()).select_from(query.subquery()))
|
||||||
|
return {"count": total or 0}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/videos/{video_id}/state")
|
||||||
|
def set_video_state(
|
||||||
|
video_id: str,
|
||||||
|
payload: dict,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
status = payload.get("status")
|
||||||
|
if status not in VALID_STATES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATES}")
|
||||||
|
if db.get(Video, video_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown video")
|
||||||
|
|
||||||
|
row = db.execute(
|
||||||
|
select(VideoState).where(
|
||||||
|
VideoState.user_id == user.id, VideoState.video_id == video_id
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if status == "new":
|
||||||
|
if row is not None:
|
||||||
|
db.delete(row)
|
||||||
|
db.commit()
|
||||||
|
return {"video_id": video_id, "status": "new"}
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
row = VideoState(user_id=user.id, video_id=video_id)
|
||||||
|
db.add(row)
|
||||||
|
row.status = status
|
||||||
|
row.watched_at = datetime.now(timezone.utc) if status == "watched" else row.watched_at
|
||||||
|
db.commit()
|
||||||
|
return {"video_id": video_id, "status": status}
|
||||||
35
backend/app/routes/me.py
Normal file
35
backend/app/routes/me.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth import current_user
|
||||||
|
from app.db import get_db
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/me", tags=["me"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def get_me(user: User = Depends(current_user)) -> dict:
|
||||||
|
return {
|
||||||
|
"id": user.id,
|
||||||
|
"email": user.email,
|
||||||
|
"display_name": user.display_name,
|
||||||
|
"avatar_url": user.avatar_url,
|
||||||
|
"role": user.role,
|
||||||
|
"preferences": user.preferences or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/preferences")
|
||||||
|
def update_preferences(
|
||||||
|
preferences: dict,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
# Merge so partial updates don't wipe other keys.
|
||||||
|
merged = dict(user.preferences or {})
|
||||||
|
merged.update(preferences)
|
||||||
|
user.preferences = merged
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
return {"preferences": merged}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import quota
|
from app import quota, state
|
||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Channel, Subscription, User, Video
|
from app.models import Channel, Subscription, User, Video
|
||||||
|
|
@ -79,7 +79,35 @@ def sync_status(
|
||||||
return {
|
return {
|
||||||
"subscriptions": sub_count,
|
"subscriptions": sub_count,
|
||||||
"channels_total": db.scalar(select(func.count()).select_from(Channel)),
|
"channels_total": db.scalar(select(func.count()).select_from(Channel)),
|
||||||
|
"channels_backfilling": db.scalar(
|
||||||
|
select(func.count()).select_from(Channel).where(Channel.backfill_done.is_(False))
|
||||||
|
),
|
||||||
"videos_total": db.scalar(select(func.count()).select_from(Video)),
|
"videos_total": db.scalar(select(func.count()).select_from(Video)),
|
||||||
|
"pending_enrich": db.scalar(
|
||||||
|
select(func.count()).select_from(Video).where(Video.enriched_at.is_(None))
|
||||||
|
),
|
||||||
"quota_used_today": quota.units_used_today(db),
|
"quota_used_today": quota.units_used_today(db),
|
||||||
"quota_remaining_today": quota.remaining_today(db),
|
"quota_remaining_today": quota.remaining_today(db),
|
||||||
|
"paused": state.is_sync_paused(db),
|
||||||
|
"is_admin": user.role == "admin",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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}
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,15 @@ from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
|
from app.state import is_sync_paused
|
||||||
from app.sync.autotag import run_autotag_all
|
from app.sync.autotag import run_autotag_all
|
||||||
from app.sync.runner import (
|
from app.sync.runner import (
|
||||||
run_deep_backfill,
|
run_deep_backfill,
|
||||||
run_enrich,
|
run_enrich,
|
||||||
run_recent_backfill,
|
run_recent_backfill,
|
||||||
run_rss_poll,
|
run_rss_poll,
|
||||||
|
run_shorts,
|
||||||
|
run_subscription_resync,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("subfeed.scheduler")
|
logger = logging.getLogger("subfeed.scheduler")
|
||||||
|
|
@ -22,6 +25,9 @@ _scheduler: BackgroundScheduler | None = None
|
||||||
def _job(name: str, fn) -> None:
|
def _job(name: str, fn) -> None:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
if is_sync_paused(db):
|
||||||
|
logger.info("job %s skipped (sync paused)", name)
|
||||||
|
return
|
||||||
result = fn(db)
|
result = fn(db)
|
||||||
logger.info("job %s -> %s", name, result)
|
logger.info("job %s -> %s", name, result)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -53,6 +59,14 @@ def _autotag_job() -> None:
|
||||||
_job("autotag", lambda db: run_autotag_all(db, only_missing=True))
|
_job("autotag", lambda db: run_autotag_all(db, only_missing=True))
|
||||||
|
|
||||||
|
|
||||||
|
def _shorts_job() -> None:
|
||||||
|
_job("shorts", run_shorts)
|
||||||
|
|
||||||
|
|
||||||
|
def _subscriptions_job() -> None:
|
||||||
|
_job("subscriptions", run_subscription_resync)
|
||||||
|
|
||||||
|
|
||||||
def start_scheduler() -> None:
|
def start_scheduler() -> None:
|
||||||
global _scheduler
|
global _scheduler
|
||||||
if not settings.scheduler_enabled or _scheduler is not None:
|
if not settings.scheduler_enabled or _scheduler is not None:
|
||||||
|
|
@ -76,6 +90,18 @@ def start_scheduler() -> None:
|
||||||
minutes=settings.autotag_interval_minutes,
|
minutes=settings.autotag_interval_minutes,
|
||||||
id="autotag",
|
id="autotag",
|
||||||
)
|
)
|
||||||
|
scheduler.add_job(
|
||||||
|
_shorts_job,
|
||||||
|
"interval",
|
||||||
|
minutes=settings.shorts_probe_interval_minutes,
|
||||||
|
id="shorts",
|
||||||
|
)
|
||||||
|
scheduler.add_job(
|
||||||
|
_subscriptions_job,
|
||||||
|
"interval",
|
||||||
|
minutes=settings.subscriptions_resync_minutes,
|
||||||
|
id="subscriptions",
|
||||||
|
)
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
_scheduler = scheduler
|
_scheduler = scheduler
|
||||||
logger.info("scheduler started")
|
logger.info("scheduler started")
|
||||||
|
|
|
||||||
29
backend/app/state.py
Normal file
29
backend/app/state.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""Global admin-controlled app state (e.g. pausing background sync)."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import AppState
|
||||||
|
|
||||||
|
log = logging.getLogger("subfeed.state")
|
||||||
|
|
||||||
|
|
||||||
|
def _row(db: Session) -> AppState:
|
||||||
|
row = db.get(AppState, 1)
|
||||||
|
if row is None:
|
||||||
|
row = AppState(id=1, sync_paused=False)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def is_sync_paused(db: Session) -> bool:
|
||||||
|
return _row(db).sync_paused
|
||||||
|
|
||||||
|
|
||||||
|
def set_sync_paused(db: Session, paused: bool) -> None:
|
||||||
|
row = _row(db)
|
||||||
|
row.sync_paused = paused
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
log.info("Background sync %s", "paused" if paused else "resumed")
|
||||||
|
|
@ -5,9 +5,14 @@ detection over a sample of recent video titles. Topics are mapped from YouTube's
|
||||||
topicDetails categories and the channel's dominant video category. System tags are
|
topicDetails categories and the channel's dominant video category. System tags are
|
||||||
regenerated freely; user tags are never touched here.
|
regenerated freely; user tags are never touched here.
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
from sqlalchemy import and_, exists, func, select
|
from sqlalchemy import and_, exists, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
log = logging.getLogger("subfeed.autotag")
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models import Channel, ChannelTag, Tag, Video
|
from app.models import Channel, ChannelTag, Tag, Video
|
||||||
|
|
||||||
|
|
@ -143,9 +148,18 @@ def map_topic_slug(slug: str) -> str | None:
|
||||||
return _TOPIC_SLUGS.get(slug)
|
return _TOPIC_SLUGS.get(slug)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_title(title: str | None) -> str:
|
||||||
|
"""Strip emojis, @mentions, #tags, URLs, numbers and punctuation so the language
|
||||||
|
detector sees actual words, not caps/emoji-heavy noise."""
|
||||||
|
text = title or ""
|
||||||
|
text = re.sub(r"http\S+", " ", text)
|
||||||
|
text = re.sub(r"[@#]\w+", " ", text)
|
||||||
|
text = re.sub(r"\d+", " ", text)
|
||||||
|
text = re.sub(r"[^\w\s]", " ", text, flags=re.UNICODE).replace("_", " ")
|
||||||
|
return " ".join(w for w in text.split() if len(w) > 1)
|
||||||
|
|
||||||
|
|
||||||
def detect_channel_language(db: Session, channel: Channel) -> tuple[str | None, float]:
|
def detect_channel_language(db: Session, channel: Channel) -> tuple[str | None, float]:
|
||||||
if channel.default_language:
|
|
||||||
return channel.default_language.split("-")[0].lower(), 0.99
|
|
||||||
titles = (
|
titles = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(Video.title)
|
select(Video.title)
|
||||||
|
|
@ -156,11 +170,16 @@ def detect_channel_language(db: Session, channel: Channel) -> tuple[str | None,
|
||||||
.scalars()
|
.scalars()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
text = " . ".join(t for t in titles if t).strip()
|
# Detect over one cleaned, concatenated blob — more context and far less skew from
|
||||||
if len(text) < 15:
|
# short, emoji/caps-heavy titles than per-title voting.
|
||||||
return None, 0.0
|
blob = " ".join(_clean_title(t) for t in titles).strip()
|
||||||
lang, confidence = _classify(text)
|
if len(blob) >= 15:
|
||||||
|
lang, confidence = _classify(blob)
|
||||||
return lang, float(confidence)
|
return lang, float(confidence)
|
||||||
|
# Sparse text: fall back to the channel's declared language if any.
|
||||||
|
if channel.default_language:
|
||||||
|
return channel.default_language.split("-")[0].lower(), 0.6
|
||||||
|
return None, 0.0
|
||||||
|
|
||||||
|
|
||||||
def compute_channel_topics(db: Session, channel: Channel) -> set[str]:
|
def compute_channel_topics(db: Session, channel: Channel) -> set[str]:
|
||||||
|
|
@ -257,6 +276,7 @@ def run_autotag_all(db: Session, only_missing: bool = False) -> dict:
|
||||||
tagged += 1
|
tagged += 1
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
log.exception("Auto-tagging failed for channel %s", channel.id)
|
||||||
removed = _cleanup_orphan_system_tags(db)
|
removed = _cleanup_orphan_system_tags(db)
|
||||||
return {"channels_tagged": tagged, "orphan_tags_removed": removed}
|
return {"channels_tagged": tagged, "orphan_tags_removed": removed}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,23 @@ Channel/video data is shared across users, so background work acts through a "se
|
||||||
user" (any user with a stored refresh token) unless a YOUTUBE_API_KEY is configured for
|
user" (any user with a stored refresh token) unless a YOUTUBE_API_KEY is configured for
|
||||||
public reads.
|
public reads.
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import quota
|
from app import quota
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
log = logging.getLogger("subfeed.sync")
|
||||||
from app.models import Channel, OAuthToken, User
|
from app.models import Channel, OAuthToken, User
|
||||||
|
from app.sync.subscriptions import import_subscriptions
|
||||||
from app.sync.videos import (
|
from app.sync.videos import (
|
||||||
backfill_channel_deep,
|
backfill_channel_deep,
|
||||||
backfill_channel_recent,
|
backfill_channel_recent,
|
||||||
enrich_pending,
|
enrich_pending,
|
||||||
poll_rss_channel,
|
poll_rss_channel,
|
||||||
|
run_shorts_classification,
|
||||||
)
|
)
|
||||||
from app.youtube.client import YouTubeClient
|
from app.youtube.client import YouTubeClient
|
||||||
|
|
||||||
|
|
@ -41,6 +47,7 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> int:
|
||||||
new += poll_rss_channel(db, channel)
|
new += poll_rss_channel(db, channel)
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
log.exception("RSS poll failed for channel %s", channel.id)
|
||||||
return new
|
return new
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -60,6 +67,31 @@ def run_enrich(db: Session, max_batches: int = 200, floor: int = 200) -> int:
|
||||||
return total
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def run_shorts(db: Session) -> dict:
|
||||||
|
return run_shorts_classification(db)
|
||||||
|
|
||||||
|
|
||||||
|
def run_subscription_resync(db: Session) -> dict:
|
||||||
|
"""Re-import every user's subscriptions so unsubscribes and new subscriptions on
|
||||||
|
YouTube are reflected automatically."""
|
||||||
|
users = (
|
||||||
|
db.execute(
|
||||||
|
select(User)
|
||||||
|
.join(OAuthToken)
|
||||||
|
.where(OAuthToken.refresh_token_enc.is_not(None))
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for user in users:
|
||||||
|
try:
|
||||||
|
import_subscriptions(db, user)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
log.exception("Subscription resync failed for user %s", user.id)
|
||||||
|
return {"users": len(users)}
|
||||||
|
|
||||||
|
|
||||||
def run_recent_backfill(
|
def run_recent_backfill(
|
||||||
db: Session, channels: list[Channel] | None = None, max_channels: int | None = None
|
db: Session, channels: list[Channel] | None = None, max_channels: int | None = None
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|
@ -85,6 +117,7 @@ def run_recent_backfill(
|
||||||
processed += 1
|
processed += 1
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
log.exception("Recent backfill failed for channel %s", channel.id)
|
||||||
return {"channels_processed": processed, "videos_added": videos_added}
|
return {"channels_processed": processed, "videos_added": videos_added}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -113,4 +146,5 @@ def run_deep_backfill(db: Session, max_channels: int = 10, max_pages: int = 5) -
|
||||||
processed += 1
|
processed += 1
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
log.exception("Deep backfill failed for channel %s", channel.id)
|
||||||
return {"channels_processed": processed, "videos_added": videos_added}
|
return {"channels_processed": processed, "videos_added": videos_added}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""Import the authenticated user's YouTube subscriptions and channel metadata."""
|
"""Import the authenticated user's YouTube subscriptions and channel metadata."""
|
||||||
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
@ -7,6 +8,8 @@ from sqlalchemy.orm import Session
|
||||||
from app.models import Channel, Subscription, User
|
from app.models import Channel, Subscription, User
|
||||||
from app.youtube.client import YouTubeClient, best_thumbnail
|
from app.youtube.client import YouTubeClient, best_thumbnail
|
||||||
|
|
||||||
|
log = logging.getLogger("subfeed.sync")
|
||||||
|
|
||||||
|
|
||||||
def _to_int(value) -> int | None:
|
def _to_int(value) -> int | None:
|
||||||
try:
|
try:
|
||||||
|
|
@ -107,9 +110,11 @@ def import_subscriptions(db: Session, user: User) -> dict:
|
||||||
detailed = len(items)
|
detailed = len(items)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {
|
result = {
|
||||||
"subscriptions": len(fetched),
|
"subscriptions": len(fetched),
|
||||||
"channels_new": new_channels,
|
"channels_new": new_channels,
|
||||||
"channels_detailed": detailed,
|
"channels_detailed": detailed,
|
||||||
"removed_stale": removed,
|
"removed_stale": removed,
|
||||||
}
|
}
|
||||||
|
log.info("Subscription import (user %s): %s", user.id, result)
|
||||||
|
return result
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@
|
||||||
uploads playlist, and enrichment via videos.list (duration, stats, category, Shorts
|
uploads playlist, and enrichment via videos.list (duration, stats, category, Shorts
|
||||||
and livestream classification)."""
|
and livestream classification)."""
|
||||||
import re
|
import re
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import or_, select, update
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -12,6 +13,7 @@ from app.config import settings
|
||||||
from app.models import Channel, Video
|
from app.models import Channel, Video
|
||||||
from app.youtube.client import YouTubeClient, best_thumbnail
|
from app.youtube.client import YouTubeClient, best_thumbnail
|
||||||
from app.youtube.rss import fetch_channel_feed
|
from app.youtube.rss import fetch_channel_feed
|
||||||
|
from app.youtube.shorts import make_client, probe_is_short
|
||||||
|
|
||||||
_DURATION_RE = re.compile(
|
_DURATION_RE = re.compile(
|
||||||
r"P(?:(\d+)D)?T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", re.IGNORECASE
|
r"P(?:(\d+)D)?T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", re.IGNORECASE
|
||||||
|
|
@ -195,11 +197,7 @@ def apply_video_details(video: Video, item: dict) -> None:
|
||||||
"defaultAudioLanguage"
|
"defaultAudioLanguage"
|
||||||
)
|
)
|
||||||
video.live_status = _live_status(snippet, live)
|
video.live_status = _live_status(snippet, live)
|
||||||
video.is_short = bool(
|
# is_short is decided separately by the youtube.com/shorts probe (run_shorts_classification).
|
||||||
video.live_status == "none"
|
|
||||||
and video.duration_seconds
|
|
||||||
and 0 < video.duration_seconds <= settings.shorts_max_seconds
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def enrich_pending(db: Session, yt: YouTubeClient, limit: int | None = None) -> int:
|
def enrich_pending(db: Session, yt: YouTubeClient, limit: int | None = None) -> int:
|
||||||
|
|
@ -225,3 +223,60 @@ def enrich_pending(db: Session, yt: YouTubeClient, limit: int | None = None) ->
|
||||||
enriched += 1
|
enriched += 1
|
||||||
db.commit()
|
db.commit()
|
||||||
return enriched
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
|
def run_shorts_classification(db: Session, limit: int | None = None) -> dict:
|
||||||
|
"""Finalize Shorts classification: cheaply mark non-candidates, then probe the
|
||||||
|
youtube.com/shorts URL for short-enough, non-live, enriched videos."""
|
||||||
|
limit = limit or settings.shorts_probe_batch
|
||||||
|
probe_max = settings.shorts_probe_max_seconds
|
||||||
|
|
||||||
|
# 1) Anything enriched that can't be a Short -> finalize without a probe.
|
||||||
|
db.execute(
|
||||||
|
update(Video)
|
||||||
|
.where(
|
||||||
|
Video.shorts_probed.is_(False),
|
||||||
|
Video.enriched_at.is_not(None),
|
||||||
|
or_(
|
||||||
|
Video.duration_seconds.is_(None),
|
||||||
|
Video.duration_seconds > probe_max,
|
||||||
|
Video.live_status != "none",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.values(is_short=False, shorts_probed=True)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 2) Probe the remaining candidates.
|
||||||
|
candidates = (
|
||||||
|
db.execute(
|
||||||
|
select(Video).where(
|
||||||
|
Video.shorts_probed.is_(False),
|
||||||
|
Video.enriched_at.is_not(None),
|
||||||
|
Video.duration_seconds <= probe_max,
|
||||||
|
Video.live_status == "none",
|
||||||
|
).limit(limit)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not candidates:
|
||||||
|
return {"probed": 0, "shorts": 0}
|
||||||
|
|
||||||
|
probed = 0
|
||||||
|
shorts = 0
|
||||||
|
with make_client() as client:
|
||||||
|
def work(video: Video):
|
||||||
|
return video, probe_is_short(client, video.id)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=16) as pool:
|
||||||
|
for video, result in pool.map(work, candidates):
|
||||||
|
if result is None:
|
||||||
|
continue # leave unprobed; retry on a later run
|
||||||
|
video.is_short = result
|
||||||
|
video.shorts_probed = True
|
||||||
|
probed += 1
|
||||||
|
if result:
|
||||||
|
shorts += 1
|
||||||
|
db.commit()
|
||||||
|
return {"probed": probed, "shorts": shorts}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ Public reads (channels/videos/playlistItems) use the configured API key when ava
|
||||||
so they don't depend on a specific user's token; subscriptions.list?mine=true always
|
so they don't depend on a specific user's token; subscriptions.list?mine=true always
|
||||||
uses OAuth.
|
uses OAuth.
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
|
@ -15,6 +16,8 @@ from app.config import settings
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.security import decrypt
|
from app.security import decrypt
|
||||||
|
|
||||||
|
log = logging.getLogger("subfeed.youtube")
|
||||||
|
|
||||||
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||||
API_BASE = "https://www.googleapis.com/youtube/v3"
|
API_BASE = "https://www.googleapis.com/youtube/v3"
|
||||||
|
|
||||||
|
|
@ -76,6 +79,7 @@ class YouTubeClient:
|
||||||
tok.expiry = now + timedelta(seconds=int(data.get("expires_in", 3600)))
|
tok.expiry = now + timedelta(seconds=int(data.get("expires_in", 3600)))
|
||||||
self.db.add(tok)
|
self.db.add(tok)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
log.info("Refreshed access token for user %s", self.user.id)
|
||||||
return tok.access_token
|
return tok.access_token
|
||||||
|
|
||||||
# --- core request ---
|
# --- core request ---
|
||||||
|
|
@ -89,6 +93,7 @@ class YouTubeClient:
|
||||||
resp = self._http.get(f"{API_BASE}/{path}", params=p, headers=headers)
|
resp = self._http.get(f"{API_BASE}/{path}", params=p, headers=headers)
|
||||||
quota.record_usage(self.db, cost)
|
quota.record_usage(self.db, cost)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
|
log.warning("YouTube API %s -> %s: %s", path, resp.status_code, resp.text[:200])
|
||||||
raise YouTubeError(f"GET {path} -> {resp.status_code}: {resp.text[:300]}")
|
raise YouTubeError(f"GET {path} -> {resp.status_code}: {resp.text[:300]}")
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
|
||||||
32
backend/app/youtube/shorts.py
Normal file
32
backend/app/youtube/shorts.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""Confirm whether a video is a Short by probing youtube.com/shorts/<id>.
|
||||||
|
|
||||||
|
A real Short returns HTTP 200 at that URL; a regular video redirects to /watch.
|
||||||
|
This uses no API quota."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
SHORTS_URL = "https://www.youtube.com/shorts/{video_id}"
|
||||||
|
# The SOCS cookie skips YouTube's cookie-consent interstitial, which otherwise
|
||||||
|
# redirects every server-side request to consent.youtube.com.
|
||||||
|
_HEADERS = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||||
|
"Cookie": "SOCS=CAI",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def probe_is_short(client: httpx.Client, video_id: str) -> bool | None:
|
||||||
|
"""True if a Short, False if a regular video, None if undetermined (retry later)."""
|
||||||
|
try:
|
||||||
|
resp = client.get(
|
||||||
|
SHORTS_URL.format(video_id=video_id), follow_redirects=False
|
||||||
|
)
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return None
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return True
|
||||||
|
if resp.status_code in (301, 302, 303, 307, 308):
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def make_client() -> httpx.Client:
|
||||||
|
return httpx.Client(timeout=10.0, headers=_HEADERS)
|
||||||
|
|
@ -5,4 +5,4 @@ echo "Applying database migrations..."
|
||||||
alembic upgrade head
|
alembic upgrade head
|
||||||
|
|
||||||
echo "Starting Subfeed API..."
|
echo "Starting Subfeed API..."
|
||||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
|
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --log-config log_config.json
|
||||||
|
|
|
||||||
40
backend/log_config.json
Normal file
40
backend/log_config.json
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": false,
|
||||||
|
"formatters": {
|
||||||
|
"default": {
|
||||||
|
"()": "uvicorn.logging.DefaultFormatter",
|
||||||
|
"fmt": "%(asctime)s %(levelprefix)s [%(name)s] %(message)s"
|
||||||
|
},
|
||||||
|
"access": {
|
||||||
|
"()": "uvicorn.logging.AccessFormatter",
|
||||||
|
"fmt": "%(asctime)s %(levelprefix)s %(client_addr)s - \"%(request_line)s\" %(status_code)s"
|
||||||
|
},
|
||||||
|
"plain": {
|
||||||
|
"format": "%(asctime)s %(levelname)-5s [%(name)s] %(message)s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"default": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "default",
|
||||||
|
"stream": "ext://sys.stdout"
|
||||||
|
},
|
||||||
|
"access": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "access",
|
||||||
|
"stream": "ext://sys.stdout"
|
||||||
|
},
|
||||||
|
"plain": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "plain",
|
||||||
|
"stream": "ext://sys.stdout"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"loggers": {
|
||||||
|
"uvicorn": { "handlers": ["default"], "level": "INFO", "propagate": false },
|
||||||
|
"uvicorn.error": { "handlers": ["default"], "level": "INFO", "propagate": false },
|
||||||
|
"uvicorn.access": { "handlers": ["access"], "level": "INFO", "propagate": false },
|
||||||
|
"subfeed": { "handlers": ["plain"], "level": "INFO", "propagate": false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,8 @@ services:
|
||||||
|
|
||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-subfeed}:${POSTGRES_PASSWORD:-subfeed}@db:5432/${POSTGRES_DB:-subfeed}
|
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-subfeed}:${POSTGRES_PASSWORD:-subfeed}@db:5432/${POSTGRES_DB:-subfeed}
|
||||||
|
|
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-theme="dark" data-scheme="midnight">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Subfeed</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"name": "subfeed-frontend",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.51.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.408.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"postcss": "^8.4.39",
|
||||||
|
"tailwindcss": "^3.4.6",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"vite": "^5.3.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
106
frontend/src/App.tsx
Normal file
106
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api, HttpError, type FeedFilters } from "./lib/api";
|
||||||
|
import {
|
||||||
|
applyTheme,
|
||||||
|
DEFAULT_THEME,
|
||||||
|
loadLocalTheme,
|
||||||
|
saveLocalTheme,
|
||||||
|
type ThemePrefs,
|
||||||
|
} from "./lib/theme";
|
||||||
|
import Login from "./components/Login";
|
||||||
|
import Header from "./components/Header";
|
||||||
|
import Sidebar from "./components/Sidebar";
|
||||||
|
import Feed from "./components/Feed";
|
||||||
|
import Toaster from "./components/Toaster";
|
||||||
|
|
||||||
|
const DEFAULT_FILTERS: FeedFilters = {
|
||||||
|
tags: [],
|
||||||
|
tagMode: "or",
|
||||||
|
q: "",
|
||||||
|
sort: "newest",
|
||||||
|
includeNormal: true,
|
||||||
|
includeShorts: false,
|
||||||
|
includeLive: false,
|
||||||
|
show: "unwatched",
|
||||||
|
};
|
||||||
|
|
||||||
|
const FILTERS_KEY = "subfeed.filters";
|
||||||
|
|
||||||
|
function loadFilters(): FeedFilters {
|
||||||
|
try {
|
||||||
|
return { ...DEFAULT_FILTERS, ...JSON.parse(localStorage.getItem(FILTERS_KEY) || "{}") };
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_FILTERS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
||||||
|
const [filters, setFiltersState] = useState<FeedFilters>(loadFilters);
|
||||||
|
const [view, setView] = useState<"grid" | "list">("grid");
|
||||||
|
|
||||||
|
function setFilters(next: FeedFilters) {
|
||||||
|
setFiltersState(next);
|
||||||
|
localStorage.setItem(FILTERS_KEY, JSON.stringify(next));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => applyTheme(theme), [theme]);
|
||||||
|
|
||||||
|
const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me });
|
||||||
|
|
||||||
|
// On login, adopt server-stored preferences.
|
||||||
|
useEffect(() => {
|
||||||
|
const prefs = meQuery.data?.preferences;
|
||||||
|
if (!prefs) return;
|
||||||
|
if (prefs.theme) {
|
||||||
|
const merged = { ...DEFAULT_THEME, ...prefs.theme };
|
||||||
|
setThemeState(merged);
|
||||||
|
saveLocalTheme(merged);
|
||||||
|
}
|
||||||
|
if (prefs.view === "grid" || prefs.view === "list") setView(prefs.view);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [meQuery.data?.id]);
|
||||||
|
|
||||||
|
function setTheme(next: ThemePrefs) {
|
||||||
|
setThemeState(next);
|
||||||
|
saveLocalTheme(next);
|
||||||
|
api.savePrefs({ theme: next }).catch(() => {});
|
||||||
|
}
|
||||||
|
function changeView(v: "grid" | "list") {
|
||||||
|
setView(v);
|
||||||
|
api.savePrefs({ view: v }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meQuery.isLoading)
|
||||||
|
return <div className="min-h-screen grid place-items-center text-muted">Loading…</div>;
|
||||||
|
if (meQuery.error instanceof HttpError && meQuery.error.status === 401)
|
||||||
|
return <Login />;
|
||||||
|
if (meQuery.error)
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen grid place-items-center text-muted">
|
||||||
|
Something went wrong.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col bg-bg text-fg">
|
||||||
|
<Header
|
||||||
|
me={meQuery.data!}
|
||||||
|
theme={theme}
|
||||||
|
setTheme={setTheme}
|
||||||
|
filters={filters}
|
||||||
|
setFilters={setFilters}
|
||||||
|
view={view}
|
||||||
|
setView={changeView}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-1 min-h-0">
|
||||||
|
<Sidebar filters={filters} setFilters={setFilters} />
|
||||||
|
<main className="flex-1 min-w-0 overflow-y-auto">
|
||||||
|
<Feed filters={filters} setFilters={setFilters} view={view} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<Toaster />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
frontend/src/components/Feed.tsx
Normal file
132
frontend/src/components/Feed.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api, type FeedFilters, type Video } from "../lib/api";
|
||||||
|
import { toast } from "../lib/toast";
|
||||||
|
import VideoCard from "./VideoCard";
|
||||||
|
|
||||||
|
const PAGE = 60;
|
||||||
|
|
||||||
|
function matchesView(status: string, show: string): boolean {
|
||||||
|
switch (show) {
|
||||||
|
case "hidden":
|
||||||
|
return status === "hidden";
|
||||||
|
case "watched":
|
||||||
|
return status === "watched";
|
||||||
|
case "saved":
|
||||||
|
return status === "saved";
|
||||||
|
case "unwatched":
|
||||||
|
return status !== "watched" && status !== "hidden";
|
||||||
|
default:
|
||||||
|
return status !== "hidden"; // all
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Feed({
|
||||||
|
filters,
|
||||||
|
setFilters,
|
||||||
|
view,
|
||||||
|
}: {
|
||||||
|
filters: FeedFilters;
|
||||||
|
setFilters: (f: FeedFilters) => void;
|
||||||
|
view: "grid" | "list";
|
||||||
|
}) {
|
||||||
|
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const query = useInfiniteQuery({
|
||||||
|
queryKey: ["feed", filters],
|
||||||
|
queryFn: ({ pageParam }) => api.feed(filters, pageParam as number, PAGE),
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (last) => (last.has_more ? last.offset + last.limit : undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => setOverrides({}), [filters]);
|
||||||
|
|
||||||
|
const countQuery = useQuery({
|
||||||
|
queryKey: ["feed-count", filters],
|
||||||
|
queryFn: () => api.feedCount(filters),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sentinel = useRef<HTMLDivElement | null>(null);
|
||||||
|
const { hasNextPage, isFetchingNextPage, fetchNextPage } = query;
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sentinel.current;
|
||||||
|
if (!el) return;
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "800px" }
|
||||||
|
);
|
||||||
|
io.observe(el);
|
||||||
|
return () => io.disconnect();
|
||||||
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
function onState(id: string, status: string) {
|
||||||
|
setOverrides((o) => ({ ...o, [id]: status }));
|
||||||
|
// Refetch once the server has the change so other views (e.g. Hidden) are in sync.
|
||||||
|
api
|
||||||
|
.setState(id, status)
|
||||||
|
.then(() => qc.invalidateQueries({ queryKey: ["feed"] }))
|
||||||
|
.catch(() => {});
|
||||||
|
if (status === "hidden") {
|
||||||
|
toast("Video hidden", { label: "Undo", onClick: () => onState(id, "new") });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChannelFilter(channelId: string, channelName: string) {
|
||||||
|
setFilters({ ...filters, channelId, channelName });
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: Video[] = (query.data?.pages ?? [])
|
||||||
|
.flatMap((p) => p.items)
|
||||||
|
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
|
||||||
|
.filter((v) => matchesView(v.status, filters.show));
|
||||||
|
|
||||||
|
if (query.isLoading) return <div className="p-8 text-muted">Loading feed…</div>;
|
||||||
|
if (query.isError) return <div className="p-8 text-muted">Couldn't load the feed.</div>;
|
||||||
|
if (items.length === 0)
|
||||||
|
return <div className="p-8 text-muted">No videos match these filters.</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="pb-3 text-sm text-muted">
|
||||||
|
{countQuery.data
|
||||||
|
? `${countQuery.data.count.toLocaleString()} video${countQuery.data.count === 1 ? "" : "s"}`
|
||||||
|
: " "}
|
||||||
|
</div>
|
||||||
|
{view === "grid" ? (
|
||||||
|
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
|
||||||
|
{items.map((v) => (
|
||||||
|
<VideoCard
|
||||||
|
key={v.id}
|
||||||
|
video={v}
|
||||||
|
view="grid"
|
||||||
|
onState={onState}
|
||||||
|
onChannelFilter={onChannelFilter}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-w-4xl mx-auto flex flex-col gap-1">
|
||||||
|
{items.map((v) => (
|
||||||
|
<VideoCard
|
||||||
|
key={v.id}
|
||||||
|
video={v}
|
||||||
|
view="list"
|
||||||
|
onState={onState}
|
||||||
|
onChannelFilter={onChannelFilter}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={sentinel} className="h-10" />
|
||||||
|
{isFetchingNextPage && (
|
||||||
|
<div className="text-center text-muted py-4">Loading more…</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
frontend/src/components/Header.tsx
Normal file
147
frontend/src/components/Header.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
LayoutGrid,
|
||||||
|
List,
|
||||||
|
LogOut,
|
||||||
|
Moon,
|
||||||
|
Palette,
|
||||||
|
Search,
|
||||||
|
Sun,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||||
|
import type { FeedFilters, Me } from "../lib/api";
|
||||||
|
import SyncStatus from "./SyncStatus";
|
||||||
|
|
||||||
|
function IconBtn(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||||
|
const { className = "", ...rest } = props;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...rest}
|
||||||
|
className={`p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition ${className}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemeMenu({
|
||||||
|
theme,
|
||||||
|
setTheme,
|
||||||
|
}: {
|
||||||
|
theme: ThemePrefs;
|
||||||
|
setTheme: (t: ThemePrefs) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="absolute right-0 mt-2 w-60 rounded-xl border border-border bg-surface shadow-2xl p-3 z-30">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted mb-2">Color scheme</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2 mb-3">
|
||||||
|
{SCHEMES.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => setTheme({ ...theme, scheme: s.id as Scheme })}
|
||||||
|
title={s.name}
|
||||||
|
className={`h-9 rounded-lg border-2 transition ${
|
||||||
|
theme.scheme === s.id ? "border-fg" : "border-transparent"
|
||||||
|
}`}
|
||||||
|
style={{ background: s.swatch }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted mb-2">Text size</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0.9}
|
||||||
|
max={1.3}
|
||||||
|
step={0.02}
|
||||||
|
value={theme.fontScale}
|
||||||
|
onChange={(e) => setTheme({ ...theme, fontScale: Number(e.target.value) })}
|
||||||
|
className="w-full accent-accent"
|
||||||
|
/>
|
||||||
|
<div className="text-right text-xs text-muted">
|
||||||
|
{Math.round(theme.fontScale * 100)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Header({
|
||||||
|
me,
|
||||||
|
theme,
|
||||||
|
setTheme,
|
||||||
|
filters,
|
||||||
|
setFilters,
|
||||||
|
view,
|
||||||
|
setView,
|
||||||
|
}: {
|
||||||
|
me: Me;
|
||||||
|
theme: ThemePrefs;
|
||||||
|
setTheme: (t: ThemePrefs) => void;
|
||||||
|
filters: FeedFilters;
|
||||||
|
setFilters: (f: FeedFilters) => void;
|
||||||
|
view: "grid" | "list";
|
||||||
|
setView: (v: "grid" | "list") => void;
|
||||||
|
}) {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await fetch("/auth/logout", { method: "POST", credentials: "include" });
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="h-14 shrink-0 border-b border-border bg-surface/80 backdrop-blur flex items-center gap-3 px-4 z-20">
|
||||||
|
<div className="text-xl font-bold tracking-tight select-none">
|
||||||
|
Sub<span className="text-accent">feed</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SyncStatus />
|
||||||
|
|
||||||
|
<div className="flex-1 max-w-xl mx-auto relative">
|
||||||
|
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
||||||
|
<input
|
||||||
|
value={filters.q}
|
||||||
|
onChange={(e) => setFilters({ ...filters, q: e.target.value })}
|
||||||
|
placeholder="Search your subscriptions…"
|
||||||
|
className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<IconBtn
|
||||||
|
onClick={() => setView(view === "grid" ? "list" : "grid")}
|
||||||
|
title={view === "grid" ? "List view" : "Grid view"}
|
||||||
|
>
|
||||||
|
{view === "grid" ? <List className="w-5 h-5" /> : <LayoutGrid className="w-5 h-5" />}
|
||||||
|
</IconBtn>
|
||||||
|
<IconBtn
|
||||||
|
onClick={() => setTheme({ ...theme, mode: theme.mode === "dark" ? "light" : "dark" })}
|
||||||
|
title="Toggle dark / light"
|
||||||
|
>
|
||||||
|
{theme.mode === "dark" ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||||
|
</IconBtn>
|
||||||
|
<div className="relative">
|
||||||
|
<IconBtn onClick={() => setMenuOpen((o) => !o)} title="Theme">
|
||||||
|
<Palette className="w-5 h-5" />
|
||||||
|
</IconBtn>
|
||||||
|
{menuOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-20" onClick={() => setMenuOpen(false)} />
|
||||||
|
<ThemeMenu theme={theme} setTheme={setTheme} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pl-2">
|
||||||
|
{me.avatar_url ? (
|
||||||
|
<img src={me.avatar_url} alt="" className="w-8 h-8 rounded-full" />
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 rounded-full bg-card grid place-items-center text-xs">
|
||||||
|
{me.email[0]?.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<IconBtn onClick={logout} title="Sign out">
|
||||||
|
<LogOut className="w-5 h-5" />
|
||||||
|
</IconBtn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
frontend/src/components/Login.tsx
Normal file
23
frontend/src/components/Login.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
export default function Login() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen grid place-items-center bg-bg text-fg">
|
||||||
|
<div className="w-[min(92vw,420px)] rounded-2xl border border-border bg-card/70 backdrop-blur p-10 text-center shadow-2xl">
|
||||||
|
<div className="text-3xl font-bold tracking-tight">
|
||||||
|
Sub<span className="text-accent">feed</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted my-5 leading-relaxed">
|
||||||
|
Your subscriptions, filtered your way.
|
||||||
|
<br />
|
||||||
|
Sign in with your Google account.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/auth/login"
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
|
||||||
|
>
|
||||||
|
Sign in with Google
|
||||||
|
</a>
|
||||||
|
<div className="text-xs text-muted mt-6">Invite-only access.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
291
frontend/src/components/Sidebar.tsx
Normal file
291
frontend/src/components/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { api, type FeedFilters, type Tag } from "../lib/api";
|
||||||
|
|
||||||
|
const SORTS = [
|
||||||
|
{ id: "newest", label: "Newest" },
|
||||||
|
{ id: "oldest", label: "Oldest" },
|
||||||
|
{ id: "views", label: "Most viewed" },
|
||||||
|
{ id: "duration_desc", label: "Longest" },
|
||||||
|
{ id: "duration_asc", label: "Shortest" },
|
||||||
|
{ id: "title", label: "Name (A–Z)" },
|
||||||
|
{ id: "subscribers", label: "Channel subscribers" },
|
||||||
|
{ id: "shuffle", label: "Surprise me" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SHOWS = [
|
||||||
|
{ id: "unwatched", label: "Unwatched" },
|
||||||
|
{ id: "all", label: "All" },
|
||||||
|
{ id: "watched", label: "Watched" },
|
||||||
|
{ id: "saved", label: "Saved" },
|
||||||
|
{ id: "hidden", label: "Hidden" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function TagChip({
|
||||||
|
tag,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
tag: Tag;
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
title={`${tag.channel_count} channel${tag.channel_count === 1 ? "" : "s"}`}
|
||||||
|
className={`text-xs px-2.5 py-1 rounded-full border shadow-sm hover:shadow active:translate-y-px transition ${
|
||||||
|
active
|
||||||
|
? "bg-accent text-accent-fg border-accent"
|
||||||
|
: "bg-card border-border text-fg hover:border-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Sidebar({
|
||||||
|
filters,
|
||||||
|
setFilters,
|
||||||
|
}: {
|
||||||
|
filters: FeedFilters;
|
||||||
|
setFilters: (f: FeedFilters) => void;
|
||||||
|
}) {
|
||||||
|
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
||||||
|
const tags = tagsQuery.data ?? [];
|
||||||
|
const languages = tags.filter((t) => t.category === "language");
|
||||||
|
const topics = tags.filter((t) => t.category === "topic");
|
||||||
|
|
||||||
|
function toggleTag(id: number) {
|
||||||
|
const has = filters.tags.includes(id);
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
tags: has ? filters.tags.filter((t) => t !== id) : [...filters.tags, id],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const active =
|
||||||
|
filters.tags.length > 0 ||
|
||||||
|
!filters.includeNormal ||
|
||||||
|
filters.includeShorts ||
|
||||||
|
filters.includeLive ||
|
||||||
|
filters.show !== "unwatched" ||
|
||||||
|
filters.sort !== "newest" ||
|
||||||
|
!!filters.channelId ||
|
||||||
|
!!filters.dateFrom ||
|
||||||
|
!!filters.dateTo;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-5 hidden md:block">
|
||||||
|
{filters.channelId && (
|
||||||
|
<Section title="Channel">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters({ ...filters, channelId: undefined, channelName: undefined })}
|
||||||
|
className="w-full flex items-center justify-between gap-2 text-sm px-3 py-2 rounded-lg bg-accent text-accent-fg shadow-sm hover:opacity-90 transition"
|
||||||
|
>
|
||||||
|
<span className="truncate">{filters.channelName ?? "This channel"}</span>
|
||||||
|
<X className="w-4 h-4 shrink-0" />
|
||||||
|
</button>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section title="Show">
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{SHOWS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => setFilters({ ...filters, show: s.id })}
|
||||||
|
className={`text-xs py-1.5 rounded-lg border shadow-sm active:translate-y-px transition ${
|
||||||
|
filters.show === s.id
|
||||||
|
? "bg-accent text-accent-fg border-accent"
|
||||||
|
: "bg-card border-border hover:border-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Sort">
|
||||||
|
<select
|
||||||
|
value={filters.sort}
|
||||||
|
onChange={(e) => setFilters({ ...filters, sort: e.target.value })}
|
||||||
|
className="w-full bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{SORTS.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Upload date">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<span className="text-muted">From</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.dateFrom ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters({ ...filters, dateFrom: e.target.value || undefined })
|
||||||
|
}
|
||||||
|
className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<span className="text-muted">To</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.dateTo ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters({ ...filters, dateTo: e.target.value || undefined })
|
||||||
|
}
|
||||||
|
className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{(filters.dateFrom || filters.dateTo) && (
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })}
|
||||||
|
className="text-[11px] text-muted hover:text-accent self-end"
|
||||||
|
>
|
||||||
|
clear dates
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Content type">
|
||||||
|
<Toggle
|
||||||
|
label="Normal"
|
||||||
|
checked={filters.includeNormal}
|
||||||
|
onChange={(v) => setFilters({ ...filters, includeNormal: v })}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Shorts"
|
||||||
|
checked={filters.includeShorts}
|
||||||
|
onChange={(v) => setFilters({ ...filters, includeShorts: v })}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Live / Upcoming"
|
||||||
|
checked={filters.includeLive}
|
||||||
|
onChange={(v) => setFilters({ ...filters, includeLive: v })}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{languages.length > 0 && (
|
||||||
|
<Section title="Language">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{languages.map((t) => (
|
||||||
|
<TagChip
|
||||||
|
key={t.id}
|
||||||
|
tag={t}
|
||||||
|
active={filters.tags.includes(t.id)}
|
||||||
|
onClick={() => toggleTag(t.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{topics.length > 0 && (
|
||||||
|
<Section
|
||||||
|
title="Topic"
|
||||||
|
right={
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({ ...filters, tagMode: filters.tagMode === "or" ? "and" : "or" })
|
||||||
|
}
|
||||||
|
className="text-[10px] uppercase tracking-wide text-muted hover:text-accent"
|
||||||
|
title="Match any vs all selected tags"
|
||||||
|
>
|
||||||
|
{filters.tagMode === "or" ? "Any" : "All"}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{topics.map((t) => (
|
||||||
|
<TagChip
|
||||||
|
key={t.id}
|
||||||
|
tag={t}
|
||||||
|
active={filters.tags.includes(t.id)}
|
||||||
|
onClick={() => toggleTag(t.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active && (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
tags: [],
|
||||||
|
tagMode: "or",
|
||||||
|
q: filters.q,
|
||||||
|
sort: "newest",
|
||||||
|
includeNormal: true,
|
||||||
|
includeShorts: false,
|
||||||
|
includeLive: false,
|
||||||
|
show: "unwatched",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full text-xs py-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
right,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
right?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted">{title}</div>
|
||||||
|
{right}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toggle({
|
||||||
|
label,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="flex items-center justify-between py-1 cursor-pointer text-sm">
|
||||||
|
<span>{label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={`w-9 h-5 rounded-full transition relative ${
|
||||||
|
checked ? "bg-accent" : "bg-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all ${
|
||||||
|
checked ? "left-[18px]" : "left-0.5"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
frontend/src/components/SyncStatus.tsx
Normal file
49
frontend/src/components/SyncStatus.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Database, Loader2, Pause, Play } from "lucide-react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { formatViews } from "../lib/format";
|
||||||
|
|
||||||
|
export default function SyncStatus() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["sync-status"],
|
||||||
|
queryFn: api.status,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
staleTime: 25_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggle = useMutation({
|
||||||
|
mutationFn: () => (data?.paused ? api.resumeSync() : api.pauseSync()),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["sync-status"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hidden lg:flex items-center gap-2 text-xs text-muted">
|
||||||
|
<Database className="w-3.5 h-3.5" />
|
||||||
|
<span>{formatViews(data.videos_total)} videos</span>
|
||||||
|
<span className="opacity-40">·</span>
|
||||||
|
{data.paused ? (
|
||||||
|
<span className="text-accent font-medium">paused</span>
|
||||||
|
) : data.channels_backfilling > 0 ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
{data.channels_backfilling} syncing
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>all synced</span>
|
||||||
|
)}
|
||||||
|
{data.is_admin && (
|
||||||
|
<button
|
||||||
|
onClick={() => toggle.mutate()}
|
||||||
|
disabled={toggle.isPending}
|
||||||
|
title={data.paused ? "Resume background sync" : "Pause background sync"}
|
||||||
|
className="ml-1 p-1 rounded-md hover:bg-card hover:text-fg transition"
|
||||||
|
>
|
||||||
|
{data.paused ? <Play className="w-3.5 h-3.5" /> : <Pause className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
frontend/src/components/Toaster.tsx
Normal file
29
frontend/src/components/Toaster.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { dismiss, getToasts, subscribe } from "../lib/toast";
|
||||||
|
|
||||||
|
export default function Toaster() {
|
||||||
|
const toasts = useSyncExternalStore(subscribe, getToasts, getToasts);
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className="bg-surface border border-border rounded-xl shadow-2xl px-4 py-3 flex items-center gap-4 animate-[fadeIn_0.15s_ease]"
|
||||||
|
>
|
||||||
|
<span className="text-sm">{t.message}</span>
|
||||||
|
{t.action && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
t.action!.onClick();
|
||||||
|
dismiss(t.id);
|
||||||
|
}}
|
||||||
|
className="text-accent text-sm font-semibold hover:underline"
|
||||||
|
>
|
||||||
|
{t.action.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
frontend/src/components/VideoCard.tsx
Normal file
204
frontend/src/components/VideoCard.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
import { Bookmark, Check, Eye, EyeOff, ListFilter } from "lucide-react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import type { Video } from "../lib/api";
|
||||||
|
import { formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||||
|
|
||||||
|
function Actions({
|
||||||
|
video,
|
||||||
|
onState,
|
||||||
|
onChannelFilter,
|
||||||
|
}: {
|
||||||
|
video: Video;
|
||||||
|
onState: (id: string, status: string) => void;
|
||||||
|
onChannelFilter?: (channelId: string, channelName: string) => void;
|
||||||
|
}) {
|
||||||
|
const act = (status: string) => (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onState(video.id, video.status === status ? "new" : status);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
|
||||||
|
<button
|
||||||
|
onClick={act("watched")}
|
||||||
|
title="Mark watched"
|
||||||
|
className={clsx(
|
||||||
|
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
||||||
|
video.status === "watched" && "text-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={act("saved")}
|
||||||
|
title="Save for later"
|
||||||
|
className={clsx(
|
||||||
|
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
||||||
|
video.status === "saved" && "text-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Bookmark className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={act("hidden")}
|
||||||
|
title={video.status === "hidden" ? "Unhide" : "Hide"}
|
||||||
|
className={clsx(
|
||||||
|
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
||||||
|
video.status === "hidden" && "text-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{video.status === "hidden" ? (
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{onChannelFilter && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onChannelFilter(video.channel_id, video.channel_title ?? "This channel");
|
||||||
|
}}
|
||||||
|
title="Only this channel"
|
||||||
|
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"
|
||||||
|
>
|
||||||
|
<ListFilter className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Thumb({ video, className }: { video: Video; className?: string }) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={video.watch_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
onClick={() => video.status === "new" && undefined}
|
||||||
|
className={clsx(
|
||||||
|
"block relative rounded-xl overflow-hidden bg-surface border border-border shadow-md group-hover:shadow-2xl transition-shadow",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{video.thumbnail_url ? (
|
||||||
|
<img
|
||||||
|
src={video.thumbnail_url}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full" />
|
||||||
|
)}
|
||||||
|
{video.duration_seconds != null && (
|
||||||
|
<span className="absolute bottom-1.5 right-1.5 bg-black/80 text-white text-[11px] font-medium px-1.5 py-0.5 rounded">
|
||||||
|
{formatDuration(video.duration_seconds)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{video.live_status === "was_live" && (
|
||||||
|
<span className="absolute top-1.5 left-1.5 bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded">
|
||||||
|
stream
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{video.status === "saved" && (
|
||||||
|
<span className="absolute top-1.5 right-1.5 bg-accent text-accent-fg rounded-full p-1">
|
||||||
|
<Bookmark className="w-3.5 h-3.5" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VideoCard({
|
||||||
|
video,
|
||||||
|
view,
|
||||||
|
onState,
|
||||||
|
onChannelFilter,
|
||||||
|
}: {
|
||||||
|
video: Video;
|
||||||
|
view: "grid" | "list";
|
||||||
|
onState: (id: string, status: string) => void;
|
||||||
|
onChannelFilter?: (channelId: string, channelName: string) => void;
|
||||||
|
}) {
|
||||||
|
const watched = video.status === "watched";
|
||||||
|
const meta = (
|
||||||
|
<>
|
||||||
|
{video.view_count != null && <>{formatViews(video.view_count)} views · </>}
|
||||||
|
{relativeTime(video.published_at)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
const title = (
|
||||||
|
<a
|
||||||
|
href={video.watch_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="font-medium leading-snug line-clamp-2 hover:text-accent"
|
||||||
|
>
|
||||||
|
{video.title}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (view === "list") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"group flex gap-3 p-2 rounded-xl hover:bg-card hover:shadow-lg transition",
|
||||||
|
watched && "opacity-55"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Thumb video={video} className="w-44 aspect-video shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{title}
|
||||||
|
<a
|
||||||
|
href={video.channel_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full hover:text-fg"
|
||||||
|
>
|
||||||
|
{video.channel_title}
|
||||||
|
</a>
|
||||||
|
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
||||||
|
</div>
|
||||||
|
<Actions video={video} onState={onState} onChannelFilter={onChannelFilter} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"group rounded-2xl border border-border bg-card/50 p-2.5 shadow-sm transition-all duration-150 hover:-translate-y-1 hover:shadow-2xl hover:bg-card hover:border-accent/40",
|
||||||
|
watched && "opacity-55"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Thumb video={video} className="aspect-video" />
|
||||||
|
<div className="flex gap-3 mt-2.5 px-0.5 pb-0.5">
|
||||||
|
{video.channel_thumbnail && (
|
||||||
|
<img
|
||||||
|
src={video.channel_thumbnail}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="w-9 h-9 rounded-full shrink-0 mt-0.5"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{title}
|
||||||
|
<a
|
||||||
|
href={video.channel_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full hover:text-fg"
|
||||||
|
>
|
||||||
|
{video.channel_title}
|
||||||
|
</a>
|
||||||
|
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
||||||
|
<Actions video={video} onState={onState} onChannelFilter={onChannelFilter} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
frontend/src/index.css
Normal file
129
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--font-scale: 1.06;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make native controls (date picker, scrollbars) follow the theme. */
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
html[data-theme="light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: calc(16px * var(--font-scale));
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Color schemes (accent + neutrals), each with dark + light ===== */
|
||||||
|
|
||||||
|
/* Midnight — deep navy/slate with blue accent (default) */
|
||||||
|
html[data-scheme="midnight"][data-theme="dark"] {
|
||||||
|
--bg: #0b1020;
|
||||||
|
--surface: #121a2e;
|
||||||
|
--card: #161f38;
|
||||||
|
--border: #243049;
|
||||||
|
--fg: #e6e9f0;
|
||||||
|
--muted: #97a3c0;
|
||||||
|
--accent: #6d8cff;
|
||||||
|
--accent-fg: #0b1020;
|
||||||
|
}
|
||||||
|
html[data-scheme="midnight"][data-theme="light"] {
|
||||||
|
--bg: #f4f6fc;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--card: #ffffff;
|
||||||
|
--border: #dde3f0;
|
||||||
|
--fg: #1a2238;
|
||||||
|
--muted: #5b6685;
|
||||||
|
--accent: #3b5bdb;
|
||||||
|
--accent-fg: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forest — dark slate with teal/green accent */
|
||||||
|
html[data-scheme="forest"][data-theme="dark"] {
|
||||||
|
--bg: #0a1512;
|
||||||
|
--surface: #0f1f1a;
|
||||||
|
--card: #12241e;
|
||||||
|
--border: #1f3a30;
|
||||||
|
--fg: #e6f0ec;
|
||||||
|
--muted: #90b1a4;
|
||||||
|
--accent: #2dd4bf;
|
||||||
|
--accent-fg: #04110d;
|
||||||
|
}
|
||||||
|
html[data-scheme="forest"][data-theme="light"] {
|
||||||
|
--bg: #f1f7f4;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--card: #ffffff;
|
||||||
|
--border: #d6e7e0;
|
||||||
|
--fg: #102a22;
|
||||||
|
--muted: #4d6b60;
|
||||||
|
--accent: #0d9488;
|
||||||
|
--accent-fg: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slate — neutral grey with warm orange accent (muted in dark) */
|
||||||
|
html[data-scheme="slate"][data-theme="dark"] {
|
||||||
|
--bg: #15171c;
|
||||||
|
--surface: #1b1e25;
|
||||||
|
--card: #1f232b;
|
||||||
|
--border: #2c313b;
|
||||||
|
--fg: #e7e9ee;
|
||||||
|
--muted: #9aa1ad;
|
||||||
|
--accent: #e8913c;
|
||||||
|
--accent-fg: #1a1206;
|
||||||
|
}
|
||||||
|
html[data-scheme="slate"][data-theme="light"] {
|
||||||
|
--bg: #f6f7f9;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--card: #ffffff;
|
||||||
|
--border: #e3e6eb;
|
||||||
|
--fg: #1c1f26;
|
||||||
|
--muted: #5d636e;
|
||||||
|
--accent: #d97706;
|
||||||
|
--accent-fg: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* YouTube — near-black with red accent */
|
||||||
|
html[data-scheme="youtube"][data-theme="dark"] {
|
||||||
|
--bg: #0f0f0f;
|
||||||
|
--surface: #181818;
|
||||||
|
--card: #1f1f1f;
|
||||||
|
--border: #303030;
|
||||||
|
--fg: #f1f1f1;
|
||||||
|
--muted: #aaaaaa;
|
||||||
|
--accent: #ff3b46;
|
||||||
|
--accent-fg: #ffffff;
|
||||||
|
}
|
||||||
|
html[data-scheme="youtube"][data-theme="light"] {
|
||||||
|
--bg: #ffffff;
|
||||||
|
--surface: #f9f9f9;
|
||||||
|
--card: #ffffff;
|
||||||
|
--border: #e5e5e5;
|
||||||
|
--fg: #0f0f0f;
|
||||||
|
--muted: #606060;
|
||||||
|
--accent: #ff0033;
|
||||||
|
--accent-fg: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thin, theme-aware scrollbars */
|
||||||
|
* {
|
||||||
|
scrollbar-color: var(--border) transparent;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
128
frontend/src/lib/api.ts
Normal file
128
frontend/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
export interface Me {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
display_name: string | null;
|
||||||
|
avatar_url: string | null;
|
||||||
|
role: string;
|
||||||
|
preferences: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string | null;
|
||||||
|
category: string;
|
||||||
|
system: boolean;
|
||||||
|
channel_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Video {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
channel_id: string;
|
||||||
|
channel_title: string | null;
|
||||||
|
channel_thumbnail: string | null;
|
||||||
|
channel_url: string;
|
||||||
|
published_at: string | null;
|
||||||
|
thumbnail_url: string | null;
|
||||||
|
duration_seconds: number | null;
|
||||||
|
view_count: number | null;
|
||||||
|
is_short: boolean;
|
||||||
|
live_status: string;
|
||||||
|
status: string;
|
||||||
|
watch_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedResponse {
|
||||||
|
items: Video[];
|
||||||
|
has_more: boolean;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedFilters {
|
||||||
|
tags: number[];
|
||||||
|
tagMode: "or" | "and";
|
||||||
|
q: string;
|
||||||
|
sort: string;
|
||||||
|
includeNormal: boolean;
|
||||||
|
includeShorts: boolean;
|
||||||
|
includeLive: boolean;
|
||||||
|
show: string;
|
||||||
|
channelId?: string;
|
||||||
|
channelName?: string;
|
||||||
|
maxAgeDays?: number;
|
||||||
|
minDuration?: number;
|
||||||
|
maxDuration?: number;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class HttpError extends Error {
|
||||||
|
status: number;
|
||||||
|
constructor(status: number) {
|
||||||
|
super(`HTTP ${status}`);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function req(url: string, opts: RequestInit = {}): Promise<any> {
|
||||||
|
const r = await fetch(url, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new HttpError(r.status);
|
||||||
|
return r.status === 204 ? null : r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function feedQuery(f: FeedFilters, offset: number, limit: number): string {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
f.tags.forEach((t) => p.append("tags", String(t)));
|
||||||
|
p.set("tag_mode", f.tagMode);
|
||||||
|
if (f.q) p.set("q", f.q);
|
||||||
|
p.set("sort", f.sort);
|
||||||
|
p.set("show_normal", String(f.includeNormal));
|
||||||
|
p.set("include_shorts", String(f.includeShorts));
|
||||||
|
p.set("include_live", String(f.includeLive));
|
||||||
|
p.set("show", f.show);
|
||||||
|
if (f.channelId) p.set("channel_id", f.channelId);
|
||||||
|
if (f.maxAgeDays) p.set("max_age_days", String(f.maxAgeDays));
|
||||||
|
if (f.dateFrom) p.set("published_after", f.dateFrom);
|
||||||
|
if (f.dateTo) p.set("published_before", f.dateTo);
|
||||||
|
if (f.minDuration != null) p.set("min_duration", String(f.minDuration));
|
||||||
|
if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration));
|
||||||
|
p.set("offset", String(offset));
|
||||||
|
p.set("limit", String(limit));
|
||||||
|
return p.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncStatus {
|
||||||
|
subscriptions: number;
|
||||||
|
channels_total: number;
|
||||||
|
channels_backfilling: number;
|
||||||
|
videos_total: number;
|
||||||
|
pending_enrich: number;
|
||||||
|
quota_used_today: number;
|
||||||
|
quota_remaining_today: number;
|
||||||
|
paused: boolean;
|
||||||
|
is_admin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
me: (): Promise<Me> => req("/api/me"),
|
||||||
|
tags: (): Promise<Tag[]> => req("/api/tags"),
|
||||||
|
status: (): Promise<SyncStatus> => req("/api/sync/status"),
|
||||||
|
feed: (f: FeedFilters, offset: number, limit: number): Promise<FeedResponse> =>
|
||||||
|
req(`/api/feed?${feedQuery(f, offset, limit)}`),
|
||||||
|
feedCount: (f: FeedFilters): Promise<{ count: number }> =>
|
||||||
|
req(`/api/feed/count?${feedQuery(f, 0, 0)}`),
|
||||||
|
setState: (id: string, status: string) =>
|
||||||
|
req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }),
|
||||||
|
pauseSync: () => req("/api/sync/pause", { method: "POST" }),
|
||||||
|
resumeSync: () => req("/api/sync/resume", { method: "POST" }),
|
||||||
|
savePrefs: (p: Record<string, any>) =>
|
||||||
|
req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export { HttpError };
|
||||||
43
frontend/src/lib/format.ts
Normal file
43
frontend/src/lib/format.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
export function relativeTime(iso: string | null): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
const then = new Date(iso).getTime();
|
||||||
|
const secs = Math.max(0, (Date.now() - then) / 1000);
|
||||||
|
const units: [number, string][] = [
|
||||||
|
[60, "s"],
|
||||||
|
[3600, "min"],
|
||||||
|
[86400, "h"],
|
||||||
|
[604800, "d"],
|
||||||
|
[2592000, "wk"],
|
||||||
|
[31536000, "mo"],
|
||||||
|
];
|
||||||
|
if (secs < 60) return "just now";
|
||||||
|
for (let i = 0; i < units.length - 1; i++) {
|
||||||
|
const [, label] = units[i];
|
||||||
|
const next = units[i + 1][0];
|
||||||
|
if (secs < next) {
|
||||||
|
const v = Math.floor(secs / units[i][0]);
|
||||||
|
return `${v} ${label} ago`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const years = Math.floor(secs / 31536000);
|
||||||
|
if (years >= 1) return `${years} yr ago`;
|
||||||
|
const months = Math.floor(secs / 2592000);
|
||||||
|
return `${months} mo ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(sec: number | null): string {
|
||||||
|
if (sec == null) return "";
|
||||||
|
const h = Math.floor(sec / 3600);
|
||||||
|
const m = Math.floor((sec % 3600) / 60);
|
||||||
|
const s = Math.floor(sec % 60);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatViews(n: number | null): string {
|
||||||
|
if (n == null) return "";
|
||||||
|
if (n >= 1e9) return `${(n / 1e9).toFixed(1).replace(/\.0$/, "")}B`;
|
||||||
|
if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M`;
|
||||||
|
if (n >= 1e3) return `${(n / 1e3).toFixed(1).replace(/\.0$/, "")}K`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
42
frontend/src/lib/theme.ts
Normal file
42
frontend/src/lib/theme.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
export type Mode = "dark" | "light";
|
||||||
|
export type Scheme = "midnight" | "forest" | "slate" | "youtube";
|
||||||
|
|
||||||
|
export const SCHEMES: { id: Scheme; name: string; swatch: string }[] = [
|
||||||
|
{ id: "midnight", name: "Midnight", swatch: "#6d8cff" },
|
||||||
|
{ id: "forest", name: "Forest", swatch: "#2dd4bf" },
|
||||||
|
{ id: "slate", name: "Slate", swatch: "#e8913c" },
|
||||||
|
{ id: "youtube", name: "YouTube", swatch: "#ff3b46" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface ThemePrefs {
|
||||||
|
mode: Mode;
|
||||||
|
scheme: Scheme;
|
||||||
|
fontScale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_THEME: ThemePrefs = {
|
||||||
|
mode: "dark",
|
||||||
|
scheme: "midnight",
|
||||||
|
fontScale: 1.06,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function applyTheme(t: ThemePrefs): void {
|
||||||
|
const el = document.documentElement;
|
||||||
|
el.dataset.theme = t.mode;
|
||||||
|
el.dataset.scheme = t.scheme;
|
||||||
|
el.style.setProperty("--font-scale", String(t.fontScale));
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY = "subfeed.theme";
|
||||||
|
|
||||||
|
export function loadLocalTheme(): ThemePrefs {
|
||||||
|
try {
|
||||||
|
return { ...DEFAULT_THEME, ...JSON.parse(localStorage.getItem(KEY) || "{}") };
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_THEME;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveLocalTheme(t: ThemePrefs): void {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(t));
|
||||||
|
}
|
||||||
41
frontend/src/lib/toast.ts
Normal file
41
frontend/src/lib/toast.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
export interface ToastAction {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
export interface ToastItem {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
action?: ToastAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
let items: ToastItem[] = [];
|
||||||
|
let listeners: Array<() => void> = [];
|
||||||
|
let counter = 1;
|
||||||
|
|
||||||
|
function emit() {
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toast(message: string, action?: ToastAction): number {
|
||||||
|
const id = counter++;
|
||||||
|
items = [...items, { id, message, action }];
|
||||||
|
emit();
|
||||||
|
setTimeout(() => dismiss(id), 9000);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismiss(id: number) {
|
||||||
|
items = items.filter((i) => i.id !== id);
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToasts(): ToastItem[] {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribe(listener: () => void): () => void {
|
||||||
|
listeners.push(listener);
|
||||||
|
return () => {
|
||||||
|
listeners = listeners.filter((l) => l !== listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
17
frontend/src/main.tsx
Normal file
17
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import React from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import App from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false, staleTime: 30_000, refetchOnWindowFocus: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
22
frontend/tailwind.config.js
Normal file
22
frontend/tailwind.config.js
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
bg: "var(--bg)",
|
||||||
|
surface: "var(--surface)",
|
||||||
|
card: "var(--card)",
|
||||||
|
border: "var(--border)",
|
||||||
|
fg: "var(--fg)",
|
||||||
|
muted: "var(--muted)",
|
||||||
|
accent: "var(--accent)",
|
||||||
|
"accent-fg": "var(--accent-fg)",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["Inter", "system-ui", "-apple-system", "Segoe UI", "Roboto", "sans-serif"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
// During `vite dev` (host with Node), proxy API calls to the backend container.
|
||||||
|
const proxy = {
|
||||||
|
"/api": "http://localhost:8080",
|
||||||
|
"/auth": "http://localhost:8080",
|
||||||
|
"/healthz": "http://localhost:8080",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: { port: 5173, proxy },
|
||||||
|
build: { outDir: "dist" },
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue