Merge: promote dev to prod
This commit is contained in:
commit
9525372962
20 changed files with 185 additions and 44 deletions
|
|
@ -66,15 +66,21 @@ def _filtered_query(
|
||||||
include_shorts: bool,
|
include_shorts: bool,
|
||||||
include_live: bool,
|
include_live: bool,
|
||||||
show: str,
|
show: str,
|
||||||
|
scope: str = "my",
|
||||||
) -> tuple[Select, object]:
|
) -> tuple[Select, object]:
|
||||||
"""Build the feed query (joins + all WHERE filters), shared by /feed and /feed/count.
|
"""Build the feed query (joins + all WHERE filters), shared by /feed and /feed/count.
|
||||||
Returns the column-bearing select plus the watch-status expression for sorting."""
|
Returns the column-bearing select plus the watch-status expression for sorting.
|
||||||
|
|
||||||
|
`scope="my"` (default) restricts the feed to the user's own non-hidden subscriptions.
|
||||||
|
`scope="all"` shows every video in the shared catalog (any user's ingested channels);
|
||||||
|
the subscription is then only LEFT-joined so per-channel priority still resolves for
|
||||||
|
channels the user happens to be subscribed to. Per-user watch state stays private in
|
||||||
|
either mode via the VideoState outer join."""
|
||||||
state = aliased(VideoState)
|
state = aliased(VideoState)
|
||||||
status_expr = func.coalesce(state.status, "new")
|
status_expr = func.coalesce(state.status, "new")
|
||||||
position_expr = func.coalesce(state.position_seconds, 0)
|
position_expr = func.coalesce(state.position_seconds, 0)
|
||||||
|
|
||||||
query = (
|
query = select(
|
||||||
select(
|
|
||||||
Video.id,
|
Video.id,
|
||||||
Video.title,
|
Video.title,
|
||||||
Video.channel_id,
|
Video.channel_id,
|
||||||
|
|
@ -89,10 +95,20 @@ def _filtered_query(
|
||||||
Video.live_status,
|
Video.live_status,
|
||||||
status_expr.label("status"),
|
status_expr.label("status"),
|
||||||
position_expr.label("position_seconds"),
|
position_expr.label("position_seconds"),
|
||||||
|
).join(Channel, Channel.id == Video.channel_id)
|
||||||
|
|
||||||
|
if scope == "all":
|
||||||
|
# Whole shared catalog; subscription is optional (only for priority sort).
|
||||||
|
query = query.outerjoin(
|
||||||
|
Subscription,
|
||||||
|
and_(
|
||||||
|
Subscription.channel_id == Video.channel_id,
|
||||||
|
Subscription.user_id == user.id,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.join(Channel, Channel.id == Video.channel_id)
|
else:
|
||||||
# Only channels this user is subscribed to (and hasn't hidden).
|
# Only channels this user is subscribed to (and hasn't hidden).
|
||||||
.join(
|
query = query.join(
|
||||||
Subscription,
|
Subscription,
|
||||||
and_(
|
and_(
|
||||||
Subscription.channel_id == Video.channel_id,
|
Subscription.channel_id == Video.channel_id,
|
||||||
|
|
@ -100,7 +116,9 @@ def _filtered_query(
|
||||||
Subscription.hidden.is_(False),
|
Subscription.hidden.is_(False),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id))
|
|
||||||
|
query = query.outerjoin(
|
||||||
|
state, and_(state.video_id == Video.id, state.user_id == user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
if channel_id:
|
if channel_id:
|
||||||
|
|
@ -199,6 +217,7 @@ def _feed_params(
|
||||||
include_shorts: bool = False,
|
include_shorts: bool = False,
|
||||||
include_live: bool = False,
|
include_live: bool = False,
|
||||||
show: str = "unwatched",
|
show: str = "unwatched",
|
||||||
|
scope: str = "my",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
return {
|
return {
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
|
|
@ -214,6 +233,7 @@ def _feed_params(
|
||||||
"include_shorts": include_shorts,
|
"include_shorts": include_shorts,
|
||||||
"include_live": include_live,
|
"include_live": include_live,
|
||||||
"show": show,
|
"show": show,
|
||||||
|
"scope": scope,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -226,7 +246,8 @@ SORTS = {
|
||||||
"title": func.lower(Video.title).asc().nulls_last(),
|
"title": func.lower(Video.title).asc().nulls_last(),
|
||||||
"subscribers": Channel.subscriber_count.desc().nulls_last(),
|
"subscribers": Channel.subscriber_count.desc().nulls_last(),
|
||||||
# Your per-channel priority (set in the channel manager), newest first within a tier.
|
# Your per-channel priority (set in the channel manager), newest first within a tier.
|
||||||
"priority": Subscription.priority.desc(),
|
# coalesce keeps it null-safe in "all" scope where unsubscribed channels have no row.
|
||||||
|
"priority": func.coalesce(Subscription.priority, 0).desc(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -243,7 +264,8 @@ def get_feed(
|
||||||
query, _status = _filtered_query(db, user, **params)
|
query, _status = _filtered_query(db, user, **params)
|
||||||
if sort == "priority":
|
if sort == "priority":
|
||||||
query = query.order_by(
|
query = query.order_by(
|
||||||
Subscription.priority.desc(), Video.published_at.desc().nulls_last()
|
func.coalesce(Subscription.priority, 0).desc(),
|
||||||
|
Video.published_at.desc().nulls_last(),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
order = SORTS.get(sort)
|
order = SORTS.get(sort)
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ const DEFAULT_FILTERS: FeedFilters = {
|
||||||
tagMode: "or",
|
tagMode: "or",
|
||||||
q: "",
|
q: "",
|
||||||
sort: "newest",
|
sort: "newest",
|
||||||
|
scope: "my",
|
||||||
includeNormal: true,
|
includeNormal: true,
|
||||||
includeShorts: false,
|
includeShorts: false,
|
||||||
includeLive: false,
|
includeLive: false,
|
||||||
|
|
|
||||||
|
|
@ -444,7 +444,7 @@ function ChannelRow({
|
||||||
<Tooltip hint={t("channels.row.queuedByOtherHint")}>
|
<Tooltip hint={t("channels.row.queuedByOtherHint")}>
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full border border-accent/40 text-accent">
|
<span className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full border border-accent/40 text-accent">
|
||||||
<History className="w-3 h-3" />
|
<History className="w-3 h-3" />
|
||||||
{t("channels.row.fullHistoryQueued")}
|
{t("channels.row.fullHistoryComing")}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,9 @@ export default function Feed({
|
||||||
if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>;
|
if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>;
|
||||||
if (query.isError) return <div className="p-8 text-muted">{t("feed.loadError")}</div>;
|
if (query.isError) return <div className="p-8 text-muted">{t("feed.loadError")}</div>;
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
if (!canRead)
|
// No YouTube connection and looking at their own (empty) subscriptions: offer both
|
||||||
|
// connecting YouTube and just browsing the shared library (no read scope needed).
|
||||||
|
if (!canRead && filters.scope === "my")
|
||||||
return (
|
return (
|
||||||
<div className="p-8 grid place-items-center text-center">
|
<div className="p-8 grid place-items-center text-center">
|
||||||
<div className="max-w-sm">
|
<div className="max-w-sm">
|
||||||
|
|
@ -160,6 +162,12 @@ export default function Feed({
|
||||||
>
|
>
|
||||||
{t("feed.setUp")}
|
{t("feed.setUp")}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters({ ...filters, scope: "all" })}
|
||||||
|
className="block mx-auto mt-3 text-sm text-muted hover:text-accent transition"
|
||||||
|
>
|
||||||
|
{t("feed.browseShared")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { BarChart3, Home, Info, LogOut, Search, Settings, Shield, Tv } from "lucide-react";
|
import { BarChart3, Home, Info, Library, LogOut, Search, Settings, Shield, Tv, User } from "lucide-react";
|
||||||
import type { FeedFilters, Me } from "../lib/api";
|
import type { FeedFilters, Me } from "../lib/api";
|
||||||
import type { Page } from "../lib/urlState";
|
import type { Page } from "../lib/urlState";
|
||||||
import { type LangCode } from "../i18n";
|
import { type LangCode } from "../i18n";
|
||||||
|
|
@ -49,6 +49,35 @@ export default function Header({
|
||||||
|
|
||||||
<SyncStatus isAdmin={me.role === "admin"} onGoToFullHistory={onGoToFullHistory} />
|
<SyncStatus isAdmin={me.role === "admin"} onGoToFullHistory={onGoToFullHistory} />
|
||||||
|
|
||||||
|
{page === "feed" && (
|
||||||
|
<div
|
||||||
|
className="flex items-center rounded-full border border-border bg-card p-0.5 text-xs shrink-0"
|
||||||
|
role="group"
|
||||||
|
aria-label={t("header.scope.label")}
|
||||||
|
>
|
||||||
|
{(["my", "all"] as const).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setFilters({ ...filters, scope: s })}
|
||||||
|
title={t("header.scope." + s + "Tip")}
|
||||||
|
aria-pressed={filters.scope === s}
|
||||||
|
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full transition ${
|
||||||
|
filters.scope === s
|
||||||
|
? "bg-accent text-accent-fg"
|
||||||
|
: "text-muted hover:text-fg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s === "my" ? (
|
||||||
|
<User className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<Library className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
<span className="hidden sm:inline">{t("header.scope." + s)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{page === "feed" ? (
|
{page === "feed" ? (
|
||||||
<div className="flex-1 max-w-xl mx-auto relative">
|
<div className="flex-1 max-w-xl mx-auto relative">
|
||||||
<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" />
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
EyeOff,
|
EyeOff,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
RefreshCw,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
@ -48,6 +49,9 @@ const SORT_IDS = [
|
||||||
|
|
||||||
const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "saved", "hidden"];
|
const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "saved", "hidden"];
|
||||||
|
|
||||||
|
// Fresh shuffle token for the "surprise me" sort.
|
||||||
|
const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);
|
||||||
|
|
||||||
const DATE_PRESETS: { days: number; key: string }[] = [
|
const DATE_PRESETS: { days: number; key: string }[] = [
|
||||||
{ days: 1, key: "24h" },
|
{ days: 1, key: "24h" },
|
||||||
{ days: 7, key: "1week" },
|
{ days: 7, key: "1week" },
|
||||||
|
|
@ -56,8 +60,9 @@ const DATE_PRESETS: { days: number; key: string }[] = [
|
||||||
{ days: 365, key: "1year" },
|
{ days: 365, key: "1year" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Filter values owned by the sidebar (everything except the header search `q`).
|
// Filter values owned by the sidebar (everything except the header search `q` and the
|
||||||
const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q"> = {
|
// `scope` mode, both preserved across a "Clear all").
|
||||||
|
const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
|
||||||
tags: [],
|
tags: [],
|
||||||
tagMode: "or",
|
tagMode: "or",
|
||||||
sort: "newest",
|
sort: "newest",
|
||||||
|
|
@ -81,13 +86,20 @@ function TagChip({
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
title={t("sidebar.channelCount", { count: tag.channel_count })}
|
title={t("sidebar.channelCount", { count: tag.channel_count })}
|
||||||
className={`text-xs px-2.5 py-1 rounded-full border shadow-sm hover:shadow active:translate-y-px transition ${
|
className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border shadow-sm hover:shadow active:translate-y-px transition ${
|
||||||
active
|
active
|
||||||
? "bg-accent text-accent-fg border-accent"
|
? "bg-accent text-accent-fg border-accent"
|
||||||
: "bg-card border-border text-fg hover:border-accent"
|
: "bg-card border-border text-fg hover:border-accent"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tag.name}
|
<span>{tag.name}</span>
|
||||||
|
<span
|
||||||
|
className={`tabular-nums text-[10px] leading-none px-1 py-0.5 rounded-full ${
|
||||||
|
active ? "bg-accent-fg/20 text-accent-fg" : "bg-muted/15 text-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tag.channel_count}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -159,7 +171,7 @@ export default function Sidebar({
|
||||||
|
|
||||||
function clearAll() {
|
function clearAll() {
|
||||||
setCustomDates(false);
|
setCustomDates(false);
|
||||||
setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q });
|
setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q, scope: filters.scope });
|
||||||
}
|
}
|
||||||
|
|
||||||
const available: Record<WidgetId, boolean> = {
|
const available: Record<WidgetId, boolean> = {
|
||||||
|
|
@ -208,9 +220,19 @@ export default function Sidebar({
|
||||||
);
|
);
|
||||||
case "sort":
|
case "sort":
|
||||||
return (
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<select
|
<select
|
||||||
value={filters.sort}
|
value={filters.sort}
|
||||||
onChange={(e) => setFilters({ ...filters, sort: e.target.value })}
|
onChange={(e) => {
|
||||||
|
const sort = e.target.value;
|
||||||
|
// Seed shuffle on selection so it lands on a fresh order, not the
|
||||||
|
// deterministic seed-0 one every time.
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
sort,
|
||||||
|
seed: sort === "shuffle" ? rollSeed() : undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
className="w-full bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
|
className="w-full bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
|
||||||
>
|
>
|
||||||
{SORT_IDS.map((id) => (
|
{SORT_IDS.map((id) => (
|
||||||
|
|
@ -219,6 +241,17 @@ export default function Sidebar({
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{filters.sort === "shuffle" && (
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
|
||||||
|
title={t("sidebar.reshuffle")}
|
||||||
|
aria-label={t("sidebar.reshuffle")}
|
||||||
|
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg shadow-sm hover:border-accent hover:text-accent active:translate-y-px transition"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
case "date":
|
case "date":
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,13 @@ export default function SyncStatus({
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
{t("header.sync.syncing", { count: syncing })}
|
{t("header.sync.syncing", { count: syncing })}
|
||||||
</span>
|
</span>
|
||||||
|
) : notFull > 0 ? (
|
||||||
|
// Recent uploads are in, but the scheduler is still backfilling full history —
|
||||||
|
// so "all synced" would be misleading. Reflect the deep work as active.
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
{t("header.sync.backfillingHistory")}
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span>{t("header.sync.allSynced")}</span>
|
<span>{t("header.sync.allSynced")}</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -74,9 +81,10 @@ export default function SyncStatus({
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Pause only makes sense when there's work to pause; Resume must always show
|
{/* Pause only makes sense when there's work to pause (recent OR deep backfill);
|
||||||
while paused so it can be lifted. Hidden entirely when idle and not paused. */}
|
Resume must always show while paused so it can be lifted. Hidden entirely when
|
||||||
{isAdmin && (data.paused || syncing > 0) && (
|
idle and not paused. */}
|
||||||
|
{isAdmin && (data.paused || syncing > 0 || notFull > 0) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => toggle.mutate()}
|
onClick={() => toggle.mutate()}
|
||||||
disabled={toggle.isPending}
|
disabled={toggle.isPending}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@
|
||||||
"full": "vollständig",
|
"full": "vollständig",
|
||||||
"fullHint": "Vollständiger Verlauf geladen.",
|
"fullHint": "Vollständiger Verlauf geladen.",
|
||||||
"fullHistoryQueued": "vollständiger Verlauf in Warteschlange",
|
"fullHistoryQueued": "vollständiger Verlauf in Warteschlange",
|
||||||
|
"fullHistoryComing": "vollständiger Verlauf unterwegs",
|
||||||
"queuedRequestedHint": "Vollständiger Verlauf angefordert — der gesamte Katalog dieses Kanals wird nachgeladen, soweit das gemeinsame Kontingent es zulässt. Klicke, um deine Anforderung abzubrechen.",
|
"queuedRequestedHint": "Vollständiger Verlauf angefordert — der gesamte Katalog dieses Kanals wird nachgeladen, soweit das gemeinsame Kontingent es zulässt. Klicke, um deine Anforderung abzubrechen.",
|
||||||
"queuedByOtherHint": "Ein anderer Abonnent hat den vollständigen Verlauf dieses Kanals bereits angefordert, sein gesamter Katalog ist also für alle unterwegs — hier gibt es nichts zu tun.",
|
"queuedByOtherHint": "Ein anderer Abonnent hat den vollständigen Verlauf dieses Kanals bereits angefordert, sein gesamter Katalog ist also für alle unterwegs — hier gibt es nichts zu tun.",
|
||||||
"getFullHistory": "vollständigen Verlauf laden",
|
"getFullHistory": "vollständigen Verlauf laden",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
"emptyTitle": "Dein Feed ist leer",
|
"emptyTitle": "Dein Feed ist leer",
|
||||||
"emptyBody": "Verbinde dein YouTube-Konto, um deine Abos zu importieren und deinen Feed aufzubauen.",
|
"emptyBody": "Verbinde dein YouTube-Konto, um deine Abos zu importieren und deinen Feed aufzubauen.",
|
||||||
"setUp": "Meinen Feed einrichten",
|
"setUp": "Meinen Feed einrichten",
|
||||||
|
"browseShared": "Oder einfach die gemeinsame Bibliothek durchsuchen",
|
||||||
"noMatches": "Keine Videos entsprechen diesen Filtern.",
|
"noMatches": "Keine Videos entsprechen diesen Filtern.",
|
||||||
"videoCount_one": "{{formattedCount}} Video",
|
"videoCount_one": "{{formattedCount}} Video",
|
||||||
"videoCount_other": "{{formattedCount}} Videos",
|
"videoCount_other": "{{formattedCount}} Videos",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,13 @@
|
||||||
"searchPlaceholder": "Deine Abos durchsuchen…",
|
"searchPlaceholder": "Deine Abos durchsuchen…",
|
||||||
"channelManager": "Kanalverwaltung",
|
"channelManager": "Kanalverwaltung",
|
||||||
"usageStats": "Nutzung & Statistik",
|
"usageStats": "Nutzung & Statistik",
|
||||||
|
"scope": {
|
||||||
|
"label": "Feed-Quelle",
|
||||||
|
"my": "Meine",
|
||||||
|
"myTip": "Nur Videos aus deinen eigenen Abos",
|
||||||
|
"all": "Bibliothek",
|
||||||
|
"allTip": "Alle Videos im gemeinsamen Katalog"
|
||||||
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"feed": "Feed",
|
"feed": "Feed",
|
||||||
|
|
@ -18,6 +25,7 @@
|
||||||
"countTooltip": "Videos aus deinen Abos im Vergleich zur Gesamtzahl im gemeinsamen Katalog, den die Abos aller Nutzer gefüllt haben.",
|
"countTooltip": "Videos aus deinen Abos im Vergleich zur Gesamtzahl im gemeinsamen Katalog, den die Abos aller Nutzer gefüllt haben.",
|
||||||
"paused": "pausiert",
|
"paused": "pausiert",
|
||||||
"syncing": "{{count}} werden synchronisiert",
|
"syncing": "{{count}} werden synchronisiert",
|
||||||
|
"backfillingHistory": "Verlauf wird geladen",
|
||||||
"allSynced": "alles synchronisiert",
|
"allSynced": "alles synchronisiert",
|
||||||
"withoutFullHistory": "{{count}} ohne vollständigen Verlauf",
|
"withoutFullHistory": "{{count}} ohne vollständigen Verlauf",
|
||||||
"fullHistoryTooltip": "Einige deiner Kanäle haben nur ihre neuesten Uploads. Klicke, um die Kanalverwaltung (auf diese gefiltert) zu öffnen und ihren vollständigen Katalog anzufordern.",
|
"fullHistoryTooltip": "Einige deiner Kanäle haben nur ihre neuesten Uploads. Klicke, um die Kanalverwaltung (auf diese gefiltert) zu öffnen und ihren vollständigen Katalog anzufordern.",
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"from": "Von",
|
"from": "Von",
|
||||||
"to": "Bis",
|
"to": "Bis",
|
||||||
"clearDates": "Daten löschen",
|
"clearDates": "Daten löschen",
|
||||||
|
"reshuffle": "Neu mischen",
|
||||||
"widget": {
|
"widget": {
|
||||||
"show": "Anzeigen",
|
"show": "Anzeigen",
|
||||||
"sort": "Sortierung",
|
"sort": "Sortierung",
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@
|
||||||
"full": "full",
|
"full": "full",
|
||||||
"fullHint": "Full history fetched.",
|
"fullHint": "Full history fetched.",
|
||||||
"fullHistoryQueued": "full history queued",
|
"fullHistoryQueued": "full history queued",
|
||||||
|
"fullHistoryComing": "full history coming",
|
||||||
"queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.",
|
"queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.",
|
||||||
"queuedByOtherHint": "Another subscriber already requested this channel's full history, so its whole back-catalog is on its way to everyone — nothing to do here.",
|
"queuedByOtherHint": "Another subscriber already requested this channel's full history, so its whole back-catalog is on its way to everyone — nothing to do here.",
|
||||||
"getFullHistory": "get full history",
|
"getFullHistory": "get full history",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
"emptyTitle": "Your feed is empty",
|
"emptyTitle": "Your feed is empty",
|
||||||
"emptyBody": "Connect your YouTube account to import your subscriptions and build your feed.",
|
"emptyBody": "Connect your YouTube account to import your subscriptions and build your feed.",
|
||||||
"setUp": "Set up my feed",
|
"setUp": "Set up my feed",
|
||||||
|
"browseShared": "Or just browse the shared library",
|
||||||
"noMatches": "No videos match these filters.",
|
"noMatches": "No videos match these filters.",
|
||||||
"videoCount_one": "{{formattedCount}} video",
|
"videoCount_one": "{{formattedCount}} video",
|
||||||
"videoCount_other": "{{formattedCount}} videos",
|
"videoCount_other": "{{formattedCount}} videos",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,13 @@
|
||||||
"searchPlaceholder": "Search your subscriptions…",
|
"searchPlaceholder": "Search your subscriptions…",
|
||||||
"channelManager": "Channel manager",
|
"channelManager": "Channel manager",
|
||||||
"usageStats": "Usage & stats",
|
"usageStats": "Usage & stats",
|
||||||
|
"scope": {
|
||||||
|
"label": "Feed source",
|
||||||
|
"my": "Mine",
|
||||||
|
"myTip": "Only videos from your own subscriptions",
|
||||||
|
"all": "Library",
|
||||||
|
"allTip": "Every video in the shared catalog"
|
||||||
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"feed": "Feed",
|
"feed": "Feed",
|
||||||
|
|
@ -18,6 +25,7 @@
|
||||||
"countTooltip": "Videos from your subscriptions, against the total in the shared catalog that every user's subscriptions have pulled in.",
|
"countTooltip": "Videos from your subscriptions, against the total in the shared catalog that every user's subscriptions have pulled in.",
|
||||||
"paused": "paused",
|
"paused": "paused",
|
||||||
"syncing": "{{count}} syncing",
|
"syncing": "{{count}} syncing",
|
||||||
|
"backfillingHistory": "fetching history",
|
||||||
"allSynced": "all synced",
|
"allSynced": "all synced",
|
||||||
"withoutFullHistory": "{{count}} without full history",
|
"withoutFullHistory": "{{count}} without full history",
|
||||||
"fullHistoryTooltip": "Some of your channels only have their recent uploads. Click to open the channel manager (filtered to these) and request their full back-catalog.",
|
"fullHistoryTooltip": "Some of your channels only have their recent uploads. Click to open the channel manager (filtered to these) and request their full back-catalog.",
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"from": "From",
|
"from": "From",
|
||||||
"to": "To",
|
"to": "To",
|
||||||
"clearDates": "clear dates",
|
"clearDates": "clear dates",
|
||||||
|
"reshuffle": "Reshuffle",
|
||||||
"widget": {
|
"widget": {
|
||||||
"show": "Show",
|
"show": "Show",
|
||||||
"sort": "Sort",
|
"sort": "Sort",
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@
|
||||||
"full": "teljes",
|
"full": "teljes",
|
||||||
"fullHint": "Teljes előzmény letöltve.",
|
"fullHint": "Teljes előzmény letöltve.",
|
||||||
"fullHistoryQueued": "teljes előzmény sorban",
|
"fullHistoryQueued": "teljes előzmény sorban",
|
||||||
|
"fullHistoryComing": "teljes előzmény úton",
|
||||||
"queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi. Kattints a kérés visszavonásához.",
|
"queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi. Kattints a kérés visszavonásához.",
|
||||||
"queuedByOtherHint": "Egy másik feliratkozó már kérte ennek a csatornának a teljes előzményét, így a teljes archívuma már úton van mindenkihez — itt nincs teendőd.",
|
"queuedByOtherHint": "Egy másik feliratkozó már kérte ennek a csatornának a teljes előzményét, így a teljes archívuma már úton van mindenkihez — itt nincs teendőd.",
|
||||||
"getFullHistory": "teljes előzmény lekérése",
|
"getFullHistory": "teljes előzmény lekérése",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
"emptyTitle": "A hírfolyamod üres",
|
"emptyTitle": "A hírfolyamod üres",
|
||||||
"emptyBody": "Kösd össze a YouTube-fiókodat, hogy importáld a feliratkozásaidat, és felépítsd a hírfolyamodat.",
|
"emptyBody": "Kösd össze a YouTube-fiókodat, hogy importáld a feliratkozásaidat, és felépítsd a hírfolyamodat.",
|
||||||
"setUp": "Hírfolyam beállítása",
|
"setUp": "Hírfolyam beállítása",
|
||||||
|
"browseShared": "Vagy csak böngészd a közös könyvtárat",
|
||||||
"noMatches": "Egy videó sem felel meg ezeknek a szűrőknek.",
|
"noMatches": "Egy videó sem felel meg ezeknek a szűrőknek.",
|
||||||
"videoCount_one": "{{formattedCount}} videó",
|
"videoCount_one": "{{formattedCount}} videó",
|
||||||
"videoCount_other": "{{formattedCount}} videó",
|
"videoCount_other": "{{formattedCount}} videó",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,13 @@
|
||||||
"searchPlaceholder": "Keresés a feliratkozásaid között…",
|
"searchPlaceholder": "Keresés a feliratkozásaid között…",
|
||||||
"channelManager": "Csatornakezelő",
|
"channelManager": "Csatornakezelő",
|
||||||
"usageStats": "Használat és statisztika",
|
"usageStats": "Használat és statisztika",
|
||||||
|
"scope": {
|
||||||
|
"label": "Hírfolyam forrása",
|
||||||
|
"my": "Sajátom",
|
||||||
|
"myTip": "Csak a saját feliratkozásaid videói",
|
||||||
|
"all": "Könyvtár",
|
||||||
|
"allTip": "A közös könyvtár összes videója"
|
||||||
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"admin": "Adminisztrátor",
|
"admin": "Adminisztrátor",
|
||||||
"feed": "Hírfolyam",
|
"feed": "Hírfolyam",
|
||||||
|
|
@ -18,6 +25,7 @@
|
||||||
"countTooltip": "A feliratkozásaidból származó videók száma a megosztott katalógus teljes számához képest, amelyet az összes felhasználó feliratkozásai gyűjtöttek össze.",
|
"countTooltip": "A feliratkozásaidból származó videók száma a megosztott katalógus teljes számához képest, amelyet az összes felhasználó feliratkozásai gyűjtöttek össze.",
|
||||||
"paused": "szüneteltetve",
|
"paused": "szüneteltetve",
|
||||||
"syncing": "{{count}} szinkronizálás alatt",
|
"syncing": "{{count}} szinkronizálás alatt",
|
||||||
|
"backfillingHistory": "előzmény letöltése",
|
||||||
"allSynced": "minden szinkronban",
|
"allSynced": "minden szinkronban",
|
||||||
"withoutFullHistory": "{{count}} teljes előzmény nélkül",
|
"withoutFullHistory": "{{count}} teljes előzmény nélkül",
|
||||||
"fullHistoryTooltip": "Néhány csatornádnak csak a legutóbbi feltöltései vannak meg. Kattints a csatornakezelő megnyitásához (ezekre szűrve), és kérd le a teljes archívumukat.",
|
"fullHistoryTooltip": "Néhány csatornádnak csak a legutóbbi feltöltései vannak meg. Kattints a csatornakezelő megnyitásához (ezekre szűrve), és kérd le a teljes archívumukat.",
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"from": "Ettől",
|
"from": "Ettől",
|
||||||
"to": "Eddig",
|
"to": "Eddig",
|
||||||
"clearDates": "dátumok törlése",
|
"clearDates": "dátumok törlése",
|
||||||
|
"reshuffle": "Újrakeverés",
|
||||||
"widget": {
|
"widget": {
|
||||||
"show": "Megjelenítés",
|
"show": "Megjelenítés",
|
||||||
"sort": "Rendezés",
|
"sort": "Rendezés",
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,11 @@ export interface FeedFilters {
|
||||||
tagMode: "or" | "and";
|
tagMode: "or" | "and";
|
||||||
q: string;
|
q: string;
|
||||||
sort: string;
|
sort: string;
|
||||||
|
// Re-roll token for the "shuffle" sort; bumped by the reshuffle button so the
|
||||||
|
// feed re-queries with a fresh order. Ignored by every other sort.
|
||||||
|
seed?: number;
|
||||||
|
// "my" = only your subscriptions (default); "all" = the whole shared catalog.
|
||||||
|
scope: "my" | "all";
|
||||||
includeNormal: boolean;
|
includeNormal: boolean;
|
||||||
includeShorts: boolean;
|
includeShorts: boolean;
|
||||||
includeLive: boolean;
|
includeLive: boolean;
|
||||||
|
|
@ -153,6 +158,8 @@ function feedQuery(f: FeedFilters, offset: number, limit: number): string {
|
||||||
p.set("tag_mode", f.tagMode);
|
p.set("tag_mode", f.tagMode);
|
||||||
if (f.q) p.set("q", f.q);
|
if (f.q) p.set("q", f.q);
|
||||||
p.set("sort", f.sort);
|
p.set("sort", f.sort);
|
||||||
|
if (f.sort === "shuffle" && f.seed) p.set("seed", String(f.seed));
|
||||||
|
p.set("scope", f.scope);
|
||||||
p.set("show_normal", String(f.includeNormal));
|
p.set("show_normal", String(f.includeNormal));
|
||||||
p.set("include_shorts", String(f.includeShorts));
|
p.set("include_shorts", String(f.includeShorts));
|
||||||
p.set("include_live", String(f.includeLive));
|
p.set("include_live", String(f.includeLive));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue