Merge: Mine search finds, relevance search, and a weighted search document
feature/mine-search-finds-and-fts: - per-user search finds surface in the Mine feed + Source filter there - full-text relevance ranking (new Relevance sort, auto-selected when searching) - search broadened to a weighted document (title + creator keywords + the queries that surfaced a video + description), DB-generated search_vector.
This commit is contained in:
commit
bb7b9972fd
12 changed files with 360 additions and 39 deletions
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, '')))"
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -237,6 +239,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
|
||||||
)
|
)
|
||||||
|
|
@ -272,6 +282,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 +369,25 @@ 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 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."""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -17,6 +18,7 @@ from app.models import (
|
||||||
ChannelTag,
|
ChannelTag,
|
||||||
Playlist,
|
Playlist,
|
||||||
PlaylistItem,
|
PlaylistItem,
|
||||||
|
SearchFind,
|
||||||
Subscription,
|
Subscription,
|
||||||
Tag,
|
Tag,
|
||||||
User,
|
User,
|
||||||
|
|
@ -153,14 +155,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 +180,25 @@ 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
|
# Source filter. In the shared Library ("all" scope) it uses the GLOBAL via_search flag
|
||||||
# default ("organic"), can be mixed in ("all"), or shown exclusively ("search"). Only
|
# (search-discovered by anyone). In "my" scope it's the user's OWN provenance:
|
||||||
# meaningful in "all" scope: "my" already restricts to subscribed channels, where a
|
# "organic" = your subscriptions, "search" = videos you found via your own search,
|
||||||
# once-searched video is legitimately the user's.
|
# "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 +221,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 +308,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 +369,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 +390,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 +465,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 +495,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}
|
||||||
|
|
||||||
|
|
@ -467,7 +523,7 @@ def get_facets(
|
||||||
# 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,14 @@ 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_, func, 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 Channel, SearchFind, 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
|
||||||
|
|
@ -213,6 +214,31 @@ def search_youtube(
|
||||||
v.id for v in videos if not v.is_short and v.live_status not in _LIVE_HIDDEN
|
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]
|
ordered = [vid for vid in ids if vid in keep]
|
||||||
|
|
||||||
|
# 6) Record these as THIS user's search finds so they surface in their own Mine feed
|
||||||
|
# (and the Mine Source filter), independent of the global via_search flag. Idempotent.
|
||||||
|
if ordered:
|
||||||
|
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"])
|
||||||
|
)
|
||||||
|
# 7) Fold the query into each result's search_terms (shared across users) so local
|
||||||
|
# full-text search inherits YouTube's relevance — a video YT returned for this query
|
||||||
|
# becomes findable by it even when its title doesn't contain the words. Skip rows
|
||||||
|
# that already include the term; the generated search_vector updates automatically.
|
||||||
|
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()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"items": _serialize_ids(db, user, ordered),
|
"items": _serialize_ids(db, user, ordered),
|
||||||
"next_cursor": page["next_page_token"],
|
"next_cursor": page["next_page_token"],
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -134,6 +137,18 @@ export default function Feed({
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
@ -296,6 +311,9 @@ export default function Feed({
|
||||||
// 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"] });
|
||||||
|
|
@ -445,7 +463,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">
|
||||||
|
|
@ -484,7 +502,9 @@ export default function Feed({
|
||||||
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 +513,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", ...SORT_KEYS] as SortKey[]) : SORT_KEYS).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") })
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue