Merge: promote dev to prod
This commit is contained in:
commit
dda94be72b
54 changed files with 2534 additions and 342 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.18.0
|
0.19.0
|
||||||
|
|
|
||||||
47
backend/alembic/versions/0030_search_finds.py
Normal file
47
backend/alembic/versions/0030_search_finds.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""per-user search finds
|
||||||
|
|
||||||
|
Revision ID: 0030_search_finds
|
||||||
|
Revises: 0029_unaccent_search
|
||||||
|
Create Date: 2026-06-30
|
||||||
|
|
||||||
|
Adds the `search_finds` table: a per-user record of which videos a user surfaced via their own
|
||||||
|
live YouTube search. This lets "videos I searched for" appear in that user's Mine feed (and the
|
||||||
|
Mine Source filter), independent of the global `videos.via_search` flag (which marks a video as
|
||||||
|
search-discovered by anyone, for the shared Library's Source filter).
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0030_search_finds"
|
||||||
|
down_revision: Union[str, None] = "0029_unaccent_search"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"search_finds",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("video_id", sa.String(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["video_id"], ["videos.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("user_id", "video_id", name="uq_user_search_video"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_search_finds_user_id", "search_finds", ["user_id"])
|
||||||
|
op.create_index("ix_search_finds_video_id", "search_finds", ["video_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_search_finds_video_id", table_name="search_finds")
|
||||||
|
op.drop_index("ix_search_finds_user_id", table_name="search_finds")
|
||||||
|
op.drop_table("search_finds")
|
||||||
49
backend/alembic/versions/0031_title_fts.py
Normal file
49
backend/alembic/versions/0031_title_fts.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""full-text relevance search on video titles
|
||||||
|
|
||||||
|
Revision ID: 0031_title_fts
|
||||||
|
Revises: 0030_search_finds
|
||||||
|
Create Date: 2026-06-30
|
||||||
|
|
||||||
|
Adds a PostgreSQL full-text search index over video titles so the feed search box can rank by
|
||||||
|
relevance (a YouTube-like "fuzzy" search: word-order-independent, multi-word AND, prefix on the
|
||||||
|
word being typed) instead of a whole-phrase substring match.
|
||||||
|
|
||||||
|
A custom text-search configuration `unaccent_simple` = the `simple` config (no stemming, no
|
||||||
|
stopwords — right for a multilingual HU/EN/DE catalog) plus the `unaccent` dictionary, so the
|
||||||
|
search stays accent-insensitive ("tiesto" matches "Tiësto"). A GIN expression index on
|
||||||
|
`to_tsvector('public.unaccent_simple', coalesce(title,''))` makes the @@ match fast; the feed
|
||||||
|
query uses the exact same expression so the planner picks the index. `unaccent` is already
|
||||||
|
enabled (migration 0029). The config-based 2-arg to_tsvector is IMMUTABLE, so it's index-safe.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0031_title_fts"
|
||||||
|
down_revision: Union[str, None] = "0030_search_finds"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"CREATE TEXT SEARCH CONFIGURATION public.unaccent_simple (COPY = pg_catalog.simple)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TEXT SEARCH CONFIGURATION public.unaccent_simple
|
||||||
|
ALTER MAPPING FOR asciiword, asciihword, hword_asciipart, word, hword, hword_part
|
||||||
|
WITH unaccent, simple
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX ix_videos_title_fts ON videos
|
||||||
|
USING gin (to_tsvector('public.unaccent_simple', coalesce(title, '')))
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_videos_title_fts")
|
||||||
|
op.execute("DROP TEXT SEARCH CONFIGURATION IF EXISTS public.unaccent_simple")
|
||||||
65
backend/alembic/versions/0032_search_document.py
Normal file
65
backend/alembic/versions/0032_search_document.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
"""richer full-text search document (title + keywords + search terms + description)
|
||||||
|
|
||||||
|
Revision ID: 0032_search_document
|
||||||
|
Revises: 0031_title_fts
|
||||||
|
Create Date: 2026-06-30
|
||||||
|
|
||||||
|
Broadens the feed's full-text search from the title alone to a weighted search document, so the
|
||||||
|
local search behaves more like YouTube's — finding videos by the uploader's own keywords or by
|
||||||
|
the search query that surfaced them, not only by words in the title:
|
||||||
|
|
||||||
|
- `keywords` — the creator's snippet.tags (already fetched with the snippet we request, so
|
||||||
|
zero extra quota), stored space-joined.
|
||||||
|
- `search_terms` — distinct live-search queries that surfaced the video (across all users),
|
||||||
|
appended by the search route. Folds YouTube's relevance judgement into local
|
||||||
|
search: a video YT returned for a query becomes findable by it locally.
|
||||||
|
|
||||||
|
These plus a truncated description feed a STORED generated `search_vector` column, weighted
|
||||||
|
title (A) > keywords+search_terms (B) > description (C) so ts_rank ranks title hits highest. A
|
||||||
|
plain GIN index on the column guarantees the planner uses it (no expression/param matching). The
|
||||||
|
title-only expression index from 0031 is dropped (superseded). Existing rows get title +
|
||||||
|
description indexed immediately; keywords/search_terms fill in as videos are (re-)enriched and
|
||||||
|
searched. Uses the `unaccent_simple` config from 0031 (accent-insensitive).
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0032_search_document"
|
||||||
|
down_revision: Union[str, None] = "0031_title_fts"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
_SEARCH_VECTOR_EXPR = (
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || "
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(keywords, '') || ' ' || "
|
||||||
|
"coalesce(search_terms, '')), 'B') || "
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(description, ''), 1000)), 'C')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("videos", sa.Column("keywords", sa.Text(), nullable=True))
|
||||||
|
op.add_column("videos", sa.Column("search_terms", sa.Text(), nullable=True))
|
||||||
|
# Replace the title-only index with a generated weighted search_vector + its GIN index.
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_videos_title_fts")
|
||||||
|
op.execute(
|
||||||
|
f"ALTER TABLE videos ADD COLUMN search_vector tsvector "
|
||||||
|
f"GENERATED ALWAYS AS ({_SEARCH_VECTOR_EXPR}) STORED"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX ix_videos_search_vector ON videos USING gin (search_vector)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_videos_search_vector")
|
||||||
|
op.drop_column("videos", "search_vector")
|
||||||
|
op.drop_column("videos", "search_terms")
|
||||||
|
op.drop_column("videos", "keywords")
|
||||||
|
# Recreate the title-only FTS index from 0031.
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX ix_videos_title_fts ON videos "
|
||||||
|
"USING gin (to_tsvector('public.unaccent_simple', coalesce(title, '')))"
|
||||||
|
)
|
||||||
83
backend/alembic/versions/0033_channel_explore.py
Normal file
83
backend/alembic/versions/0033_channel_explore.py
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
"""channel explore: about-page fields, explore provenance, explored_channels
|
||||||
|
|
||||||
|
Revision ID: 0033_channel_explore
|
||||||
|
Revises: 0032_search_document
|
||||||
|
Create Date: 2026-06-30
|
||||||
|
|
||||||
|
Supports the channel-explore feature (a dedicated channel page + ephemeral browsing of
|
||||||
|
un-subscribed channels):
|
||||||
|
- channels.total_view_count / published_at / banner_url / external_links — richer "About"
|
||||||
|
metadata fetched from channels.list (part=statistics, snippet, brandingSettings).
|
||||||
|
- channels.from_explore — this channel exists only because someone explored it (not an
|
||||||
|
organic subscription / not a search find); drives the ephemeral cleanup job.
|
||||||
|
- videos.via_explore — this video entered the catalog via a channel exploration. Hidden
|
||||||
|
from everyone's Library by default; visible only to users who explored (or subscribe to)
|
||||||
|
the channel (a per-user gate in the feed query).
|
||||||
|
- explored_channels — per-user record of "I explored this channel": gates channel-page
|
||||||
|
access to its ephemeral videos and drives the grace-period purge of un-kept channels.
|
||||||
|
All new flags default false / NULL, so every existing row stays organic.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0033_channel_explore"
|
||||||
|
down_revision: Union[str, None] = "0032_search_document"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("channels", sa.Column("total_view_count", sa.BigInteger(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"channels", sa.Column("published_at", sa.DateTime(timezone=True), nullable=True)
|
||||||
|
)
|
||||||
|
op.add_column("channels", sa.Column("banner_url", sa.String(length=1024), nullable=True))
|
||||||
|
op.add_column("channels", sa.Column("external_links", sa.JSON(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"channels",
|
||||||
|
sa.Column("from_explore", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_channels_from_explore", "channels", ["from_explore"])
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"videos",
|
||||||
|
sa.Column("via_explore", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_videos_via_explore", "videos", ["via_explore"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"explored_channels",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("channel_id", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["channel_id"], ["channels.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("user_id", "channel_id", name="uq_user_explored_channel"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_explored_channels_user_id", "explored_channels", ["user_id"])
|
||||||
|
op.create_index("ix_explored_channels_channel_id", "explored_channels", ["channel_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_explored_channels_channel_id", table_name="explored_channels")
|
||||||
|
op.drop_index("ix_explored_channels_user_id", table_name="explored_channels")
|
||||||
|
op.drop_table("explored_channels")
|
||||||
|
|
||||||
|
op.drop_index("ix_videos_via_explore", table_name="videos")
|
||||||
|
op.drop_column("videos", "via_explore")
|
||||||
|
|
||||||
|
op.drop_index("ix_channels_from_explore", table_name="channels")
|
||||||
|
op.drop_column("channels", "from_explore")
|
||||||
|
op.drop_column("channels", "external_links")
|
||||||
|
op.drop_column("channels", "banner_url")
|
||||||
|
op.drop_column("channels", "published_at")
|
||||||
|
op.drop_column("channels", "total_view_count")
|
||||||
46
backend/alembic/versions/0034_blocked_channels.py
Normal file
46
backend/alembic/versions/0034_blocked_channels.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""per-user blocked channels (YouTube search / feed / explore)
|
||||||
|
|
||||||
|
Revision ID: 0034_blocked_channels
|
||||||
|
Revises: 0033_channel_explore
|
||||||
|
Create Date: 2026-07-01
|
||||||
|
|
||||||
|
Adds the `blocked_channels` table: a per-user blocklist. Videos from a blocked channel are
|
||||||
|
never shown in that user's live YouTube search (and not ingested), and are hidden from their
|
||||||
|
feed / explore views. Per-user, so one user's block doesn't affect anyone else.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0034_blocked_channels"
|
||||||
|
down_revision: Union[str, None] = "0033_channel_explore"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"blocked_channels",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("channel_id", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["channel_id"], ["channels.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("user_id", "channel_id", name="uq_user_blocked_channel"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_blocked_channels_user_id", "blocked_channels", ["user_id"])
|
||||||
|
op.create_index("ix_blocked_channels_channel_id", "blocked_channels", ["channel_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_blocked_channels_channel_id", table_name="blocked_channels")
|
||||||
|
op.drop_index("ix_blocked_channels_user_id", table_name="blocked_channels")
|
||||||
|
op.drop_table("blocked_channels")
|
||||||
|
|
@ -118,6 +118,15 @@ class Settings(BaseSettings):
|
||||||
# items). Deleted/private/paywalled get a recovery window; abandoned upcoming and
|
# items). Deleted/private/paywalled get a recovery window; abandoned upcoming and
|
||||||
# confirmed-dead stuck-live have no age grace (deleted on the sweep that confirms them).
|
# confirmed-dead stuck-live have no age grace (deleted on the sweep that confirms them).
|
||||||
maintenance_grace_days: int = 7
|
maintenance_grace_days: int = 7
|
||||||
|
# --- Channel-explore cleanup job ---
|
||||||
|
# Runs daily by default (admin-tunable via the Scheduler dashboard). An explored-but-never-
|
||||||
|
# kept channel (no subscription, no per-user state on its videos) is hard-deleted this many
|
||||||
|
# days after it was last explored — the grace clock is ExploredChannel.created_at.
|
||||||
|
explore_cleanup_minutes: int = 1440
|
||||||
|
explore_grace_days: int = 7
|
||||||
|
# How long an un-kept live-search result (a via_search video nobody watched / saved /
|
||||||
|
# subscribed to) is kept before the same cleanup job removes it — "as if never added".
|
||||||
|
search_grace_days: int = 7
|
||||||
# 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"
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ from datetime import date, datetime
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
BigInteger,
|
BigInteger,
|
||||||
Boolean,
|
Boolean,
|
||||||
|
Computed,
|
||||||
Date,
|
Date,
|
||||||
DateTime,
|
DateTime,
|
||||||
Float,
|
Float,
|
||||||
|
|
@ -14,6 +15,7 @@ from sqlalchemy import (
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
func,
|
func,
|
||||||
)
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import TSVECTOR
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.db import Base
|
from app.db import Base
|
||||||
|
|
@ -163,6 +165,12 @@ class Channel(Base, TimestampMixin):
|
||||||
uploads_playlist_id: Mapped[str | None] = mapped_column(String(64))
|
uploads_playlist_id: Mapped[str | None] = mapped_column(String(64))
|
||||||
subscriber_count: Mapped[int | None] = mapped_column(BigInteger)
|
subscriber_count: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
video_count: Mapped[int | None] = mapped_column(BigInteger)
|
video_count: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
# Channel "About" metadata (fetched with the same channels.list call, no extra quota):
|
||||||
|
# lifetime view count, the channel's join date, banner image, and external links.
|
||||||
|
total_view_count: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
banner_url: Mapped[str | None] = mapped_column(String(1024))
|
||||||
|
external_links: Mapped[list | None] = mapped_column(JSON)
|
||||||
topic_categories: Mapped[list | None] = mapped_column(JSON)
|
topic_categories: Mapped[list | None] = mapped_column(JSON)
|
||||||
default_language: Mapped[str | None] = mapped_column(String(16))
|
default_language: Mapped[str | None] = mapped_column(String(16))
|
||||||
country: Mapped[str | None] = mapped_column(String(8))
|
country: Mapped[str | None] = mapped_column(String(8))
|
||||||
|
|
@ -181,6 +189,14 @@ class Channel(Base, TimestampMixin):
|
||||||
from_search: Mapped[bool] = mapped_column(
|
from_search: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=False, server_default="false", index=True
|
Boolean, default=False, server_default="false", index=True
|
||||||
)
|
)
|
||||||
|
# True when this channel exists only because a user EXPLORED it (opened its page without
|
||||||
|
# subscribing) — an ephemeral, per-explorer addition. The cleanup job hard-deletes such a
|
||||||
|
# channel (and its via_explore videos) once nobody keeps it (no subscription, no live
|
||||||
|
# ExploredChannel row, no per-user state) after a grace period. Cleared when someone
|
||||||
|
# subscribes (the "keep" signal promotes it to organic).
|
||||||
|
from_explore: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False, server_default="false", index=True
|
||||||
|
)
|
||||||
|
|
||||||
videos: Mapped[list["Video"]] = relationship(back_populates="channel")
|
videos: Mapped[list["Video"]] = relationship(back_populates="channel")
|
||||||
subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="channel")
|
subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="channel")
|
||||||
|
|
@ -237,6 +253,14 @@ class Video(Base, TimestampMixin):
|
||||||
topic_categories: Mapped[list | None] = mapped_column(JSON)
|
topic_categories: Mapped[list | None] = mapped_column(JSON)
|
||||||
default_language: Mapped[str | None] = mapped_column(String(16))
|
default_language: Mapped[str | None] = mapped_column(String(16))
|
||||||
detected_language: Mapped[str | None] = mapped_column(String(16))
|
detected_language: Mapped[str | None] = mapped_column(String(16))
|
||||||
|
# Creator-supplied keyword tags (snippet.tags joined by spaces) — fetched for free with the
|
||||||
|
# snippet we already request. Indexed into search_vector so the feed search matches the
|
||||||
|
# uploader's own keywords, not just words in the title.
|
||||||
|
keywords: Mapped[str | None] = mapped_column(Text)
|
||||||
|
# Accumulated distinct live-search queries that surfaced this video (across all users) —
|
||||||
|
# folds YouTube's own relevance judgement into the local search: a video YouTube returned
|
||||||
|
# for "ti amo magyarul" becomes findable by that query locally even if its title lacks it.
|
||||||
|
search_terms: Mapped[str | None] = mapped_column(Text)
|
||||||
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
|
||||||
)
|
)
|
||||||
|
|
@ -250,6 +274,14 @@ class Video(Base, TimestampMixin):
|
||||||
via_search: Mapped[bool] = mapped_column(
|
via_search: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=False, server_default="false", index=True
|
Boolean, default=False, server_default="false", index=True
|
||||||
)
|
)
|
||||||
|
# True when this video entered the catalog because a user EXPLORED its (un-subscribed)
|
||||||
|
# channel. Like via_search it's hidden from the shared Library by default, but it's
|
||||||
|
# additionally PER-USER private: a via_explore video is only visible to users who
|
||||||
|
# explored or subscribe to its channel (a gate in the feed query), so one user's
|
||||||
|
# curiosity never floods everyone else's catalog. Left False by the organic ingest paths.
|
||||||
|
via_explore: 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
|
||||||
|
|
@ -272,6 +304,21 @@ class Video(Base, TimestampMixin):
|
||||||
)
|
)
|
||||||
unavailable_reason: Mapped[str | None] = mapped_column(String(24))
|
unavailable_reason: Mapped[str | None] = mapped_column(String(24))
|
||||||
|
|
||||||
|
# Weighted full-text search document (DB-generated, never written by the app): title (A) >
|
||||||
|
# creator keywords + search queries (B) > truncated description (C). ts_rank honours the
|
||||||
|
# weights so title matches outrank description matches. Backed by a GIN index (migration
|
||||||
|
# 0032). The accent-insensitive `unaccent_simple` config comes from migration 0031.
|
||||||
|
search_vector: Mapped[object | None] = mapped_column(
|
||||||
|
TSVECTOR,
|
||||||
|
Computed(
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || "
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(keywords, '') || ' ' || "
|
||||||
|
"coalesce(search_terms, '')), 'B') || "
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(description, ''), 1000)), 'C')",
|
||||||
|
persisted=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
channel: Mapped["Channel"] = relationship(back_populates="videos")
|
channel: Mapped["Channel"] = relationship(back_populates="videos")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -344,6 +391,69 @@ class VideoState(Base, UpdatedAtMixin):
|
||||||
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
|
||||||
|
class SearchFind(Base, TimestampMixin):
|
||||||
|
"""Per-user record of a video the user surfaced via their own live YouTube search.
|
||||||
|
|
||||||
|
Lets "videos I searched for" appear in that user's own (Mine) feed and the Mine Source
|
||||||
|
filter — distinct from the GLOBAL `Video.via_search` provenance, which marks a video as
|
||||||
|
search-discovered by *anyone* and drives the shared Library's Source filter."""
|
||||||
|
|
||||||
|
__tablename__ = "search_finds"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "video_id", name="uq_user_search_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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExploredChannel(Base, TimestampMixin):
|
||||||
|
"""Per-user record of a channel the user opened the page of WITHOUT subscribing.
|
||||||
|
|
||||||
|
Exploring an un-subscribed channel ingests its recent uploads (flagged via_explore) so the
|
||||||
|
user can browse them, but keeps them out of everyone else's catalog. This row (a) gates the
|
||||||
|
explorer's access to those ephemeral videos in the feed query and (b) drives the grace-period
|
||||||
|
purge: a from_explore channel with no live ExploredChannel row (and no subscription / no
|
||||||
|
per-user state on its videos) is hard-deleted by the cleanup job. Subscribing is the "keep"
|
||||||
|
signal — it promotes the channel to organic and these rows become irrelevant. `created_at`
|
||||||
|
(from TimestampMixin) is the grace clock; a re-visit refreshes it."""
|
||||||
|
|
||||||
|
__tablename__ = "explored_channels"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "channel_id", name="uq_user_explored_channel"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
channel_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("channels.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BlockedChannel(Base, TimestampMixin):
|
||||||
|
"""Per-user channel blocklist. Videos from a blocked channel are excluded from this user's
|
||||||
|
live YouTube search (and not ingested on their behalf) and hidden from their feed / explore
|
||||||
|
views — a personal "never show me this channel again" that doesn't affect other users."""
|
||||||
|
|
||||||
|
__tablename__ = "blocked_channels"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "channel_id", name="uq_user_blocked_channel"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
channel_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("channels.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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."""
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ class QuotaAction:
|
||||||
VIDEOS_LOOKUP = "videos_lookup"
|
VIDEOS_LOOKUP = "videos_lookup"
|
||||||
VIDEOS_SEARCH = "videos_search"
|
VIDEOS_SEARCH = "videos_search"
|
||||||
CHANNELS_DISCOVER = "channels_discover"
|
CHANNELS_DISCOVER = "channels_discover"
|
||||||
|
CHANNELS_EXPLORE = "channels_explore"
|
||||||
CHANNELS_SUBSCRIBE = "channels_subscribe"
|
CHANNELS_SUBSCRIBE = "channels_subscribe"
|
||||||
CHANNELS_UNSUBSCRIBE = "channels_unsubscribe"
|
CHANNELS_UNSUBSCRIBE = "channels_unsubscribe"
|
||||||
MAINTENANCE_REVALIDATE = "maintenance_revalidate"
|
MAINTENANCE_REVALIDATE = "maintenance_revalidate"
|
||||||
|
|
|
||||||
|
|
@ -376,3 +376,15 @@ def reset_demo(
|
||||||
same automatically (admin-tunable interval); this is the admin's on-demand button."""
|
same automatically (admin-tunable interval); this is the admin's on-demand button."""
|
||||||
demo = get_or_create_demo_user(db)
|
demo = get_or_create_demo_user(db)
|
||||||
return {"reset": True, "playlists_seeded": reset_demo_state(db, demo)}
|
return {"reset": True, "playlists_seeded": reset_demo_state(db, demo)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/purge-discovery")
|
||||||
|
def purge_discovery(
|
||||||
|
_: User = Depends(admin_user), db: Session = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
"""Reclaim all un-kept discovery content NOW (ignoring the grace period): live-search result
|
||||||
|
videos nobody watched/saved/subscribed to, plus explored-but-unsubscribed channels. The
|
||||||
|
scheduled discovery-cleanup job does the same on its interval; this is the on-demand button."""
|
||||||
|
from app.sync.explore import purge_unkept_explored, purge_unkept_search
|
||||||
|
|
||||||
|
return {**purge_unkept_search(db, grace_days=0), **purge_unkept_explored(db)}
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,17 @@ import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import and_, func, select
|
from sqlalchemy import and_, func, select, update
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import quota
|
from app import quota, sysconfig
|
||||||
from app.auth import current_user, has_write_scope
|
from app.auth import current_user, has_write_scope, require_human
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import (
|
from app.models import (
|
||||||
|
BlockedChannel,
|
||||||
Channel,
|
Channel,
|
||||||
ChannelTag,
|
ChannelTag,
|
||||||
|
ExploredChannel,
|
||||||
Playlist,
|
Playlist,
|
||||||
PlaylistItem,
|
PlaylistItem,
|
||||||
Subscription,
|
Subscription,
|
||||||
|
|
@ -22,6 +24,7 @@ from app.models import (
|
||||||
Video,
|
Video,
|
||||||
)
|
)
|
||||||
from app.routes.admin import admin_user
|
from app.routes.admin import admin_user
|
||||||
|
from app.sync.explore import explore_ingest_page
|
||||||
from app.sync.runner import run_recent_backfill
|
from app.sync.runner import run_recent_backfill
|
||||||
from app.sync.subscriptions import apply_channel_details
|
from app.sync.subscriptions import apply_channel_details
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
|
|
@ -205,6 +208,145 @@ def discover_channels(
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_detail_dict(
|
||||||
|
channel: Channel, *, subscribed: bool, explored: bool, blocked: bool, stored: int
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"id": channel.id,
|
||||||
|
"title": channel.title,
|
||||||
|
"handle": channel.handle,
|
||||||
|
"description": channel.description,
|
||||||
|
"thumbnail_url": channel.thumbnail_url,
|
||||||
|
"banner_url": channel.banner_url,
|
||||||
|
"subscriber_count": channel.subscriber_count,
|
||||||
|
"video_count": channel.video_count,
|
||||||
|
"total_view_count": channel.total_view_count,
|
||||||
|
"published_at": channel.published_at.isoformat() if channel.published_at else None,
|
||||||
|
"external_links": channel.external_links or [],
|
||||||
|
"country": channel.country,
|
||||||
|
"subscribed": subscribed,
|
||||||
|
"explored": explored,
|
||||||
|
"blocked": blocked,
|
||||||
|
"stored_videos": stored,
|
||||||
|
"from_explore": channel.from_explore,
|
||||||
|
"details_synced": channel.details_synced_at is not None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{channel_id}")
|
||||||
|
def channel_detail(
|
||||||
|
channel_id: str,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""One channel's "About" detail for the channel page header/About tab, plus this user's
|
||||||
|
relationship to it (subscribed / explored) and how many of its videos are in the catalog.
|
||||||
|
Lazily fills the About metadata (view count, join date, banner, links) the first time it's
|
||||||
|
needed — a stub, or a channel last enriched before those fields existed (published_at is a
|
||||||
|
reliable sentinel: every real channel has a creation date). The enrich uses the API key
|
||||||
|
(1 unit, cached after), so it's skipped for the quota-less demo account."""
|
||||||
|
channel = db.get(Channel, channel_id)
|
||||||
|
if channel is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown channel")
|
||||||
|
if channel.published_at is None and not user.is_demo:
|
||||||
|
try:
|
||||||
|
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), YouTubeClient(db, user) as yt:
|
||||||
|
apply_channel_details(db, yt.get_channels([channel_id]))
|
||||||
|
db.commit()
|
||||||
|
channel = db.get(Channel, channel_id)
|
||||||
|
except YouTubeError as exc:
|
||||||
|
log.warning("channel detail enrich failed (%s): %s", channel_id, exc)
|
||||||
|
db.rollback()
|
||||||
|
subscribed = db.execute(
|
||||||
|
select(Subscription.id).where(
|
||||||
|
Subscription.user_id == user.id, Subscription.channel_id == channel_id
|
||||||
|
)
|
||||||
|
).first() is not None
|
||||||
|
explored = db.execute(
|
||||||
|
select(ExploredChannel.id).where(
|
||||||
|
ExploredChannel.user_id == user.id, ExploredChannel.channel_id == channel_id
|
||||||
|
)
|
||||||
|
).first() is not None
|
||||||
|
blocked = db.execute(
|
||||||
|
select(BlockedChannel.id).where(
|
||||||
|
BlockedChannel.user_id == user.id, BlockedChannel.channel_id == channel_id
|
||||||
|
)
|
||||||
|
).first() is not None
|
||||||
|
stored = db.scalar(select(func.count(Video.id)).where(Video.channel_id == channel_id)) or 0
|
||||||
|
return _channel_detail_dict(
|
||||||
|
channel, subscribed=subscribed, explored=explored, blocked=blocked, stored=int(stored)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{channel_id}/explore")
|
||||||
|
def explore_channel(
|
||||||
|
channel_id: str,
|
||||||
|
page_token: str | None = None,
|
||||||
|
user: User = Depends(require_human),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Open an un-subscribed channel for browsing. Records the exploration (a per-user,
|
||||||
|
grace-clocked row that gates this user's access to the channel's ephemeral videos and
|
||||||
|
drives the later cleanup), flags the channel ephemeral unless someone organically follows
|
||||||
|
it, then ingests one page of its uploads (newest-first) marked via_explore + enriched.
|
||||||
|
Returns the next page token so the page can "load more". Guarded against the shared
|
||||||
|
backfill quota reserve; blocked for the demo account (it spends shared quota)."""
|
||||||
|
channel = db.get(Channel, channel_id)
|
||||||
|
if channel is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown channel")
|
||||||
|
if db.execute(
|
||||||
|
select(BlockedChannel.id).where(
|
||||||
|
BlockedChannel.user_id == user.id, BlockedChannel.channel_id == channel_id
|
||||||
|
)
|
||||||
|
).first() is not None:
|
||||||
|
raise HTTPException(status_code=403, detail="You've blocked this channel.")
|
||||||
|
if quota.remaining_today(db) <= sysconfig.get_int(db, "backfill_quota_reserve"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="The shared daily quota reserve is reached — try exploring later.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Record/refresh the exploration (DB only) before ingesting, so the explorer can see the
|
||||||
|
# videos as soon as they land. Flag the channel ephemeral unless it's organically followed.
|
||||||
|
existing = db.execute(
|
||||||
|
select(ExploredChannel).where(
|
||||||
|
ExploredChannel.user_id == user.id, ExploredChannel.channel_id == channel_id
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is None:
|
||||||
|
db.add(ExploredChannel(user_id=user.id, channel_id=channel_id))
|
||||||
|
else:
|
||||||
|
existing.created_at = datetime.now(timezone.utc) # refresh the grace clock
|
||||||
|
followed = db.execute(
|
||||||
|
select(Subscription.id).where(Subscription.channel_id == channel_id).limit(1)
|
||||||
|
).first()
|
||||||
|
if followed is None and not channel.from_explore:
|
||||||
|
channel.from_explore = True
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_EXPLORE), YouTubeClient(db, user) as yt:
|
||||||
|
# Enrich for the About panel + the uploads playlist id (needed to ingest videos).
|
||||||
|
if (
|
||||||
|
channel.details_synced_at is None
|
||||||
|
or channel.uploads_playlist_id is None
|
||||||
|
or channel.published_at is None
|
||||||
|
):
|
||||||
|
apply_channel_details(db, yt.get_channels([channel_id]))
|
||||||
|
db.commit()
|
||||||
|
channel = db.get(Channel, channel_id)
|
||||||
|
result = explore_ingest_page(db, yt, channel, page_token)
|
||||||
|
except YouTubeError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail=f"YouTube couldn't load this channel: {exc}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"ingested": result["ingested"],
|
||||||
|
"next_page_token": result["next_page_token"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _user_subscription(db: Session, user: User, channel_id: str) -> Subscription:
|
def _user_subscription(db: Session, user: User, channel_id: str) -> Subscription:
|
||||||
sub = db.execute(
|
sub = db.execute(
|
||||||
select(Subscription).where(
|
select(Subscription).where(
|
||||||
|
|
@ -332,6 +474,14 @@ def subscribe(
|
||||||
subscribed_at=datetime.now(timezone.utc),
|
subscribed_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# Subscribing is the "keep" signal: a previously-explored channel becomes a normal organic
|
||||||
|
# one. Drop its ephemeral flags so the cleanup job leaves it alone and its videos stop being
|
||||||
|
# per-user private (they join the shared Library like any subscribed channel's uploads).
|
||||||
|
if channel.from_explore:
|
||||||
|
channel.from_explore = False
|
||||||
|
db.execute(
|
||||||
|
update(Video).where(Video.channel_id == channel_id, Video.via_explore.is_(True)).values(via_explore=False)
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"subscribed": channel_id}
|
return {"subscribed": channel_id}
|
||||||
|
|
||||||
|
|
@ -367,6 +517,68 @@ def unsubscribe(
|
||||||
return {"unsubscribed": channel_id}
|
return {"unsubscribed": channel_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/blocked/list")
|
||||||
|
def list_blocked(
|
||||||
|
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||||
|
) -> list[dict]:
|
||||||
|
"""The user's blocked channels (videos from these are excluded from their search/feed/explore)."""
|
||||||
|
rows = db.execute(
|
||||||
|
select(Channel)
|
||||||
|
.join(BlockedChannel, BlockedChannel.channel_id == Channel.id)
|
||||||
|
.where(BlockedChannel.user_id == user.id)
|
||||||
|
.order_by(func.lower(Channel.title))
|
||||||
|
).scalars().all()
|
||||||
|
return [
|
||||||
|
{"id": c.id, "title": c.title, "handle": c.handle, "thumbnail_url": c.thumbnail_url}
|
||||||
|
for c in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{channel_id}/block")
|
||||||
|
def block_channel(
|
||||||
|
channel_id: str,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Block a channel for this user: its videos won't appear in their live search (and won't be
|
||||||
|
ingested for them) and are hidden from their feed / explore. Idempotent. The now-hidden
|
||||||
|
un-kept search/explore videos are reclaimed by the discovery-cleanup job in due course."""
|
||||||
|
if db.get(Channel, channel_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown channel")
|
||||||
|
exists = db.execute(
|
||||||
|
select(BlockedChannel.id).where(
|
||||||
|
BlockedChannel.user_id == user.id, BlockedChannel.channel_id == channel_id
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if exists is None:
|
||||||
|
db.add(BlockedChannel(user_id=user.id, channel_id=channel_id))
|
||||||
|
# Stop any active exploration of it so it can be cleaned up.
|
||||||
|
db.execute(
|
||||||
|
ExploredChannel.__table__.delete().where(
|
||||||
|
(ExploredChannel.user_id == user.id)
|
||||||
|
& (ExploredChannel.channel_id == channel_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return {"blocked": channel_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{channel_id}/block")
|
||||||
|
def unblock_channel(
|
||||||
|
channel_id: str,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
db.execute(
|
||||||
|
BlockedChannel.__table__.delete().where(
|
||||||
|
(BlockedChannel.user_id == user.id)
|
||||||
|
& (BlockedChannel.channel_id == channel_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return {"unblocked": channel_id}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{channel_id}/tags")
|
@router.post("/{channel_id}/tags")
|
||||||
def attach_tag(
|
def attach_tag(
|
||||||
channel_id: str,
|
channel_id: str,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import Select, and_, false, func, or_, select
|
from sqlalchemy import BigInteger, Select, and_, cast, false, func, or_, select
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session, aliased
|
from sqlalchemy.orm import Session, aliased
|
||||||
from sqlalchemy.orm.exc import StaleDataError
|
from sqlalchemy.orm.exc import StaleDataError
|
||||||
|
|
@ -13,10 +14,13 @@ from app import quota
|
||||||
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 (
|
from app.models import (
|
||||||
|
BlockedChannel,
|
||||||
Channel,
|
Channel,
|
||||||
ChannelTag,
|
ChannelTag,
|
||||||
|
ExploredChannel,
|
||||||
Playlist,
|
Playlist,
|
||||||
PlaylistItem,
|
PlaylistItem,
|
||||||
|
SearchFind,
|
||||||
Subscription,
|
Subscription,
|
||||||
Tag,
|
Tag,
|
||||||
User,
|
User,
|
||||||
|
|
@ -153,14 +157,20 @@ def _filtered_query(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Only channels this user is subscribed to (and hasn't hidden).
|
# "Mine": the user's own videos = their non-hidden subscriptions OR videos they
|
||||||
query = query.join(
|
# surfaced via their own live YouTube search. Both are LEFT-joined so the Source
|
||||||
|
# filter below can include either or both, and the subscription row stays available
|
||||||
|
# for the priority sort.
|
||||||
|
query = query.outerjoin(
|
||||||
Subscription,
|
Subscription,
|
||||||
and_(
|
and_(
|
||||||
Subscription.channel_id == Video.channel_id,
|
Subscription.channel_id == Video.channel_id,
|
||||||
Subscription.user_id == user.id,
|
Subscription.user_id == user.id,
|
||||||
Subscription.hidden.is_(False),
|
Subscription.hidden.is_(False),
|
||||||
),
|
),
|
||||||
|
).outerjoin(
|
||||||
|
SearchFind,
|
||||||
|
and_(SearchFind.video_id == Video.id, SearchFind.user_id == user.id),
|
||||||
)
|
)
|
||||||
|
|
||||||
query = query.outerjoin(
|
query = query.outerjoin(
|
||||||
|
|
@ -172,16 +182,50 @@ def _filtered_query(
|
||||||
# either way they shouldn't show in any feed view meanwhile.
|
# either way they shouldn't show in any feed view meanwhile.
|
||||||
query = query.where(Video.unavailable_since.is_(None))
|
query = query.where(Video.unavailable_since.is_(None))
|
||||||
|
|
||||||
# Provenance filter for the Library: search-discovered videos are "noise" hidden by
|
# Explored videos are PER-EXPLORER private: a via_explore video (ingested when someone
|
||||||
# default ("organic"), can be mixed in ("all"), or shown exclusively ("search"). Only
|
# opened an un-subscribed channel's page) is visible only to users who explored or
|
||||||
# meaningful in "all" scope: "my" already restricts to subscribed channels, where a
|
# subscribe to its channel, so one user's curiosity never leaks into everyone else's
|
||||||
# once-searched video is legitimately the user's.
|
# catalog. Applied in BOTH scopes; the channel page reaches its ephemeral videos by
|
||||||
|
# asking for scope=all + channel_id (the channel is in this accessible set).
|
||||||
|
accessible_explore = (
|
||||||
|
select(Subscription.channel_id).where(Subscription.user_id == user.id)
|
||||||
|
).union(
|
||||||
|
select(ExploredChannel.channel_id).where(ExploredChannel.user_id == user.id)
|
||||||
|
)
|
||||||
|
query = query.where(
|
||||||
|
or_(
|
||||||
|
Video.via_explore.is_(False),
|
||||||
|
Video.channel_id.in_(accessible_explore),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Per-user channel blocklist: never show this user videos from a channel they blocked
|
||||||
|
# (applies to feed, Library, explore, and the channel page alike).
|
||||||
|
query = query.where(
|
||||||
|
Video.channel_id.notin_(
|
||||||
|
select(BlockedChannel.channel_id).where(BlockedChannel.user_id == user.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Source filter. In the shared Library ("all" scope) it uses the GLOBAL via_search flag
|
||||||
|
# (search-discovered by anyone). In "my" scope it's the user's OWN provenance:
|
||||||
|
# "organic" = your subscriptions, "search" = videos you found via your own search,
|
||||||
|
# "all" = both. Default "organic" keeps the Mine feed = subscriptions only.
|
||||||
if scope == "all":
|
if scope == "all":
|
||||||
if library_source == "organic":
|
if library_source == "organic":
|
||||||
query = query.where(Video.via_search.is_(False))
|
query = query.where(Video.via_search.is_(False))
|
||||||
elif library_source == "search":
|
elif library_source == "search":
|
||||||
query = query.where(Video.via_search.is_(True))
|
query = query.where(Video.via_search.is_(True))
|
||||||
# "all" → both organic and search-discovered videos
|
# "all" → both organic and search-discovered videos
|
||||||
|
else:
|
||||||
|
subscribed = Subscription.channel_id.isnot(None)
|
||||||
|
searched = SearchFind.video_id.isnot(None)
|
||||||
|
if library_source == "search":
|
||||||
|
query = query.where(searched)
|
||||||
|
elif library_source == "all":
|
||||||
|
query = query.where(or_(subscribed, searched))
|
||||||
|
else: # "organic" (default): subscriptions only
|
||||||
|
query = query.where(subscribed)
|
||||||
|
|
||||||
if channel_id:
|
if channel_id:
|
||||||
query = query.where(Video.channel_id == channel_id)
|
query = query.where(Video.channel_id == channel_id)
|
||||||
|
|
@ -204,9 +248,29 @@ def _filtered_query(
|
||||||
published_before, datetime.min.time(), tzinfo=timezone.utc
|
published_before, datetime.min.time(), tzinfo=timezone.utc
|
||||||
) + timedelta(days=1)
|
) + timedelta(days=1)
|
||||||
query = query.where(Video.published_at < end)
|
query = query.where(Video.published_at < end)
|
||||||
|
# Full-text relevance search over the weighted search document (title > creator keywords +
|
||||||
|
# the queries that surfaced the video > description; see Video.search_vector). YouTube-like
|
||||||
|
# "fuzzy" matching: word-order-independent, multi-word AND, prefix on the word being typed,
|
||||||
|
# accent-insensitive (unaccent_simple). The channel name still matches as a substring so
|
||||||
|
# channel searches work. `rank_expr` (weight-aware ts_rank) drives the "relevance" sort.
|
||||||
|
rank_expr = None
|
||||||
if q:
|
if q:
|
||||||
# Accent-insensitive: unaccent() both sides so "tiesto" matches "Tiësto" (ILIKE alone
|
ts_str = _to_tsquery_str(q)
|
||||||
# is case- but not diacritic-insensitive). unaccent is enabled by migration 0029.
|
if ts_str:
|
||||||
|
tsq = func.to_tsquery("public.unaccent_simple", ts_str)
|
||||||
|
query = query.where(
|
||||||
|
or_(
|
||||||
|
Video.search_vector.op("@@")(tsq),
|
||||||
|
func.unaccent(Channel.title).ilike(func.unaccent(f"%{q}%")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Scale ts_rank (a float4) to an integer score so the keyset cursor compares it
|
||||||
|
# EXACTLY — a raw float4 vs the float8 cursor value mismatches on round-trip and
|
||||||
|
# breaks paging (the same page repeats). 1e6 granularity is ample; ties break on
|
||||||
|
# published_at then id.
|
||||||
|
rank_expr = cast(func.ts_rank(Video.search_vector, tsq) * 1000000, BigInteger)
|
||||||
|
else:
|
||||||
|
# No usable tokens (e.g. only punctuation): fall back to a plain substring match.
|
||||||
like = func.unaccent(f"%{q}%")
|
like = func.unaccent(f"%{q}%")
|
||||||
query = query.where(
|
query = query.where(
|
||||||
or_(
|
or_(
|
||||||
|
|
@ -271,7 +335,19 @@ def _filtered_query(
|
||||||
else: # all
|
else: # all
|
||||||
query = query.where(status_expr != "hidden")
|
query = query.where(status_expr != "hidden")
|
||||||
|
|
||||||
return query, status_expr
|
return query, status_expr, rank_expr
|
||||||
|
|
||||||
|
|
||||||
|
def _to_tsquery_str(q: str) -> str | None:
|
||||||
|
"""Turn free-text into a to_tsquery() string: word runs ANDed together, the LAST word
|
||||||
|
prefix-matched (`:*`) for as-you-type search — e.g. "ti amo magyar" → "ti & amo & magyar:*".
|
||||||
|
Only `\\w` runs containing an alphanumeric are kept, so no tsquery operators can leak in
|
||||||
|
(the string is built from sanitised tokens, never the raw query)."""
|
||||||
|
tokens = [t for t in re.findall(r"\w+", q, flags=re.UNICODE) if any(c.isalnum() for c in t)]
|
||||||
|
if not tokens:
|
||||||
|
return None
|
||||||
|
tokens[-1] = tokens[-1] + ":*"
|
||||||
|
return " & ".join(tokens)
|
||||||
|
|
||||||
|
|
||||||
# Shared query parameters for /feed and /feed/count.
|
# Shared query parameters for /feed and /feed/count.
|
||||||
|
|
@ -320,7 +396,7 @@ def _feed_params(
|
||||||
# replaces OFFSET/LIMIT: deep scrolling no longer scans-and-discards skipped rows,
|
# replaces OFFSET/LIMIT: deep scrolling no longer scans-and-discards skipped rows,
|
||||||
# and the page boundary is stable even as the scheduler ingests new videos mid-scroll
|
# and the page boundary is stable even as the scheduler ingests new videos mid-scroll
|
||||||
# (OFFSET would shift and duplicate/skip rows).
|
# (OFFSET would shift and duplicate/skip rows).
|
||||||
def _sort_keys(sort: str, seed: int) -> list[dict]:
|
def _sort_keys(sort: str, seed: int, rank_expr=None) -> list[dict]:
|
||||||
pub = {"expr": Video.published_at, "asc": False, "nullable": True, "kind": "dt"}
|
pub = {"expr": Video.published_at, "asc": False, "nullable": True, "kind": "dt"}
|
||||||
table: dict[str, list[dict]] = {
|
table: dict[str, list[dict]] = {
|
||||||
"newest": [pub],
|
"newest": [pub],
|
||||||
|
|
@ -341,6 +417,13 @@ def _sort_keys(sort: str, seed: int) -> list[dict]:
|
||||||
# the cursor stays valid across pages of one shuffled run.
|
# the cursor stays valid across pages of one shuffled run.
|
||||||
"shuffle": [{"expr": func.md5(func.concat(Video.id, str(seed))), "asc": True, "nullable": False, "kind": "str"}],
|
"shuffle": [{"expr": func.md5(func.concat(Video.id, str(seed))), "asc": True, "nullable": False, "kind": "str"}],
|
||||||
}
|
}
|
||||||
|
# Relevance (search-result ranking) only exists when there's a query to rank against;
|
||||||
|
# without `rank_expr` it falls through to newest. Ties break by newest, then id.
|
||||||
|
if rank_expr is not None:
|
||||||
|
table["relevance"] = [
|
||||||
|
{"expr": rank_expr, "asc": False, "nullable": False, "kind": "num"},
|
||||||
|
pub,
|
||||||
|
]
|
||||||
return table.get(sort) or table["newest"]
|
return table.get(sort) or table["newest"]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -409,8 +492,8 @@ def get_feed(
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
query, _status = _filtered_query(db, user, **params)
|
query, _status, rank_expr = _filtered_query(db, user, **params)
|
||||||
keys = _sort_keys(sort, seed)
|
keys = _sort_keys(sort, seed, rank_expr)
|
||||||
|
|
||||||
# Expose each key column on the row so we can build the next cursor from the last item.
|
# Expose each key column on the row so we can build the next cursor from the last item.
|
||||||
query = query.add_columns(*[k["expr"].label(f"_k{i}") for i, k in enumerate(keys)])
|
query = query.add_columns(*[k["expr"].label(f"_k{i}") for i, k in enumerate(keys)])
|
||||||
|
|
@ -439,7 +522,7 @@ def get_feed_count(
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
query, _status = _filtered_query(db, user, **params)
|
query, _status, _rank = _filtered_query(db, user, **params)
|
||||||
total = db.scalar(select(func.count()).select_from(query.subquery()))
|
total = db.scalar(select(func.count()).select_from(query.subquery()))
|
||||||
return {"count": total or 0}
|
return {"count": total or 0}
|
||||||
|
|
||||||
|
|
@ -461,13 +544,13 @@ def get_facets(
|
||||||
(tag ids)."""
|
(tag ids)."""
|
||||||
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
||||||
counts: dict[int, int] = {}
|
counts: dict[int, int] = {}
|
||||||
for category in ("language", "topic"):
|
for category in ("language", "topic", "other"):
|
||||||
# Disjunctive (OR) facets drop the category's own selections so its chips keep
|
# Disjunctive (OR) facets drop the category's own selections so its chips keep
|
||||||
# independent counts (you can OR more of them in). Conjunctive (AND, topics only)
|
# independent counts (you can OR more of them in). Conjunctive (AND, topics only)
|
||||||
# keeps them applied, so each remaining chip narrows to channels that ALSO have all
|
# keeps them applied, so each remaining chip narrows to channels that ALSO have all
|
||||||
# already-selected topics — and tags that can't co-occur drop to zero (hidden).
|
# already-selected topics — and tags that can't co-occur drop to zero (hidden).
|
||||||
conjunctive = category == "topic" and params.get("tag_mode") == "and"
|
conjunctive = category == "topic" and params.get("tag_mode") == "and"
|
||||||
base, _status = _filtered_query(
|
base, _status, _rank = _filtered_query(
|
||||||
db,
|
db,
|
||||||
user,
|
user,
|
||||||
**{**params, "exclude_tag_category": None if conjunctive else category},
|
**{**params, "exclude_tag_category": None if conjunctive else category},
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,24 @@ from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import and_, select
|
from sqlalchemy import and_, delete, func, or_, select, update
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session, aliased
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
from app import quota, sysconfig
|
from app import quota, sysconfig
|
||||||
from app.auth import require_human
|
from app.auth import require_human
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Channel, User, Video, VideoState
|
from app.models import (
|
||||||
|
BlockedChannel,
|
||||||
|
Channel,
|
||||||
|
Playlist,
|
||||||
|
PlaylistItem,
|
||||||
|
SearchFind,
|
||||||
|
Subscription,
|
||||||
|
User,
|
||||||
|
Video,
|
||||||
|
VideoState,
|
||||||
|
)
|
||||||
from app.routes.feed import _serialize, feed_columns
|
from app.routes.feed import _serialize, feed_columns
|
||||||
from app.sync.subscriptions import apply_channel_details
|
from app.sync.subscriptions import apply_channel_details
|
||||||
from app.sync.videos import _insert_stubs, apply_video_details, parse_dt
|
from app.sync.videos import _insert_stubs, apply_video_details, parse_dt
|
||||||
|
|
@ -90,74 +101,20 @@ def _serialize_ids(db: Session, user: User, ids: list[str]) -> list[dict]:
|
||||||
return [by_id[i] for i in ids if i in by_id]
|
return [by_id[i] for i in ids if i in by_id]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/youtube")
|
def _ingest_candidates(
|
||||||
def search_youtube(
|
db: Session, yt: YouTubeClient, user: User, candidates: list[dict]
|
||||||
q: str | None = None,
|
) -> list[str]:
|
||||||
cursor: str | None = None,
|
"""Materialise one page of search candidates into the catalog (channel stubs + enrich,
|
||||||
user: User = Depends(require_human),
|
video stubs flagged via_search, enrich, Shorts classification) and return the KEEPABLE
|
||||||
db: Session = Depends(get_db),
|
video ids — non-Short, non-live — in the input relevance order."""
|
||||||
) -> dict:
|
|
||||||
"""One page of live YouTube search results, materialised into the catalog. `cursor` is the
|
|
||||||
YouTube nextPageToken (each page is another 100-unit search)."""
|
|
||||||
term = (q or "").strip()
|
|
||||||
if not term:
|
|
||||||
raise HTTPException(status_code=400, detail="A search term is required.")
|
|
||||||
|
|
||||||
# Source: "scrape" (InnerTube, no API quota) or "api" (search.list, 100 units/page).
|
|
||||||
source = (sysconfig.get_str(db, "search_source") or "scrape").lower()
|
|
||||||
if source != "scrape":
|
|
||||||
source = "api"
|
|
||||||
|
|
||||||
# Per-user daily cap applies to both sources (anti-abuse); with scrape it's the only guard
|
|
||||||
# since the search itself spends no quota.
|
|
||||||
daily_limit = sysconfig.get_int(db, "search_daily_limit_per_user")
|
|
||||||
used = quota.actions_today(db, user.id, quota.QuotaAction.VIDEOS_SEARCH)
|
|
||||||
if used >= daily_limit:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=429,
|
|
||||||
detail=f"You've reached today's YouTube search limit ({daily_limit}). Try again tomorrow.",
|
|
||||||
)
|
|
||||||
# The 100-unit budget pre-check only applies to the API source.
|
|
||||||
if source == "api" and not quota.can_spend(db, SEARCH_COST):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=429,
|
|
||||||
detail="The shared YouTube quota for today is used up. Try again tomorrow.",
|
|
||||||
)
|
|
||||||
|
|
||||||
with YouTubeClient(db, user) as yt:
|
|
||||||
try:
|
|
||||||
if source == "api":
|
|
||||||
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_SEARCH):
|
|
||||||
page = yt.search_videos(term, page_token=cursor)
|
|
||||||
else:
|
|
||||||
page = search_scrape.search(term, continuation=cursor)
|
|
||||||
# Zero-cost, but logged so the per-user daily cap above keeps counting it.
|
|
||||||
quota.log_action(db, user.id, quota.QuotaAction.VIDEOS_SEARCH)
|
|
||||||
except (YouTubeError, search_scrape.ScrapeError) as exc:
|
|
||||||
raise HTTPException(status_code=502, detail=f"YouTube search failed: {exc}")
|
|
||||||
|
|
||||||
# Drop currently-live/upcoming results (search can't filter them and we never ingest
|
|
||||||
# them this way) and anything missing a channel id.
|
|
||||||
candidates = [
|
|
||||||
it
|
|
||||||
for it in page["items"]
|
|
||||||
if it["live_broadcast"] == "none" and it["channel_id"]
|
|
||||||
]
|
|
||||||
ids = [it["id"] for it in candidates]
|
ids = [it["id"] for it in candidates]
|
||||||
if not ids:
|
if not ids:
|
||||||
return {
|
return []
|
||||||
"items": [],
|
|
||||||
"next_cursor": page["next_page_token"],
|
|
||||||
"has_more": bool(page["next_page_token"]),
|
|
||||||
"source": source,
|
|
||||||
}
|
|
||||||
|
|
||||||
# 1) Channels are the FK target — create stubs for unknown ones (flagged from_search),
|
# 1) Channels are the FK target — create stubs for unknown ones (flagged from_search),
|
||||||
# then enrich the new ones (channels.list, 1 unit). Stubs survive an enrich failure.
|
# then enrich the new ones (channels.list, 1 unit). Stubs survive an enrich failure.
|
||||||
channel_ids = list({it["channel_id"] for it in candidates})
|
channel_ids = list({it["channel_id"] for it in candidates})
|
||||||
existing = set(
|
existing = set(db.scalars(select(Channel.id).where(Channel.id.in_(channel_ids))).all())
|
||||||
db.scalars(select(Channel.id).where(Channel.id.in_(channel_ids))).all()
|
|
||||||
)
|
|
||||||
new_channel_ids = [c for c in channel_ids if c not in existing]
|
new_channel_ids = [c for c in channel_ids if c not in existing]
|
||||||
if new_channel_ids:
|
if new_channel_ids:
|
||||||
titles = {it["channel_id"]: it["channel_title"] for it in candidates}
|
titles = {it["channel_id"]: it["channel_title"] for it in candidates}
|
||||||
|
|
@ -208,14 +165,182 @@ def search_youtube(
|
||||||
# 4) Confirm/deny Shorts (no quota) so none enter via search.
|
# 4) Confirm/deny Shorts (no quota) so none enter via search.
|
||||||
_classify_shorts(db, videos)
|
_classify_shorts(db, videos)
|
||||||
|
|
||||||
# 5) Exclude Shorts and still-live results; keep the search's relevance order.
|
keep = {v.id for v in videos if not v.is_short and v.live_status not in _LIVE_HIDDEN}
|
||||||
keep = {
|
return [vid for vid in ids if vid in keep]
|
||||||
v.id for v in videos if not v.is_short and v.live_status not in _LIVE_HIDDEN
|
|
||||||
}
|
|
||||||
ordered = [vid for vid in ids if vid in keep]
|
def _new_first(db: Session, user: User, ids: list[str]) -> list[str]:
|
||||||
|
"""Reorder results so genuinely-new discoveries come first and things the user already has
|
||||||
|
(a subscribed channel, or a video they've watched / in-progress / saved / playlisted) sink
|
||||||
|
to the back — keeping the relevance order within each group."""
|
||||||
|
if not ids:
|
||||||
|
return ids
|
||||||
|
sub_ch = select(Subscription.channel_id).where(Subscription.user_id == user.id)
|
||||||
|
has_state = (
|
||||||
|
select(VideoState.id)
|
||||||
|
.where(VideoState.video_id == Video.id, VideoState.user_id == user.id)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
in_playlist = (
|
||||||
|
select(PlaylistItem.id)
|
||||||
|
.join(Playlist, Playlist.id == PlaylistItem.playlist_id)
|
||||||
|
.where(PlaylistItem.video_id == Video.id, Playlist.user_id == user.id)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
have = set(
|
||||||
|
db.scalars(
|
||||||
|
select(Video.id).where(
|
||||||
|
Video.id.in_(ids),
|
||||||
|
or_(Video.channel_id.in_(sub_ch), has_state, in_playlist),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
return [i for i in ids if i not in have] + [i for i in ids if i in have]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/youtube")
|
||||||
|
def search_youtube(
|
||||||
|
q: str | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
|
limit: int = 20,
|
||||||
|
user: User = Depends(require_human),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Live YouTube search results materialised into the catalog. `limit` is how many results to
|
||||||
|
gather (the count selector); with the free scrape source we page through continuations until
|
||||||
|
that many are collected, so there's no manual "load more". The API source costs 100 units per
|
||||||
|
page, so it returns a single page (≤50) regardless. `cursor` lets the caller fetch further."""
|
||||||
|
term = (q or "").strip()
|
||||||
|
if not term:
|
||||||
|
raise HTTPException(status_code=400, detail="A search term is required.")
|
||||||
|
limit = max(1, min(limit, 100))
|
||||||
|
|
||||||
|
# Source: "scrape" (InnerTube, no API quota) or "api" (search.list, 100 units/page).
|
||||||
|
source = (sysconfig.get_str(db, "search_source") or "scrape").lower()
|
||||||
|
if source != "scrape":
|
||||||
|
source = "api"
|
||||||
|
|
||||||
|
# Per-user daily cap applies to both sources (anti-abuse); with scrape it's the only guard
|
||||||
|
# since the search itself spends no quota.
|
||||||
|
daily_limit = sysconfig.get_int(db, "search_daily_limit_per_user")
|
||||||
|
used = quota.actions_today(db, user.id, quota.QuotaAction.VIDEOS_SEARCH)
|
||||||
|
if used >= daily_limit:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail=f"You've reached today's YouTube search limit ({daily_limit}). Try again tomorrow.",
|
||||||
|
)
|
||||||
|
# The 100-unit budget pre-check only applies to the API source.
|
||||||
|
if source == "api" and not quota.can_spend(db, SEARCH_COST):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="The shared YouTube quota for today is used up. Try again tomorrow.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Channels this user has blocked are dropped before ingestion — never shown, never stored.
|
||||||
|
blocked = set(
|
||||||
|
db.scalars(
|
||||||
|
select(BlockedChannel.channel_id).where(BlockedChannel.user_id == user.id)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
collected: list[str] = []
|
||||||
|
next_cursor = cursor
|
||||||
|
MAX_PAGES = 12 # scrape safety bound
|
||||||
|
with YouTubeClient(db, user) as yt:
|
||||||
|
for _ in range(MAX_PAGES):
|
||||||
|
try:
|
||||||
|
if source == "api":
|
||||||
|
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_SEARCH):
|
||||||
|
page = yt.search_videos(term, page_token=next_cursor)
|
||||||
|
else:
|
||||||
|
page = search_scrape.search(term, continuation=next_cursor)
|
||||||
|
# Zero-cost, but logged so the per-user daily cap above keeps counting it.
|
||||||
|
quota.log_action(db, user.id, quota.QuotaAction.VIDEOS_SEARCH)
|
||||||
|
except (YouTubeError, search_scrape.ScrapeError) as exc:
|
||||||
|
if collected:
|
||||||
|
break # keep what we already gathered
|
||||||
|
raise HTTPException(status_code=502, detail=f"YouTube search failed: {exc}")
|
||||||
|
|
||||||
|
# Drop currently-live/upcoming, channel-less, and blocked-channel results.
|
||||||
|
candidates = [
|
||||||
|
it
|
||||||
|
for it in page["items"]
|
||||||
|
if it["live_broadcast"] == "none"
|
||||||
|
and it["channel_id"]
|
||||||
|
and it["channel_id"] not in blocked
|
||||||
|
]
|
||||||
|
collected.extend(_ingest_candidates(db, yt, user, candidates))
|
||||||
|
next_cursor = page["next_page_token"]
|
||||||
|
# The API source pages cost 100 units each — never auto-page; one page per request.
|
||||||
|
if source == "api" or len(collected) >= limit or not next_cursor:
|
||||||
|
break
|
||||||
|
|
||||||
|
ordered = collected[:limit]
|
||||||
|
|
||||||
|
if ordered:
|
||||||
|
# Record these as THIS user's search finds (Mine feed / Source filter), and fold the
|
||||||
|
# query into each result's search_terms so local full-text search inherits YouTube's
|
||||||
|
# relevance. Idempotent; the generated search_vector updates automatically.
|
||||||
|
db.execute(
|
||||||
|
pg_insert(SearchFind)
|
||||||
|
.values([{"user_id": user.id, "video_id": vid} for vid in ordered])
|
||||||
|
.on_conflict_do_nothing(index_elements=["user_id", "video_id"])
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
update(Video)
|
||||||
|
.where(Video.id.in_(ordered))
|
||||||
|
.where(func.coalesce(Video.search_terms, "").notilike(f"%{term}%"))
|
||||||
|
.values(
|
||||||
|
search_terms=func.trim(
|
||||||
|
func.coalesce(Video.search_terms, "").op("||")(" ").op("||")(term)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
ordered = _new_first(db, user, ordered)
|
||||||
return {
|
return {
|
||||||
"items": _serialize_ids(db, user, ordered),
|
"items": _serialize_ids(db, user, ordered),
|
||||||
"next_cursor": page["next_page_token"],
|
"next_cursor": next_cursor,
|
||||||
"has_more": bool(page["next_page_token"]),
|
"has_more": bool(next_cursor),
|
||||||
"source": source,
|
"source": source,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clear")
|
||||||
|
def clear_search(
|
||||||
|
payload: dict,
|
||||||
|
user: User = Depends(require_human),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Discard the given live-search results "as if never added": drop this user's search-finds
|
||||||
|
for them, then hard-delete the now-orphaned ones — a via_search video nobody KEPT (no watch
|
||||||
|
state, not playlisted, channel not subscribed) and that no other user still has as a search
|
||||||
|
find. Kept videos and organically-owned ones are left untouched."""
|
||||||
|
ids = [str(v) for v in (payload.get("video_ids") or [])]
|
||||||
|
if not ids:
|
||||||
|
return {"removed": 0}
|
||||||
|
db.execute(
|
||||||
|
delete(SearchFind).where(
|
||||||
|
SearchFind.user_id == user.id, SearchFind.video_id.in_(ids)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
has_state = select(VideoState.id).where(VideoState.video_id == Video.id).exists()
|
||||||
|
in_playlist = select(PlaylistItem.id).where(PlaylistItem.video_id == Video.id).exists()
|
||||||
|
subscribed = (
|
||||||
|
select(Subscription.id).where(Subscription.channel_id == Video.channel_id).exists()
|
||||||
|
)
|
||||||
|
other_find = select(SearchFind.id).where(SearchFind.video_id == Video.id).exists()
|
||||||
|
removed = db.execute(
|
||||||
|
delete(Video).where(
|
||||||
|
Video.id.in_(ids),
|
||||||
|
Video.via_search.is_(True),
|
||||||
|
~has_state,
|
||||||
|
~in_playlist,
|
||||||
|
~subscribed,
|
||||||
|
~other_find,
|
||||||
|
)
|
||||||
|
).rowcount
|
||||||
|
db.commit()
|
||||||
|
return {"removed": removed}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from app.models import SchedulerSetting
|
||||||
from app.notifications import create_notification
|
from app.notifications import create_notification
|
||||||
from app.state import is_sync_paused
|
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.explore import purge_ephemeral
|
||||||
from app.sync.maintenance import run_maintenance
|
from app.sync.maintenance import run_maintenance
|
||||||
from app.sync.playlists import sync_all_playlists
|
from app.sync.playlists import sync_all_playlists
|
||||||
from app.sync.runner import (
|
from app.sync.runner import (
|
||||||
|
|
@ -57,6 +58,7 @@ JOB_INTERVALS: dict[str, int] = {
|
||||||
"playlist_sync": settings.playlist_sync_minutes,
|
"playlist_sync": settings.playlist_sync_minutes,
|
||||||
"maintenance": settings.maintenance_interval_minutes,
|
"maintenance": settings.maintenance_interval_minutes,
|
||||||
"demo_reset": settings.demo_reset_minutes,
|
"demo_reset": settings.demo_reset_minutes,
|
||||||
|
"explore_cleanup": settings.explore_cleanup_minutes,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Sane bounds for an admin-set interval (minutes).
|
# Sane bounds for an admin-set interval (minutes).
|
||||||
|
|
@ -268,6 +270,12 @@ def _demo_reset_job() -> None:
|
||||||
_job("demo_reset", work)
|
_job("demo_reset", work)
|
||||||
|
|
||||||
|
|
||||||
|
def _explore_cleanup_job() -> None:
|
||||||
|
# Reclaim un-kept ephemeral discovery content (explored channels + live-search results) after
|
||||||
|
# their grace periods. 0 quota (pure DB), so no quota attribution.
|
||||||
|
_job("explore_cleanup", purge_ephemeral)
|
||||||
|
|
||||||
|
|
||||||
# job_id -> wrapper. The single source of truth for which jobs exist and how to run one,
|
# job_id -> wrapper. The single source of truth for which jobs exist and how to run one,
|
||||||
# shared by start_scheduler (recurring registration) and trigger_job (manual "run now").
|
# shared by start_scheduler (recurring registration) and trigger_job (manual "run now").
|
||||||
JOB_FUNCS: dict[str, Callable[[], None]] = {
|
JOB_FUNCS: dict[str, Callable[[], None]] = {
|
||||||
|
|
@ -280,6 +288,7 @@ JOB_FUNCS: dict[str, Callable[[], None]] = {
|
||||||
"playlist_sync": _playlist_sync_job,
|
"playlist_sync": _playlist_sync_job,
|
||||||
"maintenance": _maintenance_job,
|
"maintenance": _maintenance_job,
|
||||||
"demo_reset": _demo_reset_job,
|
"demo_reset": _demo_reset_job,
|
||||||
|
"explore_cleanup": _explore_cleanup_job,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
180
backend/app/sync/explore.py
Normal file
180
backend/app/sync/explore.py
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
"""Channel exploration: ingest an un-subscribed channel's uploads on demand (flagged
|
||||||
|
via_explore, kept per-explorer) and purge channels nobody ends up keeping.
|
||||||
|
|
||||||
|
A user opens a channel's page without subscribing → we pull a page of its recent uploads,
|
||||||
|
mark the new rows via_explore, and enrich them so they're immediately browsable. The videos
|
||||||
|
stay private to users who explored (or subscribe to) the channel — the feed query gates them
|
||||||
|
— so one user's curiosity never floods everyone's catalog. Subscribing is the "keep" signal;
|
||||||
|
otherwise the cleanup job hard-deletes the channel and its untouched ephemeral videos after a
|
||||||
|
grace period (mirrors the maintenance job's grace-then-delete safety)."""
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import sysconfig
|
||||||
|
from app.models import (
|
||||||
|
Channel,
|
||||||
|
ExploredChannel,
|
||||||
|
PlaylistItem,
|
||||||
|
SearchFind,
|
||||||
|
Subscription,
|
||||||
|
Video,
|
||||||
|
VideoState,
|
||||||
|
)
|
||||||
|
from app.sync.videos import _insert_stubs, _stub_from_playlist_item, apply_video_details
|
||||||
|
from app.utils import now_utc as _now
|
||||||
|
from app.youtube.client import YouTubeClient
|
||||||
|
|
||||||
|
log = logging.getLogger("siftlode.explore")
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_new(db: Session, yt: YouTubeClient, video_ids: list[str]) -> int:
|
||||||
|
"""Enrich just the not-yet-enriched videos among `video_ids` (videos.list, 1 unit/50)."""
|
||||||
|
if not video_ids:
|
||||||
|
return 0
|
||||||
|
videos = (
|
||||||
|
db.execute(
|
||||||
|
select(Video).where(
|
||||||
|
Video.id.in_(video_ids), Video.enriched_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not videos:
|
||||||
|
return 0
|
||||||
|
items = {it["id"]: it for it in yt.get_videos([v.id for v in videos])}
|
||||||
|
now = _now()
|
||||||
|
for video in videos:
|
||||||
|
item = items.get(video.id)
|
||||||
|
if item is None:
|
||||||
|
video.enriched_at = now # unavailable; stop retrying
|
||||||
|
continue
|
||||||
|
apply_video_details(video, item)
|
||||||
|
video.enriched_at = now
|
||||||
|
db.commit()
|
||||||
|
return len(videos)
|
||||||
|
|
||||||
|
|
||||||
|
def explore_ingest_page(
|
||||||
|
db: Session, yt: YouTubeClient, channel: Channel, page_token: str | None = None
|
||||||
|
) -> dict:
|
||||||
|
"""Ingest one page (up to 50, newest-first) of the channel's uploads, marking the new
|
||||||
|
videos via_explore, then enrich them. Returns the count ingested and the next page token
|
||||||
|
(None when the back-catalogue is exhausted) so the caller can offer "load more". Stateless
|
||||||
|
pagination: the caller round-trips the token, so the channel's own backfill cursor (used by
|
||||||
|
the organic deep backfill) is never touched."""
|
||||||
|
if not channel.uploads_playlist_id:
|
||||||
|
return {"ingested": 0, "next_page_token": None}
|
||||||
|
data = yt.get_playlist_items(channel.uploads_playlist_id, page_token)
|
||||||
|
stubs: list[dict] = []
|
||||||
|
for item in data.get("items", []):
|
||||||
|
stub = _stub_from_playlist_item(item, channel.id)
|
||||||
|
if stub is not None:
|
||||||
|
stub["via_explore"] = True
|
||||||
|
stubs.append(stub)
|
||||||
|
inserted = _insert_stubs(db, stubs)
|
||||||
|
self_enriched = _enrich_new(db, yt, [s["id"] for s in stubs])
|
||||||
|
log.info(
|
||||||
|
"explore ingest channel=%s ingested=%s enriched=%s", channel.id, inserted, self_enriched
|
||||||
|
)
|
||||||
|
return {"ingested": inserted, "next_page_token": data.get("nextPageToken")}
|
||||||
|
|
||||||
|
|
||||||
|
def purge_unkept_explored(db: Session) -> dict:
|
||||||
|
"""Cleanup job: hard-delete explored channels nobody kept and their untouched ephemeral
|
||||||
|
videos, after a grace period (mirrors the maintenance grace-then-delete safety).
|
||||||
|
|
||||||
|
Keep signals that spare a channel/video:
|
||||||
|
- any user SUBSCRIBES to the channel (subscribe promotes it to organic — channel-level), or
|
||||||
|
- a live ExploredChannel row (someone explored it within the grace window — channel-level), or
|
||||||
|
- a video has per-user state: watched/in-progress (VideoState), in a playlist (PlaylistItem),
|
||||||
|
or surfaced by the user's own search (SearchFind) — these survive even if the channel goes.
|
||||||
|
A channel row is dropped only once it holds no videos at all. 0 quota."""
|
||||||
|
grace_days = sysconfig.get_int(db, "explore_grace_days")
|
||||||
|
cutoff = _now() - timedelta(days=grace_days)
|
||||||
|
|
||||||
|
expired = db.execute(
|
||||||
|
delete(ExploredChannel).where(ExploredChannel.created_at < cutoff)
|
||||||
|
).rowcount
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
sub_exists = (
|
||||||
|
select(Subscription.id).where(Subscription.channel_id == Channel.id).exists()
|
||||||
|
)
|
||||||
|
exp_exists = (
|
||||||
|
select(ExploredChannel.id).where(ExploredChannel.channel_id == Channel.id).exists()
|
||||||
|
)
|
||||||
|
orphan_ids = (
|
||||||
|
db.execute(
|
||||||
|
select(Channel.id).where(
|
||||||
|
Channel.from_explore.is_(True), ~sub_exists, ~exp_exists
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not orphan_ids:
|
||||||
|
return {"expired": expired, "videos_deleted": 0, "channels_deleted": 0}
|
||||||
|
|
||||||
|
state_exists = select(VideoState.id).where(VideoState.video_id == Video.id).exists()
|
||||||
|
pl_exists = select(PlaylistItem.id).where(PlaylistItem.video_id == Video.id).exists()
|
||||||
|
find_exists = select(SearchFind.id).where(SearchFind.video_id == Video.id).exists()
|
||||||
|
videos_deleted = db.execute(
|
||||||
|
delete(Video).where(
|
||||||
|
Video.channel_id.in_(orphan_ids),
|
||||||
|
Video.via_explore.is_(True),
|
||||||
|
~state_exists,
|
||||||
|
~pl_exists,
|
||||||
|
~find_exists,
|
||||||
|
)
|
||||||
|
).rowcount
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
has_videos = select(Video.id).where(Video.channel_id == Channel.id).exists()
|
||||||
|
channels_deleted = db.execute(
|
||||||
|
delete(Channel).where(Channel.id.in_(orphan_ids), ~has_videos)
|
||||||
|
).rowcount
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"expired": expired,
|
||||||
|
"videos_deleted": videos_deleted,
|
||||||
|
"channels_deleted": channels_deleted,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def purge_unkept_search(db: Session, grace_days: int | None = None) -> dict:
|
||||||
|
"""Remove live-search result videos nobody kept, after a grace period — "as if never added".
|
||||||
|
|
||||||
|
A `via_search` video is KEPT (and spared) when anyone has watched / in-progress / hidden it
|
||||||
|
(a VideoState), saved/playlisted it (a PlaylistItem), or subscribes to its channel (it's then
|
||||||
|
organically owned). A bare SearchFind does NOT count — it's the ephemeral "shown in search"
|
||||||
|
marker itself — so a result you only glanced at is reclaimed. Deleting the video cascades its
|
||||||
|
SearchFinds. `grace_days=0` purges immediately (the admin "purge now" path); None uses config.
|
||||||
|
0 quota."""
|
||||||
|
grace = sysconfig.get_int(db, "search_grace_days") if grace_days is None else grace_days
|
||||||
|
cutoff = _now() - timedelta(days=grace)
|
||||||
|
state_exists = select(VideoState.id).where(VideoState.video_id == Video.id).exists()
|
||||||
|
pl_exists = select(PlaylistItem.id).where(PlaylistItem.video_id == Video.id).exists()
|
||||||
|
sub_exists = (
|
||||||
|
select(Subscription.id).where(Subscription.channel_id == Video.channel_id).exists()
|
||||||
|
)
|
||||||
|
deleted = db.execute(
|
||||||
|
delete(Video).where(
|
||||||
|
Video.via_search.is_(True),
|
||||||
|
Video.created_at < cutoff,
|
||||||
|
~state_exists,
|
||||||
|
~pl_exists,
|
||||||
|
~sub_exists,
|
||||||
|
)
|
||||||
|
).rowcount
|
||||||
|
db.commit()
|
||||||
|
return {"search_videos_deleted": deleted}
|
||||||
|
|
||||||
|
|
||||||
|
def purge_ephemeral(db: Session) -> dict:
|
||||||
|
"""The scheduler's discovery-cleanup pass: reclaim un-kept explored channels AND un-kept
|
||||||
|
live-search result videos in one run."""
|
||||||
|
return {**purge_unkept_explored(db), **purge_unkept_search(db)}
|
||||||
|
|
@ -18,6 +18,29 @@ def _to_int(value) -> int | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_dt(value: str | None) -> datetime | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _external_links(branding: dict) -> list | None:
|
||||||
|
"""Channel "About" links. The API exposes a few in brandingSettings.channel.profileColor/
|
||||||
|
/... varies; the durable spot is brandingSettings.channel.links is gone post-2023, so we
|
||||||
|
read what's present (settings/links) defensively and keep [{title, url}] pairs."""
|
||||||
|
channel = branding.get("channel", {})
|
||||||
|
links = channel.get("links") or []
|
||||||
|
out = [
|
||||||
|
{"title": l.get("title"), "url": l.get("url")}
|
||||||
|
for l in links
|
||||||
|
if isinstance(l, dict) and l.get("url")
|
||||||
|
]
|
||||||
|
return out or None
|
||||||
|
|
||||||
|
|
||||||
def apply_channel_details(db: Session, items: list[dict]) -> None:
|
def apply_channel_details(db: Session, items: list[dict]) -> None:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
for item in items:
|
for item in items:
|
||||||
|
|
@ -28,6 +51,7 @@ def apply_channel_details(db: Session, items: list[dict]) -> None:
|
||||||
content = item.get("contentDetails", {})
|
content = item.get("contentDetails", {})
|
||||||
stats = item.get("statistics", {})
|
stats = item.get("statistics", {})
|
||||||
topics = item.get("topicDetails", {})
|
topics = item.get("topicDetails", {})
|
||||||
|
branding = item.get("brandingSettings", {})
|
||||||
|
|
||||||
channel.title = snippet.get("title") or channel.title
|
channel.title = snippet.get("title") or channel.title
|
||||||
channel.handle = snippet.get("customUrl")
|
channel.handle = snippet.get("customUrl")
|
||||||
|
|
@ -40,6 +64,10 @@ def apply_channel_details(db: Session, items: list[dict]) -> None:
|
||||||
channel.uploads_playlist_id = content.get("relatedPlaylists", {}).get("uploads")
|
channel.uploads_playlist_id = content.get("relatedPlaylists", {}).get("uploads")
|
||||||
channel.subscriber_count = _to_int(stats.get("subscriberCount"))
|
channel.subscriber_count = _to_int(stats.get("subscriberCount"))
|
||||||
channel.video_count = _to_int(stats.get("videoCount"))
|
channel.video_count = _to_int(stats.get("videoCount"))
|
||||||
|
channel.total_view_count = _to_int(stats.get("viewCount"))
|
||||||
|
channel.published_at = _parse_dt(snippet.get("publishedAt"))
|
||||||
|
channel.banner_url = branding.get("image", {}).get("bannerExternalUrl")
|
||||||
|
channel.external_links = _external_links(branding)
|
||||||
channel.topic_categories = topics.get("topicCategories")
|
channel.topic_categories = topics.get("topicCategories")
|
||||||
channel.details_synced_at = now
|
channel.details_synced_at = now
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -234,6 +234,9 @@ def apply_video_details(video: Video, item: dict) -> None:
|
||||||
video.view_count = int(stats["viewCount"]) if stats.get("viewCount") else None
|
video.view_count = int(stats["viewCount"]) if stats.get("viewCount") else None
|
||||||
video.like_count = int(stats["likeCount"]) if stats.get("likeCount") else None
|
video.like_count = int(stats["likeCount"]) if stats.get("likeCount") else None
|
||||||
video.category_id = int(snippet["categoryId"]) if snippet.get("categoryId") else None
|
video.category_id = int(snippet["categoryId"]) if snippet.get("categoryId") else None
|
||||||
|
# Creator keyword tags (free with the snippet we already fetch) → search document.
|
||||||
|
tags = snippet.get("tags")
|
||||||
|
video.keywords = " ".join(tags) if tags else None
|
||||||
video.topic_categories = topics.get("topicCategories")
|
video.topic_categories = topics.get("topicCategories")
|
||||||
video.default_language = snippet.get("defaultLanguage") or snippet.get(
|
video.default_language = snippet.get("defaultLanguage") or snippet.get(
|
||||||
"defaultAudioLanguage"
|
"defaultAudioLanguage"
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,11 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||||
# --- Batch sizes ---
|
# --- Batch sizes ---
|
||||||
ConfigSpec("enrich_batch_size", "int", "batch", "enrich_batch_size", min=1, max=50),
|
ConfigSpec("enrich_batch_size", "int", "batch", "enrich_batch_size", min=1, max=50),
|
||||||
ConfigSpec("autotag_title_sample", "int", "batch", "autotag_title_sample", min=1, max=1_000),
|
ConfigSpec("autotag_title_sample", "int", "batch", "autotag_title_sample", min=1, max=1_000),
|
||||||
|
# --- Discovery cleanup (search + explore ephemeral content) ---
|
||||||
|
# Days an explored-but-unsubscribed channel is kept before the cleanup job purges it.
|
||||||
|
ConfigSpec("explore_grace_days", "int", "explore", "explore_grace_days", min=0, max=3_650),
|
||||||
|
# Days an un-kept live-search result video is kept before the cleanup job removes it.
|
||||||
|
ConfigSpec("search_grace_days", "int", "explore", "search_grace_days", min=0, max=3_650),
|
||||||
# --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) ---
|
# --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) ---
|
||||||
ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True),
|
ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True),
|
||||||
# Optional egress proxy for YouTube API calls (fixed-IP host for an IP-restricted key).
|
# Optional egress proxy for YouTube API calls (fixed-IP host for an IP-restricted key).
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,7 @@ class YouTubeClient:
|
||||||
data = self._get(
|
data = self._get(
|
||||||
"channels",
|
"channels",
|
||||||
{
|
{
|
||||||
"part": "snippet,contentDetails,statistics,topicDetails",
|
"part": "snippet,contentDetails,statistics,topicDetails,brandingSettings",
|
||||||
"id": ",".join(batch),
|
"id": ",".join(batch),
|
||||||
"maxResults": 50,
|
"maxResults": 50,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ import Header from "./components/Header";
|
||||||
import NavSidebar from "./components/NavSidebar";
|
import NavSidebar from "./components/NavSidebar";
|
||||||
import Sidebar from "./components/Sidebar";
|
import Sidebar from "./components/Sidebar";
|
||||||
import Feed from "./components/Feed";
|
import Feed from "./components/Feed";
|
||||||
|
import ChannelPage from "./components/ChannelPage";
|
||||||
|
import BackToTop from "./components/BackToTop";
|
||||||
import Channels, { type ChannelStatusFilter, type ChannelsView } from "./components/Channels";
|
import Channels, { type ChannelStatusFilter, type ChannelsView } from "./components/Channels";
|
||||||
import Playlists from "./components/Playlists";
|
import Playlists from "./components/Playlists";
|
||||||
import Stats from "./components/Stats";
|
import Stats from "./components/Stats";
|
||||||
|
|
@ -128,10 +130,18 @@ export default function App() {
|
||||||
// popstate handler below derives ytSearch from it, so Back steps player → search → feed in
|
// popstate handler below derives ytSearch from it, so Back steps player → search → feed in
|
||||||
// that order (a player opened over the results pops first, then the search, then the feed) —
|
// that order (a player opened over the results pops first, then the search, then the feed) —
|
||||||
// instead of the search vanishing because it had no history entry of its own.
|
// instead of the search vanishing because it had no history entry of its own.
|
||||||
const [ytSearch, setYtSearch] = useState<string | null>(null);
|
const [ytSearch, setYtSearch] = useState<string | null>(() => {
|
||||||
|
// Restore a live search across a reload ONLY when it was served by the zero-quota scrape
|
||||||
|
// source (re-fetching is free) — an api-source search would re-spend ~100 units, so it's
|
||||||
|
// dropped to the normal feed as before. The scrape flag rides in history.state (survives F5).
|
||||||
|
const st = window.history.state;
|
||||||
|
return st?._yt && st?._ytScrape ? (st._yt as string) : null;
|
||||||
|
});
|
||||||
const enterYtSearch = useCallback((q: string) => {
|
const enterYtSearch = useCallback((q: string) => {
|
||||||
setYtSearch(q);
|
setYtSearch(q);
|
||||||
const st = window.history.state || {};
|
// Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps
|
||||||
|
// it once the results resolve, so a reload only restores a confirmed scrape-sourced search.
|
||||||
|
const { _ytScrape: _drop, ...st } = window.history.state || {};
|
||||||
if (st._yt) window.history.replaceState({ ...st, _yt: q }, ""); // refine current search
|
if (st._yt) window.history.replaceState({ ...st, _yt: q }, ""); // refine current search
|
||||||
else window.history.pushState({ ...st, sfPage: "feed", _yt: q }, ""); // new sub-view entry
|
else window.history.pushState({ ...st, sfPage: "feed", _yt: q }, ""); // new sub-view entry
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -140,6 +150,27 @@ export default function App() {
|
||||||
if (window.history.state?._yt) window.history.back();
|
if (window.history.state?._yt) window.history.back();
|
||||||
else setYtSearch(null);
|
else setYtSearch(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
// The open channel page (null = no channel page). Like the YouTube-search sub-view it owns a
|
||||||
|
// browser-history entry (history.state._chan = channel id, _chanName = a display name to show
|
||||||
|
// before the detail loads), so Back closes the channel page (after any player opened over it)
|
||||||
|
// and reload returns to the normal app. Channels/videos are reached by id, so it's not in the URL.
|
||||||
|
const [channelView, setChannelView] = useState<{ id: string; name?: string } | null>(() => {
|
||||||
|
// Restore the open channel page across a reload (it rides in history.state, which survives F5),
|
||||||
|
// unlike the YouTube search which is intentionally dropped (it would re-spend quota).
|
||||||
|
const c = window.history.state?._chan as string | undefined;
|
||||||
|
return c ? { id: c, name: (window.history.state?._chanName as string) ?? undefined } : null;
|
||||||
|
});
|
||||||
|
const openChannel = useCallback((id: string, name?: string) => {
|
||||||
|
setChannelView({ id, name });
|
||||||
|
const st = window.history.state || {};
|
||||||
|
if (st._chan)
|
||||||
|
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "");
|
||||||
|
else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "");
|
||||||
|
}, []);
|
||||||
|
const closeChannel = useCallback(() => {
|
||||||
|
if (window.history.state?._chan) window.history.back();
|
||||||
|
else setChannelView(null);
|
||||||
|
}, []);
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
|
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
|
||||||
const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all");
|
const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all");
|
||||||
|
|
@ -186,14 +217,17 @@ export default function App() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPage(next: Page) {
|
function setPage(next: Page) {
|
||||||
if (next === page) return;
|
// While a channel page is open it overlays the content column, so a nav click must close it
|
||||||
|
// even when the underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
|
||||||
|
if (next === page && !channelView) return;
|
||||||
const go = () => {
|
const go = () => {
|
||||||
|
setChannelView(null); // leave any open channel page when navigating via the rail
|
||||||
setPageState(next);
|
setPageState(next);
|
||||||
localStorage.setItem(PAGE_KEY, next);
|
localStorage.setItem(PAGE_KEY, next);
|
||||||
// Push an in-app history entry so the browser/mouse Back button steps through pages
|
// Push an in-app history entry so the browser/mouse Back button steps through pages
|
||||||
// instead of leaving the app (e.g. back to the OAuth redirect). The URL stays clean —
|
// instead of leaving the app (e.g. back to the OAuth redirect). The URL stays clean —
|
||||||
// the page rides in history.state, not the query string (filters never go in the URL).
|
// the page rides in history.state, not the query string (filters never go in the URL).
|
||||||
// A fresh { sfPage } (no carried-over _sub/_ov markers) so each page starts at its root.
|
// A fresh { sfPage } (no carried-over _sub/_ov/_chan markers) so each page starts at its root.
|
||||||
window.history.pushState({ sfPage: next }, "");
|
window.history.pushState({ sfPage: next }, "");
|
||||||
};
|
};
|
||||||
// Guard leaving the Settings page with unsaved preference changes.
|
// Guard leaving the Settings page with unsaved preference changes.
|
||||||
|
|
@ -245,10 +279,17 @@ export default function App() {
|
||||||
// from history.state on popstate. (stripUrlParams preserves history.state, so this stamp
|
// from history.state on popstate. (stripUrlParams preserves history.state, so this stamp
|
||||||
// survives a later query-string strip.)
|
// survives a later query-string strip.)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Drop any stale _yt from a prior session (a reload starts on the normal feed; ytSearch
|
// A reload returns to the normal feed by dropping a stale _yt — EXCEPT when the search was
|
||||||
// begins null), so the first Back doesn't resurrect a search we're no longer showing.
|
// scrape-sourced (zero quota): then we keep _yt + _ytScrape so it restores and re-fetches for
|
||||||
const { _yt: _staleYt, ...rest } = window.history.state || {};
|
// free (ytSearch above already initialised from it). An api-source search is still dropped (it
|
||||||
|
// would re-spend ~100 units). _chan/_chanName are always kept so a channel page survives F5.
|
||||||
|
const st = window.history.state || {};
|
||||||
|
if (st._yt && st._ytScrape) {
|
||||||
|
window.history.replaceState({ ...st, sfPage: page }, "");
|
||||||
|
} else {
|
||||||
|
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
|
||||||
window.history.replaceState({ ...rest, sfPage: page }, "");
|
window.history.replaceState({ ...rest, sfPage: page }, "");
|
||||||
|
}
|
||||||
function onPop(e: PopStateEvent) {
|
function onPop(e: PopStateEvent) {
|
||||||
const p = (e.state?.sfPage as Page) ?? "feed";
|
const p = (e.state?.sfPage as Page) ?? "feed";
|
||||||
// Guard a Back step that leaves Settings with unsaved changes: re-assert the Settings
|
// Guard a Back step that leaves Settings with unsaved changes: re-assert the Settings
|
||||||
|
|
@ -273,6 +314,9 @@ export default function App() {
|
||||||
// The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or
|
// The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or
|
||||||
// clears it to match the entry we landed on (and a player popped first leaves it intact).
|
// clears it to match the entry we landed on (and a player popped first leaves it intact).
|
||||||
setYtSearch((e.state?._yt as string) ?? null);
|
setYtSearch((e.state?._yt as string) ?? null);
|
||||||
|
// The channel page rides in history.state._chan the same way.
|
||||||
|
const chan = e.state?._chan as string | undefined;
|
||||||
|
setChannelView(chan ? { id: chan, name: (e.state?._chanName as string) ?? undefined } : null);
|
||||||
}
|
}
|
||||||
window.addEventListener("popstate", onPop);
|
window.addEventListener("popstate", onPop);
|
||||||
return () => window.removeEventListener("popstate", onPop);
|
return () => window.removeEventListener("popstate", onPop);
|
||||||
|
|
@ -518,6 +562,18 @@ export default function App() {
|
||||||
language={i18n.language as LangCode}
|
language={i18n.language as LangCode}
|
||||||
/>
|
/>
|
||||||
<div className="relative flex-1 min-w-0 flex flex-col">
|
<div className="relative flex-1 min-w-0 flex flex-col">
|
||||||
|
{channelView ? (
|
||||||
|
<ChannelPage
|
||||||
|
key={channelView.id}
|
||||||
|
channelId={channelView.id}
|
||||||
|
initialName={channelView.name}
|
||||||
|
me={meQuery.data!}
|
||||||
|
view={view}
|
||||||
|
onBack={closeChannel}
|
||||||
|
onOpenChannel={openChannel}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Header
|
<Header
|
||||||
me={meQuery.data!}
|
me={meQuery.data!}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
|
|
@ -556,10 +612,7 @@ export default function App() {
|
||||||
setView={setChannelsView}
|
setView={setChannelsView}
|
||||||
filtersResetToken={channelsFilterReset}
|
filtersResetToken={channelsFilterReset}
|
||||||
onOpenWizard={() => setWizardOpen(true)}
|
onOpenWizard={() => setWizardOpen(true)}
|
||||||
onViewChannel={(id, name) => {
|
onViewChannel={(id, name) => openChannel(id, name)}
|
||||||
setFilters({ ...filters, channelId: id, channelName: name, show: "all" });
|
|
||||||
setPage("feed");
|
|
||||||
}}
|
|
||||||
onFilterByTag={(tagId, name) => {
|
onFilterByTag={(tagId, name) => {
|
||||||
setFilters({ ...filters, tags: [tagId], channelId: undefined, channelName: undefined });
|
setFilters({ ...filters, tags: [tagId], channelId: undefined, channelName: undefined });
|
||||||
setPage("feed");
|
setPage("feed");
|
||||||
|
|
@ -599,10 +652,13 @@ export default function App() {
|
||||||
ytSearch={ytSearch}
|
ytSearch={ytSearch}
|
||||||
onYtSearch={enterYtSearch}
|
onYtSearch={enterYtSearch}
|
||||||
onExitYtSearch={exitYtSearch}
|
onExitYtSearch={exitYtSearch}
|
||||||
|
onOpenChannel={openChannel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{/* Toasts rise from the bottom-left, by the notification bell in the nav rail.
|
{/* Toasts rise from the bottom-left, by the notification bell in the nav rail.
|
||||||
Anchored inside the content column so they clear the sidebar automatically
|
Anchored inside the content column so they clear the sidebar automatically
|
||||||
(collapsed or expanded) without tracking its width. */}
|
(collapsed or expanded) without tracking its width. */}
|
||||||
|
|
@ -625,6 +681,8 @@ export default function App() {
|
||||||
<ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} />
|
<ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} />
|
||||||
)}
|
)}
|
||||||
<ErrorDialog />
|
<ErrorDialog />
|
||||||
|
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
|
||||||
|
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
46
frontend/src/components/BackToTop.tsx
Normal file
46
frontend/src/components/BackToTop.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { ArrowUp } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
// A floating "back to top" button that fades in once the current page's scroll container is
|
||||||
|
// scrolled past a threshold, and smooth-scrolls it back to the top. Every page scrolls inside its
|
||||||
|
// own <main class="overflow-y-auto"> (the normal pages share App's; the channel page has its own),
|
||||||
|
// so it targets the live <main> and re-binds whenever `dep` changes (i.e. on navigation). Portaled
|
||||||
|
// to <body> so its fixed position is viewport-relative regardless of transformed ancestors.
|
||||||
|
export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; threshold?: number }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
const scrollerRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = document.querySelector("main");
|
||||||
|
scrollerRef.current = el;
|
||||||
|
if (!el) {
|
||||||
|
setShow(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const onScroll = () => setShow(el.scrollTop > threshold);
|
||||||
|
onScroll(); // a freshly-bound page starts at the top → hidden
|
||||||
|
el.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => el.removeEventListener("scroll", onScroll);
|
||||||
|
}, [dep, threshold]);
|
||||||
|
|
||||||
|
const toTop = () => scrollerRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
|
||||||
|
// No aria-label/title attributes: ad-block "annoyance" cosmetic filters (e.g. Brave Shields)
|
||||||
|
// commonly hide floating corner buttons via attribute selectors like [aria-label="Back to top"].
|
||||||
|
// The accessible name comes from a visually-hidden child instead, which those selectors can't match.
|
||||||
|
return createPortal(
|
||||||
|
<button
|
||||||
|
onClick={toTop}
|
||||||
|
className={`fixed bottom-5 right-5 z-30 inline-flex items-center justify-center w-11 h-11 rounded-full bg-card border border-border text-fg shadow-lg hover:border-accent hover:text-accent transition-all duration-200 ${
|
||||||
|
show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ArrowUp className="w-5 h-5" aria-hidden="true" />
|
||||||
|
<span className="sr-only">{t("common.backToTop")}</span>
|
||||||
|
</button>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
341
frontend/src/components/ChannelPage.tsx
Normal file
341
frontend/src/components/ChannelPage.tsx
Normal file
|
|
@ -0,0 +1,341 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw } from "lucide-react";
|
||||||
|
import Avatar from "./Avatar";
|
||||||
|
import Feed from "./Feed";
|
||||||
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
import { notify } from "../lib/notifications";
|
||||||
|
import { api, type FeedFilters, type Me } from "../lib/api";
|
||||||
|
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
||||||
|
|
||||||
|
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
|
||||||
|
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
|
||||||
|
// auto-ingests recent uploads in the background ("explore") so they're browsable immediately,
|
||||||
|
// and offers "load more" to page deeper — without permanently polluting everyone's library
|
||||||
|
// (the backend keeps explored videos private to this user until they subscribe).
|
||||||
|
export default function ChannelPage({
|
||||||
|
channelId,
|
||||||
|
initialName,
|
||||||
|
me,
|
||||||
|
view,
|
||||||
|
onBack,
|
||||||
|
onOpenChannel,
|
||||||
|
}: {
|
||||||
|
channelId: string;
|
||||||
|
initialName?: string;
|
||||||
|
me: Me;
|
||||||
|
view: "grid" | "list";
|
||||||
|
onBack: () => void;
|
||||||
|
onOpenChannel: (id: string, name?: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const [tab, setTab] = useState<"videos" | "about">("videos");
|
||||||
|
|
||||||
|
const detail = useQuery({
|
||||||
|
queryKey: ["channel", channelId],
|
||||||
|
queryFn: () => api.channelDetail(channelId),
|
||||||
|
});
|
||||||
|
const ch = detail.data;
|
||||||
|
|
||||||
|
// Background auto-ingest ("explore") of an un-subscribed channel's uploads.
|
||||||
|
const [nextToken, setNextToken] = useState<string | null | undefined>(undefined);
|
||||||
|
const [exploring, setExploring] = useState(false);
|
||||||
|
const autoTried = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const runExplore = useCallback(
|
||||||
|
(token?: string | null) => {
|
||||||
|
setExploring(true);
|
||||||
|
return api
|
||||||
|
.exploreChannel(channelId, token ?? undefined)
|
||||||
|
.then((r) => {
|
||||||
|
setNextToken(r.next_page_token);
|
||||||
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||||
|
// Refetch the detail so `explored` flips true → the "Exploring" badge shows on the
|
||||||
|
// first visit (the GET that drove this render predated the explore that just ran).
|
||||||
|
qc.invalidateQueries({ queryKey: ["channel", channelId] });
|
||||||
|
})
|
||||||
|
.finally(() => setExploring(false));
|
||||||
|
},
|
||||||
|
[channelId, qc]
|
||||||
|
);
|
||||||
|
|
||||||
|
// First visit of an un-subscribed channel → ingest its recent uploads. Skipped for the demo
|
||||||
|
// account (explore spends shared quota) and when videos are already present (a revisit, or a
|
||||||
|
// subscribed channel whose uploads the scheduler backfills).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ch || me.is_demo) return;
|
||||||
|
if (autoTried.current === channelId) return;
|
||||||
|
if (ch.subscribed || ch.blocked) return;
|
||||||
|
if (ch.explored && ch.stored_videos > 0) return;
|
||||||
|
autoTried.current = channelId;
|
||||||
|
runExplore().catch(() => {});
|
||||||
|
}, [ch, channelId, me.is_demo, runExplore]);
|
||||||
|
|
||||||
|
const block = useMutation({
|
||||||
|
mutationFn: () => (ch?.blocked ? api.unblockChannel(channelId) : api.blockChannel(channelId)),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["channel", channelId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["blocked-channels"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const subscribe = useMutation({
|
||||||
|
mutationFn: () => api.subscribeChannel(channelId),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["channel", channelId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["channels"] });
|
||||||
|
notify({ level: "info", message: t("channel.subscribed", { name: ch?.title ?? "" }) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const unsubscribe = useMutation({
|
||||||
|
mutationFn: () => api.unsubscribeChannel(channelId),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["channel", channelId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["channels"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const onUnsubscribe = async () => {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: t("channel.unsubTitle"),
|
||||||
|
message: t("channel.unsubBody", { name: ch?.title ?? channelId }),
|
||||||
|
confirmLabel: t("channel.unsubscribe"),
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (ok) unsubscribe.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Channel-scoped feed: the whole catalog filtered to this channel (scope=all + source=all so
|
||||||
|
// the explored, per-user-private videos show — the backend gates them to this explorer).
|
||||||
|
const [filters, setFilters] = useState<FeedFilters>(() => ({
|
||||||
|
tags: [],
|
||||||
|
tagMode: "or",
|
||||||
|
q: "",
|
||||||
|
sort: "newest",
|
||||||
|
scope: "all",
|
||||||
|
librarySource: "all",
|
||||||
|
includeNormal: true,
|
||||||
|
includeShorts: false,
|
||||||
|
includeLive: true,
|
||||||
|
show: "all",
|
||||||
|
channelId,
|
||||||
|
channelName: initialName,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const name = ch?.title ?? initialName ?? channelId;
|
||||||
|
const ytUrl = channelYouTubeUrl(channelId, ch?.handle);
|
||||||
|
const joined = ch?.published_at
|
||||||
|
? new Date(ch.published_at).toLocaleDateString(i18n.language, { year: "numeric", month: "short" })
|
||||||
|
: null;
|
||||||
|
// Handle + stats on one compact meta line under the name (saves the separate stats row).
|
||||||
|
const metaParts = [
|
||||||
|
ch?.handle ? `@${ch.handle.replace(/^@/, "")}` : null,
|
||||||
|
ch?.subscriber_count != null ? t("channel.subscribers", { formatted: formatViews(ch.subscriber_count) }) : null,
|
||||||
|
ch?.video_count != null ? t("channel.videoCount", { count: ch.video_count }) : null,
|
||||||
|
ch?.total_view_count != null ? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) }) : null,
|
||||||
|
joined ? t("channel.joined", { date: joined }) : null,
|
||||||
|
].filter(Boolean) as string[];
|
||||||
|
const tabClass = (active: boolean) =>
|
||||||
|
active
|
||||||
|
? "pb-2 text-sm font-medium border-b-2 border-fg text-fg"
|
||||||
|
: "pb-2 text-sm text-muted hover:text-fg border-b-2 border-transparent";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 min-w-0 overflow-y-auto">
|
||||||
|
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
|
||||||
|
<div className="relative px-4 sm:px-6 pt-3">
|
||||||
|
{ch?.banner_url ? (
|
||||||
|
// Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a
|
||||||
|
// low default size, so request a crisp wide version (=w1707) and object-cover the
|
||||||
|
// desktop "safe area" — the centre 2560×423 (~6:1) band.
|
||||||
|
<div
|
||||||
|
className="w-full overflow-hidden rounded-2xl bg-surface"
|
||||||
|
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`${ch.banner_url}=w1707`}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover object-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-14 w-full rounded-2xl bg-surface" />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="absolute top-5 left-7 sm:left-9 z-10 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-black/45 text-white hover:bg-black/65 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
{t("channel.back")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 sm:px-6">
|
||||||
|
{/* Identity + actions. relative+z-10 so the avatar that overlaps up into the banner
|
||||||
|
paints ABOVE it (the banner's positioned container would otherwise cover it). */}
|
||||||
|
<div className="relative z-10 flex items-end gap-4 -mt-8">
|
||||||
|
<Avatar
|
||||||
|
src={ch?.thumbnail_url ?? null}
|
||||||
|
fallback={name}
|
||||||
|
className="w-20 h-20 rounded-full border-4 border-bg shrink-0 bg-bg"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0 pb-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h1 className="text-xl font-semibold truncate">{name}</h1>
|
||||||
|
{ch?.blocked ? (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-danger/15 text-danger">
|
||||||
|
{t("channel.blockedBadge")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
ch?.explored &&
|
||||||
|
!ch?.subscribed && (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400">
|
||||||
|
{t("channel.exploringBadge")}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted mt-0.5 flex flex-wrap gap-x-1.5 gap-y-0.5">
|
||||||
|
{metaParts.map((part, i) => (
|
||||||
|
<span key={i}>
|
||||||
|
{i > 0 && <span className="mr-1.5">·</span>}
|
||||||
|
{part}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0 pb-1">
|
||||||
|
<a
|
||||||
|
href={ytUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
title={t("channels.row.openOnYouTube")}
|
||||||
|
aria-label={t("channels.row.openOnYouTube")}
|
||||||
|
className="inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg border border-border"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
{!me.is_demo && (
|
||||||
|
<button
|
||||||
|
onClick={() => block.mutate()}
|
||||||
|
disabled={block.isPending}
|
||||||
|
title={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||||
|
aria-label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||||
|
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border disabled:opacity-50 transition ${
|
||||||
|
ch?.blocked
|
||||||
|
? "border-danger text-danger"
|
||||||
|
: "border-border text-muted hover:text-danger hover:border-danger"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Ban className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!me.is_demo &&
|
||||||
|
!ch?.blocked &&
|
||||||
|
(ch?.subscribed ? (
|
||||||
|
<button
|
||||||
|
onClick={onUnsubscribe}
|
||||||
|
disabled={unsubscribe.isPending}
|
||||||
|
title={t("channel.unsubscribe")}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
{t("channel.subscribedState")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => subscribe.mutate()}
|
||||||
|
disabled={subscribe.isPending}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
{t("channel.subscribe")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{exploring && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted mt-2">
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
{t("channel.loadingVideos")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex items-center gap-4 mt-3 border-b border-border">
|
||||||
|
<button onClick={() => setTab("videos")} className={tabClass(tab === "videos")}>
|
||||||
|
{t("channel.tabVideos")}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setTab("about")} className={tabClass(tab === "about")}>
|
||||||
|
{t("channel.tabAbout")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
{tab === "videos" ? (
|
||||||
|
<>
|
||||||
|
<Feed
|
||||||
|
filters={filters}
|
||||||
|
setFilters={setFilters}
|
||||||
|
view={view}
|
||||||
|
canRead={me.can_read}
|
||||||
|
isDemo={me.is_demo}
|
||||||
|
onOpenWizard={() => {}}
|
||||||
|
ytSearch={null}
|
||||||
|
onYtSearch={() => {}}
|
||||||
|
onExitYtSearch={() => {}}
|
||||||
|
onOpenChannel={onOpenChannel}
|
||||||
|
channelScoped
|
||||||
|
/>
|
||||||
|
{!ch?.subscribed && nextToken && (
|
||||||
|
<div className="flex justify-center pb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => runExplore(nextToken)}
|
||||||
|
disabled={exploring}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-border hover:bg-surface disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{exploring ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{t("channel.loadMore")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="px-4 sm:px-6 py-4 max-w-3xl">
|
||||||
|
{ch?.description ? (
|
||||||
|
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{ch.description}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
|
||||||
|
)}
|
||||||
|
{ch?.external_links && ch.external_links.length > 0 && (
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{ch.external_links.map((l, i) => (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={l.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-accent hover:underline"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
{l.title || l.url}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
|
Ban,
|
||||||
Check,
|
Check,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
|
@ -11,7 +12,9 @@ import {
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Search,
|
||||||
UserMinus,
|
UserMinus,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
|
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
|
||||||
import { useDismiss } from "../lib/useDismiss";
|
import { useDismiss } from "../lib/useDismiss";
|
||||||
|
|
@ -85,7 +88,15 @@ export default function Channels({
|
||||||
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
|
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
|
||||||
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
||||||
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
|
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
|
||||||
|
const blockedQuery = useQuery({ queryKey: ["blocked-channels"], queryFn: api.blockedChannels });
|
||||||
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
||||||
|
const unblock = useMutation({
|
||||||
|
mutationFn: (id: string) => api.unblockChannel(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["blocked-channels"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
qc.invalidateQueries({ queryKey: ["channels"] });
|
qc.invalidateQueries({ queryKey: ["channels"] });
|
||||||
|
|
@ -181,9 +192,26 @@ export default function Channels({
|
||||||
onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"),
|
onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Channel-name search + tag-chip filtering, applied client-side over the status-filtered list
|
||||||
|
// (prominent controls instead of the DataTable's hidden per-column popovers).
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [tagFilter, setTagFilter] = useState<number[]>([]);
|
||||||
|
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusChannelName) setSearch(focusChannelName);
|
||||||
|
}, [focusChannelName]);
|
||||||
|
// The header's reset intent clears the search + tag chips (skip the initial mount).
|
||||||
|
const resetRef = useRef(filtersResetToken);
|
||||||
|
useEffect(() => {
|
||||||
|
if (filtersResetToken === resetRef.current) return;
|
||||||
|
resetRef.current = filtersResetToken;
|
||||||
|
setSearch("");
|
||||||
|
setTagFilter([]);
|
||||||
|
}, [filtersResetToken]);
|
||||||
|
|
||||||
// The Sync-status filter stays a top-level chip set (not a column filter) because the
|
// The Sync-status filter stays a top-level chip set (not a column filter) because the
|
||||||
// header's "go to full history" deep-link drives it; the DataTable then handles name
|
// header's "go to full history" deep-link drives it; search/tag filtering + sort + pagination
|
||||||
// search, tag filtering, sort and pagination over whatever the status chip leaves.
|
// run over whatever the status chip leaves.
|
||||||
const channels = (channelsQuery.data ?? []).filter((c) =>
|
const channels = (channelsQuery.data ?? []).filter((c) =>
|
||||||
statusFilter === "needs_full"
|
statusFilter === "needs_full"
|
||||||
? !c.backfill_done
|
? !c.backfill_done
|
||||||
|
|
@ -193,6 +221,12 @@ export default function Channels({
|
||||||
? c.hidden
|
? c.hidden
|
||||||
: true
|
: true
|
||||||
);
|
);
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
const visibleChannels = channels.filter((c) => {
|
||||||
|
if (q && !`${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q)) return false;
|
||||||
|
if (tagFilter.length && !tagFilter.some((id) => c.tag_ids.includes(id))) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
const s = statusQuery.data;
|
const s = statusQuery.data;
|
||||||
|
|
||||||
const onView = (c: ManagedChannel) =>
|
const onView = (c: ManagedChannel) =>
|
||||||
|
|
@ -231,7 +265,6 @@ export default function Channels({
|
||||||
header: t("channels.cols.channel"),
|
header: t("channels.cols.channel"),
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
|
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
|
||||||
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
|
|
||||||
cardPrimary: true,
|
cardPrimary: true,
|
||||||
render: (c) => (
|
render: (c) => (
|
||||||
<ChannelLink
|
<ChannelLink
|
||||||
|
|
@ -305,12 +338,6 @@ export default function Channels({
|
||||||
key: "tags",
|
key: "tags",
|
||||||
header: t("channels.cols.tags"),
|
header: t("channels.cols.tags"),
|
||||||
cardLabel: false,
|
cardLabel: false,
|
||||||
filter: {
|
|
||||||
kind: "multi",
|
|
||||||
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
|
|
||||||
// OR semantics: show channels carrying any of the selected tags.
|
|
||||||
test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))),
|
|
||||||
},
|
|
||||||
render: (c) => (
|
render: (c) => (
|
||||||
<TagsCell
|
<TagsCell
|
||||||
c={c}
|
c={c}
|
||||||
|
|
@ -468,21 +495,60 @@ export default function Channels({
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Your tags — read-only overview; add/rename/delete live in the manager dialog. */}
|
{/* Channel-name search */}
|
||||||
|
<div className="relative mb-3 max-w-sm">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted pointer-events-none" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder={t("channels.searchPlaceholder")}
|
||||||
|
className="w-full bg-card border border-border rounded-lg pl-8 pr-8 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch("")}
|
||||||
|
aria-label={t("common.cancel")}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted hover:text-fg"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Your tags — click a chip to filter the table by it (add/rename/delete live in the manager). */}
|
||||||
<div className="flex flex-wrap items-center gap-1.5 mb-4">
|
<div className="flex flex-wrap items-center gap-1.5 mb-4">
|
||||||
<Tooltip hint={t("channels.tags.yourTagsHint")}>
|
<Tooltip hint={t("channels.tags.yourTagsHint")}>
|
||||||
<span className="text-xs uppercase tracking-wide text-muted mr-1 cursor-help underline decoration-dotted decoration-muted/40 underline-offset-4">
|
<span className="text-xs uppercase tracking-wide text-muted mr-1 cursor-help underline decoration-dotted decoration-muted/40 underline-offset-4">
|
||||||
{t("channels.tags.yourTags")}
|
{t("channels.tags.yourTags")}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{userTags.map((tg) => (
|
{userTags.map((tg) => {
|
||||||
<span
|
const active = tagFilter.includes(tg.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
key={tg.id}
|
key={tg.id}
|
||||||
className="text-xs px-2 py-1 rounded-full bg-card border border-border"
|
onClick={() =>
|
||||||
|
setTagFilter((f) => (active ? f.filter((x) => x !== tg.id) : [...f, tg.id]))
|
||||||
|
}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={`text-xs px-2 py-1 rounded-full border transition ${
|
||||||
|
active
|
||||||
|
? "bg-accent text-accent-fg border-accent"
|
||||||
|
: "bg-card border-border text-muted hover:text-fg hover:border-accent"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{tg.name}
|
{tg.name}
|
||||||
</span>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
{tagFilter.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setTagFilter([])}
|
||||||
|
className="text-xs px-2 py-1 text-muted hover:text-accent transition"
|
||||||
|
>
|
||||||
|
{t("datatable.clear")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setTagManagerOpen(true)}
|
onClick={() => setTagManagerOpen(true)}
|
||||||
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
||||||
|
|
@ -500,18 +566,47 @@ export default function Channels({
|
||||||
<div className="text-muted py-8">{t("channels.loading")}</div>
|
<div className="text-muted py-8">{t("channels.loading")}</div>
|
||||||
) : (
|
) : (
|
||||||
<DataTable
|
<DataTable
|
||||||
rows={channels}
|
rows={visibleChannels}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey={(c) => c.id}
|
rowKey={(c) => c.id}
|
||||||
persistKey="siftlode.channelsTable"
|
persistKey="siftlode.channelsTable"
|
||||||
controlsPosition="top"
|
controlsPosition="top"
|
||||||
controlsLeading={statusChips}
|
controlsLeading={statusChips}
|
||||||
externalFilter={focusChannelName ? { key: "channel", value: focusChannelName } : null}
|
|
||||||
resetFiltersToken={filtersResetToken}
|
|
||||||
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
||||||
emptyText={t("channels.empty")}
|
emptyText={t("channels.empty")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */}
|
||||||
|
{(blockedQuery.data?.length ?? 0) > 0 && (
|
||||||
|
<div className="mt-6 border-t border-border pt-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Ban className="w-4 h-4 text-muted" />
|
||||||
|
<span className="text-xs uppercase tracking-wide text-muted">
|
||||||
|
{t("channels.blocked.title")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{blockedQuery.data!.map((c) => (
|
||||||
|
<span
|
||||||
|
key={c.id}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs pl-2.5 pr-1 py-1 rounded-full bg-card border border-border"
|
||||||
|
>
|
||||||
|
{c.title ?? c.id}
|
||||||
|
<button
|
||||||
|
onClick={() => unblock.mutate(c.id)}
|
||||||
|
title={t("channel.unblock")}
|
||||||
|
aria-label={t("channel.unblock")}
|
||||||
|
className="rounded-full p-0.5 text-muted hover:text-danger transition"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted mt-2">{t("channels.blocked.hint")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -609,8 +704,20 @@ function TagsCell({
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
// Open the picker upward when there isn't room below (rows near the viewport bottom would
|
||||||
|
// otherwise clip it against the scroll container), as long as there's room above.
|
||||||
|
const [openUp, setOpenUp] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
const btnRef = useRef<HTMLButtonElement | null>(null);
|
||||||
useDismiss(open, () => setOpen(false), ref);
|
useDismiss(open, () => setOpen(false), ref);
|
||||||
|
const toggle = () => {
|
||||||
|
if (!open) {
|
||||||
|
const r = btnRef.current?.getBoundingClientRect();
|
||||||
|
const est = Math.min(userTags.length * 30 + 12, 264); // matches the max-height cap below
|
||||||
|
setOpenUp(!!r && r.bottom + est > window.innerHeight - 8 && r.top - est > 8);
|
||||||
|
}
|
||||||
|
setOpen((o) => !o);
|
||||||
|
};
|
||||||
if (userTags.length === 0) return null;
|
if (userTags.length === 0) return null;
|
||||||
// Show only the tags actually attached to this channel; the “+” opens a per-channel
|
// Show only the tags actually attached to this channel; the “+” opens a per-channel
|
||||||
// picker to attach/detach (kept the convenient "kirakás" without listing every tag
|
// picker to attach/detach (kept the convenient "kirakás" without listing every tag
|
||||||
|
|
@ -629,7 +736,8 @@ function TagsCell({
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen((o) => !o)}
|
ref={btnRef}
|
||||||
|
onClick={toggle}
|
||||||
title={t("channels.row.editTags")}
|
title={t("channels.row.editTags")}
|
||||||
aria-label={t("channels.row.editTags")}
|
aria-label={t("channels.row.editTags")}
|
||||||
className="w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
className="w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
||||||
|
|
@ -637,7 +745,11 @@ function TagsCell({
|
||||||
<Plus className="w-3 h-3" />
|
<Plus className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="glass-menu absolute left-0 top-full mt-1 z-30 w-44 rounded-xl p-1.5 animate-[popIn_0.16s_ease]">
|
<div
|
||||||
|
className={`glass-menu absolute left-0 z-30 w-44 max-h-[264px] overflow-y-auto rounded-xl p-1.5 animate-[popIn_0.16s_ease] ${
|
||||||
|
openUp ? "bottom-full mb-1" : "top-full mt-1"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{userTags.map((tg) => {
|
{userTags.map((tg) => {
|
||||||
const on = c.tag_ids.includes(tg.id);
|
const on = c.tag_ids.includes(tg.id);
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ArrowDown, ArrowUp, ArrowLeft, RefreshCw, Youtube } from "lucide-react";
|
import { ArrowDown, ArrowUp, ArrowLeft, Info, RefreshCw, Trash2, Youtube } from "lucide-react";
|
||||||
import { api, HttpError, type FeedFilters, type Video } from "../lib/api";
|
import { api, HttpError, type FeedFilters, type Video } from "../lib/api";
|
||||||
import i18n from "../i18n";
|
import i18n from "../i18n";
|
||||||
import { notify, resolveVideo } from "../lib/notifications";
|
import { notify, resolveVideo } from "../lib/notifications";
|
||||||
|
|
@ -23,9 +23,11 @@ const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);
|
||||||
|
|
||||||
// Ordering = a key + direction (like the Playlists page), mapped to the backend sort strings
|
// Ordering = a key + direction (like the Playlists page), mapped to the backend sort strings
|
||||||
// (which encode both). One entry per concept; a single arrow flips the direction.
|
// (which encode both). One entry per concept; a single arrow flips the direction.
|
||||||
type SortKey = "date" | "popular" | "duration" | "title" | "subscribers" | "priority" | "shuffle";
|
// "relevance" (full-text search ranking) and "shuffle" are directionless single-string sorts.
|
||||||
|
type SortKey = "date" | "popular" | "duration" | "title" | "subscribers" | "priority" | "shuffle" | "relevance";
|
||||||
const SORT_KEYS: SortKey[] = ["date", "popular", "duration", "title", "subscribers", "priority", "shuffle"];
|
const SORT_KEYS: SortKey[] = ["date", "popular", "duration", "title", "subscribers", "priority", "shuffle"];
|
||||||
const SORT_MAP: Record<Exclude<SortKey, "shuffle">, { asc: string; desc: string }> = {
|
const DIRECTIONLESS: SortKey[] = ["shuffle", "relevance"];
|
||||||
|
const SORT_MAP: Record<Exclude<SortKey, "shuffle" | "relevance">, { asc: string; desc: string }> = {
|
||||||
date: { desc: "newest", asc: "oldest" },
|
date: { desc: "newest", asc: "oldest" },
|
||||||
popular: { desc: "views", asc: "views_asc" },
|
popular: { desc: "views", asc: "views_asc" },
|
||||||
duration: { desc: "duration_desc", asc: "duration_asc" },
|
duration: { desc: "duration_desc", asc: "duration_asc" },
|
||||||
|
|
@ -33,11 +35,11 @@ const SORT_MAP: Record<Exclude<SortKey, "shuffle">, { asc: string; desc: string
|
||||||
subscribers: { desc: "subscribers", asc: "subscribers_asc" },
|
subscribers: { desc: "subscribers", asc: "subscribers_asc" },
|
||||||
priority: { desc: "priority", asc: "priority_asc" },
|
priority: { desc: "priority", asc: "priority_asc" },
|
||||||
};
|
};
|
||||||
const SORT_DEFAULT_DIR: Record<Exclude<SortKey, "shuffle">, "asc" | "desc"> = {
|
const SORT_DEFAULT_DIR: Record<Exclude<SortKey, "shuffle" | "relevance">, "asc" | "desc"> = {
|
||||||
date: "desc", popular: "desc", duration: "desc", title: "asc", subscribers: "desc", priority: "desc",
|
date: "desc", popular: "desc", duration: "desc", title: "asc", subscribers: "desc", priority: "desc",
|
||||||
};
|
};
|
||||||
function parseSort(s: string): { key: SortKey; dir: "asc" | "desc" } {
|
function parseSort(s: string): { key: SortKey; dir: "asc" | "desc" } {
|
||||||
if (s === "shuffle") return { key: "shuffle", dir: "desc" };
|
if (s === "shuffle" || s === "relevance") return { key: s, dir: "desc" };
|
||||||
for (const k of Object.keys(SORT_MAP) as (keyof typeof SORT_MAP)[]) {
|
for (const k of Object.keys(SORT_MAP) as (keyof typeof SORT_MAP)[]) {
|
||||||
if (SORT_MAP[k].asc === s) return { key: k, dir: "asc" };
|
if (SORT_MAP[k].asc === s) return { key: k, dir: "asc" };
|
||||||
if (SORT_MAP[k].desc === s) return { key: k, dir: "desc" };
|
if (SORT_MAP[k].desc === s) return { key: k, dir: "desc" };
|
||||||
|
|
@ -45,7 +47,8 @@ function parseSort(s: string): { key: SortKey; dir: "asc" | "desc" } {
|
||||||
return { key: "date", dir: "desc" };
|
return { key: "date", dir: "desc" };
|
||||||
}
|
}
|
||||||
function buildSort(key: SortKey, dir: "asc" | "desc"): string {
|
function buildSort(key: SortKey, dir: "asc" | "desc"): string {
|
||||||
return key === "shuffle" ? "shuffle" : SORT_MAP[key][dir];
|
if (key === "shuffle" || key === "relevance") return key;
|
||||||
|
return SORT_MAP[key][dir];
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesView(status: string, show: string): boolean {
|
function matchesView(status: string, show: string): boolean {
|
||||||
|
|
@ -74,6 +77,8 @@ export default function Feed({
|
||||||
ytSearch,
|
ytSearch,
|
||||||
onYtSearch,
|
onYtSearch,
|
||||||
onExitYtSearch,
|
onExitYtSearch,
|
||||||
|
onOpenChannel,
|
||||||
|
channelScoped,
|
||||||
}: {
|
}: {
|
||||||
filters: FeedFilters;
|
filters: FeedFilters;
|
||||||
setFilters: (f: FeedFilters) => void;
|
setFilters: (f: FeedFilters) => void;
|
||||||
|
|
@ -88,8 +93,17 @@ export default function Feed({
|
||||||
ytSearch: string | null;
|
ytSearch: string | null;
|
||||||
onYtSearch: (q: string) => void;
|
onYtSearch: (q: string) => void;
|
||||||
onExitYtSearch: () => void;
|
onExitYtSearch: () => void;
|
||||||
|
// Open a channel's dedicated page (from a card's channel name / avatar). Threaded down to
|
||||||
|
// the cards via VirtualFeed.
|
||||||
|
onOpenChannel?: (channelId: string, channelName?: string) => void;
|
||||||
|
// True when this feed is scoped to a single channel (the channel page). Drops sort options
|
||||||
|
// that are constant within one channel (subscribers, priority) — they'd be meaningless.
|
||||||
|
channelScoped?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const sortKeys = channelScoped
|
||||||
|
? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority")
|
||||||
|
: SORT_KEYS;
|
||||||
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
||||||
const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
|
const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
|
||||||
// The open player: which video and where to start (null = resume from saved position).
|
// The open player: which video and where to start (null = resume from saved position).
|
||||||
|
|
@ -104,6 +118,9 @@ export default function Feed({
|
||||||
);
|
);
|
||||||
|
|
||||||
const ytActive = !!ytSearch;
|
const ytActive = !!ytSearch;
|
||||||
|
// How many live-search results to gather (the count selector replaces a manual "load more";
|
||||||
|
// the free scrape source pages through continuations until this many are collected).
|
||||||
|
const [ytCount, setYtCount] = useState(20);
|
||||||
|
|
||||||
// Debounce only the search term feeding the queries: the input still updates instantly (it
|
// Debounce only the search term feeding the queries: the input still updates instantly (it
|
||||||
// owns filters.q), but the feed/count keys settle after a pause, so typing doesn't refetch —
|
// owns filters.q), but the feed/count keys settle after a pause, so typing doesn't refetch —
|
||||||
|
|
@ -125,8 +142,8 @@ export default function Feed({
|
||||||
// retry:false so a 429 (quota/limit) surfaces at once for inline display. staleTime keeps a
|
// retry:false so a 429 (quota/limit) surfaces at once for inline display. staleTime keeps a
|
||||||
// revisited search from re-spending.
|
// revisited search from re-spending.
|
||||||
const ytQuery = useInfiniteQuery({
|
const ytQuery = useInfiniteQuery({
|
||||||
queryKey: ["yt-search", ytSearch],
|
queryKey: ["yt-search", ytSearch, ytCount],
|
||||||
queryFn: ({ pageParam }) => api.searchYoutube(ytSearch as string, pageParam as string | null),
|
queryFn: ({ pageParam }) => api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount),
|
||||||
initialPageParam: null as string | null,
|
initialPageParam: null as string | null,
|
||||||
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
||||||
enabled: ytActive,
|
enabled: ytActive,
|
||||||
|
|
@ -134,6 +151,30 @@ export default function Feed({
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Remember (in history.state) that this live search was served by the zero-quota scrape source,
|
||||||
|
// so a reload restores the results instead of dropping to the feed — re-fetching scrape pages
|
||||||
|
// costs no quota. An api-source search is left unmarked (a reload would re-spend ~100 units).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ytActive) return;
|
||||||
|
const st = window.history.state;
|
||||||
|
if (!st || st._yt !== ytSearch) return; // only mark our own sub-view entry
|
||||||
|
if (ytQuery.data?.pages?.[0]?.source === "scrape" && !st._ytScrape) {
|
||||||
|
window.history.replaceState({ ...st, _ytScrape: true }, "");
|
||||||
|
}
|
||||||
|
}, [ytActive, ytSearch, ytQuery.data]);
|
||||||
|
|
||||||
|
// Switching to the relevance sort when a search starts happens atomically in the header's
|
||||||
|
// input onChange (race-free with the query update). Here we only handle the reverse: when
|
||||||
|
// the term is cleared, fall back to the default sort — relevance has no dropdown option and
|
||||||
|
// doesn't rank without a query.
|
||||||
|
const qEmpty = !filters.q.trim();
|
||||||
|
useEffect(() => {
|
||||||
|
if (qEmpty && parseSort(filters.sort).key === "relevance") {
|
||||||
|
setFilters({ ...filters, sort: buildSort("date", "desc") });
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [qEmpty]);
|
||||||
|
|
||||||
// Drop optimistic status overrides when filters change OR fresh server data
|
// Drop optimistic status overrides when filters change OR fresh server data
|
||||||
// arrives — after a refetch the server is authoritative, so a stale override
|
// arrives — after a refetch the server is authoritative, so a stale override
|
||||||
// (e.g. a video reverted to "new" from the notification center) won't keep it
|
// (e.g. a video reverted to "new" from the notification center) won't keep it
|
||||||
|
|
@ -242,11 +283,11 @@ export default function Feed({
|
||||||
[qc]
|
[qc]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onChannelFilter = useCallback(
|
const handleOpenChannel = useCallback(
|
||||||
(channelId: string, channelName: string) => {
|
(channelId: string, channelName: string) => {
|
||||||
setFilters({ ...filters, channelId, channelName });
|
onOpenChannel?.(channelId, channelName);
|
||||||
},
|
},
|
||||||
[filters, setFilters]
|
[onOpenChannel]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onToggleSave = useCallback(
|
const onToggleSave = useCallback(
|
||||||
|
|
@ -290,12 +331,16 @@ export default function Feed({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-center gap-2 flex-wrap pb-3 mb-1 border-b border-border">
|
<div className="pb-3 mb-1 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// The search just ingested new catalog videos, so the normal feed (which was
|
// The search just ingested new catalog videos, so the normal feed (which was
|
||||||
// disabled and still holds its pre-search cache) is stale. Drop those caches so
|
// disabled and still holds its pre-search cache) is stale. Drop those caches so
|
||||||
// it re-fetches fresh on return instead of showing the old (often empty) result.
|
// it re-fetches fresh on return instead of showing the old (often empty) result.
|
||||||
|
// Surface the just-searched videos in the feed you return to: switch the Source
|
||||||
|
// filter to "search" (your own search finds) and rank them by relevance.
|
||||||
|
setFilters({ ...filters, librarySource: "search", sort: "relevance" });
|
||||||
onExitYtSearch();
|
onExitYtSearch();
|
||||||
qc.removeQueries({ queryKey: ["feed"] });
|
qc.removeQueries({ queryKey: ["feed"] });
|
||||||
qc.removeQueries({ queryKey: ["feed-count"] });
|
qc.removeQueries({ queryKey: ["feed-count"] });
|
||||||
|
|
@ -314,6 +359,47 @@ export default function Feed({
|
||||||
<span className="text-xs text-muted">
|
<span className="text-xs text-muted">
|
||||||
{zeroQuota ? t("feed.yt.freeNote") : t("feed.yt.quotaNote")}
|
{zeroQuota ? t("feed.yt.freeNote") : t("feed.yt.quotaNote")}
|
||||||
</span>
|
</span>
|
||||||
|
<label className="ml-auto flex items-center gap-1.5 text-xs text-muted shrink-0">
|
||||||
|
{t("feed.yt.show")}
|
||||||
|
<select
|
||||||
|
value={ytCount}
|
||||||
|
onChange={(e) => setYtCount(Number(e.target.value))}
|
||||||
|
title={t("feed.yt.showHint")}
|
||||||
|
className="bg-card border border-border rounded-lg px-2 py-1 text-xs outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{[20, 40, 60, 100].map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
{n}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{ytItems.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mt-2 text-xs text-muted">
|
||||||
|
<Info className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
<span>{t("feed.yt.ephemeralNote")}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const ids = ytItems.map((v) => v.id);
|
||||||
|
api
|
||||||
|
.clearSearch(ids)
|
||||||
|
.then((r) => notify({ message: i18n.t("feed.yt.cleared", { count: r.removed }) }))
|
||||||
|
.catch(() => {});
|
||||||
|
setFilters({ ...filters, librarySource: "organic", sort: "newest" });
|
||||||
|
onExitYtSearch();
|
||||||
|
qc.removeQueries({ queryKey: ["feed"] });
|
||||||
|
qc.removeQueries({ queryKey: ["feed-count"] });
|
||||||
|
qc.removeQueries({ queryKey: ["facets"] });
|
||||||
|
qc.removeQueries({ queryKey: ["yt-search"] });
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border text-muted hover:text-danger hover:border-danger transition"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
{t("feed.yt.clearNow")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ytQuery.isLoading ? (
|
{ytQuery.isLoading ? (
|
||||||
|
|
@ -329,7 +415,7 @@ export default function Feed({
|
||||||
view={view}
|
view={view}
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onToggleSave={onToggleSave}
|
onToggleSave={onToggleSave}
|
||||||
onChannelFilter={onChannelFilter}
|
onOpenChannel={handleOpenChannel}
|
||||||
onResetState={onResetState}
|
onResetState={onResetState}
|
||||||
onOpen={openVideo}
|
onOpen={openVideo}
|
||||||
hasNextPage={false}
|
hasNextPage={false}
|
||||||
|
|
@ -360,6 +446,7 @@ export default function Feed({
|
||||||
startAt={activeVideo.startAt}
|
startAt={activeVideo.startAt}
|
||||||
onClose={() => setActiveVideo(null)}
|
onClose={() => setActiveVideo(null)}
|
||||||
onState={onState}
|
onState={onState}
|
||||||
|
onOpenChannel={onOpenChannel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -412,7 +499,7 @@ export default function Feed({
|
||||||
|
|
||||||
const toolbar = (
|
const toolbar = (
|
||||||
<div className="pb-3">
|
<div className="pb-3">
|
||||||
<div className="flex items-center gap-1.5 flex-wrap mb-2.5">
|
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
|
||||||
{SHOW_IDS.map((id) => (
|
{SHOW_IDS.map((id) => (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
|
|
@ -445,7 +532,7 @@ export default function Feed({
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{filters.scope === "all" && (
|
{(
|
||||||
<>
|
<>
|
||||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||||
<label className="inline-flex items-center gap-1.5 text-xs text-muted">
|
<label className="inline-flex items-center gap-1.5 text-xs text-muted">
|
||||||
|
|
@ -468,9 +555,8 @@ export default function Feed({
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||||
<div className="flex items-center gap-2 border-t border-border pt-2.5">
|
<span className="text-xs text-muted whitespace-nowrap">
|
||||||
<span className="text-sm text-muted">
|
|
||||||
{countQuery.data
|
{countQuery.data
|
||||||
? t("feed.videoCount", {
|
? t("feed.videoCount", {
|
||||||
count: countQuery.data.count,
|
count: countQuery.data.count,
|
||||||
|
|
@ -478,13 +564,15 @@ export default function Feed({
|
||||||
})
|
})
|
||||||
: " "}
|
: " "}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1" />
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<span className="text-xs text-muted">{t("feed.sortLabel")}</span>
|
<span className="text-xs text-muted">{t("feed.sortLabel")}</span>
|
||||||
<select
|
<select
|
||||||
value={sortKey}
|
value={sortKey}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const key = e.target.value as SortKey;
|
const key = e.target.value as SortKey;
|
||||||
const dir = key === "shuffle" ? "desc" : SORT_DEFAULT_DIR[key];
|
const dir = DIRECTIONLESS.includes(key)
|
||||||
|
? "desc"
|
||||||
|
: SORT_DEFAULT_DIR[key as Exclude<SortKey, "shuffle" | "relevance">];
|
||||||
setFilters({
|
setFilters({
|
||||||
...filters,
|
...filters,
|
||||||
sort: buildSort(key, dir),
|
sort: buildSort(key, dir),
|
||||||
|
|
@ -493,13 +581,14 @@ export default function Feed({
|
||||||
}}
|
}}
|
||||||
className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
|
className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
|
||||||
>
|
>
|
||||||
{SORT_KEYS.map((k) => (
|
{/* Relevance only ranks meaningfully when there's a search term — offer it then. */}
|
||||||
|
{(filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys).map((k) => (
|
||||||
<option key={k} value={k}>
|
<option key={k} value={k}>
|
||||||
{t("feed.sortKey." + k)}
|
{t("feed.sortKey." + k)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{sortKey !== "shuffle" && (
|
{!DIRECTIONLESS.includes(sortKey) && (
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setFilters({ ...filters, sort: buildSort(sortKey, sortDir === "asc" ? "desc" : "asc") })
|
setFilters({ ...filters, sort: buildSort(sortKey, sortDir === "asc" ? "desc" : "asc") })
|
||||||
|
|
@ -523,6 +612,7 @@ export default function Feed({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -547,7 +637,7 @@ export default function Feed({
|
||||||
view={view}
|
view={view}
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onToggleSave={onToggleSave}
|
onToggleSave={onToggleSave}
|
||||||
onChannelFilter={onChannelFilter}
|
onOpenChannel={handleOpenChannel}
|
||||||
onResetState={onResetState}
|
onResetState={onResetState}
|
||||||
onOpen={openVideo}
|
onOpen={openVideo}
|
||||||
hasNextPage={!!hasNextPage}
|
hasNextPage={!!hasNextPage}
|
||||||
|
|
@ -561,6 +651,7 @@ export default function Feed({
|
||||||
startAt={activeVideo.startAt}
|
startAt={activeVideo.startAt}
|
||||||
onClose={() => setActiveVideo(null)}
|
onClose={() => setActiveVideo(null)}
|
||||||
onState={onState}
|
onState={onState}
|
||||||
|
onOpenChannel={onOpenChannel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isFetchingNextPage && (
|
{isFetchingNextPage && (
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,14 @@ export default function Header({
|
||||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
||||||
<input
|
<input
|
||||||
value={filters.q}
|
value={filters.q}
|
||||||
onChange={(e) => setFilters({ ...filters, q: e.target.value })}
|
onChange={(e) => {
|
||||||
|
const q = e.target.value;
|
||||||
|
// When a search first appears, rank the feed by relevance — set atomically with
|
||||||
|
// the query (race-free vs. per-keystroke updates). Only overrides the default
|
||||||
|
// "newest" sort; a custom sort the user chose is left alone. Cleared in Feed.
|
||||||
|
const startSearch = !filters.q.trim() && !!q.trim() && filters.sort === "newest";
|
||||||
|
setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) });
|
||||||
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
// Enter runs a live YouTube search for the typed term (the box itself filters
|
// Enter runs a live YouTube search for the typed term (the box itself filters
|
||||||
// the local catalog as you type; Enter escalates to the YouTube API).
|
// the local catalog as you type; Enter escalates to the YouTube API).
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ export default function PlayerModal({
|
||||||
startIndex,
|
startIndex,
|
||||||
onClose,
|
onClose,
|
||||||
onState,
|
onState,
|
||||||
|
onOpenChannel,
|
||||||
}: {
|
}: {
|
||||||
video: Video;
|
video: Video;
|
||||||
// Where to start the opened video: a number of seconds (0 = Restart), or null/undefined
|
// Where to start the opened video: a number of seconds (0 = Restart), or null/undefined
|
||||||
|
|
@ -57,6 +58,9 @@ export default function PlayerModal({
|
||||||
startIndex?: number;
|
startIndex?: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onState: (id: string, status: string) => void;
|
onState: (id: string, status: string) => void;
|
||||||
|
// Open the active video's channel page in-app (closes the player first). The small external
|
||||||
|
// icon next to the name keeps the open-on-YouTube behaviour.
|
||||||
|
onOpenChannel?: (channelId: string, channelName?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
@ -552,14 +556,38 @@ export default function PlayerModal({
|
||||||
<span className="font-medium shrink-0">{liveData?.author ?? ""}</span>
|
<span className="font-medium shrink-0">{liveData?.author ?? ""}</span>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0 min-w-0">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!onOpenChannel) return onClose();
|
||||||
|
const cid = active.channel_id;
|
||||||
|
const cname = active.channel_title ?? undefined;
|
||||||
|
// Closing the player pops its own history entry (useBackToClose → history.back()).
|
||||||
|
// Open the channel only AFTER that pop's popstate — pushing the channel entry
|
||||||
|
// before it would let the back remove it again (the bug: clicking the name only
|
||||||
|
// closed the player). One-shot listener fires after App's popstate handler.
|
||||||
|
const open = () => {
|
||||||
|
window.removeEventListener("popstate", open);
|
||||||
|
onOpenChannel(cid, cname);
|
||||||
|
};
|
||||||
|
window.addEventListener("popstate", open);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="font-medium hover:text-accent truncate text-left"
|
||||||
|
>
|
||||||
|
{active.channel_title}
|
||||||
|
</button>
|
||||||
<a
|
<a
|
||||||
href={active.channel_url}
|
href={active.channel_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="font-medium hover:text-accent shrink-0"
|
title={t("channels.row.openOnYouTube")}
|
||||||
|
aria-label={t("channels.row.openOnYouTube")}
|
||||||
|
className="text-muted hover:text-accent shrink-0"
|
||||||
>
|
>
|
||||||
{active.channel_title}
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{!navigated ? (
|
{!navigated ? (
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
Pencil,
|
Pencil,
|
||||||
Play,
|
Play,
|
||||||
RadioTower,
|
RadioTower,
|
||||||
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api";
|
import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api";
|
||||||
|
|
@ -354,6 +355,15 @@ export default function Scheduler() {
|
||||||
mutationFn: (v: number) => api.updateMaintenanceBatch(v),
|
mutationFn: (v: number) => api.updateMaintenanceBatch(v),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }),
|
||||||
});
|
});
|
||||||
|
const purgeMut = useMutation({
|
||||||
|
mutationFn: () => api.purgeDiscovery(),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
const removed =
|
||||||
|
(res.search_videos_deleted ?? 0) + (res.videos_deleted ?? 0) + (res.channels_deleted ?? 0);
|
||||||
|
notify({ level: "success", message: t("scheduler.purgedDiscovery", { count: removed }) });
|
||||||
|
qc.invalidateQueries({ queryKey: ["scheduler"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (q.isLoading && !data)
|
if (q.isLoading && !data)
|
||||||
return <div className="p-8 text-muted">{t("scheduler.loading")}</div>;
|
return <div className="p-8 text-muted">{t("scheduler.loading")}</div>;
|
||||||
|
|
@ -398,6 +408,16 @@ export default function Scheduler() {
|
||||||
{t("scheduler.runAll")}
|
{t("scheduler.runAll")}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip hint={t("scheduler.purgeDiscoveryHint")}>
|
||||||
|
<button
|
||||||
|
onClick={() => purgeMut.mutate()}
|
||||||
|
disabled={purgeMut.isPending}
|
||||||
|
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
{t("scheduler.purgeDiscovery")}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip hint={t("scheduler.pauseHint")}>
|
<Tooltip hint={t("scheduler.pauseHint")}>
|
||||||
<button
|
<button
|
||||||
onClick={() => pauseResume.mutate(data.paused)}
|
onClick={() => pauseResume.mutate(data.paused)}
|
||||||
|
|
|
||||||
|
|
@ -182,7 +182,8 @@ export default function Sidebar({
|
||||||
const languages = tags.filter((t) => t.category === "language");
|
const languages = tags.filter((t) => t.category === "language");
|
||||||
const topics = tags.filter((t) => t.category === "topic");
|
const topics = tags.filter((t) => t.category === "topic");
|
||||||
// The user's own (non-system) tags — those assigned to channels in the Channel manager.
|
// The user's own (non-system) tags — those assigned to channels in the Channel manager.
|
||||||
// Facets only cover language/topic, so these show their static channel_count.
|
// Facets cover them too now (the "other" category), so they're contextual like the rest:
|
||||||
|
// zero-count chips hide once facets load, counts reflect the current filter.
|
||||||
const userTags = tags.filter((t) => !t.system);
|
const userTags = tags.filter((t) => !t.system);
|
||||||
const [customDates, setCustomDates] = useState(false);
|
const [customDates, setCustomDates] = useState(false);
|
||||||
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
||||||
|
|
@ -407,18 +408,22 @@ export default function Sidebar({
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "tags":
|
case "tags": {
|
||||||
|
const tagChips = visibleChips(userTags);
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{userTags.map((tg) => (
|
{tagChips.map((tg) => (
|
||||||
<TagChip
|
<TagChip
|
||||||
key={tg.id}
|
key={tg.id}
|
||||||
tag={tg}
|
tag={tg}
|
||||||
count={tg.channel_count}
|
count={chipCount(tg)}
|
||||||
active={filters.tags.includes(tg.id)}
|
active={filters.tags.includes(tg.id)}
|
||||||
onClick={() => toggleTag(tg.id)}
|
onClick={() => toggleTag(tg.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{facetsReady && tagChips.length === 0 && (
|
||||||
|
<span className="text-[11px] text-muted">{t("sidebar.noMatchingTags")}</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setTagManagerOpen(true)}
|
onClick={() => setTagManagerOpen(true)}
|
||||||
title={t("sidebar.manageTags")}
|
title={t("sidebar.manageTags")}
|
||||||
|
|
@ -431,6 +436,7 @@ export default function Sidebar({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const orderedAvailable = layout.order.filter((id) => available[id]);
|
const orderedAvailable = layout.order.filter((id) => available[id]);
|
||||||
const renderedIds = editing
|
const renderedIds = editing
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { memo } from "react";
|
import { memo, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Bookmark,
|
Bookmark,
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
ListFilter,
|
|
||||||
Play,
|
Play,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
@ -21,13 +20,11 @@ function Actions({
|
||||||
onState,
|
onState,
|
||||||
onResetState,
|
onResetState,
|
||||||
onToggleSave,
|
onToggleSave,
|
||||||
onChannelFilter,
|
|
||||||
}: {
|
}: {
|
||||||
video: Video;
|
video: Video;
|
||||||
onState: (id: string, status: string) => void;
|
onState: (id: string, status: string) => void;
|
||||||
onResetState?: (id: string) => void;
|
onResetState?: (id: string) => void;
|
||||||
onToggleSave: (id: string, saved: boolean) => void;
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
onChannelFilter?: (channelId: string, channelName: string) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const act = (status: string) => (e: React.MouseEvent) => {
|
const act = (status: string) => (e: React.MouseEvent) => {
|
||||||
|
|
@ -97,19 +94,6 @@ function Actions({
|
||||||
<RotateCcw className="w-4 h-4" />
|
<RotateCcw className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onChannelFilter && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onChannelFilter(video.channel_id, video.channel_title ?? t("card.thisChannel"));
|
|
||||||
}}
|
|
||||||
title={t("card.onlyThisChannel")}
|
|
||||||
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"
|
|
||||||
>
|
|
||||||
<ListFilter className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -244,7 +228,7 @@ function VideoCard({
|
||||||
onState,
|
onState,
|
||||||
onResetState,
|
onResetState,
|
||||||
onToggleSave,
|
onToggleSave,
|
||||||
onChannelFilter,
|
onOpenChannel,
|
||||||
onOpen,
|
onOpen,
|
||||||
}: {
|
}: {
|
||||||
video: Video;
|
video: Video;
|
||||||
|
|
@ -252,7 +236,7 @@ function VideoCard({
|
||||||
onState: (id: string, status: string) => void;
|
onState: (id: string, status: string) => void;
|
||||||
onResetState?: (id: string) => void;
|
onResetState?: (id: string) => void;
|
||||||
onToggleSave: (id: string, saved: boolean) => void;
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
onChannelFilter?: (channelId: string, channelName: string) => void;
|
onOpenChannel?: (channelId: string, channelName: string) => void;
|
||||||
onOpen?: (v: Video, startAt?: number | null) => void;
|
onOpen?: (v: Video, startAt?: number | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
@ -280,12 +264,26 @@ function VideoCard({
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
// Show the full title in a native tooltip only when it's clamped (overflows its 2 lines).
|
||||||
|
const titleRef = useRef<HTMLAnchorElement>(null);
|
||||||
|
const [titleClamped, setTitleClamped] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = titleRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const check = () => setTitleClamped(el.scrollHeight > el.clientHeight + 1);
|
||||||
|
check();
|
||||||
|
const ro = new ResizeObserver(check);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [video.title]);
|
||||||
const title = (
|
const title = (
|
||||||
<a
|
<a
|
||||||
|
ref={titleRef}
|
||||||
href={video.watch_url}
|
href={video.watch_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
onClick={(e) => openInApp(e, video, onOpen)}
|
onClick={(e) => openInApp(e, video, onOpen)}
|
||||||
|
title={titleClamped ? video.title ?? undefined : undefined}
|
||||||
className="font-medium leading-snug line-clamp-2 hover:text-accent"
|
className="font-medium leading-snug line-clamp-2 hover:text-accent"
|
||||||
>
|
>
|
||||||
{video.title}
|
{video.title}
|
||||||
|
|
@ -303,18 +301,18 @@ function VideoCard({
|
||||||
<Thumb video={video} className="w-44 aspect-video shrink-0" onOpen={onOpen} />
|
<Thumb video={video} className="w-44 aspect-video shrink-0" onOpen={onOpen} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{title}
|
{title}
|
||||||
<a
|
<button
|
||||||
href={video.channel_url}
|
onClick={(e) => {
|
||||||
target="_blank"
|
e.stopPropagation();
|
||||||
rel="noreferrer"
|
onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
|
||||||
onClick={(e) => e.stopPropagation()}
|
}}
|
||||||
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full hover:text-fg"
|
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full text-left hover:text-fg"
|
||||||
>
|
>
|
||||||
{video.channel_title}
|
{video.channel_title}
|
||||||
</a>
|
</button>
|
||||||
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
||||||
</div>
|
</div>
|
||||||
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} />
|
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -335,17 +333,17 @@ function VideoCard({
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{title}
|
{title}
|
||||||
<a
|
<button
|
||||||
href={video.channel_url}
|
onClick={(e) => {
|
||||||
target="_blank"
|
e.stopPropagation();
|
||||||
rel="noreferrer"
|
onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
|
||||||
onClick={(e) => e.stopPropagation()}
|
}}
|
||||||
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full hover:text-fg"
|
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full text-left hover:text-fg"
|
||||||
>
|
>
|
||||||
{video.channel_title}
|
{video.channel_title}
|
||||||
</a>
|
</button>
|
||||||
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
||||||
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} />
|
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ export interface VirtualFeedProps {
|
||||||
view: "grid" | "list";
|
view: "grid" | "list";
|
||||||
onState: (id: string, status: string) => void;
|
onState: (id: string, status: string) => void;
|
||||||
onToggleSave: (id: string, saved: boolean) => void;
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
onChannelFilter: (channelId: string, channelName: string) => void;
|
onOpenChannel: (channelId: string, channelName: string) => void;
|
||||||
onResetState: (id: string) => void;
|
onResetState: (id: string) => void;
|
||||||
onOpen: (v: Video, startAt?: number | null) => void;
|
onOpen: (v: Video, startAt?: number | null) => void;
|
||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
|
|
@ -53,7 +53,7 @@ export default function VirtualFeed({
|
||||||
view,
|
view,
|
||||||
onState,
|
onState,
|
||||||
onToggleSave,
|
onToggleSave,
|
||||||
onChannelFilter,
|
onOpenChannel,
|
||||||
onResetState,
|
onResetState,
|
||||||
onOpen,
|
onOpen,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
|
|
@ -149,7 +149,7 @@ export default function VirtualFeed({
|
||||||
view="grid"
|
view="grid"
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onToggleSave={onToggleSave}
|
onToggleSave={onToggleSave}
|
||||||
onChannelFilter={onChannelFilter}
|
onOpenChannel={onOpenChannel}
|
||||||
onResetState={onResetState}
|
onResetState={onResetState}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
/>
|
/>
|
||||||
|
|
@ -162,7 +162,7 @@ export default function VirtualFeed({
|
||||||
view="list"
|
view="list"
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onToggleSave={onToggleSave}
|
onToggleSave={onToggleSave}
|
||||||
onChannelFilter={onChannelFilter}
|
onOpenChannel={onOpenChannel}
|
||||||
onResetState={onResetState}
|
onResetState={onResetState}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
23
frontend/src/i18n/locales/de/channel.json
Normal file
23
frontend/src/i18n/locales/de/channel.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"back": "Zurück",
|
||||||
|
"exploringBadge": "Erkunden",
|
||||||
|
"subscribe": "Abonnieren",
|
||||||
|
"subscribedState": "Abonniert",
|
||||||
|
"unsubscribe": "Abbestellen",
|
||||||
|
"unsubTitle": "Abbestellen?",
|
||||||
|
"unsubBody": "{{name}} auf YouTube nicht mehr folgen?",
|
||||||
|
"subscribed": "{{name}} abonniert",
|
||||||
|
"subscribers": "{{formatted}} Abonnenten",
|
||||||
|
"videoCount_one": "{{count}} Video",
|
||||||
|
"videoCount_other": "{{count}} Videos",
|
||||||
|
"totalViews": "{{formatted}} Aufrufe",
|
||||||
|
"joined": "Beigetreten {{date}}",
|
||||||
|
"loadingVideos": "Videos dieses Kanals werden geladen…",
|
||||||
|
"tabVideos": "Videos",
|
||||||
|
"tabAbout": "Info",
|
||||||
|
"loadMore": "Mehr von YouTube laden",
|
||||||
|
"noDescription": "Dieser Kanal hat keine Beschreibung.",
|
||||||
|
"block": "Kanal blockieren",
|
||||||
|
"unblock": "Blockierung aufheben",
|
||||||
|
"blockedBadge": "Blockiert"
|
||||||
|
}
|
||||||
|
|
@ -122,5 +122,10 @@
|
||||||
"resetFailed": "Kanal konnte nicht zurückgesetzt werden"
|
"resetFailed": "Kanal konnte nicht zurückgesetzt werden"
|
||||||
},
|
},
|
||||||
"confirmUnsubscribe": "Kanal „{{name}}“ bei YouTube abbestellen? Dies ändert dein echtes YouTube-Konto. Um ihn nur aus deinem Feed zu entfernen, blende ihn stattdessen aus.",
|
"confirmUnsubscribe": "Kanal „{{name}}“ bei YouTube abbestellen? Dies ändert dein echtes YouTube-Konto. Um ihn nur aus deinem Feed zu entfernen, blende ihn stattdessen aus.",
|
||||||
"confirmReset": "Kanal „{{name}}“ von Grund auf neu laden? Dies startet den Backfill neu (aktuell + vollständiger Verlauf) und verbraucht etwas API-Kontingent."
|
"confirmReset": "Kanal „{{name}}“ von Grund auf neu laden? Dies startet den Backfill neu (aktuell + vollständiger Verlauf) und verbraucht etwas API-Kontingent.",
|
||||||
|
"searchPlaceholder": "Kanäle suchen…",
|
||||||
|
"blocked": {
|
||||||
|
"title": "Blockierte Kanäle",
|
||||||
|
"hint": "Ihre Videos sind in deinem Feed, der Bibliothek und der Live-Suche ausgeblendet."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,5 +15,6 @@
|
||||||
"accessRequestsMessage": "{{count}} ausstehend — prüfe sie auf der Benutzer-Seite.",
|
"accessRequestsMessage": "{{count}} ausstehend — prüfe sie auf der Benutzer-Seite.",
|
||||||
"accessRequestsReview": "Überprüfen",
|
"accessRequestsReview": "Überprüfen",
|
||||||
"demoTitle": "Demo-Konto",
|
"demoTitle": "Demo-Konto",
|
||||||
"demoSharedNotice": "Du bist im gemeinsamen Demo-Konto. Alles, was du hier tust — Playlists, ausgeblendete Videos, Einstellungen — ist gemeinsam und kann von anderen Demo-Besuchern gleichzeitig geändert werden."
|
"demoSharedNotice": "Du bist im gemeinsamen Demo-Konto. Alles, was du hier tust — Playlists, ausgeblendete Videos, Einstellungen — ist gemeinsam und kann von anderen Demo-Besuchern gleichzeitig geändert werden.",
|
||||||
|
"backToTop": "Nach oben"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,29 +9,98 @@
|
||||||
"quota": "Kontingent",
|
"quota": "Kontingent",
|
||||||
"backfill": "Nachladen (Backfill)",
|
"backfill": "Nachladen (Backfill)",
|
||||||
"shorts": "Shorts-Prüfung",
|
"shorts": "Shorts-Prüfung",
|
||||||
"batch": "Stapelgrößen"
|
"batch": "Stapelgrößen",
|
||||||
|
"explore": "Kanal erkunden"
|
||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"smtp_host": { "label": "SMTP-Host", "hint": "z. B. smtp.gmail.com" },
|
"smtp_host": {
|
||||||
"smtp_port": { "label": "SMTP-Port", "hint": "Üblicherweise 587 (STARTTLS)." },
|
"label": "SMTP-Host",
|
||||||
"smtp_user": { "label": "SMTP-Benutzername", "hint": "Das sendende Konto / der Login." },
|
"hint": "z. B. smtp.gmail.com"
|
||||||
"smtp_from": { "label": "Absenderadresse", "hint": "z. B. Siftlode <addr@gmail.com>. Standard ist der Benutzername." },
|
},
|
||||||
"smtp_password": { "label": "SMTP-Passwort", "hint": "App-Passwort. Verschlüsselt gespeichert; nur schreibbar — wird nie wieder angezeigt." },
|
"smtp_port": {
|
||||||
"quota_daily_budget": { "label": "Tägliches Kontingentbudget", "hint": "YouTube-Data-API-Einheiten pro Tag (kostenloses Limit 10.000). Standard 9000 lässt Puffer." },
|
"label": "SMTP-Port",
|
||||||
"backfill_quota_reserve": { "label": "Nachlade-Kontingentreserve", "hint": "Reservierte Einheiten, damit geplantes Nachladen interaktive Syncs / Anreicherung nie aushungert." },
|
"hint": "Üblicherweise 587 (STARTTLS)."
|
||||||
"search_daily_limit_per_user": { "label": "Live-Suche — Tageslimit pro Nutzer", "hint": "Maximale Live-YouTube-Suchen pro Nutzer und Tag. Bei der API-Quelle kostet jede Suche 100 Einheiten (das gemeinsame Budget reicht also für insgesamt nur ~80-90/Tag); bei der Scrape-Quelle ist es ein reines Missbrauchslimit. 0 = Live-Suche deaktiviert." },
|
},
|
||||||
"search_source": { "label": "Quelle der Live-Suche", "hint": "Woher die Ergebnisse der Live-YouTube-Suche stammen. \"scrape\" nutzt YouTubes internen Endpunkt und verbraucht kein API-Kontingent (empfohlen). \"api\" nutzt das offizielle search.list (100 Einheiten/Seite). Auf \"api\" umstellen, falls das Scraping ausfällt oder blockiert wird." },
|
"smtp_user": {
|
||||||
"backfill_recent_max_videos": { "label": "Aktuelles Nachladen — max. Videos", "hint": "Neueste Videos pro Kanal beim ersten Durchlauf." },
|
"label": "SMTP-Benutzername",
|
||||||
"backfill_recent_max_days": { "label": "Aktuelles Nachladen — max. Alter (Tage)", "hint": "Wie weit der erste Durchlauf pro Kanal zurückreicht." },
|
"hint": "Das sendende Konto / der Login."
|
||||||
"shorts_probe_max_seconds": { "label": "Shorts-Prüfung — max. Dauer (s)", "hint": "Nur Videos bis zu dieser Länge werden als mögliche Shorts geprüft." },
|
},
|
||||||
"shorts_probe_batch": { "label": "Shorts-Prüfung — Stapelgröße", "hint": "Wie viele Videos pro Lauf klassifiziert werden." },
|
"smtp_from": {
|
||||||
"enrich_batch_size": { "label": "Anreicherungs-Stapelgröße", "hint": "videos.list-IDs pro Aufruf (YouTube begrenzt auf 50)." },
|
"label": "Absenderadresse",
|
||||||
"autotag_title_sample": { "label": "Auto-Tag-Titelstichprobe", "hint": "Pro Kanal abgetastete aktuelle Videotitel zur Spracherkennung." },
|
"hint": "z. B. Siftlode <addr@gmail.com>. Standard ist der Benutzername."
|
||||||
"youtube_api_key": { "label": "YouTube-API-Schlüssel", "hint": "Optional. Für öffentliche Lesezugriffe (Kanäle/Videos), damit gemeinsames Nachladen nicht vom OAuth eines Nutzers abhängt. Verschlüsselt gespeichert; nur schreibbar." },
|
},
|
||||||
"youtube_api_proxy": { "label": "YouTube-API-Egress-Proxy", "hint": "Optional. HTTP(S)-Proxy-URL für ausgehende YouTube-API-Aufrufe — über einen Host mit fester IP leiten, damit ein IP-beschränkter Schlüssel von einem Server mit dynamischer IP funktioniert. Leer = direkt." },
|
"smtp_password": {
|
||||||
"google_client_id": { "label": "Google-Client-ID", "hint": "OAuth-2.0-Client-ID für die Google-Anmeldung. Verschlüsselt gespeichert; nur schreibbar. Beide Google-Felder leer lassen, um die Google-Anmeldung zu deaktivieren (E-Mail+Passwort funktioniert weiter)." },
|
"label": "SMTP-Passwort",
|
||||||
"google_client_secret": { "label": "Google-Client-Secret", "hint": "OAuth-2.0-Client-Secret. Verschlüsselt gespeichert; nur schreibbar." },
|
"hint": "App-Passwort. Verschlüsselt gespeichert; nur schreibbar — wird nie wieder angezeigt."
|
||||||
"allow_registration": { "label": "Registrierung erlauben", "hint": "Wenn aktiviert, kann jeder eine E-Mail+Passwort-Registrierung einreichen. Für die Anmeldung sind weiterhin E-Mail-Bestätigung und Admin-Freigabe nötig." }
|
},
|
||||||
|
"quota_daily_budget": {
|
||||||
|
"label": "Tägliches Kontingentbudget",
|
||||||
|
"hint": "YouTube-Data-API-Einheiten pro Tag (kostenloses Limit 10.000). Standard 9000 lässt Puffer."
|
||||||
|
},
|
||||||
|
"backfill_quota_reserve": {
|
||||||
|
"label": "Nachlade-Kontingentreserve",
|
||||||
|
"hint": "Reservierte Einheiten, damit geplantes Nachladen interaktive Syncs / Anreicherung nie aushungert."
|
||||||
|
},
|
||||||
|
"search_daily_limit_per_user": {
|
||||||
|
"label": "Live-Suche — Tageslimit pro Nutzer",
|
||||||
|
"hint": "Maximale Live-YouTube-Suchen pro Nutzer und Tag. Bei der API-Quelle kostet jede Suche 100 Einheiten (das gemeinsame Budget reicht also für insgesamt nur ~80-90/Tag); bei der Scrape-Quelle ist es ein reines Missbrauchslimit. 0 = Live-Suche deaktiviert."
|
||||||
|
},
|
||||||
|
"search_source": {
|
||||||
|
"label": "Quelle der Live-Suche",
|
||||||
|
"hint": "Woher die Ergebnisse der Live-YouTube-Suche stammen. \"scrape\" nutzt YouTubes internen Endpunkt und verbraucht kein API-Kontingent (empfohlen). \"api\" nutzt das offizielle search.list (100 Einheiten/Seite). Auf \"api\" umstellen, falls das Scraping ausfällt oder blockiert wird."
|
||||||
|
},
|
||||||
|
"backfill_recent_max_videos": {
|
||||||
|
"label": "Aktuelles Nachladen — max. Videos",
|
||||||
|
"hint": "Neueste Videos pro Kanal beim ersten Durchlauf."
|
||||||
|
},
|
||||||
|
"backfill_recent_max_days": {
|
||||||
|
"label": "Aktuelles Nachladen — max. Alter (Tage)",
|
||||||
|
"hint": "Wie weit der erste Durchlauf pro Kanal zurückreicht."
|
||||||
|
},
|
||||||
|
"shorts_probe_max_seconds": {
|
||||||
|
"label": "Shorts-Prüfung — max. Dauer (s)",
|
||||||
|
"hint": "Nur Videos bis zu dieser Länge werden als mögliche Shorts geprüft."
|
||||||
|
},
|
||||||
|
"shorts_probe_batch": {
|
||||||
|
"label": "Shorts-Prüfung — Stapelgröße",
|
||||||
|
"hint": "Wie viele Videos pro Lauf klassifiziert werden."
|
||||||
|
},
|
||||||
|
"enrich_batch_size": {
|
||||||
|
"label": "Anreicherungs-Stapelgröße",
|
||||||
|
"hint": "videos.list-IDs pro Aufruf (YouTube begrenzt auf 50)."
|
||||||
|
},
|
||||||
|
"autotag_title_sample": {
|
||||||
|
"label": "Auto-Tag-Titelstichprobe",
|
||||||
|
"hint": "Pro Kanal abgetastete aktuelle Videotitel zur Spracherkennung."
|
||||||
|
},
|
||||||
|
"youtube_api_key": {
|
||||||
|
"label": "YouTube-API-Schlüssel",
|
||||||
|
"hint": "Optional. Für öffentliche Lesezugriffe (Kanäle/Videos), damit gemeinsames Nachladen nicht vom OAuth eines Nutzers abhängt. Verschlüsselt gespeichert; nur schreibbar."
|
||||||
|
},
|
||||||
|
"youtube_api_proxy": {
|
||||||
|
"label": "YouTube-API-Egress-Proxy",
|
||||||
|
"hint": "Optional. HTTP(S)-Proxy-URL für ausgehende YouTube-API-Aufrufe — über einen Host mit fester IP leiten, damit ein IP-beschränkter Schlüssel von einem Server mit dynamischer IP funktioniert. Leer = direkt."
|
||||||
|
},
|
||||||
|
"google_client_id": {
|
||||||
|
"label": "Google-Client-ID",
|
||||||
|
"hint": "OAuth-2.0-Client-ID für die Google-Anmeldung. Verschlüsselt gespeichert; nur schreibbar. Beide Google-Felder leer lassen, um die Google-Anmeldung zu deaktivieren (E-Mail+Passwort funktioniert weiter)."
|
||||||
|
},
|
||||||
|
"google_client_secret": {
|
||||||
|
"label": "Google-Client-Secret",
|
||||||
|
"hint": "OAuth-2.0-Client-Secret. Verschlüsselt gespeichert; nur schreibbar."
|
||||||
|
},
|
||||||
|
"allow_registration": {
|
||||||
|
"label": "Registrierung erlauben",
|
||||||
|
"hint": "Wenn aktiviert, kann jeder eine E-Mail+Passwort-Registrierung einreichen. Für die Anmeldung sind weiterhin E-Mail-Bestätigung und Admin-Freigabe nötig."
|
||||||
|
},
|
||||||
|
"explore_grace_days": {
|
||||||
|
"label": "Erkunden — Karenzzeit (Tage)",
|
||||||
|
"hint": "Wie lange ein erkundeter, nicht abonnierter Kanal (und seine flüchtigen Videos) behalten wird, bevor die Aufräumaufgabe ihn entfernt. Ein Abo behält ihn dauerhaft."
|
||||||
|
},
|
||||||
|
"search_grace_days": {
|
||||||
|
"label": "Suchergebnisse — Karenzzeit (Tage)",
|
||||||
|
"hint": "Wie lange ein nicht behaltenes Live-Suchergebnis-Video (niemand hat es angesehen/gespeichert/abonniert) behalten wird, bevor die Entdeckungs-Aufräumaufgabe es entfernt."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"saving": "Speichern…",
|
"saving": "Speichern…",
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@
|
||||||
"title": "Name",
|
"title": "Name",
|
||||||
"subscribers": "Kanal-Abonnenten",
|
"subscribers": "Kanal-Abonnenten",
|
||||||
"priority": "Kanal-Priorität",
|
"priority": "Kanal-Priorität",
|
||||||
"shuffle": "Überraschung"
|
"shuffle": "Überraschung",
|
||||||
|
"relevance": "Relevanz"
|
||||||
},
|
},
|
||||||
"loadingMore": "Mehr wird geladen…",
|
"loadingMore": "Mehr wird geladen…",
|
||||||
"hiddenNamed": "Ausgeblendet: „{{title}}”",
|
"hiddenNamed": "Ausgeblendet: „{{title}}”",
|
||||||
|
|
@ -32,7 +33,7 @@
|
||||||
"unwatch": "Nicht mehr als angesehen",
|
"unwatch": "Nicht mehr als angesehen",
|
||||||
"source": {
|
"source": {
|
||||||
"label": "Quelle",
|
"label": "Quelle",
|
||||||
"tip": "Bibliothek danach filtern, wie Videos hierher kamen: nur organischer Katalog, mit Suchergebnissen, oder nur über Live-YouTube-Suche gefundene Videos.",
|
"tip": "Danach filtern, wie Videos hierher kamen: nur deine Abos/Katalog, samt deiner über Live-YouTube-Suche gefundenen Videos, oder nur diese Suchergebnisse.",
|
||||||
"organic": "Ohne Suchergebnisse",
|
"organic": "Ohne Suchergebnisse",
|
||||||
"all": "Mit Suchergebnissen",
|
"all": "Mit Suchergebnissen",
|
||||||
"only": "Nur Suchergebnisse"
|
"only": "Nur Suchergebnisse"
|
||||||
|
|
@ -47,6 +48,12 @@
|
||||||
"noResults": "Keine YouTube-Videos gefunden.",
|
"noResults": "Keine YouTube-Videos gefunden.",
|
||||||
"error": "YouTube-Suche fehlgeschlagen.",
|
"error": "YouTube-Suche fehlgeschlagen.",
|
||||||
"loadMore": "Mehr laden (verbraucht Kontingent)",
|
"loadMore": "Mehr laden (verbraucht Kontingent)",
|
||||||
"loadMoreFree": "Mehr laden"
|
"loadMoreFree": "Mehr laden",
|
||||||
|
"show": "Zeigen",
|
||||||
|
"ephemeralNote": "Diese Ergebnisse sind temporär — sie verschwinden automatisch, außer du siehst dir eines an oder speicherst es.",
|
||||||
|
"clearNow": "Jetzt löschen",
|
||||||
|
"cleared_one": "{{count}} Ergebnis gelöscht",
|
||||||
|
"cleared_other": "{{count}} Ergebnisse gelöscht",
|
||||||
|
"showHint": "Wie viele Ergebnisse auf einmal — Mehr laden holt einen weiteren Block dieser Größe."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,6 @@
|
||||||
"channels_subscribe": "Abonnieren",
|
"channels_subscribe": "Abonnieren",
|
||||||
"channels_unsubscribe": "Abo beenden",
|
"channels_unsubscribe": "Abo beenden",
|
||||||
"maintenance_revalidate": "Wartungs-Neuvalidierung",
|
"maintenance_revalidate": "Wartungs-Neuvalidierung",
|
||||||
"other": "Sonstiges"
|
"other": "Sonstiges",
|
||||||
|
"channels_explore": "Kanal erkunden"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,8 @@
|
||||||
"shorts": "Prüft youtube.com/shorts, um Shorts zu markieren. Fällt er aus, werden Shorts im Feed nicht von normalen Videos getrennt.",
|
"shorts": "Prüft youtube.com/shorts, um Shorts zu markieren. Fällt er aus, werden Shorts im Feed nicht von normalen Videos getrennt.",
|
||||||
"subscriptions": "Importiert die YouTube-Abos jedes Nutzers neu. Fällt er aus, werden auf YouTube hinzugefügte oder entfernte Abos hier nicht übernommen.",
|
"subscriptions": "Importiert die YouTube-Abos jedes Nutzers neu. Fällt er aus, werden auf YouTube hinzugefügte oder entfernte Abos hier nicht übernommen.",
|
||||||
"playlist_sync": "Spiegelt die YouTube-Wiedergabelisten jedes Nutzers in die App. Fällt er aus, erscheinen Änderungen an deinen YouTube-Listen lokal nicht.",
|
"playlist_sync": "Spiegelt die YouTube-Wiedergabelisten jedes Nutzers in die App. Fällt er aus, erscheinen Änderungen an deinen YouTube-Listen lokal nicht.",
|
||||||
"maintenance": "Findet Videos, die nicht mehr abspielbar sind (gelöscht, auf privat gesetzt, abgebrochene Premieren), blendet sie aus dem Feed aus und entfernt sie nach einer Schonfrist; prüft die am längsten nicht geprüften Videos erneut. Fällt er aus, bleiben tote Videos im Katalog."
|
"maintenance": "Findet Videos, die nicht mehr abspielbar sind (gelöscht, auf privat gesetzt, abgebrochene Premieren), blendet sie aus dem Feed aus und entfernt sie nach einer Schonfrist; prüft die am längsten nicht geprüften Videos erneut. Fällt er aus, bleiben tote Videos im Katalog.",
|
||||||
|
"explore_cleanup": "Entfernt Kanäle, die du erkundet, aber nie abonniert hast (und ihre Videos), nach einer Karenzzeit, damit Neugier-Stöbern den Katalog nicht überfüllt. Wenn es stoppt, bleiben verlassene erkundete Kanäle bestehen."
|
||||||
},
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"rss_poll": "RSS-Abfrage (neue Uploads)",
|
"rss_poll": "RSS-Abfrage (neue Uploads)",
|
||||||
|
|
@ -77,7 +78,8 @@
|
||||||
"subscriptions": "Abo-Resync",
|
"subscriptions": "Abo-Resync",
|
||||||
"playlist_sync": "YouTube-Wiedergabelisten-Sync",
|
"playlist_sync": "YouTube-Wiedergabelisten-Sync",
|
||||||
"maintenance": "Wartung + Validierung",
|
"maintenance": "Wartung + Validierung",
|
||||||
"demo_reset": "Demo-Konto-Reset"
|
"demo_reset": "Demo-Konto-Reset",
|
||||||
|
"explore_cleanup": "Bereinigung erkundeter Kanäle"
|
||||||
},
|
},
|
||||||
"queue": {
|
"queue": {
|
||||||
"recentPending": "Zu synchronisierende Kanäle",
|
"recentPending": "Zu synchronisierende Kanäle",
|
||||||
|
|
@ -90,5 +92,8 @@
|
||||||
"shortsPendingHint": "Bereits angereicherte Videos, die noch auf die Shorts-Prüfung warten.",
|
"shortsPendingHint": "Bereits angereicherte Videos, die noch auf die Shorts-Prüfung warten.",
|
||||||
"liveRefresh": "Live zu aktualisieren",
|
"liveRefresh": "Live zu aktualisieren",
|
||||||
"liveRefreshHint": "Live-/bevorstehende Videos (und gerade beendete Streams ohne Dauer), bis sie sich stabilisieren."
|
"liveRefreshHint": "Live-/bevorstehende Videos (und gerade beendete Streams ohne Dauer), bis sie sich stabilisieren."
|
||||||
}
|
},
|
||||||
|
"purgeDiscovery": "Entdeckung leeren",
|
||||||
|
"purgeDiscoveryHint": "Alle nicht behaltenen Entdeckungsinhalte jetzt entfernen (von niemandem angesehene/gespeicherte Suchergebnisse und erkundete, nicht abonnierte Kanäle) — ohne Karenzzeit.",
|
||||||
|
"purgedDiscovery": "{{count}} Entdeckungselemente entfernt"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
23
frontend/src/i18n/locales/en/channel.json
Normal file
23
frontend/src/i18n/locales/en/channel.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"back": "Back",
|
||||||
|
"exploringBadge": "Exploring",
|
||||||
|
"subscribe": "Subscribe",
|
||||||
|
"subscribedState": "Subscribed",
|
||||||
|
"unsubscribe": "Unsubscribe",
|
||||||
|
"unsubTitle": "Unsubscribe?",
|
||||||
|
"unsubBody": "Stop following {{name}} on YouTube?",
|
||||||
|
"subscribed": "Subscribed to {{name}}",
|
||||||
|
"subscribers": "{{formatted}} subscribers",
|
||||||
|
"videoCount_one": "{{count}} video",
|
||||||
|
"videoCount_other": "{{count}} videos",
|
||||||
|
"totalViews": "{{formatted}} views",
|
||||||
|
"joined": "Joined {{date}}",
|
||||||
|
"loadingVideos": "Loading this channel's videos…",
|
||||||
|
"tabVideos": "Videos",
|
||||||
|
"tabAbout": "About",
|
||||||
|
"loadMore": "Load more from YouTube",
|
||||||
|
"noDescription": "This channel has no description.",
|
||||||
|
"block": "Block channel",
|
||||||
|
"unblock": "Unblock channel",
|
||||||
|
"blockedBadge": "Blocked"
|
||||||
|
}
|
||||||
|
|
@ -122,5 +122,10 @@
|
||||||
"resetFailed": "Couldn't reset this channel"
|
"resetFailed": "Couldn't reset this channel"
|
||||||
},
|
},
|
||||||
"confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead.",
|
"confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead.",
|
||||||
"confirmReset": "Re-fetch \"{{name}}\" from scratch? This re-runs its backfill (recent + full history) and spends some API quota."
|
"confirmReset": "Re-fetch \"{{name}}\" from scratch? This re-runs its backfill (recent + full history) and spends some API quota.",
|
||||||
|
"searchPlaceholder": "Search channels…",
|
||||||
|
"blocked": {
|
||||||
|
"title": "Blocked channels",
|
||||||
|
"hint": "Their videos are hidden from your feed, Library, and live search."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,5 +15,6 @@
|
||||||
"accessRequestsMessage": "{{count}} pending — review them on the Users page.",
|
"accessRequestsMessage": "{{count}} pending — review them on the Users page.",
|
||||||
"accessRequestsReview": "Review",
|
"accessRequestsReview": "Review",
|
||||||
"demoTitle": "Demo account",
|
"demoTitle": "Demo account",
|
||||||
"demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time."
|
"demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time.",
|
||||||
|
"backToTop": "Back to top"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,29 +9,98 @@
|
||||||
"quota": "Quota",
|
"quota": "Quota",
|
||||||
"backfill": "Backfill",
|
"backfill": "Backfill",
|
||||||
"shorts": "Shorts probe",
|
"shorts": "Shorts probe",
|
||||||
"batch": "Batch sizes"
|
"batch": "Batch sizes",
|
||||||
|
"explore": "Channel explore"
|
||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"smtp_host": { "label": "SMTP host", "hint": "e.g. smtp.gmail.com" },
|
"smtp_host": {
|
||||||
"smtp_port": { "label": "SMTP port", "hint": "Usually 587 (STARTTLS)." },
|
"label": "SMTP host",
|
||||||
"smtp_user": { "label": "SMTP username", "hint": "The sending account / login." },
|
"hint": "e.g. smtp.gmail.com"
|
||||||
"smtp_from": { "label": "From address", "hint": "e.g. Siftlode <addr@gmail.com>. Defaults to the username." },
|
},
|
||||||
"smtp_password": { "label": "SMTP password", "hint": "App password. Stored encrypted; write-only — it's never shown back." },
|
"smtp_port": {
|
||||||
"quota_daily_budget": { "label": "Daily quota budget", "hint": "Total YouTube Data API units to spend per day (free tier is 10,000). Default 9000 leaves headroom." },
|
"label": "SMTP port",
|
||||||
"backfill_quota_reserve": { "label": "Backfill quota reserve", "hint": "Units kept in reserve so scheduled backfill never starves interactive syncs / enrichment." },
|
"hint": "Usually 587 (STARTTLS)."
|
||||||
"search_daily_limit_per_user": { "label": "Live search — daily limit per user", "hint": "Max live YouTube searches per user per day. With the API source each search costs 100 units (so the shared budget affords only ~80-90/day total); with the scrape source it's a pure anti-abuse limit. Set 0 to disable live search." },
|
},
|
||||||
"search_source": { "label": "Live search source", "hint": "Where live YouTube search results come from. \"scrape\" uses YouTube's internal endpoint and spends no API quota (recommended). \"api\" uses the official search.list (100 units/page). Switch to \"api\" if scraping ever breaks or is blocked." },
|
"smtp_user": {
|
||||||
"backfill_recent_max_videos": { "label": "Recent backfill — max videos", "hint": "Newest videos fetched per channel on the first pass." },
|
"label": "SMTP username",
|
||||||
"backfill_recent_max_days": { "label": "Recent backfill — max age (days)", "hint": "How far back the first pass reaches per channel." },
|
"hint": "The sending account / login."
|
||||||
"shorts_probe_max_seconds": { "label": "Shorts probe — max duration (s)", "hint": "Only videos at or below this length are probed as possible Shorts." },
|
},
|
||||||
"shorts_probe_batch": { "label": "Shorts probe — batch size", "hint": "How many videos to classify per run." },
|
"smtp_from": {
|
||||||
"enrich_batch_size": { "label": "Enrichment batch size", "hint": "videos.list ids per call (YouTube caps this at 50)." },
|
"label": "From address",
|
||||||
"autotag_title_sample": { "label": "Auto-tag title sample", "hint": "Recent video titles sampled per channel for language detection." },
|
"hint": "e.g. Siftlode <addr@gmail.com>. Defaults to the username."
|
||||||
"youtube_api_key": { "label": "YouTube API key", "hint": "Optional. Used for public reads (channels/videos) so shared backfill doesn't depend on a user's OAuth. Stored encrypted; write-only." },
|
},
|
||||||
"youtube_api_proxy": { "label": "YouTube API egress proxy", "hint": "Optional. HTTP(S) proxy URL for outbound YouTube API calls — route them through a fixed-IP host so an IP-restricted key keeps working from a dynamic-IP server. Leave empty for direct." },
|
"smtp_password": {
|
||||||
"google_client_id": { "label": "Google client ID", "hint": "OAuth 2.0 client ID for Sign in with Google. Stored encrypted; write-only. Leave both Google fields empty to disable Google sign-in (email+password still works)." },
|
"label": "SMTP password",
|
||||||
"google_client_secret": { "label": "Google client secret", "hint": "OAuth 2.0 client secret. Stored encrypted; write-only." },
|
"hint": "App password. Stored encrypted; write-only — it's never shown back."
|
||||||
"allow_registration": { "label": "Allow registration", "hint": "When on, anyone can submit an email+password registration. They still need to verify their email and be approved by an admin before they can sign in." }
|
},
|
||||||
|
"quota_daily_budget": {
|
||||||
|
"label": "Daily quota budget",
|
||||||
|
"hint": "Total YouTube Data API units to spend per day (free tier is 10,000). Default 9000 leaves headroom."
|
||||||
|
},
|
||||||
|
"backfill_quota_reserve": {
|
||||||
|
"label": "Backfill quota reserve",
|
||||||
|
"hint": "Units kept in reserve so scheduled backfill never starves interactive syncs / enrichment."
|
||||||
|
},
|
||||||
|
"search_daily_limit_per_user": {
|
||||||
|
"label": "Live search — daily limit per user",
|
||||||
|
"hint": "Max live YouTube searches per user per day. With the API source each search costs 100 units (so the shared budget affords only ~80-90/day total); with the scrape source it's a pure anti-abuse limit. Set 0 to disable live search."
|
||||||
|
},
|
||||||
|
"search_source": {
|
||||||
|
"label": "Live search source",
|
||||||
|
"hint": "Where live YouTube search results come from. \"scrape\" uses YouTube's internal endpoint and spends no API quota (recommended). \"api\" uses the official search.list (100 units/page). Switch to \"api\" if scraping ever breaks or is blocked."
|
||||||
|
},
|
||||||
|
"backfill_recent_max_videos": {
|
||||||
|
"label": "Recent backfill — max videos",
|
||||||
|
"hint": "Newest videos fetched per channel on the first pass."
|
||||||
|
},
|
||||||
|
"backfill_recent_max_days": {
|
||||||
|
"label": "Recent backfill — max age (days)",
|
||||||
|
"hint": "How far back the first pass reaches per channel."
|
||||||
|
},
|
||||||
|
"shorts_probe_max_seconds": {
|
||||||
|
"label": "Shorts probe — max duration (s)",
|
||||||
|
"hint": "Only videos at or below this length are probed as possible Shorts."
|
||||||
|
},
|
||||||
|
"shorts_probe_batch": {
|
||||||
|
"label": "Shorts probe — batch size",
|
||||||
|
"hint": "How many videos to classify per run."
|
||||||
|
},
|
||||||
|
"enrich_batch_size": {
|
||||||
|
"label": "Enrichment batch size",
|
||||||
|
"hint": "videos.list ids per call (YouTube caps this at 50)."
|
||||||
|
},
|
||||||
|
"autotag_title_sample": {
|
||||||
|
"label": "Auto-tag title sample",
|
||||||
|
"hint": "Recent video titles sampled per channel for language detection."
|
||||||
|
},
|
||||||
|
"youtube_api_key": {
|
||||||
|
"label": "YouTube API key",
|
||||||
|
"hint": "Optional. Used for public reads (channels/videos) so shared backfill doesn't depend on a user's OAuth. Stored encrypted; write-only."
|
||||||
|
},
|
||||||
|
"youtube_api_proxy": {
|
||||||
|
"label": "YouTube API egress proxy",
|
||||||
|
"hint": "Optional. HTTP(S) proxy URL for outbound YouTube API calls — route them through a fixed-IP host so an IP-restricted key keeps working from a dynamic-IP server. Leave empty for direct."
|
||||||
|
},
|
||||||
|
"google_client_id": {
|
||||||
|
"label": "Google client ID",
|
||||||
|
"hint": "OAuth 2.0 client ID for Sign in with Google. Stored encrypted; write-only. Leave both Google fields empty to disable Google sign-in (email+password still works)."
|
||||||
|
},
|
||||||
|
"google_client_secret": {
|
||||||
|
"label": "Google client secret",
|
||||||
|
"hint": "OAuth 2.0 client secret. Stored encrypted; write-only."
|
||||||
|
},
|
||||||
|
"allow_registration": {
|
||||||
|
"label": "Allow registration",
|
||||||
|
"hint": "When on, anyone can submit an email+password registration. They still need to verify their email and be approved by an admin before they can sign in."
|
||||||
|
},
|
||||||
|
"explore_grace_days": {
|
||||||
|
"label": "Explore — grace period (days)",
|
||||||
|
"hint": "How long an explored-but-unsubscribed channel (and its ephemeral videos) is kept before the cleanup job removes it. Subscribing keeps it permanently."
|
||||||
|
},
|
||||||
|
"search_grace_days": {
|
||||||
|
"label": "Search results — grace period (days)",
|
||||||
|
"hint": "How long an un-kept live-search result video (nobody watched / saved / subscribed to) is kept before the discovery-cleanup job removes it."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@
|
||||||
"title": "Name",
|
"title": "Name",
|
||||||
"subscribers": "Channel subscribers",
|
"subscribers": "Channel subscribers",
|
||||||
"priority": "Channel priority",
|
"priority": "Channel priority",
|
||||||
"shuffle": "Surprise me"
|
"shuffle": "Surprise me",
|
||||||
|
"relevance": "Relevance"
|
||||||
},
|
},
|
||||||
"loadingMore": "Loading more…",
|
"loadingMore": "Loading more…",
|
||||||
"hiddenNamed": "Hidden “{{title}}”",
|
"hiddenNamed": "Hidden “{{title}}”",
|
||||||
|
|
@ -32,7 +33,7 @@
|
||||||
"unwatch": "Unwatch",
|
"unwatch": "Unwatch",
|
||||||
"source": {
|
"source": {
|
||||||
"label": "Source",
|
"label": "Source",
|
||||||
"tip": "Filter the Library by how videos got here: organic catalog only, plus search results, or only videos found via live YouTube search.",
|
"tip": "Filter by how videos got here: your subscriptions/catalog only, plus videos you found via live YouTube search, or only those search results.",
|
||||||
"organic": "Hide search results",
|
"organic": "Hide search results",
|
||||||
"all": "Include search results",
|
"all": "Include search results",
|
||||||
"only": "Search results only"
|
"only": "Search results only"
|
||||||
|
|
@ -47,6 +48,12 @@
|
||||||
"noResults": "No YouTube videos found.",
|
"noResults": "No YouTube videos found.",
|
||||||
"error": "YouTube search failed.",
|
"error": "YouTube search failed.",
|
||||||
"loadMore": "Load more (uses quota)",
|
"loadMore": "Load more (uses quota)",
|
||||||
"loadMoreFree": "Load more"
|
"loadMoreFree": "Load more",
|
||||||
|
"show": "Show",
|
||||||
|
"ephemeralNote": "These results are temporary — they clear automatically unless you watch or save one.",
|
||||||
|
"clearNow": "Clear now",
|
||||||
|
"cleared_one": "Cleared {{count}} result",
|
||||||
|
"cleared_other": "Cleared {{count}} results",
|
||||||
|
"showHint": "How many results to fetch at a time — Load more pulls another batch of this size."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,6 @@
|
||||||
"channels_subscribe": "Subscribe",
|
"channels_subscribe": "Subscribe",
|
||||||
"channels_unsubscribe": "Unsubscribe",
|
"channels_unsubscribe": "Unsubscribe",
|
||||||
"maintenance_revalidate": "Maintenance re-validation",
|
"maintenance_revalidate": "Maintenance re-validation",
|
||||||
"other": "Other"
|
"other": "Other",
|
||||||
|
"channels_explore": "Channel explore"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,8 @@
|
||||||
"shorts": "Probes youtube.com/shorts to mark which videos are Shorts. If it stops, Shorts aren't separated from normal videos in the feed.",
|
"shorts": "Probes youtube.com/shorts to mark which videos are Shorts. If it stops, Shorts aren't separated from normal videos in the feed.",
|
||||||
"subscriptions": "Re-imports every user's YouTube subscriptions. If it stops, channels subscribed to or dropped on YouTube aren't reflected here.",
|
"subscriptions": "Re-imports every user's YouTube subscriptions. If it stops, channels subscribed to or dropped on YouTube aren't reflected here.",
|
||||||
"playlist_sync": "Mirrors each user's YouTube playlists into the app. If it stops, changes to your YouTube playlists don't show up locally.",
|
"playlist_sync": "Mirrors each user's YouTube playlists into the app. If it stops, changes to your YouTube playlists don't show up locally.",
|
||||||
"maintenance": "Finds videos that can no longer be played (deleted, made private, abandoned premieres), hides them from the feed and removes them after a grace period; re-checks the least-recently-checked videos. If it stops, dead videos linger in the catalogue."
|
"maintenance": "Finds videos that can no longer be played (deleted, made private, abandoned premieres), hides them from the feed and removes them after a grace period; re-checks the least-recently-checked videos. If it stops, dead videos linger in the catalogue.",
|
||||||
|
"explore_cleanup": "Removes channels you explored but never subscribed to (and their videos) after a grace period, so curiosity browsing doesn't pile up in the catalogue. If it stops, abandoned explored channels linger."
|
||||||
},
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"rss_poll": "RSS poll (new uploads)",
|
"rss_poll": "RSS poll (new uploads)",
|
||||||
|
|
@ -77,7 +78,8 @@
|
||||||
"subscriptions": "Subscription resync",
|
"subscriptions": "Subscription resync",
|
||||||
"playlist_sync": "YouTube playlist sync",
|
"playlist_sync": "YouTube playlist sync",
|
||||||
"maintenance": "Maintenance + validation",
|
"maintenance": "Maintenance + validation",
|
||||||
"demo_reset": "Demo account reset"
|
"demo_reset": "Demo account reset",
|
||||||
|
"explore_cleanup": "Explored-channel cleanup"
|
||||||
},
|
},
|
||||||
"queue": {
|
"queue": {
|
||||||
"recentPending": "Channels to sync",
|
"recentPending": "Channels to sync",
|
||||||
|
|
@ -90,5 +92,8 @@
|
||||||
"shortsPendingHint": "Enriched videos still awaiting the Shorts probe.",
|
"shortsPendingHint": "Enriched videos still awaiting the Shorts probe.",
|
||||||
"liveRefresh": "Live to refresh",
|
"liveRefresh": "Live to refresh",
|
||||||
"liveRefreshHint": "Live/upcoming videos (and just-ended streams without a duration yet) re-checked until they settle."
|
"liveRefreshHint": "Live/upcoming videos (and just-ended streams without a duration yet) re-checked until they settle."
|
||||||
}
|
},
|
||||||
|
"purgeDiscovery": "Purge discovery",
|
||||||
|
"purgeDiscoveryHint": "Remove all un-kept discovery content now (search results nobody watched/saved, and explored-but-unsubscribed channels) — ignoring the grace period.",
|
||||||
|
"purgedDiscovery": "Removed {{count}} discovery items"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
23
frontend/src/i18n/locales/hu/channel.json
Normal file
23
frontend/src/i18n/locales/hu/channel.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"back": "Vissza",
|
||||||
|
"exploringBadge": "Felfedezés",
|
||||||
|
"subscribe": "Feliratkozás",
|
||||||
|
"subscribedState": "Feliratkozva",
|
||||||
|
"unsubscribe": "Leiratkozás",
|
||||||
|
"unsubTitle": "Leiratkozol?",
|
||||||
|
"unsubBody": "Megszünteted a(z) {{name}} követését a YouTube-on?",
|
||||||
|
"subscribed": "Feliratkoztál: {{name}}",
|
||||||
|
"subscribers": "{{formatted}} feliratkozó",
|
||||||
|
"videoCount_one": "{{count}} videó",
|
||||||
|
"videoCount_other": "{{count}} videó",
|
||||||
|
"totalViews": "{{formatted}} megtekintés",
|
||||||
|
"joined": "Csatlakozott: {{date}}",
|
||||||
|
"loadingVideos": "A csatorna videóinak betöltése…",
|
||||||
|
"tabVideos": "Videók",
|
||||||
|
"tabAbout": "Névjegy",
|
||||||
|
"loadMore": "Több betöltése a YouTube-ról",
|
||||||
|
"noDescription": "Ennek a csatornának nincs leírása.",
|
||||||
|
"block": "Csatorna tiltása",
|
||||||
|
"unblock": "Tiltás feloldása",
|
||||||
|
"blockedBadge": "Tiltva"
|
||||||
|
}
|
||||||
|
|
@ -122,5 +122,10 @@
|
||||||
"resetFailed": "Nem sikerült resetelni a csatornát"
|
"resetFailed": "Nem sikerült resetelni a csatornát"
|
||||||
},
|
},
|
||||||
"confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a hírfolyamodból szeretnéd eltávolítani, inkább rejtsd el.",
|
"confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a hírfolyamodból szeretnéd eltávolítani, inkább rejtsd el.",
|
||||||
"confirmReset": "Újraletöltöd a(z) „{{name}}” csatornát a nulláról? Ez újrafuttatja a backfillt (legutóbbi + teljes előzmény), és némi API-kvótát fogyaszt."
|
"confirmReset": "Újraletöltöd a(z) „{{name}}” csatornát a nulláról? Ez újrafuttatja a backfillt (legutóbbi + teljes előzmény), és némi API-kvótát fogyaszt.",
|
||||||
|
"searchPlaceholder": "Csatornák keresése…",
|
||||||
|
"blocked": {
|
||||||
|
"title": "Tiltott csatornák",
|
||||||
|
"hint": "A videóik el vannak rejtve a feededből, a Library-ből és a YouTube-keresésből."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,5 +15,6 @@
|
||||||
"accessRequestsMessage": "{{count}} függőben — nézd át a Felhasználók oldalon.",
|
"accessRequestsMessage": "{{count}} függőben — nézd át a Felhasználók oldalon.",
|
||||||
"accessRequestsReview": "Áttekintés",
|
"accessRequestsReview": "Áttekintés",
|
||||||
"demoTitle": "Demo fiók",
|
"demoTitle": "Demo fiók",
|
||||||
"demoSharedNotice": "Egy közös demo fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demo látogatók egyszerre módosíthatják."
|
"demoSharedNotice": "Egy közös demo fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demo látogatók egyszerre módosíthatják.",
|
||||||
|
"backToTop": "Vissza a tetejére"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,29 +9,98 @@
|
||||||
"quota": "Kvóta",
|
"quota": "Kvóta",
|
||||||
"backfill": "Letöltés (backfill)",
|
"backfill": "Letöltés (backfill)",
|
||||||
"shorts": "Shorts-vizsgálat",
|
"shorts": "Shorts-vizsgálat",
|
||||||
"batch": "Kötegméretek"
|
"batch": "Kötegméretek",
|
||||||
|
"explore": "Csatorna-felfedezés"
|
||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"smtp_host": { "label": "SMTP-kiszolgáló", "hint": "pl. smtp.gmail.com" },
|
"smtp_host": {
|
||||||
"smtp_port": { "label": "SMTP-port", "hint": "Általában 587 (STARTTLS)." },
|
"label": "SMTP-kiszolgáló",
|
||||||
"smtp_user": { "label": "SMTP-felhasználónév", "hint": "A küldő fiók / bejelentkezés." },
|
"hint": "pl. smtp.gmail.com"
|
||||||
"smtp_from": { "label": "Feladó cím", "hint": "pl. Siftlode <addr@gmail.com>. Alapból a felhasználónév." },
|
},
|
||||||
"smtp_password": { "label": "SMTP-jelszó", "hint": "Alkalmazásjelszó. Titkosítva tárolva; csak írható — soha nem jelenik meg újra." },
|
"smtp_port": {
|
||||||
"quota_daily_budget": { "label": "Napi kvótakeret", "hint": "Naponta elkölthető YouTube Data API-egységek (az ingyenes keret 10 000). Az alap 9000 tartalékot hagy." },
|
"label": "SMTP-port",
|
||||||
"backfill_quota_reserve": { "label": "Letöltési kvótatartalék", "hint": "Tartalékban tartott egységek, hogy az ütemezett letöltés ne éheztesse ki az interaktív szinkront / gazdagítást." },
|
"hint": "Általában 587 (STARTTLS)."
|
||||||
"search_daily_limit_per_user": { "label": "Élő keresés — napi limit / felhasználó", "hint": "Maximális élő YouTube-keresés felhasználónként naponta. Az API-forrásnál egy keresés 100 egységbe kerül (így a közös büdzséből összesen csak ~80-90 fér bele naponta); a scrape-forrásnál ez tiszta visszaélés-elleni limit. 0 = élő keresés kikapcsolva." },
|
},
|
||||||
"search_source": { "label": "Élő keresés forrása", "hint": "Honnan jönnek az élő YouTube-keresés találatai. A „scrape” a YouTube belső végpontját használja és nem fogyaszt API-kvótát (ajánlott). Az „api” a hivatalos search.list-et (100 egység/oldal). Válts „api”-ra, ha a scrape valaha elromlik vagy letiltják." },
|
"smtp_user": {
|
||||||
"backfill_recent_max_videos": { "label": "Friss letöltés — max videó", "hint": "Csatornánként az első körben letöltött legújabb videók száma." },
|
"label": "SMTP-felhasználónév",
|
||||||
"backfill_recent_max_days": { "label": "Friss letöltés — max kor (nap)", "hint": "Az első kör csatornánként meddig nyúl vissza." },
|
"hint": "A küldő fiók / bejelentkezés."
|
||||||
"shorts_probe_max_seconds": { "label": "Shorts-vizsgálat — max hossz (mp)", "hint": "Csak az ennél nem hosszabb videókat vizsgáljuk lehetséges Shortsként." },
|
},
|
||||||
"shorts_probe_batch": { "label": "Shorts-vizsgálat — kötegméret", "hint": "Futásonként ennyi videót osztályozunk." },
|
"smtp_from": {
|
||||||
"enrich_batch_size": { "label": "Gazdagítási kötegméret", "hint": "videos.list azonosítók hívásonként (a YouTube 50-ben maximálja)." },
|
"label": "Feladó cím",
|
||||||
"autotag_title_sample": { "label": "Auto-címke címmintavétel", "hint": "Csatornánként ennyi friss videócímet mintázunk a nyelvfelismeréshez." },
|
"hint": "pl. Siftlode <addr@gmail.com>. Alapból a felhasználónév."
|
||||||
"youtube_api_key": { "label": "YouTube API-kulcs", "hint": "Opcionális. Publikus olvasásokhoz (csatornák/videók), hogy a megosztott letöltés ne függjön egy felhasználó OAuth-jától. Titkosítva tárolva; csak írható." },
|
},
|
||||||
"youtube_api_proxy": { "label": "YouTube API egress proxy", "hint": "Opcionális. HTTP(S) proxy URL a kimenő YouTube API-hívásokhoz — fix IP-jű hoston átengedve egy IP-korlátozott kulcs dinamikus IP-jű szerverről is működik. Üresen = közvetlen." },
|
"smtp_password": {
|
||||||
"google_client_id": { "label": "Google kliens-azonosító", "hint": "OAuth 2.0 kliens-azonosító a Google-bejelentkezéshez. Titkosítva tárolva; csak írható. Hagyd üresen mindkét Google-mezőt a Google-bejelentkezés kikapcsolásához (az e-mail+jelszó továbbra is működik)." },
|
"label": "SMTP-jelszó",
|
||||||
"google_client_secret": { "label": "Google kliens-titok", "hint": "OAuth 2.0 kliens-titok. Titkosítva tárolva; csak írható." },
|
"hint": "Alkalmazásjelszó. Titkosítva tárolva; csak írható — soha nem jelenik meg újra."
|
||||||
"allow_registration": { "label": "Regisztráció engedélyezése", "hint": "Bekapcsolva bárki beküldhet email+jelszó regisztrációt. Belépéshez továbbra is email-igazolás és admin-jóváhagyás szükséges." }
|
},
|
||||||
|
"quota_daily_budget": {
|
||||||
|
"label": "Napi kvótakeret",
|
||||||
|
"hint": "Naponta elkölthető YouTube Data API-egységek (az ingyenes keret 10 000). Az alap 9000 tartalékot hagy."
|
||||||
|
},
|
||||||
|
"backfill_quota_reserve": {
|
||||||
|
"label": "Letöltési kvótatartalék",
|
||||||
|
"hint": "Tartalékban tartott egységek, hogy az ütemezett letöltés ne éheztesse ki az interaktív szinkront / gazdagítást."
|
||||||
|
},
|
||||||
|
"search_daily_limit_per_user": {
|
||||||
|
"label": "Élő keresés — napi limit / felhasználó",
|
||||||
|
"hint": "Maximális élő YouTube-keresés felhasználónként naponta. Az API-forrásnál egy keresés 100 egységbe kerül (így a közös büdzséből összesen csak ~80-90 fér bele naponta); a scrape-forrásnál ez tiszta visszaélés-elleni limit. 0 = élő keresés kikapcsolva."
|
||||||
|
},
|
||||||
|
"search_source": {
|
||||||
|
"label": "Élő keresés forrása",
|
||||||
|
"hint": "Honnan jönnek az élő YouTube-keresés találatai. A „scrape” a YouTube belső végpontját használja és nem fogyaszt API-kvótát (ajánlott). Az „api” a hivatalos search.list-et (100 egység/oldal). Válts „api”-ra, ha a scrape valaha elromlik vagy letiltják."
|
||||||
|
},
|
||||||
|
"backfill_recent_max_videos": {
|
||||||
|
"label": "Friss letöltés — max videó",
|
||||||
|
"hint": "Csatornánként az első körben letöltött legújabb videók száma."
|
||||||
|
},
|
||||||
|
"backfill_recent_max_days": {
|
||||||
|
"label": "Friss letöltés — max kor (nap)",
|
||||||
|
"hint": "Az első kör csatornánként meddig nyúl vissza."
|
||||||
|
},
|
||||||
|
"shorts_probe_max_seconds": {
|
||||||
|
"label": "Shorts-vizsgálat — max hossz (mp)",
|
||||||
|
"hint": "Csak az ennél nem hosszabb videókat vizsgáljuk lehetséges Shortsként."
|
||||||
|
},
|
||||||
|
"shorts_probe_batch": {
|
||||||
|
"label": "Shorts-vizsgálat — kötegméret",
|
||||||
|
"hint": "Futásonként ennyi videót osztályozunk."
|
||||||
|
},
|
||||||
|
"enrich_batch_size": {
|
||||||
|
"label": "Gazdagítási kötegméret",
|
||||||
|
"hint": "videos.list azonosítók hívásonként (a YouTube 50-ben maximálja)."
|
||||||
|
},
|
||||||
|
"autotag_title_sample": {
|
||||||
|
"label": "Auto-címke címmintavétel",
|
||||||
|
"hint": "Csatornánként ennyi friss videócímet mintázunk a nyelvfelismeréshez."
|
||||||
|
},
|
||||||
|
"youtube_api_key": {
|
||||||
|
"label": "YouTube API-kulcs",
|
||||||
|
"hint": "Opcionális. Publikus olvasásokhoz (csatornák/videók), hogy a megosztott letöltés ne függjön egy felhasználó OAuth-jától. Titkosítva tárolva; csak írható."
|
||||||
|
},
|
||||||
|
"youtube_api_proxy": {
|
||||||
|
"label": "YouTube API egress proxy",
|
||||||
|
"hint": "Opcionális. HTTP(S) proxy URL a kimenő YouTube API-hívásokhoz — fix IP-jű hoston átengedve egy IP-korlátozott kulcs dinamikus IP-jű szerverről is működik. Üresen = közvetlen."
|
||||||
|
},
|
||||||
|
"google_client_id": {
|
||||||
|
"label": "Google kliens-azonosító",
|
||||||
|
"hint": "OAuth 2.0 kliens-azonosító a Google-bejelentkezéshez. Titkosítva tárolva; csak írható. Hagyd üresen mindkét Google-mezőt a Google-bejelentkezés kikapcsolásához (az e-mail+jelszó továbbra is működik)."
|
||||||
|
},
|
||||||
|
"google_client_secret": {
|
||||||
|
"label": "Google kliens-titok",
|
||||||
|
"hint": "OAuth 2.0 kliens-titok. Titkosítva tárolva; csak írható."
|
||||||
|
},
|
||||||
|
"allow_registration": {
|
||||||
|
"label": "Regisztráció engedélyezése",
|
||||||
|
"hint": "Bekapcsolva bárki beküldhet email+jelszó regisztrációt. Belépéshez továbbra is email-igazolás és admin-jóváhagyás szükséges."
|
||||||
|
},
|
||||||
|
"explore_grace_days": {
|
||||||
|
"label": "Felfedezés — türelmi idő (nap)",
|
||||||
|
"hint": "Meddig marad meg egy felfedezett, de nem feliratkozott csatorna (és efemer videói), mielőtt a takarító feladat törli. A feliratkozás véglegesen megtartja."
|
||||||
|
},
|
||||||
|
"search_grace_days": {
|
||||||
|
"label": "Keresési találatok — türelmi idő (nap)",
|
||||||
|
"hint": "Meddig marad meg egy meg nem tartott YouTube-keresési találat (amit senki nem nézett meg / mentett el / iratkozott fel rá), mielőtt a felfedezés-takarító feladat törli."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"save": "Mentés",
|
"save": "Mentés",
|
||||||
"saving": "Mentés…",
|
"saving": "Mentés…",
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@
|
||||||
"title": "Név",
|
"title": "Név",
|
||||||
"subscribers": "Csatorna feliratkozók",
|
"subscribers": "Csatorna feliratkozók",
|
||||||
"priority": "Csatorna prioritás",
|
"priority": "Csatorna prioritás",
|
||||||
"shuffle": "Meglepetés"
|
"shuffle": "Meglepetés",
|
||||||
|
"relevance": "Relevancia"
|
||||||
},
|
},
|
||||||
"loadingMore": "Továbbiak betöltése…",
|
"loadingMore": "Továbbiak betöltése…",
|
||||||
"hiddenNamed": "Elrejtve: „{{title}}”",
|
"hiddenNamed": "Elrejtve: „{{title}}”",
|
||||||
|
|
@ -32,7 +33,7 @@
|
||||||
"unwatch": "Megnézés visszavonása",
|
"unwatch": "Megnézés visszavonása",
|
||||||
"source": {
|
"source": {
|
||||||
"label": "Forrás",
|
"label": "Forrás",
|
||||||
"tip": "Szűrd a könyvtárat aszerint, hogyan kerültek be a videók: csak organikus katalógus, kereséssel együtt, vagy csak az élő YouTube-keresésből talált videók.",
|
"tip": "Szűrés aszerint, hogyan kerültek be a videók: csak a feliratkozásaid/katalógus, az élő YouTube-keresésből talált videóiddal együtt, vagy csak azok a keresési találatok.",
|
||||||
"organic": "Keresés nélkül",
|
"organic": "Keresés nélkül",
|
||||||
"all": "Kereséssel együtt",
|
"all": "Kereséssel együtt",
|
||||||
"only": "Csak keresésből"
|
"only": "Csak keresésből"
|
||||||
|
|
@ -47,6 +48,12 @@
|
||||||
"noResults": "Nincs YouTube-találat.",
|
"noResults": "Nincs YouTube-találat.",
|
||||||
"error": "A YouTube-keresés nem sikerült.",
|
"error": "A YouTube-keresés nem sikerült.",
|
||||||
"loadMore": "Továbbiak betöltése (kvótát fogyaszt)",
|
"loadMore": "Továbbiak betöltése (kvótát fogyaszt)",
|
||||||
"loadMoreFree": "Továbbiak betöltése"
|
"loadMoreFree": "Továbbiak betöltése",
|
||||||
|
"show": "Mutat",
|
||||||
|
"ephemeralNote": "Ezek a találatok ideiglenesek — maguktól eltűnnek, hacsak meg nem nézel vagy el nem mentesz egyet.",
|
||||||
|
"clearNow": "Törlés most",
|
||||||
|
"cleared_one": "{{count}} találat törölve",
|
||||||
|
"cleared_other": "{{count}} találat törölve",
|
||||||
|
"showHint": "Hány találatot szerezzen egyszerre — a Több betöltése ennyivel tölt tovább."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,6 @@
|
||||||
"channels_subscribe": "Feliratkozás",
|
"channels_subscribe": "Feliratkozás",
|
||||||
"channels_unsubscribe": "Leiratkozás",
|
"channels_unsubscribe": "Leiratkozás",
|
||||||
"maintenance_revalidate": "Karbantartó újraellenőrzés",
|
"maintenance_revalidate": "Karbantartó újraellenőrzés",
|
||||||
"other": "Egyéb"
|
"other": "Egyéb",
|
||||||
|
"channels_explore": "Csatorna-felfedezés"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,8 @@
|
||||||
"shorts": "A youtube.com/shorts próbával jelöli, mely videók Shorts-ok. Ha leáll, a Shorts-ok nem különülnek el a normál videóktól a hírfolyamban.",
|
"shorts": "A youtube.com/shorts próbával jelöli, mely videók Shorts-ok. Ha leáll, a Shorts-ok nem különülnek el a normál videóktól a hírfolyamban.",
|
||||||
"subscriptions": "Újraimportálja minden felhasználó YouTube-feliratkozásait. Ha leáll, a YouTube-on felvett vagy törölt feliratkozások nem tükröződnek itt.",
|
"subscriptions": "Újraimportálja minden felhasználó YouTube-feliratkozásait. Ha leáll, a YouTube-on felvett vagy törölt feliratkozások nem tükröződnek itt.",
|
||||||
"playlist_sync": "Tükrözi a felhasználók YouTube lejátszási listáit az appba. Ha leáll, a YouTube-listák változásai nem jelennek meg lokálisan.",
|
"playlist_sync": "Tükrözi a felhasználók YouTube lejátszási listáit az appba. Ha leáll, a YouTube-listák változásai nem jelennek meg lokálisan.",
|
||||||
"maintenance": "Megkeresi a már sehol le nem játszható videókat (törölt, priváttá tett, elhagyott premier), elrejti őket a hírfolyamból, és türelmi idő után eltávolítja; újraellenőrzi a legrégebben ellenőrzött videókat. Ha leáll, a halott videók a katalógusban maradnak."
|
"maintenance": "Megkeresi a már sehol le nem játszható videókat (törölt, priváttá tett, elhagyott premier), elrejti őket a hírfolyamból, és türelmi idő után eltávolítja; újraellenőrzi a legrégebben ellenőrzött videókat. Ha leáll, a halott videók a katalógusban maradnak.",
|
||||||
|
"explore_cleanup": "A türelmi idő után eltávolítja a felfedezett, de nem követett csatornákat (és videóikat), hogy a kíváncsiság-böngészés ne halmozódjon a katalógusban. Ha leáll, az elhagyott felfedezett csatornák megmaradnak."
|
||||||
},
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"rss_poll": "RSS-lekérdezés (új feltöltések)",
|
"rss_poll": "RSS-lekérdezés (új feltöltések)",
|
||||||
|
|
@ -77,7 +78,8 @@
|
||||||
"subscriptions": "Feliratkozások újraszinkronja",
|
"subscriptions": "Feliratkozások újraszinkronja",
|
||||||
"playlist_sync": "YouTube lejátszási listák szinkronja",
|
"playlist_sync": "YouTube lejátszási listák szinkronja",
|
||||||
"maintenance": "Karbantartás + ellenőrzés",
|
"maintenance": "Karbantartás + ellenőrzés",
|
||||||
"demo_reset": "Demo fiók visszaállítása"
|
"demo_reset": "Demo fiók visszaállítása",
|
||||||
|
"explore_cleanup": "Felfedezett csatornák takarítása"
|
||||||
},
|
},
|
||||||
"queue": {
|
"queue": {
|
||||||
"recentPending": "Szinkronra váró csatorna",
|
"recentPending": "Szinkronra váró csatorna",
|
||||||
|
|
@ -90,5 +92,8 @@
|
||||||
"shortsPendingHint": "Már dúsított videók, amelyek még a Shorts-próbára várnak.",
|
"shortsPendingHint": "Már dúsított videók, amelyek még a Shorts-próbára várnak.",
|
||||||
"liveRefresh": "Frissítendő élő",
|
"liveRefresh": "Frissítendő élő",
|
||||||
"liveRefreshHint": "Élő/közelgő videók (és a frissen véget ért, hossz nélküli adások), amíg le nem ülepednek."
|
"liveRefreshHint": "Élő/közelgő videók (és a frissen véget ért, hossz nélküli adások), amíg le nem ülepednek."
|
||||||
}
|
},
|
||||||
|
"purgeDiscovery": "Felfedezés ürítése",
|
||||||
|
"purgeDiscoveryHint": "Most eltávolítja az összes meg nem tartott felfedezés-tartalmat (senki által meg nem nézett/mentett keresési találatok és felfedezett, de nem követett csatornák) — a türelmi időt figyelmen kívül hagyva.",
|
||||||
|
"purgedDiscovery": "{{count}} felfedezés-elem eltávolítva"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,38 @@ export interface VideoDetail {
|
||||||
duration_seconds: number | null;
|
duration_seconds: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChannelLink {
|
||||||
|
title: string | null;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelDetail {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
handle: string | null;
|
||||||
|
description: string | null;
|
||||||
|
thumbnail_url: string | null;
|
||||||
|
banner_url: string | null;
|
||||||
|
subscriber_count: number | null;
|
||||||
|
video_count: number | null;
|
||||||
|
total_view_count: number | null;
|
||||||
|
published_at: string | null;
|
||||||
|
external_links: ChannelLink[];
|
||||||
|
country: string | null;
|
||||||
|
subscribed: boolean;
|
||||||
|
explored: boolean;
|
||||||
|
blocked: boolean;
|
||||||
|
stored_videos: number;
|
||||||
|
from_explore: boolean;
|
||||||
|
details_synced: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExploreResult {
|
||||||
|
channel_id: string;
|
||||||
|
ingested: number;
|
||||||
|
next_page_token: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FeedResponse {
|
export interface FeedResponse {
|
||||||
items: Video[];
|
items: Video[];
|
||||||
has_more: boolean;
|
has_more: boolean;
|
||||||
|
|
@ -570,11 +602,16 @@ export const api = {
|
||||||
// `quiet` so the YT-search panel can render quota/limit errors (incl. 429) inline instead of
|
// `quiet` so the YT-search panel can render quota/limit errors (incl. 429) inline instead of
|
||||||
// popping the global modal. `cursor` is the next-page token (an InnerTube continuation token
|
// popping the global modal. `cursor` is the next-page token (an InnerTube continuation token
|
||||||
// in scrape mode, or a Data API pageToken in api mode); the response's `source` says which.
|
// in scrape mode, or a Data API pageToken in api mode); the response's `source` says which.
|
||||||
searchYoutube: (q: string, cursor: string | null): Promise<FeedResponse> => {
|
searchYoutube: (q: string, cursor: string | null, limit?: number): Promise<FeedResponse> => {
|
||||||
const p = new URLSearchParams({ q });
|
const p = new URLSearchParams({ q });
|
||||||
if (cursor) p.set("cursor", cursor);
|
if (cursor) p.set("cursor", cursor);
|
||||||
|
if (limit) p.set("limit", String(limit));
|
||||||
return req(`/api/search/youtube?${p.toString()}`, {}, { quiet: true });
|
return req(`/api/search/youtube?${p.toString()}`, {}, { quiet: true });
|
||||||
},
|
},
|
||||||
|
// Discard a set of live-search results "as if never added" (drops your search-finds + deletes
|
||||||
|
// the now-orphaned, un-kept videos). Returns how many videos were removed.
|
||||||
|
clearSearch: (videoIds: string[]): Promise<{ removed: number }> =>
|
||||||
|
req("/api/search/clear", { method: "POST", body: JSON.stringify({ video_ids: videoIds }) }),
|
||||||
facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> =>
|
facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> =>
|
||||||
req(`/api/facets?${filterParams(f).toString()}`),
|
req(`/api/facets?${filterParams(f).toString()}`),
|
||||||
// idempotent: setting a state / saving a progress checkpoint is safe to replay, so these
|
// idempotent: setting a state / saving a progress checkpoint is safe to replay, so these
|
||||||
|
|
@ -628,6 +665,29 @@ export const api = {
|
||||||
subscribeChannel: (id: string) =>
|
subscribeChannel: (id: string) =>
|
||||||
req(`/api/channels/${id}/subscribe`, { method: "POST" }),
|
req(`/api/channels/${id}/subscribe`, { method: "POST" }),
|
||||||
syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }),
|
syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }),
|
||||||
|
// --- channel page (About detail + ephemeral exploration of un-subscribed channels) ---
|
||||||
|
channelDetail: (id: string): Promise<ChannelDetail> => req(`/api/channels/${id}`),
|
||||||
|
// Open an un-subscribed channel for browsing: ingest a page of its uploads (newest-first,
|
||||||
|
// marked via_explore) + record the exploration. `pageToken` continues into older uploads
|
||||||
|
// ("load more"). `quiet` so a quota-reserve 429 / load error renders inline on the page.
|
||||||
|
exploreChannel: (id: string, pageToken?: string | null): Promise<ExploreResult> => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (pageToken) p.set("page_token", pageToken);
|
||||||
|
const qs = p.toString();
|
||||||
|
return req(
|
||||||
|
`/api/channels/${id}/explore${qs ? `?${qs}` : ""}`,
|
||||||
|
{ method: "POST" },
|
||||||
|
{ quiet: true }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
// --- per-user channel blocklist (excluded from live search + feed/explore) ---
|
||||||
|
blockedChannels: (): Promise<{ id: string; title: string | null; handle: string | null; thumbnail_url: string | null }[]> =>
|
||||||
|
req("/api/channels/blocked/list"),
|
||||||
|
blockChannel: (id: string) => req(`/api/channels/${id}/block`, { method: "POST" }),
|
||||||
|
unblockChannel: (id: string) => req(`/api/channels/${id}/block`, { method: "DELETE" }),
|
||||||
|
// Admin: reclaim all un-kept discovery content (search results + explored channels) now.
|
||||||
|
purgeDiscovery: (): Promise<Record<string, number>> =>
|
||||||
|
req("/api/admin/purge-discovery", { method: "POST" }),
|
||||||
|
|
||||||
// --- playlists ---
|
// --- playlists ---
|
||||||
playlists: (containsVideoId?: string): Promise<Playlist[]> =>
|
playlists: (containsVideoId?: string): Promise<Playlist[]> =>
|
||||||
|
|
|
||||||
|
|
@ -66,9 +66,17 @@ export function useBackToClose(onClose: () => void): void {
|
||||||
const idx = overlayStack.lastIndexOf(token);
|
const idx = overlayStack.lastIndexOf(token);
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
// Closed programmatically (not via Back): drop our own history entry, and tell the
|
// Closed programmatically (not via Back): drop our own history entry, and tell the
|
||||||
// remaining overlays' handlers to ignore the resulting popstate.
|
// remaining overlays' handlers to ignore the resulting popstate. A one-shot listener
|
||||||
|
// clears the flag on that very popstate even when NO overlay remains underneath to
|
||||||
|
// consume it — otherwise suppressPop leaks `true` and silently swallows the NEXT
|
||||||
|
// overlay's first Back (reopen-after-button-close → first Back does nothing).
|
||||||
overlayStack.splice(idx, 1);
|
overlayStack.splice(idx, 1);
|
||||||
suppressPop = true;
|
suppressPop = true;
|
||||||
|
const clearSuppress = () => {
|
||||||
|
suppressPop = false;
|
||||||
|
window.removeEventListener("popstate", clearSuppress);
|
||||||
|
};
|
||||||
|
window.addEventListener("popstate", clearSuppress);
|
||||||
window.history.back();
|
window.history.back();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,23 @@ export interface ReleaseEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.19.0",
|
||||||
|
date: "2026-07-01",
|
||||||
|
summary: "Channel pages, much smarter search, and self-tidying YouTube search results.",
|
||||||
|
features: [
|
||||||
|
"Channel pages: open any channel's own page inside Siftlode — banner, stats (subscribers, videos, views, joined) and its uploads, with Videos and About tabs. Click a channel name anywhere to open it.",
|
||||||
|
"Explore channels you don't follow: opening an un-subscribed channel loads its recent uploads just for you — private until you subscribe. Channels you explore but don't keep tidy themselves away automatically after a while.",
|
||||||
|
"Smarter search: results are now relevance-ranked and match far more than the title — a video's tags, its description, and even the searches that first surfaced it all count, so you can find things by meaning instead of exact words. In your own feed (Mine), a Source filter separates your subscriptions from your search finds.",
|
||||||
|
"Temporary search results: live YouTube search results are clearly marked temporary — they clear themselves unless you watch or save one, with a “Clear now” button to do it at once. Pick how many to fetch (20/40/60/100), and videos you already have are sorted below fresh discoveries.",
|
||||||
|
"Block channels: hide a channel everywhere — from search, your feed and channel pages — and manage your blocked channels from the Channel manager.",
|
||||||
|
"The Channel manager gets a search box and clickable tag chips, and a floating “back to top” button appears on long pages.",
|
||||||
|
],
|
||||||
|
fixes: [
|
||||||
|
"The Back button no longer does nothing the first time: after you closed the video player (or a dialog) with its ✕/Escape and opened another, the next Back press now closes it right away — everywhere in the app.",
|
||||||
|
"Reloading the page now keeps your YouTube search results on screen (they re-fetch for free) instead of dropping you back to the feed.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.18.0",
|
version: "0.18.0",
|
||||||
date: "2026-06-29",
|
date: "2026-06-29",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue