merge: M3 auto-tagging

This commit is contained in:
npeter83 2026-06-11 01:57:19 +02:00
commit 96f9c5a797
9 changed files with 482 additions and 1 deletions

View file

@ -15,6 +15,9 @@ once and stored locally, so filtering/searching/sorting are instant and don't bu
> - **M2** (ingest core): subscription import, free RSS detection, recent-first + deep backfill > - **M2** (ingest core): subscription import, free RSS detection, recent-first + deep backfill
> from the uploads playlist, enrichment (duration/stats/category, Shorts & livestream > from the uploads playlist, enrichment (duration/stats/category, Shorts & livestream
> classification), a shared daily quota guard, and a background scheduler. > classification), a shared daily quota guard, and a background scheduler.
> - **M3** (auto-tagging): system tags for channel language (offline detection) and topic
> (from YouTube topics + dominant category), regenerated automatically; user tags are
> never overwritten.
## Requirements ## Requirements

View file

@ -0,0 +1,89 @@
"""tags and channel_tags
Revision ID: 0003_tags
Revises: 0002_ingest_core
Create Date: 2026-06-11
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0003_tags"
down_revision: Union[str, None] = "0002_ingest_core"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"tags",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column("name", sa.String(length=64), nullable=False),
sa.Column("color", sa.String(length=16), nullable=True),
sa.Column("category", sa.String(length=16), nullable=False, server_default="other"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index("ix_tags_user_id", "tags", ["user_id"])
# System tag names unique among system tags; user tag names unique per user.
op.create_index(
"uq_tags_system_name",
"tags",
["name"],
unique=True,
postgresql_where=sa.text("user_id IS NULL"),
)
op.create_index(
"uq_tags_user_name",
"tags",
["user_id", "name"],
unique=True,
postgresql_where=sa.text("user_id IS NOT NULL"),
)
op.create_table(
"channel_tags",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"channel_id",
sa.String(length=32),
sa.ForeignKey("channels.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"tag_id",
sa.Integer(),
sa.ForeignKey("tags.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column("source", sa.String(length=8), nullable=False, server_default="auto"),
sa.Column("confidence", sa.Float(), nullable=True),
sa.UniqueConstraint("channel_id", "tag_id", "user_id", name="uq_channel_tag"),
)
op.create_index("ix_channel_tags_channel_id", "channel_tags", ["channel_id"])
op.create_index("ix_channel_tags_tag_id", "channel_tags", ["tag_id"])
def downgrade() -> None:
op.drop_table("channel_tags")
op.drop_index("uq_tags_user_name", table_name="tags")
op.drop_index("uq_tags_system_name", table_name="tags")
op.drop_index("ix_tags_user_id", table_name="tags")
op.drop_table("tags")

View file

@ -48,6 +48,14 @@ class Settings(BaseSettings):
# interactive syncs / enrichment of fresh videos. # interactive syncs / enrichment of fresh videos.
backfill_quota_reserve: int = 2000 backfill_quota_reserve: int = 2000
# --- Auto-tagging / feed defaults ---
# Number of recent video titles sampled per channel for language detection.
autotag_title_sample: int = 40
autotag_interval_minutes: int = 30
# live_status values hidden from the feed by default. Completed-stream VODs
# ("was_live") are real watchable content and stay visible.
feed_default_hidden_live: str = "live,upcoming"
@property @property
def allowed_email_set(self) -> set[str]: def allowed_email_set(self) -> set[str]:
return {e.strip().lower() for e in self.allowed_emails.split(",") if e.strip()} return {e.strip().lower() for e in self.allowed_emails.split(",") if e.strip()}

View file

@ -9,7 +9,7 @@ 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 from app.routes import health, sync, tags
from app.scheduler import shutdown_scheduler, start_scheduler from app.scheduler import shutdown_scheduler, start_scheduler
@ -43,6 +43,7 @@ if settings.frontend_origin:
app.include_router(health.router) 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)
STATIC_DIR = Path(__file__).parent / "static" STATIC_DIR = Path(__file__).parent / "static"
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets") app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")

View file

@ -5,6 +5,7 @@ from sqlalchemy import (
Boolean, Boolean,
Date, Date,
DateTime, DateTime,
Float,
ForeignKey, ForeignKey,
Integer, Integer,
JSON, JSON,
@ -155,6 +156,51 @@ class Video(Base):
channel: Mapped["Channel"] = relationship(back_populates="videos") channel: Mapped["Channel"] = relationship(back_populates="videos")
class Tag(Base):
"""A label. System tags (user_id NULL) are computed automatically and shared by all
users; user tags (user_id set) are personal. category groups them in the UI."""
__tablename__ = "tags"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
name: Mapped[str] = mapped_column(String(64))
color: Mapped[str | None] = mapped_column(String(16))
# language | topic | other
category: Mapped[str] = mapped_column(
String(16), default="other", server_default="other"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
class ChannelTag(Base):
"""Links a tag to a channel. source='auto' rows are managed by the auto-tagger and
regenerated freely; source='user' rows are never touched by the system."""
__tablename__ = "channel_tags"
__table_args__ = (
UniqueConstraint("channel_id", "tag_id", "user_id", name="uq_channel_tag"),
)
id: Mapped[int] = mapped_column(primary_key=True)
channel_id: Mapped[str] = mapped_column(
ForeignKey("channels.id", ondelete="CASCADE"), index=True
)
tag_id: Mapped[int] = mapped_column(
ForeignKey("tags.id", ondelete="CASCADE"), index=True
)
# NULL = system/auto link visible to everyone; set = a specific user's link.
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE")
)
source: Mapped[str] = mapped_column(String(8), default="auto", server_default="auto")
confidence: Mapped[float | None] = mapped_column(Float)
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."""

View file

@ -0,0 +1,43 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.auth import current_user
from app.db import get_db
from app.models import ChannelTag, Tag, User
from app.sync.autotag import run_autotag_all
router = APIRouter(prefix="/api/tags", tags=["tags"])
@router.get("")
def list_tags(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
rows = db.execute(
select(Tag, func.count(ChannelTag.id))
.outerjoin(ChannelTag, ChannelTag.tag_id == Tag.id)
.where(or_(Tag.user_id.is_(None), Tag.user_id == user.id))
.group_by(Tag.id)
.order_by(Tag.category, Tag.name)
).all()
return [
{
"id": tag.id,
"name": tag.name,
"color": tag.color,
"category": tag.category,
"system": tag.user_id is None,
"channel_count": count,
}
for tag, count in rows
]
@router.post("/recompute")
def recompute(
user: User = Depends(current_user),
db: Session = Depends(get_db),
only_missing: bool = False,
) -> dict:
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
return run_autotag_all(db, only_missing=only_missing)

View file

@ -6,6 +6,7 @@ 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.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,
@ -48,6 +49,10 @@ def _backfill_job() -> None:
_job("backfill", work) _job("backfill", work)
def _autotag_job() -> None:
_job("autotag", lambda db: run_autotag_all(db, only_missing=True))
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:
@ -65,6 +70,12 @@ def start_scheduler() -> None:
minutes=settings.backfill_interval_minutes, minutes=settings.backfill_interval_minutes,
id="backfill", id="backfill",
) )
scheduler.add_job(
_autotag_job,
"interval",
minutes=settings.autotag_interval_minutes,
id="autotag",
)
scheduler.start() scheduler.start()
_scheduler = scheduler _scheduler = scheduler
logger.info("scheduler started") logger.info("scheduler started")

279
backend/app/sync/autotag.py Normal file
View file

@ -0,0 +1,279 @@
"""Compute system (auto) tags for channels: a language tag and one or more topic tags.
Language comes from the channel's declared default language or, failing that, offline
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
regenerated freely; user tags are never touched here.
"""
from sqlalchemy import and_, exists, func, select
from sqlalchemy.orm import Session
from app.config import settings
from app.models import Channel, ChannelTag, Tag, Video
# --- language id (offline, deterministic, small) ---
try:
from py3langid.langid import MODEL_FILE, LanguageIdentifier
_identifier = LanguageIdentifier.from_pickled_model(MODEL_FILE, norm_probs=True)
def _classify(text: str):
return _identifier.classify(text)
def _restrict(codes: list[str]) -> None:
_identifier.set_languages(codes)
except Exception: # pragma: no cover - fallback to module-level API
import py3langid as _langid
def _classify(text: str):
return _langid.classify(text)
def _restrict(codes: list[str]) -> None:
_langid.set_languages(codes)
LANG_NAMES = {
"en": "English",
"hu": "Hungarian",
"de": "German",
"fr": "French",
"es": "Spanish",
"it": "Italian",
"pt": "Portuguese",
"nl": "Dutch",
"ru": "Russian",
"pl": "Polish",
"ro": "Romanian",
"cs": "Czech",
"sk": "Slovak",
"tr": "Turkish",
"sv": "Swedish",
"da": "Danish",
"no": "Norwegian",
"fi": "Finnish",
"ja": "Japanese",
"ko": "Korean",
"zh": "Chinese",
"hi": "Hindi",
"ar": "Arabic",
"uk": "Ukrainian",
"el": "Greek",
}
# Constrain detection to plausible languages so short/branded titles can't trip the
# detector into obscure false positives (e.g. Latin, Luxembourgish).
_restrict(list(LANG_NAMES))
LANG_COLOR = "#3b82f6"
DEFAULT_TOPIC_COLOR = "#6b7280"
TOPIC_COLORS = {
"Gaming": "#8b5cf6",
"Music": "#ec4899",
"Cooking": "#f59e0b",
"Tech": "#06b6d4",
"Education": "#10b981",
"Sports": "#ef4444",
"Film": "#a855f7",
"TV": "#a855f7",
"Politics": "#64748b",
"Health & Fitness": "#22c55e",
"Vehicles": "#eab308",
"Animals": "#84cc16",
"Fashion & Beauty": "#f472b6",
"Travel": "#14b8a6",
"Comedy": "#fb923c",
"Entertainment": "#c084fc",
"Business": "#0ea5e9",
"Science": "#06b6d4",
"News": "#94a3b8",
}
# Exact topic slug -> friendly tag (for slugs the heuristics below don't catch).
_TOPIC_SLUGS = {
"Knowledge": "Education",
"Food": "Cooking",
"Technology": "Tech",
# "Lifestyle_(sociology)" is YouTube's catch-all (on ~65% of channels) — too generic
# to be a useful filter, so it is intentionally not mapped.
"Society": "Society",
"Politics": "Politics",
"Health": "Health & Fitness",
"Physical_fitness": "Health & Fitness",
"Vehicle": "Vehicles",
"Pet": "Animals",
"Tourism": "Travel",
"Fashion": "Fashion & Beauty",
"Physical_attractiveness": "Fashion & Beauty",
"Humor": "Comedy",
"Entertainment": "Entertainment",
"Film": "Film",
"Television_program": "TV",
"Performing_arts": "Performing Arts",
"Business": "Business",
"Military": "Military",
"Religion": "Religion",
"Hobby": "Hobbies",
}
# Dominant video category id -> friendly tag (overly generic ones are skipped).
_CATEGORY_NAMES = {
1: "Film",
2: "Vehicles",
10: "Music",
15: "Animals",
17: "Sports",
19: "Travel",
20: "Gaming",
23: "Comedy",
25: "News",
26: "Fashion & Beauty",
27: "Education",
28: "Tech",
}
def map_topic_slug(slug: str) -> str | None:
low = slug.lower()
if "game" in low:
return "Gaming"
if "music" in low:
return "Music"
if any(s in low for s in ("sport", "football", "basketball", "tennis", "boxing")):
return "Sports"
return _TOPIC_SLUGS.get(slug)
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 = (
db.execute(
select(Video.title)
.where(Video.channel_id == channel.id, Video.title.is_not(None))
.order_by(Video.published_at.desc())
.limit(settings.autotag_title_sample)
)
.scalars()
.all()
)
text = " . ".join(t for t in titles if t).strip()
if len(text) < 15:
return None, 0.0
lang, confidence = _classify(text)
return lang, float(confidence)
def compute_channel_topics(db: Session, channel: Channel) -> set[str]:
topics: set[str] = set()
for url in channel.topic_categories or []:
slug = url.rstrip("/").split("/")[-1]
name = map_topic_slug(slug)
if name:
topics.add(name)
dominant = db.execute(
select(Video.category_id)
.where(Video.channel_id == channel.id, Video.category_id.is_not(None))
.group_by(Video.category_id)
.order_by(func.count().desc())
.limit(1)
).scalar_one_or_none()
if dominant is not None and dominant in _CATEGORY_NAMES:
topics.add(_CATEGORY_NAMES[dominant])
return topics
def ensure_system_tag(db: Session, name: str, category: str, color: str | None) -> Tag:
tag = db.execute(
select(Tag).where(Tag.user_id.is_(None), Tag.name == name)
).scalar_one_or_none()
if tag is None:
tag = Tag(user_id=None, name=name, category=category, color=color)
db.add(tag)
db.flush()
return tag
def apply_channel_autotags(db: Session, channel: Channel) -> None:
desired: dict[int, float | None] = {}
lang, confidence = detect_channel_language(db, channel)
if lang:
tag = ensure_system_tag(db, LANG_NAMES.get(lang, lang.upper()), "language", LANG_COLOR)
desired[tag.id] = confidence
for topic in compute_channel_topics(db, channel):
tag = ensure_system_tag(
db, topic, "topic", TOPIC_COLORS.get(topic, DEFAULT_TOPIC_COLOR)
)
desired.setdefault(tag.id, None)
existing = {
ct.tag_id: ct
for ct in db.execute(
select(ChannelTag).where(
ChannelTag.channel_id == channel.id,
ChannelTag.user_id.is_(None),
ChannelTag.source == "auto",
)
).scalars()
}
for tag_id, ct in existing.items():
if tag_id not in desired:
db.delete(ct)
for tag_id, conf in desired.items():
ct = existing.get(tag_id)
if ct is None:
db.add(
ChannelTag(
channel_id=channel.id,
tag_id=tag_id,
user_id=None,
source="auto",
confidence=conf,
)
)
else:
ct.confidence = conf
db.commit()
def run_autotag_all(db: Session, only_missing: bool = False) -> dict:
query = select(Channel)
if only_missing:
query = query.where(
~exists().where(
and_(
ChannelTag.channel_id == Channel.id,
ChannelTag.source == "auto",
ChannelTag.user_id.is_(None),
)
)
)
channels = db.execute(query).scalars().all()
tagged = 0
for channel in channels:
try:
apply_channel_autotags(db, channel)
tagged += 1
except Exception:
db.rollback()
removed = _cleanup_orphan_system_tags(db)
return {"channels_tagged": tagged, "orphan_tags_removed": removed}
def _cleanup_orphan_system_tags(db: Session) -> int:
"""Delete system tags that no longer link to any channel (e.g. after a mapping change)."""
orphans = (
db.execute(
select(Tag).where(
Tag.user_id.is_(None),
~exists().where(ChannelTag.tag_id == Tag.id),
)
)
.scalars()
.all()
)
for tag in orphans:
db.delete(tag)
db.commit()
return len(orphans)

View file

@ -9,5 +9,6 @@ authlib>=1.3,<2.0
httpx>=0.27,<1.0 httpx>=0.27,<1.0
feedparser>=6.0,<7.0 feedparser>=6.0,<7.0
apscheduler>=3.10,<4.0 apscheduler>=3.10,<4.0
py3langid>=0.3,<1.0
itsdangerous>=2.1,<3.0 itsdangerous>=2.1,<3.0
cryptography>=42,<46 cryptography>=42,<46