feat: M3 — automatic channel tagging (language + topic system tags)
- Tag and ChannelTag models + migration 0003 (partial unique indexes split system vs per-user tag names) - Offline language detection (py3langid) constrained to a curated language set, with the channel's declared default language as a strong prior - Topic tags mapped from YouTube topicDetails + dominant video category; the generic "Lifestyle" catch-all is intentionally dropped - System (auto) tags are regenerated idempotently and never touch user tags; orphaned system tags are cleaned up - GET /api/tags and admin POST /api/tags/recompute; scheduled autotag job
This commit is contained in:
parent
64b18b3cc1
commit
68dad91e8a
9 changed files with 482 additions and 1 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
89
backend/alembic/versions/0003_tags.py
Normal file
89
backend/alembic/versions/0003_tags.py
Normal 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")
|
||||||
|
|
@ -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()}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
43
backend/app/routes/tags.py
Normal file
43
backend/app/routes/tags.py
Normal 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)
|
||||||
|
|
@ -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
279
backend/app/sync/autotag.py
Normal 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)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue