Merge chore/code-hygiene: Phase 2 #3 Channels (cleanup + 2 bug fixes)
Behavior-preserving cleanup (channels.py _is_blocked/_channel_summary helpers; frontend notifyYouTubeActionError + formatCountOrDash dedup; LS-registered table keys; 7 dead channels.json i18n keys removed) plus two review-found bug fixes (ChannelPage subscribe silent-403 + stale my-status/discovered-channels caches). Verified: tsc, ruff, knip, localdev boots healthy.
This commit is contained in:
commit
2ebaa23f10
10 changed files with 91 additions and 99 deletions
|
|
@ -101,12 +101,7 @@ def list_channels(
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": ch.id,
|
**_channel_summary(ch),
|
||||||
"title": ch.title,
|
|
||||||
"handle": ch.handle,
|
|
||||||
"thumbnail_url": ch.thumbnail_url,
|
|
||||||
"subscriber_count": ch.subscriber_count,
|
|
||||||
"video_count": ch.video_count,
|
|
||||||
"stored_videos": (agg.get(ch.id) or {}).get("stored", 0),
|
"stored_videos": (agg.get(ch.id) or {}).get("stored", 0),
|
||||||
"last_video_at": (agg.get(ch.id) or {}).get("last_video_at"),
|
"last_video_at": (agg.get(ch.id) or {}).get("last_video_at"),
|
||||||
"total_duration_seconds": (agg.get(ch.id) or {}).get("total_duration_seconds", 0),
|
"total_duration_seconds": (agg.get(ch.id) or {}).get("total_duration_seconds", 0),
|
||||||
|
|
@ -195,12 +190,7 @@ def discover_channels(
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": ch.id,
|
**_channel_summary(ch),
|
||||||
"title": ch.title,
|
|
||||||
"handle": ch.handle,
|
|
||||||
"thumbnail_url": ch.thumbnail_url,
|
|
||||||
"subscriber_count": ch.subscriber_count,
|
|
||||||
"video_count": ch.video_count,
|
|
||||||
"playlist_video_count": int(vid_count),
|
"playlist_video_count": int(vid_count),
|
||||||
"playlist_count": int(pl_count),
|
"playlist_count": int(pl_count),
|
||||||
"details_synced": ch.details_synced_at is not None,
|
"details_synced": ch.details_synced_at is not None,
|
||||||
|
|
@ -209,6 +199,18 @@ def discover_channels(
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_summary(ch: Channel) -> dict:
|
||||||
|
"""The channel fields common to the list and discovery projections."""
|
||||||
|
return {
|
||||||
|
"id": ch.id,
|
||||||
|
"title": ch.title,
|
||||||
|
"handle": ch.handle,
|
||||||
|
"thumbnail_url": ch.thumbnail_url,
|
||||||
|
"subscriber_count": ch.subscriber_count,
|
||||||
|
"video_count": ch.video_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _channel_detail_dict(
|
def _channel_detail_dict(
|
||||||
channel: Channel, *, subscribed: bool, explored: bool, blocked: bool, stored: int
|
channel: Channel, *, subscribed: bool, explored: bool, blocked: bool, stored: int
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|
@ -268,11 +270,7 @@ def channel_detail(
|
||||||
ExploredChannel.user_id == user.id, ExploredChannel.channel_id == channel_id
|
ExploredChannel.user_id == user.id, ExploredChannel.channel_id == channel_id
|
||||||
)
|
)
|
||||||
).first() is not None
|
).first() is not None
|
||||||
blocked = db.execute(
|
blocked = _is_blocked(db, user, channel_id)
|
||||||
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
|
stored = db.scalar(select(func.count(Video.id)).where(Video.channel_id == channel_id)) or 0
|
||||||
return _channel_detail_dict(
|
return _channel_detail_dict(
|
||||||
channel, subscribed=subscribed, explored=explored, blocked=blocked, stored=int(stored)
|
channel, subscribed=subscribed, explored=explored, blocked=blocked, stored=int(stored)
|
||||||
|
|
@ -295,11 +293,7 @@ def explore_channel(
|
||||||
channel = db.get(Channel, channel_id)
|
channel = db.get(Channel, channel_id)
|
||||||
if channel is None:
|
if channel is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown channel")
|
raise HTTPException(status_code=404, detail="Unknown channel")
|
||||||
if db.execute(
|
if _is_blocked(db, user, channel_id):
|
||||||
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.")
|
raise HTTPException(status_code=403, detail="You've blocked this channel.")
|
||||||
if quota.remaining_today(db) <= sysconfig.get_int(db, "backfill_quota_reserve"):
|
if quota.remaining_today(db) <= sysconfig.get_int(db, "backfill_quota_reserve"):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -359,6 +353,19 @@ def _user_subscription(db: Session, user: User, channel_id: str) -> Subscription
|
||||||
return sub
|
return sub
|
||||||
|
|
||||||
|
|
||||||
|
def _is_blocked(db: Session, user: User, channel_id: str) -> bool:
|
||||||
|
"""Whether this user has blocked this channel."""
|
||||||
|
return (
|
||||||
|
db.execute(
|
||||||
|
select(BlockedChannel.id).where(
|
||||||
|
BlockedChannel.user_id == user.id,
|
||||||
|
BlockedChannel.channel_id == channel_id,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{channel_id}")
|
@router.patch("/{channel_id}")
|
||||||
def update_channel(
|
def update_channel(
|
||||||
channel_id: str,
|
channel_id: str,
|
||||||
|
|
@ -546,12 +553,7 @@ def block_channel(
|
||||||
un-kept search/explore videos are reclaimed by the discovery-cleanup job in due course."""
|
un-kept search/explore videos are reclaimed by the discovery-cleanup job in due course."""
|
||||||
if db.get(Channel, channel_id) is None:
|
if db.get(Channel, channel_id) is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown channel")
|
raise HTTPException(status_code=404, detail="Unknown channel")
|
||||||
exists = db.execute(
|
if not _is_blocked(db, user, channel_id):
|
||||||
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))
|
db.add(BlockedChannel(user_id=user.id, channel_id=channel_id))
|
||||||
# Stop any active exploration of it so it can be cleaned up.
|
# Stop any active exploration of it so it can be cleaned up.
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { UserPlus } from "lucide-react";
|
import { UserPlus } from "lucide-react";
|
||||||
import { api, HttpError, type DiscoveredChannel } from "../lib/api";
|
import { api, type DiscoveredChannel } from "../lib/api";
|
||||||
import { accountKey } from "../lib/storage";
|
import { accountKey, LS } from "../lib/storage";
|
||||||
import { formatViews } from "../lib/format";
|
import { formatCountOrDash } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import ChannelLink from "./ChannelLink";
|
import ChannelLink from "./ChannelLink";
|
||||||
import DataTable, { type Column } from "./DataTable";
|
import DataTable, { type Column } from "./DataTable";
|
||||||
|
|
@ -48,19 +49,8 @@ export default function ChannelDiscovery({
|
||||||
meta: { kind: "channel-subscribed", channelId: c.id, channelName: name },
|
meta: { kind: "channel-subscribed", channelId: c.id, channelName: name },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) =>
|
||||||
// A 403 means the user hasn't granted the write scope — offer to connect instead of
|
notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed"), onOpenWizard),
|
||||||
// a vague failure (mirrors the subscriptions tab).
|
|
||||||
if (err instanceof HttpError && err.status === 403) {
|
|
||||||
notify({
|
|
||||||
level: "error",
|
|
||||||
message: t("channels.notify.needYouTube"),
|
|
||||||
action: { label: t("channels.notify.connect"), onClick: onOpenWizard },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
notify({ level: "error", message: t("channels.discovery.subscribeFailed") });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Subscribing changes the user's real YouTube account and spends a little quota — confirm
|
// Subscribing changes the user's real YouTube account and spends a little quota — confirm
|
||||||
|
|
@ -96,9 +86,7 @@ export default function ChannelDiscovery({
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortValue: (c) => c.subscriber_count ?? -1,
|
sortValue: (c) => c.subscriber_count ?? -1,
|
||||||
render: (c) => (
|
render: (c) => (
|
||||||
<span className="text-muted tabular-nums">
|
<span className="text-muted tabular-nums">{formatCountOrDash(c.subscriber_count)}</span>
|
||||||
{c.subscriber_count != null ? formatViews(c.subscriber_count) : "—"}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -109,9 +97,7 @@ export default function ChannelDiscovery({
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortValue: (c) => c.video_count ?? -1,
|
sortValue: (c) => c.video_count ?? -1,
|
||||||
render: (c) => (
|
render: (c) => (
|
||||||
<span className="text-muted tabular-nums">
|
<span className="text-muted tabular-nums">{formatCountOrDash(c.video_count)}</span>
|
||||||
{c.video_count != null ? formatViews(c.video_count) : "—"}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -174,7 +160,7 @@ export default function ChannelDiscovery({
|
||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey={(c) => c.id}
|
rowKey={(c) => c.id}
|
||||||
persistKey={accountKey("siftlode.channelDiscoveryTable") ?? undefined}
|
persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
|
||||||
controlsPosition="top"
|
controlsPosition="top"
|
||||||
emptyText={t("channels.discovery.empty")}
|
emptyText={t("channels.discovery.empty")}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import Avatar from "./Avatar";
|
||||||
import Feed from "./Feed";
|
import Feed from "./Feed";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import { api, type FeedFilters, type Me } from "../lib/api";
|
import { api, type FeedFilters, type Me } from "../lib/api";
|
||||||
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
||||||
|
|
||||||
|
|
@ -89,8 +90,14 @@ export default function ChannelPage({
|
||||||
qc.invalidateQueries({ queryKey: ["channel", channelId] });
|
qc.invalidateQueries({ queryKey: ["channel", channelId] });
|
||||||
qc.invalidateQueries({ queryKey: ["feed"] });
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||||
qc.invalidateQueries({ queryKey: ["channels"] });
|
qc.invalidateQueries({ queryKey: ["channels"] });
|
||||||
|
// Match ChannelDiscovery: the channel leaves discovery and the manager's stats move.
|
||||||
|
qc.invalidateQueries({ queryKey: ["my-status"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["discovered-channels"] });
|
||||||
notify({ level: "info", message: t("channel.subscribed", { name: ch?.title ?? "" }) });
|
notify({ level: "info", message: t("channel.subscribed", { name: ch?.title ?? "" }) });
|
||||||
},
|
},
|
||||||
|
// A 403 (missing write scope) otherwise fails silently on the channel page (no wizard handle
|
||||||
|
// here, so no Connect button — but the user at least sees why it didn't work).
|
||||||
|
onError: (err) => notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed")),
|
||||||
});
|
});
|
||||||
const unsubscribe = useMutation({
|
const unsubscribe = useMutation({
|
||||||
mutationFn: () => api.unsubscribeChannel(channelId),
|
mutationFn: () => api.unsubscribeChannel(channelId),
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,11 @@ import {
|
||||||
UserMinus,
|
UserMinus,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
|
import { api, type ManagedChannel, type Tag } from "../lib/api";
|
||||||
import { accountKey } from "../lib/storage";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
|
import { accountKey, LS } from "../lib/storage";
|
||||||
import { useDismiss } from "../lib/useDismiss";
|
import { useDismiss } from "../lib/useDismiss";
|
||||||
import { formatEta, formatTotalHours, formatViews, relativeTime } from "../lib/format";
|
import { formatCountOrDash, formatEta, formatTotalHours, relativeTime } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import DataTable, { type Column } from "./DataTable";
|
import DataTable, { type Column } from "./DataTable";
|
||||||
|
|
@ -73,19 +74,6 @@ export default function Channels({
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
|
|
||||||
// A YouTube-gated action (sync, backfill, unsubscribe) that 403s means the user hasn't
|
|
||||||
// granted the needed scope — surface that with a "Connect" action instead of a vague fail.
|
|
||||||
const notifyActionError = (err: unknown, fallbackKey: string) => {
|
|
||||||
if (err instanceof HttpError && err.status === 403) {
|
|
||||||
notify({
|
|
||||||
level: "error",
|
|
||||||
message: t("channels.notify.needYouTube"),
|
|
||||||
action: { label: t("channels.notify.connect"), onClick: onOpenWizard },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
notify({ level: "error", message: t(fallbackKey) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
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 });
|
||||||
|
|
@ -160,7 +148,7 @@ export default function Channels({
|
||||||
invalidate();
|
invalidate();
|
||||||
notify({ level: "success", message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }) });
|
notify({ level: "success", message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }) });
|
||||||
},
|
},
|
||||||
onError: (e) => notifyActionError(e, "channels.notify.syncFailed"),
|
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.syncFailed"), onOpenWizard),
|
||||||
});
|
});
|
||||||
const unsubscribe = useMutation({
|
const unsubscribe = useMutation({
|
||||||
mutationFn: (v: { id: string; name: string }) => api.unsubscribeChannel(v.id),
|
mutationFn: (v: { id: string; name: string }) => api.unsubscribeChannel(v.id),
|
||||||
|
|
@ -169,7 +157,7 @@ export default function Channels({
|
||||||
qc.invalidateQueries({ queryKey: ["my-status"] });
|
qc.invalidateQueries({ queryKey: ["my-status"] });
|
||||||
notify({ level: "success", message: t("channels.notify.unsubscribed", { name: v.name }) });
|
notify({ level: "success", message: t("channels.notify.unsubscribed", { name: v.name }) });
|
||||||
},
|
},
|
||||||
onError: (e) => notifyActionError(e, "channels.notify.unsubscribeFailed"),
|
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.unsubscribeFailed"), onOpenWizard),
|
||||||
});
|
});
|
||||||
const resetBackfill = useMutation({
|
const resetBackfill = useMutation({
|
||||||
mutationFn: (v: { id: string; name: string }) => api.resetChannelBackfill(v.id),
|
mutationFn: (v: { id: string; name: string }) => api.resetChannelBackfill(v.id),
|
||||||
|
|
@ -178,7 +166,7 @@ export default function Channels({
|
||||||
qc.invalidateQueries({ queryKey: ["my-status"] });
|
qc.invalidateQueries({ queryKey: ["my-status"] });
|
||||||
notify({ level: "success", message: t("channels.notify.resetDone", { name: v.name }) });
|
notify({ level: "success", message: t("channels.notify.resetDone", { name: v.name }) });
|
||||||
},
|
},
|
||||||
onError: (e) => notifyActionError(e, "channels.notify.resetFailed"),
|
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), onOpenWizard),
|
||||||
});
|
});
|
||||||
const deepAll = useMutation({
|
const deepAll = useMutation({
|
||||||
mutationFn: () => api.deepAll(true),
|
mutationFn: () => api.deepAll(true),
|
||||||
|
|
@ -190,7 +178,7 @@ export default function Channels({
|
||||||
message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }),
|
message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"),
|
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Channel-name search + tag-chip filtering, applied client-side over the status-filtered list
|
// Channel-name search + tag-chip filtering, applied client-side over the status-filtered list
|
||||||
|
|
@ -294,9 +282,7 @@ export default function Channels({
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortValue: (c) => c.subscriber_count ?? -1,
|
sortValue: (c) => c.subscriber_count ?? -1,
|
||||||
render: (c) => (
|
render: (c) => (
|
||||||
<span className="text-muted tabular-nums">
|
<span className="text-muted tabular-nums">{formatCountOrDash(c.subscriber_count)}</span>
|
||||||
{c.subscriber_count != null ? formatViews(c.subscriber_count) : "—"}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -570,7 +556,7 @@ export default function Channels({
|
||||||
rows={visibleChannels}
|
rows={visibleChannels}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey={(c) => c.id}
|
rowKey={(c) => c.id}
|
||||||
persistKey={accountKey("siftlode.channelsTable") ?? undefined}
|
persistKey={accountKey(LS.channelsTable) ?? undefined}
|
||||||
controlsPosition="top"
|
controlsPosition="top"
|
||||||
controlsLeading={statusChips}
|
controlsLeading={statusChips}
|
||||||
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
{
|
{
|
||||||
"intro": "Lege die <0>Priorität</0> eines Kanals fest, um seine Videos nach oben zu schieben, wenn du nach „Kanalpriorität“ sortierst, hänge eigene <1>Tags</1> an, um den Feed zu filtern, oder <2>blende</2> einen Kanal <2>aus</2>, um ihn ohne Abbestellung aus dem Feed zu entfernen.",
|
"intro": "Lege die <0>Priorität</0> eines Kanals fest, um seine Videos nach oben zu schieben, wenn du nach „Kanalpriorität“ sortierst, hänge eigene <1>Tags</1> an, um den Feed zu filtern, oder <2>blende</2> einen Kanal <2>aus</2>, um ihn ohne Abbestellung aus dem Feed zu entfernen.",
|
||||||
"filterPlaceholder": "Kanäle filtern…",
|
|
||||||
"syncSubscriptions": "Abos von YouTube lesen",
|
"syncSubscriptions": "Abos von YouTube lesen",
|
||||||
"syncSubscriptionsHint": "Importiert deine Abo-Liste erneut von YouTube — fügt neu abonnierte Kanäle hinzu und entfernt abbestellte. Die Videos selbst werden weiterhin automatisch im Hintergrund synchronisiert; sie werden hierbei nicht neu geladen.",
|
"syncSubscriptionsHint": "Importiert deine Abo-Liste erneut von YouTube — fügt neu abonnierte Kanäle hinzu und entfernt abbestellte. Die Videos selbst werden weiterhin automatisch im Hintergrund synchronisiert; sie werden hierbei nicht neu geladen.",
|
||||||
"backfillEverything": "Alles nachladen",
|
"backfillEverything": "Alles nachladen",
|
||||||
|
|
@ -35,9 +34,7 @@
|
||||||
"tags": {
|
"tags": {
|
||||||
"yourTags": "Deine Tags",
|
"yourTags": "Deine Tags",
|
||||||
"manage": "Tags verwalten",
|
"manage": "Tags verwalten",
|
||||||
"yourTagsHint": "Deine persönlichen Labels. Hänge sie unten an Kanäle an und filtere den Feed dann über die Seitenleiste nach Tag. (Getrennt von den automatischen Sprach-/Themen-Tags.)",
|
"yourTagsHint": "Deine persönlichen Labels. Hänge sie unten an Kanäle an und filtere den Feed dann über die Seitenleiste nach Tag. (Getrennt von den automatischen Sprach-/Themen-Tags.)"
|
||||||
"newTag": "neuer Tag",
|
|
||||||
"createTag": "Tag erstellen"
|
|
||||||
},
|
},
|
||||||
"loading": "Kanäle werden geladen…",
|
"loading": "Kanäle werden geladen…",
|
||||||
"empty": "Keine Kanäle.",
|
"empty": "Keine Kanäle.",
|
||||||
|
|
@ -74,8 +71,6 @@
|
||||||
"quotaLeftHint": "Heute noch verbleibendes gemeinsames YouTube-API-Budget (wird um Mitternacht US-Pazifikzeit zurückgesetzt)."
|
"quotaLeftHint": "Heute noch verbleibendes gemeinsames YouTube-API-Budget (wird um Mitternacht US-Pazifikzeit zurückgesetzt)."
|
||||||
},
|
},
|
||||||
"row": {
|
"row": {
|
||||||
"stored": "{{formatted}} gespeichert",
|
|
||||||
"subs": "{{formatted}} Abonnenten",
|
|
||||||
"openOnYouTube": "Auf YouTube öffnen",
|
"openOnYouTube": "Auf YouTube öffnen",
|
||||||
"editTags": "Tags bearbeiten",
|
"editTags": "Tags bearbeiten",
|
||||||
"filterFeedByTag": "Feed nach „{{name}}“ filtern",
|
"filterFeedByTag": "Feed nach „{{name}}“ filtern",
|
||||||
|
|
@ -98,8 +93,6 @@
|
||||||
"fullHistoryComing": "vollständiger Verlauf unterwegs",
|
"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",
|
|
||||||
"getFullHistoryHint": "Bisher nur neueste Uploads. Klicke, um den gesamten Katalog dieses Kanals anzufordern (ältere Videos + vollständige Suche).",
|
|
||||||
"hiddenHint": "Ausgeblendet — die Videos dieses Kanals bleiben aus deinem Feed heraus. Klicke, um sie wieder anzuzeigen.",
|
"hiddenHint": "Ausgeblendet — die Videos dieses Kanals bleiben aus deinem Feed heraus. Klicke, um sie wieder anzuzeigen.",
|
||||||
"hideHint": "Blendet die Videos dieses Kanals aus deinem Feed aus. Er bleibt abonniert; dies bestellt nicht bei YouTube ab.",
|
"hideHint": "Blendet die Videos dieses Kanals aus deinem Feed aus. Er bleibt abonniert; dies bestellt nicht bei YouTube ab.",
|
||||||
"unhide": "Einblenden",
|
"unhide": "Einblenden",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
{
|
{
|
||||||
"intro": "Set a channel's <0>priority</0> to push its videos up when you sort by “Channel priority”, attach your own <1>tags</1> to filter the feed, or <2>hide</2> a channel to drop it from the feed without unsubscribing.",
|
"intro": "Set a channel's <0>priority</0> to push its videos up when you sort by “Channel priority”, attach your own <1>tags</1> to filter the feed, or <2>hide</2> a channel to drop it from the feed without unsubscribing.",
|
||||||
"filterPlaceholder": "Filter channels…",
|
|
||||||
"syncSubscriptions": "Read subscriptions from YouTube",
|
"syncSubscriptions": "Read subscriptions from YouTube",
|
||||||
"syncSubscriptionsHint": "Re-import your subscription list from YouTube — adds channels you've newly followed and drops ones you've unfollowed. The videos themselves keep syncing automatically in the background; this does not re-fetch them.",
|
"syncSubscriptionsHint": "Re-import your subscription list from YouTube — adds channels you've newly followed and drops ones you've unfollowed. The videos themselves keep syncing automatically in the background; this does not re-fetch them.",
|
||||||
"backfillEverything": "Backfill everything",
|
"backfillEverything": "Backfill everything",
|
||||||
|
|
@ -35,9 +34,7 @@
|
||||||
"tags": {
|
"tags": {
|
||||||
"yourTags": "Your tags",
|
"yourTags": "Your tags",
|
||||||
"manage": "Manage tags",
|
"manage": "Manage tags",
|
||||||
"yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)",
|
"yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)"
|
||||||
"newTag": "new tag",
|
|
||||||
"createTag": "Create tag"
|
|
||||||
},
|
},
|
||||||
"loading": "Loading channels…",
|
"loading": "Loading channels…",
|
||||||
"empty": "No channels.",
|
"empty": "No channels.",
|
||||||
|
|
@ -74,8 +71,6 @@
|
||||||
"quotaLeftHint": "Shared YouTube API budget left today (resets midnight US Pacific)."
|
"quotaLeftHint": "Shared YouTube API budget left today (resets midnight US Pacific)."
|
||||||
},
|
},
|
||||||
"row": {
|
"row": {
|
||||||
"stored": "{{formatted}} stored",
|
|
||||||
"subs": "{{formatted}} subs",
|
|
||||||
"openOnYouTube": "Open on YouTube",
|
"openOnYouTube": "Open on YouTube",
|
||||||
"editTags": "Edit tags",
|
"editTags": "Edit tags",
|
||||||
"filterFeedByTag": "Filter the feed by “{{name}}”",
|
"filterFeedByTag": "Filter the feed by “{{name}}”",
|
||||||
|
|
@ -98,8 +93,6 @@
|
||||||
"fullHistoryComing": "full history coming",
|
"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",
|
|
||||||
"getFullHistoryHint": "Only recent uploads so far. Click to request this channel's full back-catalog (older videos + complete search).",
|
|
||||||
"hiddenHint": "Hidden — this channel's videos are kept out of your feed. Click to show them again.",
|
"hiddenHint": "Hidden — this channel's videos are kept out of your feed. Click to show them again.",
|
||||||
"hideHint": "Hide this channel's videos from your feed. It stays subscribed; this doesn't unsubscribe on YouTube.",
|
"hideHint": "Hide this channel's videos from your feed. It stays subscribed; this doesn't unsubscribe on YouTube.",
|
||||||
"unhide": "Unhide",
|
"unhide": "Unhide",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
{
|
{
|
||||||
"intro": "Állíts be egy csatornának <0>prioritást</0>, hogy a videói előrébb kerüljenek, amikor „Csatorna prioritás” szerint rendezel, csatolj saját <1>címkéket</1> a hírfolyam szűréséhez, vagy <2>rejts el</2> egy csatornát, hogy leiratkozás nélkül kivedd a hírfolyamból.",
|
"intro": "Állíts be egy csatornának <0>prioritást</0>, hogy a videói előrébb kerüljenek, amikor „Csatorna prioritás” szerint rendezel, csatolj saját <1>címkéket</1> a hírfolyam szűréséhez, vagy <2>rejts el</2> egy csatornát, hogy leiratkozás nélkül kivedd a hírfolyamból.",
|
||||||
"filterPlaceholder": "Csatornák szűrése…",
|
|
||||||
"syncSubscriptions": "Feliratkozások beolvasása a YouTube-ról",
|
"syncSubscriptions": "Feliratkozások beolvasása a YouTube-ról",
|
||||||
"syncSubscriptionsHint": "Újraimportálja a feliratkozási listádat a YouTube-ról — hozzáadja az újonnan követett csatornákat, és eltávolítja azokat, amelyekről leiratkoztál. Maguk a videók a háttérben automatikusan tovább szinkronizálódnak; ez nem tölti le őket újra.",
|
"syncSubscriptionsHint": "Újraimportálja a feliratkozási listádat a YouTube-ról — hozzáadja az újonnan követett csatornákat, és eltávolítja azokat, amelyekről leiratkoztál. Maguk a videók a háttérben automatikusan tovább szinkronizálódnak; ez nem tölti le őket újra.",
|
||||||
"backfillEverything": "Minden letöltése",
|
"backfillEverything": "Minden letöltése",
|
||||||
|
|
@ -35,9 +34,7 @@
|
||||||
"tags": {
|
"tags": {
|
||||||
"yourTags": "Címkéid",
|
"yourTags": "Címkéid",
|
||||||
"manage": "Címkék kezelése",
|
"manage": "Címkék kezelése",
|
||||||
"yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)",
|
"yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)"
|
||||||
"newTag": "új címke",
|
|
||||||
"createTag": "Címke létrehozása"
|
|
||||||
},
|
},
|
||||||
"loading": "Csatornák betöltése…",
|
"loading": "Csatornák betöltése…",
|
||||||
"empty": "Nincsenek csatornák.",
|
"empty": "Nincsenek csatornák.",
|
||||||
|
|
@ -74,8 +71,6 @@
|
||||||
"quotaLeftHint": "A ma még hátralévő megosztott YouTube API-keret (csendes-óceáni idő szerint éjfélkor nullázódik)."
|
"quotaLeftHint": "A ma még hátralévő megosztott YouTube API-keret (csendes-óceáni idő szerint éjfélkor nullázódik)."
|
||||||
},
|
},
|
||||||
"row": {
|
"row": {
|
||||||
"stored": "{{formatted}} tárolt",
|
|
||||||
"subs": "{{formatted}} feliratkozó",
|
|
||||||
"openOnYouTube": "Megnyitás a YouTube-on",
|
"openOnYouTube": "Megnyitás a YouTube-on",
|
||||||
"editTags": "Címkék szerkesztése",
|
"editTags": "Címkék szerkesztése",
|
||||||
"filterFeedByTag": "Hírfolyam szűrése: „{{name}}”",
|
"filterFeedByTag": "Hírfolyam szűrése: „{{name}}”",
|
||||||
|
|
@ -98,8 +93,6 @@
|
||||||
"fullHistoryComing": "teljes előzmény úton",
|
"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",
|
|
||||||
"getFullHistoryHint": "Egyelőre csak a legutóbbi feltöltések. Kattints a csatorna teljes archívumának lekéréséhez (régebbi videók + teljes keresés).",
|
|
||||||
"hiddenHint": "Elrejtve — a csatorna videói nem jelennek meg a hírfolyamodban. Kattints, hogy újra láthatóak legyenek.",
|
"hiddenHint": "Elrejtve — a csatorna videói nem jelennek meg a hírfolyamodban. Kattints, hogy újra láthatóak legyenek.",
|
||||||
"hideHint": "A csatorna videóinak elrejtése a hírfolyamodból. Feliratkozva marad; ez nem iratkoztat le a YouTube-on.",
|
"hideHint": "A csatorna videóinak elrejtése a hírfolyamodból. Feliratkozva marad; ez nem iratkoztat le a YouTube-on.",
|
||||||
"unhide": "Megjelenítés",
|
"unhide": "Megjelenítés",
|
||||||
|
|
|
||||||
|
|
@ -101,3 +101,8 @@ export function formatViews(n: number | null): string {
|
||||||
if (n >= 1e3) return `${(n / 1e3).toFixed(1).replace(/\.0$/, "")}K`;
|
if (n >= 1e3) return `${(n / 1e3).toFixed(1).replace(/\.0$/, "")}K`;
|
||||||
return String(n);
|
return String(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A count for a right-aligned table cell: the humanized number, or an em-dash when unknown (null).
|
||||||
|
export function formatCountOrDash(n: number | null | undefined): string {
|
||||||
|
return n != null ? formatViews(n) : "—";
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ export const LS = {
|
||||||
perfMode: "siftlode.perfMode",
|
perfMode: "siftlode.perfMode",
|
||||||
channelFilter: "siftlode.channelFilter",
|
channelFilter: "siftlode.channelFilter",
|
||||||
channelsView: "siftlode.channelsView",
|
channelsView: "siftlode.channelsView",
|
||||||
|
channelsTable: "siftlode.channelsTable",
|
||||||
|
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
|
||||||
settingsTab: "siftlode.settingsTab",
|
settingsTab: "siftlode.settingsTab",
|
||||||
statsTab: "siftlode.statsTab",
|
statsTab: "siftlode.statsTab",
|
||||||
adminUsersTab: "siftlode.adminUsersTab",
|
adminUsersTab: "siftlode.adminUsersTab",
|
||||||
|
|
|
||||||
25
frontend/src/lib/youtubeErrors.ts
Normal file
25
frontend/src/lib/youtubeErrors.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { HttpError } from "./api";
|
||||||
|
import { notify } from "./notifications";
|
||||||
|
import i18n from "../i18n";
|
||||||
|
|
||||||
|
// A YouTube-gated action (subscribe, sync, backfill, unsubscribe) that 403s means the user hasn't
|
||||||
|
// granted the write scope — surface a "Connect" affordance instead of a vague failure. Pass
|
||||||
|
// `onConnect` to offer the connect-wizard button (callers that have no wizard handle, e.g. the
|
||||||
|
// channel page, just get the message). `fallbackMessage` is the already-translated non-403 error.
|
||||||
|
export function notifyYouTubeActionError(
|
||||||
|
err: unknown,
|
||||||
|
fallbackMessage: string,
|
||||||
|
onConnect?: () => void,
|
||||||
|
): void {
|
||||||
|
if (err instanceof HttpError && err.status === 403) {
|
||||||
|
notify({
|
||||||
|
level: "error",
|
||||||
|
message: i18n.t("channels.notify.needYouTube"),
|
||||||
|
action: onConnect
|
||||||
|
? { label: i18n.t("channels.notify.connect"), onClick: onConnect }
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
notify({ level: "error", message: fallbackMessage });
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue