siftlode/backend/alembic/versions/0028_live_search_provenance.py
npeter83 9b1bdb6b42 feat(search): live YouTube search backend
Add a live YouTube search that materialises results into the shared catalog so
they render with the normal feed cards + in-app player and gain per-user state.

- YouTubeClient.search_videos(): search.list (100 units), embeddable-only, returns
  flat stubs + nextPageToken; surfaces liveBroadcastContent for live filtering.
- routes/search.py GET /api/search/youtube: require_human + per-user daily cap
  (search_daily_limit_per_user, default 70) + can_spend pre-check (429 on either);
  drops live/upcoming, upserts channel stubs (channels.list) + video stubs, enriches
  (videos.list), runs the youtube.com/shorts probe, then excludes Shorts/live and
  returns feed cards in relevance order with the YouTube pageToken as the cursor.
- Provenance: videos.via_search / channels.from_search (migration 0028) flag
  search-discovered rows; the feed hides them from the Library (scope=all) by default
  via exclude_search_discovered, leaving the Mine feed untouched.
- quota.actions_today() counts a user's per-action events today for the cap; only the
  search.list call is attributed VIDEOS_SEARCH so the counter is exactly 1 per search.
2026-06-29 02:01:31 +02:00

46 lines
1.6 KiB
Python

"""live YouTube search: provenance flags
Revision ID: 0028_live_search_provenance
Revises: 0027_message_e2ee
Create Date: 2026-06-29
Adds provenance flags so videos (and channels) that entered the catalog only because a live
YouTube search surfaced them can be told apart from organically-synced content and hidden from
the Library by default:
- videos.via_search — this video first arrived via search
- channels.from_search — this channel exists only because search surfaced one of its videos
Both default false (every existing row is organic), indexed for the feed's exclude filter.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0028_live_search_provenance"
down_revision: Union[str, None] = "0027_message_e2ee"
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(
"via_search", sa.Boolean(), nullable=False, server_default="false"
),
)
op.create_index("ix_videos_via_search", "videos", ["via_search"])
op.add_column(
"channels",
sa.Column(
"from_search", sa.Boolean(), nullable=False, server_default="false"
),
)
op.create_index("ix_channels_from_search", "channels", ["from_search"])
def downgrade() -> None:
op.drop_index("ix_channels_from_search", table_name="channels")
op.drop_column("channels", "from_search")
op.drop_index("ix_videos_via_search", table_name="videos")
op.drop_column("videos", "via_search")