From dc70a978edee618762516872eecfa70da18697ff Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 19 Jun 2026 11:48:11 +0200 Subject: [PATCH 1/2] refactor(quota): canonical _ taxonomy for quota events The quota-attribution action keys were inconsistent (mixed verbs, ad-hoc names, English-only labels). Introduce a QuotaAction constants holder (one source of truth), rename every attribution call site to it, and add migration 0020 to rename historical quota_events.action values. The display label now resolves from i18n (quotaActions., EN/HU/DE) instead of a hard-coded English map. Scheduler job ids and progress phase labels are a separate namespace and are left untouched. --- .../versions/0020_rename_quota_actions.py | 51 +++++++++++++++++++ backend/app/quota.py | 28 +++++++++- backend/app/routes/channels.py | 12 ++--- backend/app/routes/feed.py | 2 +- backend/app/routes/playlists.py | 10 ++-- backend/app/routes/sync.py | 6 +-- backend/app/scheduler.py | 10 ++-- backend/app/sync/playlists.py | 2 +- .../src/i18n/locales/de/quotaActions.json | 16 ++++++ .../src/i18n/locales/en/quotaActions.json | 16 ++++++ .../src/i18n/locales/hu/quotaActions.json | 16 ++++++ frontend/src/lib/format.ts | 15 ++---- 12 files changed, 151 insertions(+), 33 deletions(-) create mode 100644 backend/alembic/versions/0020_rename_quota_actions.py create mode 100644 frontend/src/i18n/locales/de/quotaActions.json create mode 100644 frontend/src/i18n/locales/en/quotaActions.json create mode 100644 frontend/src/i18n/locales/hu/quotaActions.json diff --git a/backend/alembic/versions/0020_rename_quota_actions.py b/backend/alembic/versions/0020_rename_quota_actions.py new file mode 100644 index 0000000..a427f6d --- /dev/null +++ b/backend/alembic/versions/0020_rename_quota_actions.py @@ -0,0 +1,51 @@ +"""rename quota_events.action to the canonical _ taxonomy + +Revision ID: 0020_rename_quota_actions +Revises: 0019_user_yt_channel_id +Create Date: 2026-06-19 + +The quota-attribution action keys were inconsistent (mixed verbs, ad-hoc names). Rename the +stored historical values to the canonical scheme defined in app.quota.QuotaAction: +``_``, snake_case, explicit pull/push. Pure data migration on quota_events; +fully reversible. (Scheduler job ids and progress phase labels are a separate namespace and +are NOT touched.) +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0020_rename_quota_actions" +down_revision: Union[str, None] = "0019_user_yt_channel_id" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# old action value -> new canonical key (mirrors app.quota.QuotaAction). +RENAMES: list[tuple[str, str]] = [ + ("sync_subscriptions", "subscriptions_pull"), + ("subscription_resync", "subscriptions_resync"), + ("playlist_sync", "playlists_pull"), + ("playlist_push", "playlists_push"), + ("playlist_delete", "playlists_delete"), + ("backfill_recent", "videos_backfill_recent"), + ("backfill_deep", "videos_backfill_full"), + ("enrich", "videos_enrich"), + ("video_lookup", "videos_lookup"), + ("discovery", "channels_discover"), + ("subscribe", "channels_subscribe"), + ("unsubscribe", "channels_unsubscribe"), + ("maintenance", "maintenance_revalidate"), + ("api", "other"), +] + +_SQL = sa.text("UPDATE quota_events SET action = :to WHERE action = :frm") + + +def upgrade() -> None: + for frm, to in RENAMES: + op.execute(_SQL.bindparams(frm=frm, to=to)) + + +def downgrade() -> None: + for frm, to in RENAMES: + op.execute(_SQL.bindparams(frm=to, to=frm)) diff --git a/backend/app/quota.py b/backend/app/quota.py index 3fe57cf..e2c7696 100644 --- a/backend/app/quota.py +++ b/backend/app/quota.py @@ -17,6 +17,32 @@ from app.models import ApiQuotaUsage, QuotaEvent _PACIFIC = ZoneInfo("America/Los_Angeles") + +class QuotaAction: + """Canonical quota-attribution action keys (one source of truth). + + Naming scheme: ``_``, all snake_case, explicit pull/push direction. + The stored value is this machine key; the user-facing label is resolved from i18n + (frontend ``quotaActions.``). Distinct from scheduler job ids and progress phase + labels — do not conflate. + """ + + SUBSCRIPTIONS_PULL = "subscriptions_pull" + SUBSCRIPTIONS_RESYNC = "subscriptions_resync" + PLAYLISTS_PULL = "playlists_pull" + PLAYLISTS_PUSH = "playlists_push" + PLAYLISTS_DELETE = "playlists_delete" + VIDEOS_BACKFILL_RECENT = "videos_backfill_recent" + VIDEOS_BACKFILL_FULL = "videos_backfill_full" + VIDEOS_ENRICH = "videos_enrich" + VIDEOS_LOOKUP = "videos_lookup" + CHANNELS_DISCOVER = "channels_discover" + CHANNELS_SUBSCRIBE = "channels_subscribe" + CHANNELS_UNSUBSCRIBE = "channels_unsubscribe" + MAINTENANCE_REVALIDATE = "maintenance_revalidate" + OTHER = "other" + + # Request/job-scoped attribution for quota spend: who triggered it and what kind of work. # Set at entry points (route handlers, scheduler jobs) via attribute(); read by # record_usage. Default = background/system, generic action. @@ -24,7 +50,7 @@ _actor_id: contextvars.ContextVar[int | None] = contextvars.ContextVar( "quota_actor_id", default=None ) _action: contextvars.ContextVar[str] = contextvars.ContextVar( - "quota_action", default="api" + "quota_action", default=QuotaAction.OTHER ) diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index d996047..3045e04 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -166,7 +166,7 @@ def discover_channels( # to yourself. Lazy + cached; needs OAuth, so demo / token-less users just skip it. if user.yt_channel_id is None and user.token is not None and not user.is_demo: try: - with quota.attribute(user.id, "discovery"), YouTubeClient(db, user) as yt: + with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), YouTubeClient(db, user) as yt: own = yt.get_my_channel_id() if own: user.yt_channel_id = own @@ -181,7 +181,7 @@ def discover_channels( need = [ch.id for ch, _, _ in rows if ch.details_synced_at is None][:DISCOVERY_ENRICH_CAP] if need: try: - with quota.attribute(user.id, "discovery"), YouTubeClient(db, user) as yt: + with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), YouTubeClient(db, user) as yt: apply_channel_details(db, yt.get_channels(need)) db.commit() rows = _discovery_rows(db, user) @@ -243,7 +243,7 @@ def update_channel( # fetched its recent uploads yet, do that one channel now (cheap) instead of waiting for # the scheduler. Deep paging still follows on the scheduler's next run (recent-then-deep). if deep_turned_on and channel is not None and channel.recent_synced_at is None: - with quota.attribute(user.id, "backfill_recent"): + with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT): run_recent_backfill(db, [channel], max_channels=1) return { @@ -274,7 +274,7 @@ def reset_backfill( channel.recent_synced_at = None sub.deep_requested = True db.commit() - with quota.attribute(user.id, "backfill_recent"): + with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT): run_recent_backfill(db, [channel], max_channels=1) return {"id": channel_id, "reset": True} @@ -306,7 +306,7 @@ def subscribe( if existing is not None: raise HTTPException(status_code=409, detail="Already subscribed to this channel.") try: - with quota.attribute(user.id, "subscribe"), YouTubeClient(db, user) as yt: + with quota.attribute(user.id, quota.QuotaAction.CHANNELS_SUBSCRIBE), YouTubeClient(db, user) as yt: yt_sub_id = yt.insert_subscription(channel_id) # Enrich a stub channel (title/thumbnail/subscriber count) so it shows up # properly right away; no video pull here. @@ -356,7 +356,7 @@ def unsubscribe( detail="No YouTube subscription id on record — sync subscriptions first.", ) try: - with quota.attribute(user.id, "unsubscribe"), YouTubeClient(db, user) as yt: + with quota.attribute(user.id, quota.QuotaAction.CHANNELS_UNSUBSCRIBE), YouTubeClient(db, user) as yt: yt.delete_subscription(sub.yt_subscription_id) except YouTubeError as exc: # 422 (a real, explainable error → speaking modal), not 502 which the client treats as diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index fb24597..caa9d87 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -549,7 +549,7 @@ def get_video_detail( } try: - with quota.attribute(user.id, "video_lookup"), YouTubeClient(db, user) as yt: + with quota.attribute(user.id, quota.QuotaAction.VIDEOS_LOOKUP), YouTubeClient(db, user) as yt: items = yt.get_videos([video_id]) except YouTubeError as exc: raise HTTPException(status_code=422, detail=f"YouTube lookup failed: {exc}") diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py index 741b2ca..89228cb 100644 --- a/backend/app/routes/playlists.py +++ b/backend/app/routes/playlists.py @@ -245,7 +245,7 @@ def sync_youtube( status_code=403, detail="Connect YouTube (read access) to sync your playlists.", ) - with quota.attribute(user.id, "playlist_sync"): + with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL): return sync_user_playlists(db, user) @@ -266,7 +266,7 @@ def revert_youtube( status_code=403, detail="Connect YouTube (read access) to sync your playlists." ) try: - with quota.attribute(user.id, "playlist_sync"): + with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL): return repull_playlist(db, user, pl) except YouTubeError: db.rollback() @@ -298,7 +298,7 @@ def push_plan( status_code=403, detail="Enable YouTube editing in Settings first." ) try: - with quota.attribute(user.id, "playlist_push"): + with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH): plan = plan_push(db, user, pl) except YouTubeError: raise HTTPException(status_code=422, detail="Couldn't read the playlist on YouTube.") @@ -322,7 +322,7 @@ def push( status_code=403, detail="Enable YouTube editing in Settings first." ) try: - with quota.attribute(user.id, "playlist_push"): + with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH): plan = plan_push(db, user, pl) if plan["units_estimate"] > quota.remaining_today(db): raise HTTPException( @@ -418,7 +418,7 @@ def delete_playlist( status_code=403, detail="Enable YouTube editing in Settings first." ) try: - with quota.attribute(user.id, "playlist_delete"): + with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE): with YouTubeClient(db, user) as yt: yt.delete_playlist(pl.yt_playlist_id) except YouTubeError: diff --git a/backend/app/routes/sync.py b/backend/app/routes/sync.py index 02ffa91..e6fdcc2 100644 --- a/backend/app/routes/sync.py +++ b/backend/app/routes/sync.py @@ -41,7 +41,7 @@ def sync_subscriptions( detail="Connect your YouTube account (read access) to import subscriptions.", ) before = quota.units_used_today(db) - with quota.attribute(user.id, "sync_subscriptions"): + with quota.attribute(user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL): result = import_subscriptions(db, user) result["quota_used_estimate"] = quota.units_used_today(db) - before result["quota_remaining_today"] = quota.remaining_today(db) @@ -63,7 +63,7 @@ def sync_backfill( max_channels: int = 25, ) -> dict: before = quota.units_used_today(db) - with quota.attribute(user.id, "backfill_recent"): + with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT): result = run_recent_backfill(db, _user_channels(db, user), max_channels=max_channels) result["quota_used_estimate"] = quota.units_used_today(db) - before return result @@ -74,7 +74,7 @@ def sync_enrich( user: User = Depends(require_human), db: Session = Depends(get_db) ) -> dict: before = quota.units_used_today(db) - with quota.attribute(user.id, "enrich"): + with quota.attribute(user.id, quota.QuotaAction.VIDEOS_ENRICH): enriched = run_enrich(db) return { "enriched": enriched, diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index abcb70f..8c08c40 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -204,7 +204,7 @@ def _rss_job() -> None: def _enrich_job() -> None: def work(db): - with quota.attribute(None, "enrich"): + with quota.attribute(None, quota.QuotaAction.VIDEOS_ENRICH): return run_enrich(db) _job("enrich", work) @@ -214,9 +214,9 @@ def _backfill_job() -> None: # Recent-first for not-yet-synced channels, then deep backfill for the rest. All # background spend is attributed to the system (no actor), split by action. def work(db): - with quota.attribute(None, "backfill_recent"): + with quota.attribute(None, quota.QuotaAction.VIDEOS_BACKFILL_RECENT): recent = run_recent_backfill(db, max_channels=25) - with quota.attribute(None, "backfill_deep"): + with quota.attribute(None, quota.QuotaAction.VIDEOS_BACKFILL_FULL): deep = run_deep_backfill(db, max_channels=10) return {"recent": recent, "deep": deep} @@ -233,7 +233,7 @@ def _shorts_job() -> None: def _subscriptions_job() -> None: def work(db): - with quota.attribute(None, "subscription_resync"): + with quota.attribute(None, quota.QuotaAction.SUBSCRIPTIONS_RESYNC): return run_subscription_resync(db) _job("subscriptions", work) @@ -247,7 +247,7 @@ def _playlist_sync_job() -> None: def _maintenance_job() -> None: def work(db): - with quota.attribute(None, "maintenance"): + with quota.attribute(None, quota.QuotaAction.MAINTENANCE_REVALIDATE): return run_maintenance(db) _job("maintenance", work) diff --git a/backend/app/sync/playlists.py b/backend/app/sync/playlists.py index f21ef4f..a5fe3a7 100644 --- a/backend/app/sync/playlists.py +++ b/backend/app/sync/playlists.py @@ -387,7 +387,7 @@ def sync_all_playlists(db: Session) -> dict: progress.report(i, len(users), "playlist_sync") continue try: - with quota.attribute(user.id, "playlist_sync"): + with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL): total += sync_user_playlists(db, user).get("synced", 0) except YouTubeError: db.rollback() diff --git a/frontend/src/i18n/locales/de/quotaActions.json b/frontend/src/i18n/locales/de/quotaActions.json new file mode 100644 index 0000000..e1c128e --- /dev/null +++ b/frontend/src/i18n/locales/de/quotaActions.json @@ -0,0 +1,16 @@ +{ + "subscriptions_pull": "Abonnements einlesen", + "subscriptions_resync": "Automatische Abo-Resynchronisierung", + "playlists_pull": "Playlists einlesen", + "playlists_push": "Playlists hochladen", + "playlists_delete": "Playlist löschen", + "videos_backfill_recent": "Aktuelle Uploads laden", + "videos_backfill_full": "Vollständigen Verlauf laden", + "videos_enrich": "Video-Anreicherung", + "videos_lookup": "Video-Abfrage", + "channels_discover": "Kanal-Entdeckung", + "channels_subscribe": "Abonnieren", + "channels_unsubscribe": "Abo beenden", + "maintenance_revalidate": "Wartungs-Neuvalidierung", + "other": "Sonstiges" +} diff --git a/frontend/src/i18n/locales/en/quotaActions.json b/frontend/src/i18n/locales/en/quotaActions.json new file mode 100644 index 0000000..4805e4e --- /dev/null +++ b/frontend/src/i18n/locales/en/quotaActions.json @@ -0,0 +1,16 @@ +{ + "subscriptions_pull": "Read subscriptions", + "subscriptions_resync": "Auto subscription resync", + "playlists_pull": "Read playlists", + "playlists_push": "Push playlists", + "playlists_delete": "Delete playlist", + "videos_backfill_recent": "Recent backfill", + "videos_backfill_full": "Full-history backfill", + "videos_enrich": "Video enrichment", + "videos_lookup": "Video lookup", + "channels_discover": "Channel discovery", + "channels_subscribe": "Subscribe", + "channels_unsubscribe": "Unsubscribe", + "maintenance_revalidate": "Maintenance re-validation", + "other": "Other" +} diff --git a/frontend/src/i18n/locales/hu/quotaActions.json b/frontend/src/i18n/locales/hu/quotaActions.json new file mode 100644 index 0000000..6be1891 --- /dev/null +++ b/frontend/src/i18n/locales/hu/quotaActions.json @@ -0,0 +1,16 @@ +{ + "subscriptions_pull": "Feliratkozások beolvasása", + "subscriptions_resync": "Automatikus feliratkozás-újraszinkron", + "playlists_pull": "Lejátszási listák beolvasása", + "playlists_push": "Lejátszási listák feltöltése", + "playlists_delete": "Lejátszási lista törlése", + "videos_backfill_recent": "Friss feltöltések betöltése", + "videos_backfill_full": "Teljes előzmény betöltése", + "videos_enrich": "Videó-gazdagítás", + "videos_lookup": "Videó-lekérdezés", + "channels_discover": "Csatorna-felfedezés", + "channels_subscribe": "Feliratkozás", + "channels_unsubscribe": "Leiratkozás", + "maintenance_revalidate": "Karbantartó újraellenőrzés", + "other": "Egyéb" +} diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 5aa8ac2..d7ebe17 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -44,18 +44,11 @@ export function formatDuration(sec: number | null): string { return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; } -const QUOTA_ACTION_LABELS: Record = { - sync_subscriptions: "Sync subscriptions", - backfill_recent: "Recent backfill", - backfill_deep: "Full-history backfill", - enrich: "Enrichment", - unsubscribe: "Unsubscribe", - subscription_resync: "Auto subscription resync", - api: "Other", -}; - +// Human label for a canonical quota-action key (see backend app.quota.QuotaAction). Resolved +// from i18n (quotaActions.) so it's translated in all UI languages; falls back to the raw +// key for any unmapped/legacy value. export function quotaActionLabel(action: string): string { - return QUOTA_ACTION_LABELS[action] ?? action; + return i18n.t(`quotaActions.${action}`, { defaultValue: action }); } /** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). */ From 874520bd8a72e234dce4ee4f9ef61107a1b0bb5c Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 19 Jun 2026 11:48:20 +0200 Subject: [PATCH 2/2] feat(stats): consolidate per-user sync status into the Stats page The Settings > Sync tab moves into the Stats module: Stats is now a per-user page (Overview tab = sync status + your own API usage + manual actions) with the admin instance-wide quota dashboard + background-sync pause as an admin-only System tab. The Stats nav item is visible to all users (was admin-only); the Settings Sync tab is removed. Sync i18n strings move from settings.sync.* to stats.sync.* (EN/HU/DE). --- frontend/src/App.tsx | 4 +- frontend/src/components/NavSidebar.tsx | 7 +- frontend/src/components/SettingsPanel.tsx | 159 +------------ frontend/src/components/Stats.tsx | 251 ++++++++++++++++++--- frontend/src/i18n/locales/de/settings.json | 38 ---- frontend/src/i18n/locales/de/stats.json | 43 +++- frontend/src/i18n/locales/en/settings.json | 38 ---- frontend/src/i18n/locales/en/stats.json | 43 +++- frontend/src/i18n/locales/hu/settings.json | 38 ---- frontend/src/i18n/locales/hu/stats.json | 43 +++- 10 files changed, 358 insertions(+), 306 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 179cc92..7e85e97 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -465,8 +465,8 @@ export default function App() { notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) }); }} /> - ) : page === "stats" && meQuery.data!.role === "admin" ? ( - + ) : page === "stats" ? ( + ) : page === "scheduler" && meQuery.data!.role === "admin" ? ( ) : page === "playlists" ? ( diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index cef12ec..e229011 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -133,13 +133,12 @@ export default function NavSidebar({ { page: "channels", icon: Tv, label: t("header.account.channels") }, { page: "playlists", icon: ListVideo, label: t("header.account.playlists") }, { page: "notifications", icon: Bell, label: t("inbox.navLabel"), badge: unread }, + // Per-user sync status + your own API usage (admins get an extra system-wide tab inside). + { page: "stats", icon: BarChart3, label: t("header.account.stats") }, ]; const systemItems: NavItem[] = me.role === "admin" - ? [ - { page: "stats", icon: BarChart3, label: t("header.account.stats") }, - { page: "scheduler", icon: Activity, label: t("header.account.scheduler") }, - ] + ? [{ page: "scheduler", icon: Activity, label: t("header.account.scheduler") }] : []; const rowBase = diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 6e65456..c92ddbe 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -1,10 +1,9 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, RotateCcw, Save, Trash2, User, UserPlus, X } from "lucide-react"; +import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User, UserPlus, X } from "lucide-react"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { api, type Invite, type Me } from "../lib/api"; -import { formatEta, quotaActionLabel } from "../lib/format"; import Avatar from "./Avatar"; import { notify, type NotifSettings } from "../lib/notifications"; import Tooltip from "./Tooltip"; @@ -32,11 +31,10 @@ export interface PrefsController { saveState: PrefsSaveState; } -type TabId = "appearance" | "notifications" | "sync" | "account"; +type TabId = "appearance" | "notifications" | "account"; const TABS: { id: TabId; icon: typeof Monitor }[] = [ { id: "appearance", icon: Monitor }, { id: "notifications", icon: Bell }, - { id: "sync", icon: RefreshCw }, { id: "account", icon: User }, ]; @@ -98,7 +96,6 @@ export default function SettingsPanel({
{tab === "appearance" && } {tab === "notifications" && } - {tab === "sync" && } {tab === "account" && }
@@ -317,158 +314,6 @@ function Notifications({ prefs }: { prefs: PrefsController }) { ); } -function Sync({ me }: { me: Me }) { - const { t } = useTranslation(); - const qc = useQueryClient(); - const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); - const usage = useQuery({ queryKey: ["my-usage"], queryFn: api.myUsage }); - const s = status.data; - const syncSubs = useMutation({ - mutationFn: () => api.syncSubscriptions(), - onSuccess: (r: { subscriptions?: number }) => { - qc.invalidateQueries({ queryKey: ["my-status"] }); - qc.invalidateQueries({ queryKey: ["channels"] }); - notify({ - level: "success", - message: t("settings.sync.synced", { count: r.subscriptions ?? 0 }), - }); - }, - onError: () => notify({ level: "error", message: t("settings.sync.syncFailed") }), - }); - const pauseResume = useMutation({ - mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()), - onSuccess: () => qc.invalidateQueries({ queryKey: ["my-status"] }), - }); - const deepAll = useMutation({ - mutationFn: () => api.deepAll(true), - onSuccess: (r: { updated?: number }) => { - qc.invalidateQueries({ queryKey: ["my-status"] }); - qc.invalidateQueries({ queryKey: ["channels"] }); - notify({ - level: "success", - message: t("settings.sync.fullHistoryRequested", { count: r.updated ?? 0 }), - }); - }, - onError: () => notify({ level: "error", message: t("settings.sync.fullHistoryFailed") }), - }); - - return ( - <> -
- {s ? ( -
- - {s.channels_total} - - - {`${s.channels_recent_synced}/${s.channels_total}`} - - - {`${s.channels_deep_done}/${s.channels_deep_requested}`} - - {s.deep_pending_count > 0 && ( - - {formatEta(s.deep_eta_seconds)} - - )} - - {s.my_videos.toLocaleString()} - - - {s.quota_remaining_today.toLocaleString()} - -
- ) : ( -
{t("settings.sync.loading")}
- )} -
- -
- {usage.data ? ( - <> -
- - {usage.data.today.toLocaleString()} - - {usage.data.last_7d.toLocaleString()} - {usage.data.last_30d.toLocaleString()} - {usage.data.all_time.toLocaleString()} -
- {Object.keys(usage.data.by_action).length > 0 && ( -
-
{t("settings.sync.byAction")}
- {Object.entries(usage.data.by_action) - .sort((a, b) => b[1] - a[1]) - .map(([action, units]) => ( -
- {quotaActionLabel(action)} - {units.toLocaleString()} -
- ))} -
- )} - - ) : ( -
{t("settings.sync.noUsage")}
- )} -
- - {!me.is_demo && ( -
- - - - - - -
- )} - - {me.role === "admin" && s && ( -
- - - -
- )} - - ); -} - function AccessRow({ title, granted, diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index 46a776e..97a6109 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -1,24 +1,236 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { useQuery } from "@tanstack/react-query"; -import { api, type AdminQuotaRow } from "../lib/api"; -import { quotaActionLabel } from "../lib/format"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { History, Pause, Play, RefreshCw } from "lucide-react"; +import { api, type AdminQuotaRow, type Me } from "../lib/api"; +import { formatEta, quotaActionLabel } from "../lib/format"; +import { notify } from "../lib/notifications"; +import Tooltip from "./Tooltip"; const RANGES = [7, 30, 90] as const; +const STATS_TAB_KEY = "siftlode.statsTab"; +type StatsTab = "overview" | "system"; -export default function Stats() { +// Usage & stats page. "Overview" is per-user (sync status + your own API usage + actions); +// "System" is the admin-only instance-wide quota dashboard + background-sync control. The +// per-user content used to live in Settings → Sync; it moved here so Settings stays config-only. +export default function Stats({ me }: { me: Me }) { const { t } = useTranslation(); + const isAdmin = me.role === "admin"; + const [tab, setTabState] = useState(() => { + const v = localStorage.getItem(STATS_TAB_KEY); + return v === "system" && isAdmin ? "system" : "overview"; + }); + const setTab = (id: StatsTab) => { + setTabState(id); + localStorage.setItem(STATS_TAB_KEY, id); + }; + + return ( +
+ {/* Only admins have a second (System) tab; everyone else just sees the Overview. */} + {isAdmin && ( +
+ {(["overview", "system"] as StatsTab[]).map((id) => ( + + ))} +
+ )} + + {tab === "system" && isAdmin ? : } +
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+ {children} +
+ ); +} + +function Row({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { + return ( +
+ + + {label} + + + {children} +
+ ); +} + +// Per-user view: sync status + your own API usage + (for real accounts) the manual sync actions. +function Overview({ me }: { me: Me }) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); + const usage = useQuery({ queryKey: ["my-usage"], queryFn: api.myUsage }); + const s = status.data; + const syncSubs = useMutation({ + mutationFn: () => api.syncSubscriptions(), + onSuccess: (r: { subscriptions?: number }) => { + qc.invalidateQueries({ queryKey: ["my-status"] }); + qc.invalidateQueries({ queryKey: ["channels"] }); + notify({ level: "success", message: t("stats.sync.synced", { count: r.subscriptions ?? 0 }) }); + }, + onError: () => notify({ level: "error", message: t("stats.sync.syncFailed") }), + }); + const deepAll = useMutation({ + mutationFn: () => api.deepAll(true), + onSuccess: (r: { updated?: number }) => { + qc.invalidateQueries({ queryKey: ["my-status"] }); + qc.invalidateQueries({ queryKey: ["channels"] }); + notify({ + level: "success", + message: t("stats.sync.fullHistoryRequested", { count: r.updated ?? 0 }), + }); + }, + onError: () => notify({ level: "error", message: t("stats.sync.fullHistoryFailed") }), + }); + + return ( + <> +
+ {s ? ( +
+ + {s.channels_total} + + + {`${s.channels_recent_synced}/${s.channels_total}`} + + + {`${s.channels_deep_done}/${s.channels_deep_requested}`} + + {s.deep_pending_count > 0 && ( + + {formatEta(s.deep_eta_seconds)} + + )} + + {s.my_videos.toLocaleString()} + + + {s.quota_remaining_today.toLocaleString()} + +
+ ) : ( +
{t("stats.sync.loading")}
+ )} +
+ +
+ {usage.data ? ( + <> +
+ + {usage.data.today.toLocaleString()} + + {usage.data.last_7d.toLocaleString()} + {usage.data.last_30d.toLocaleString()} + {usage.data.all_time.toLocaleString()} +
+ {Object.keys(usage.data.by_action).length > 0 && ( +
+
{t("stats.sync.byAction")}
+ {Object.entries(usage.data.by_action) + .sort((a, b) => b[1] - a[1]) + .map(([action, units]) => ( +
+ {quotaActionLabel(action)} + {units.toLocaleString()} +
+ ))} +
+ )} + + ) : ( +
{t("stats.sync.noUsage")}
+ )} +
+ + {!me.is_demo && ( +
+ + + + + + +
+ )} + + ); +} + +// Admin-only view: instance-wide quota dashboard + the global background-sync pause toggle. +function SystemStats() { + const { t } = useTranslation(); + const qc = useQueryClient(); const [days, setDays] = useState(30); - const q = useQuery({ - queryKey: ["admin-quota", days], - queryFn: () => api.adminQuota(days), + const q = useQuery({ queryKey: ["admin-quota", days], queryFn: () => api.adminQuota(days) }); + const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); + const pauseResume = useMutation({ + mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()), + onSuccess: () => qc.invalidateQueries({ queryKey: ["my-status"] }), }); const data = q.data; const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total)); const grandTotal = (data?.rows ?? []).reduce((s, r) => s + r.total, 0); + const s = status.data; return ( -
+ <> + {s && ( +
+ + + +
+ )} +

{t("stats.title")}

@@ -53,25 +265,17 @@ export default function Stats() { {/* Daily totals (instance-wide, Pacific days) */}
- {t("stats.dailyTotal", { - count: data.range_days, - units: grandTotal.toLocaleString(), - })} + {t("stats.dailyTotal", { count: data.range_days, units: grandTotal.toLocaleString() })}
{data.daily.length === 0 ? (
{t("stats.noUsageInRange")}
) : (
{data.daily.map((d) => ( - // Each day gets a full-height track so the column is always visible - // (even on near-zero days); the accent fill renders the value on top.
)} -
+ ); } @@ -106,16 +310,11 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) { const isSystem = row.user_id === null; return (
- {/* Proportion bar */}
diff --git a/frontend/src/i18n/locales/de/settings.json b/frontend/src/i18n/locales/de/settings.json index 0b9677f..b610ea8 100644 --- a/frontend/src/i18n/locales/de/settings.json +++ b/frontend/src/i18n/locales/de/settings.json @@ -3,7 +3,6 @@ "tabs": { "appearance": "Darstellung", "notifications": "Benachrichtigungen", - "sync": "Synchronisierung", "account": "Konto" }, "save": { @@ -42,43 +41,6 @@ "testTitle": "Testbenachrichtigung", "testMessage": "So sieht eine Benachrichtigung aus." }, - "sync": { - "myStatus": "Mein Synchronisierungsstatus", - "channels": "Kanäle", - "channelsHint": "Wie viele Kanäle du abonniert hast.", - "recentSynced": "Neueste synchronisiert", - "recentSyncedHint": "Kanäle, deren neueste Uploads (~letztes Jahr) bereits in der lokalen Datenbank sind und so in deinem Feed erscheinen.", - "fullHistory": "Vollständiger Verlauf", - "fullHistoryHint": "Kanäle, deren gesamter Katalog abgerufen wurde, von denen, für die du den vollständigen Verlauf angefordert hast (pro Kanal auf der Kanäle-Seite aktivierbar).", - "fullHistoryEta": "Vollständiger Verlauf – Restzeit", - "fullHistoryEtaHint": "Grobe Schätzung, um das Nachladen des vollständigen Verlaufs von {{count}} ausstehenden Kanal/Kanälen abzuschließen, basierend auf dem gemeinsamen Tageskontingent.", - "myVideos": "Meine Videos", - "myVideosHint": "Gesamtzahl der dir verfügbaren Videos über deine abonnierten Kanäle.", - "quotaLeft": "Heute verbleibendes Kontingent", - "quotaLeftHint": "Heute verbleibendes gemeinsames YouTube-API-Budget (wird um Mitternacht US-Pazifik zurückgesetzt). Das Nachladen ordnet sich diesem unter.", - "loading": "Wird geladen…", - "apiUsage": "Deine API-Nutzung", - "today": "Heute", - "todayHint": "YouTube-API-Einheiten, die deine eigenen Aktionen heute verbraucht haben (wird um Mitternacht US-Pazifik zurückgesetzt). Die Hintergrund-Synchronisierung zählt hier nicht.", - "last7d": "Letzte 7 Tage", - "last30d": "Letzte 30 Tage", - "allTime": "Gesamt", - "byAction": "Nach Aktion (30 Tage)", - "noUsage": "Noch keine Nutzung.", - "actions": "Aktionen", - "syncSubscriptions": "Abos synchronisieren", - "syncSubscriptionsHint": "Importiert deine YouTube-Abos erneut und holt neu gefolgte Kanäle hinzu.", - "backfillEverything": "Alles nachladen", - "backfillEverythingHint": "Fordert das Nachladen des vollständigen Katalogs für jeden Kanal an, den du abonniert hast. Ältere Videos und die vollständige Suche werden gefüllt, soweit das gemeinsame Tageskontingent es zulässt.", - "synced": "{{count}} Abos synchronisiert", - "syncFailed": "Synchronisierung fehlgeschlagen", - "fullHistoryRequested": "Vollständiger Verlauf für {{count}} Kanäle angefordert", - "fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden", - "admin": "Admin", - "adminPauseHint": "Pausiert oder setzt die Hintergrund-Synchronisierung für die gesamte App fort (alle Nutzer).", - "resumeBackgroundSync": "Hintergrund-Synchronisierung fortsetzen", - "pauseBackgroundSync": "Hintergrund-Synchronisierung pausieren" - }, "account": { "title": "Konto", "youtubeAccess": "YouTube-Zugriff", diff --git a/frontend/src/i18n/locales/de/stats.json b/frontend/src/i18n/locales/de/stats.json index 6f90ffe..3abf5ce 100644 --- a/frontend/src/i18n/locales/de/stats.json +++ b/frontend/src/i18n/locales/de/stats.json @@ -1,4 +1,8 @@ { + "tabs": { + "overview": "Übersicht", + "system": "System" + }, "title": "API-Kontingentnutzung", "rangeDays": "{{count}} T", "introBefore": "YouTube-Data-API-Einheiten, zugeordnet danach, wer die Nutzung ausgelöst hat. ", @@ -9,5 +13,42 @@ "dailyTotal": "Tagessumme ({{count}} T · {{units}} Einheiten)", "noUsageInRange": "Keine Nutzung in diesem Zeitraum.", "dailyTooltip": "{{day}}: {{units}} Einheiten", - "system": "System (Hintergrund)" + "system": "System (Hintergrund)", + "sync": { + "myStatus": "Mein Synchronisierungsstatus", + "channels": "Kanäle", + "channelsHint": "Wie viele Kanäle du abonniert hast.", + "recentSynced": "Neueste synchronisiert", + "recentSyncedHint": "Kanäle, deren neueste Uploads (~letztes Jahr) bereits in der lokalen Datenbank sind und so in deinem Feed erscheinen.", + "fullHistory": "Vollständiger Verlauf", + "fullHistoryHint": "Kanäle, deren gesamter Katalog abgerufen wurde, von denen, für die du den vollständigen Verlauf angefordert hast (pro Kanal auf der Kanäle-Seite aktivierbar).", + "fullHistoryEta": "Vollständiger Verlauf – Restzeit", + "fullHistoryEtaHint": "Grobe Schätzung, um das Nachladen des vollständigen Verlaufs von {{count}} ausstehenden Kanal/Kanälen abzuschließen, basierend auf dem gemeinsamen Tageskontingent.", + "myVideos": "Meine Videos", + "myVideosHint": "Gesamtzahl der dir verfügbaren Videos über deine abonnierten Kanäle.", + "quotaLeft": "Heute verbleibendes Kontingent", + "quotaLeftHint": "Heute verbleibendes gemeinsames YouTube-API-Budget (wird um Mitternacht US-Pazifik zurückgesetzt). Das Nachladen ordnet sich diesem unter.", + "loading": "Wird geladen…", + "apiUsage": "Deine API-Nutzung", + "today": "Heute", + "todayHint": "YouTube-API-Einheiten, die deine eigenen Aktionen heute verbraucht haben (wird um Mitternacht US-Pazifik zurückgesetzt). Die Hintergrund-Synchronisierung zählt hier nicht.", + "last7d": "Letzte 7 Tage", + "last30d": "Letzte 30 Tage", + "allTime": "Gesamt", + "byAction": "Nach Aktion (30 Tage)", + "noUsage": "Noch keine Nutzung.", + "actions": "Aktionen", + "syncSubscriptions": "Abos synchronisieren", + "syncSubscriptionsHint": "Importiert deine YouTube-Abos erneut und holt neu gefolgte Kanäle hinzu.", + "backfillEverything": "Alles nachladen", + "backfillEverythingHint": "Fordert das Nachladen des vollständigen Katalogs für jeden Kanal an, den du abonniert hast. Ältere Videos und die vollständige Suche werden gefüllt, soweit das gemeinsame Tageskontingent es zulässt.", + "synced": "{{count}} Abos synchronisiert", + "syncFailed": "Synchronisierung fehlgeschlagen", + "fullHistoryRequested": "Vollständiger Verlauf für {{count}} Kanäle angefordert", + "fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden", + "admin": "Admin", + "adminPauseHint": "Pausiert oder setzt die Hintergrund-Synchronisierung für die gesamte App fort (alle Nutzer).", + "resumeBackgroundSync": "Hintergrund-Synchronisierung fortsetzen", + "pauseBackgroundSync": "Hintergrund-Synchronisierung pausieren" + } } diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index b7e837f..7302b80 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -3,7 +3,6 @@ "tabs": { "appearance": "Appearance", "notifications": "Notifications", - "sync": "Sync", "account": "Account" }, "save": { @@ -42,43 +41,6 @@ "testTitle": "Test notification", "testMessage": "This is what a notification looks like." }, - "sync": { - "myStatus": "My sync status", - "channels": "Channels", - "channelsHint": "How many channels you're subscribed to.", - "recentSynced": "Recent synced", - "recentSyncedHint": "Channels whose recent uploads (~last year) are already in the local database, so they show in your feed.", - "fullHistory": "Full history", - "fullHistoryHint": "Channels whose entire back-catalog has been fetched, out of the ones you've requested full history for (opt in per channel on the Channels page).", - "fullHistoryEta": "Full history ETA", - "fullHistoryEtaHint": "Rough estimate to finish full-history backfill of {{count}} pending channel(s), based on the shared daily quota.", - "myVideos": "My videos", - "myVideosHint": "Total videos available to you across your subscribed channels.", - "quotaLeft": "Quota left today", - "quotaLeftHint": "Shared YouTube API budget remaining today (resets midnight US Pacific). Backfill yields to it.", - "loading": "Loading…", - "apiUsage": "Your API usage", - "today": "Today", - "todayHint": "YouTube API units your own actions used today (resets midnight US Pacific). Background sync isn't counted here.", - "last7d": "Last 7 days", - "last30d": "Last 30 days", - "allTime": "All time", - "byAction": "By action (30d)", - "noUsage": "No usage yet.", - "actions": "Actions", - "syncSubscriptions": "Sync subscriptions", - "syncSubscriptionsHint": "Re-import your YouTube subscriptions and pull in any newly-followed channels.", - "backfillEverything": "Backfill everything", - "backfillEverythingHint": "Request full back-catalog backfill for every channel you're subscribed to. Older videos + complete search fill in as the shared daily quota allows.", - "synced": "Synced {{count}} subscriptions", - "syncFailed": "Sync failed", - "fullHistoryRequested": "Full history requested for {{count}} channels", - "fullHistoryFailed": "Couldn't request full history", - "admin": "Admin", - "adminPauseHint": "Pause or resume the background sync for the whole app (all users).", - "resumeBackgroundSync": "Resume background sync", - "pauseBackgroundSync": "Pause background sync" - }, "account": { "title": "Account", "youtubeAccess": "YouTube access", diff --git a/frontend/src/i18n/locales/en/stats.json b/frontend/src/i18n/locales/en/stats.json index b8a0efc..1131e73 100644 --- a/frontend/src/i18n/locales/en/stats.json +++ b/frontend/src/i18n/locales/en/stats.json @@ -1,4 +1,8 @@ { + "tabs": { + "overview": "Overview", + "system": "System" + }, "title": "API quota usage", "rangeDays": "{{count}}d", "introBefore": "YouTube Data API units attributed by who triggered the spend. ", @@ -9,5 +13,42 @@ "dailyTotal": "Daily total ({{count}}d · {{units}} units)", "noUsageInRange": "No usage in this range.", "dailyTooltip": "{{day}}: {{units}} units", - "system": "System (background)" + "system": "System (background)", + "sync": { + "myStatus": "My sync status", + "channels": "Channels", + "channelsHint": "How many channels you're subscribed to.", + "recentSynced": "Recent synced", + "recentSyncedHint": "Channels whose recent uploads (~last year) are already in the local database, so they show in your feed.", + "fullHistory": "Full history", + "fullHistoryHint": "Channels whose entire back-catalog has been fetched, out of the ones you've requested full history for (opt in per channel on the Channels page).", + "fullHistoryEta": "Full history ETA", + "fullHistoryEtaHint": "Rough estimate to finish full-history backfill of {{count}} pending channel(s), based on the shared daily quota.", + "myVideos": "My videos", + "myVideosHint": "Total videos available to you across your subscribed channels.", + "quotaLeft": "Quota left today", + "quotaLeftHint": "Shared YouTube API budget remaining today (resets midnight US Pacific). Backfill yields to it.", + "loading": "Loading…", + "apiUsage": "Your API usage", + "today": "Today", + "todayHint": "YouTube API units your own actions used today (resets midnight US Pacific). Background sync isn't counted here.", + "last7d": "Last 7 days", + "last30d": "Last 30 days", + "allTime": "All time", + "byAction": "By action (30d)", + "noUsage": "No usage yet.", + "actions": "Actions", + "syncSubscriptions": "Sync subscriptions", + "syncSubscriptionsHint": "Re-import your YouTube subscriptions and pull in any newly-followed channels.", + "backfillEverything": "Backfill everything", + "backfillEverythingHint": "Request full back-catalog backfill for every channel you're subscribed to. Older videos + complete search fill in as the shared daily quota allows.", + "synced": "Synced {{count}} subscriptions", + "syncFailed": "Sync failed", + "fullHistoryRequested": "Full history requested for {{count}} channels", + "fullHistoryFailed": "Couldn't request full history", + "admin": "Admin", + "adminPauseHint": "Pause or resume the background sync for the whole app (all users).", + "resumeBackgroundSync": "Resume background sync", + "pauseBackgroundSync": "Pause background sync" + } } diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index 0212e51..d06369f 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -3,7 +3,6 @@ "tabs": { "appearance": "Megjelenés", "notifications": "Értesítések", - "sync": "Szinkronizálás", "account": "Fiók" }, "save": { @@ -42,43 +41,6 @@ "testTitle": "Tesztértesítés", "testMessage": "Így néz ki egy értesítés." }, - "sync": { - "myStatus": "Saját szinkronizálási állapot", - "channels": "Csatornák", - "channelsHint": "Hány csatornára vagy feliratkozva.", - "recentSynced": "Friss szinkronizálva", - "recentSyncedHint": "Azok a csatornák, amelyek legutóbbi feltöltései (~az elmúlt év) már a helyi adatbázisban vannak, így megjelennek a hírfolyamodban.", - "fullHistory": "Teljes előzmény", - "fullHistoryHint": "Azok a csatornák, amelyek teljes archívuma letöltődött, azokból, amelyekhez teljes előzményt kértél (csatornánként választható a Csatornák oldalon).", - "fullHistoryEta": "Teljes előzmény becsült ideje", - "fullHistoryEtaHint": "Hozzávetőleges becslés {{count}} függőben lévő csatorna teljes előzményének letöltéséhez, a megosztott napi kvóta alapján.", - "myVideos": "Saját videók", - "myVideosHint": "A számodra elérhető videók összesen a feliratkozott csatornáidon.", - "quotaLeft": "Ma maradt kvóta", - "quotaLeftHint": "A ma fennmaradó megosztott YouTube API-keret (éjfélkor nullázódik, USA csendes-óceáni idő szerint). A letöltés ennek alárendelődik.", - "loading": "Betöltés…", - "apiUsage": "Saját API-használat", - "today": "Ma", - "todayHint": "A saját műveleteid által ma használt YouTube API-egységek (éjfélkor nullázódik, USA csendes-óceáni idő szerint). A háttér-szinkronizálás itt nem számít bele.", - "last7d": "Utolsó 7 nap", - "last30d": "Utolsó 30 nap", - "allTime": "Mindenkori", - "byAction": "Művelet szerint (30 nap)", - "noUsage": "Még nincs használat.", - "actions": "Műveletek", - "syncSubscriptions": "Feliratkozások szinkronizálása", - "syncSubscriptionsHint": "Újraimportálja a YouTube-feliratkozásaidat és behúzza az újonnan követett csatornákat.", - "backfillEverything": "Minden letöltése", - "backfillEverythingHint": "Teljes archívum letöltésének kérése minden csatornához, amelyre fel vagy iratkozva. A régebbi videók és a teljes keresés annyira töltődnek fel, amennyit a megosztott napi kvóta enged.", - "synced": "{{count}} feliratkozás szinkronizálva", - "syncFailed": "A szinkronizálás sikertelen", - "fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához", - "fullHistoryFailed": "Nem sikerült teljes előzményt kérni", - "admin": "Adminisztrátor", - "adminPauseHint": "A háttér-szinkronizálás szüneteltetése vagy folytatása az egész alkalmazásra (minden felhasználóra).", - "resumeBackgroundSync": "Háttér-szinkronizálás folytatása", - "pauseBackgroundSync": "Háttér-szinkronizálás szüneteltetése" - }, "account": { "title": "Fiók", "youtubeAccess": "YouTube-hozzáférés", diff --git a/frontend/src/i18n/locales/hu/stats.json b/frontend/src/i18n/locales/hu/stats.json index 3c1b901..d85adb0 100644 --- a/frontend/src/i18n/locales/hu/stats.json +++ b/frontend/src/i18n/locales/hu/stats.json @@ -1,4 +1,8 @@ { + "tabs": { + "overview": "Áttekintés", + "system": "Rendszer" + }, "title": "API-kvóta használat", "rangeDays": "{{count}} nap", "introBefore": "A YouTube Data API-egységek aszerint, hogy ki váltotta ki a felhasználást. A ", @@ -9,5 +13,42 @@ "dailyTotal": "Napi összesen ({{count}} nap · {{units}} egység)", "noUsageInRange": "Nincs használat ebben az időszakban.", "dailyTooltip": "{{day}}: {{units}} egység", - "system": "Rendszer (háttér)" + "system": "Rendszer (háttér)", + "sync": { + "myStatus": "Saját szinkronizálási állapot", + "channels": "Csatornák", + "channelsHint": "Hány csatornára vagy feliratkozva.", + "recentSynced": "Friss szinkronizálva", + "recentSyncedHint": "Azok a csatornák, amelyek legutóbbi feltöltései (~az elmúlt év) már a helyi adatbázisban vannak, így megjelennek a hírfolyamodban.", + "fullHistory": "Teljes előzmény", + "fullHistoryHint": "Azok a csatornák, amelyek teljes archívuma letöltődött, azokból, amelyekhez teljes előzményt kértél (csatornánként választható a Csatornák oldalon).", + "fullHistoryEta": "Teljes előzmény becsült ideje", + "fullHistoryEtaHint": "Hozzávetőleges becslés {{count}} függőben lévő csatorna teljes előzményének letöltéséhez, a megosztott napi kvóta alapján.", + "myVideos": "Saját videók", + "myVideosHint": "A számodra elérhető videók összesen a feliratkozott csatornáidon.", + "quotaLeft": "Ma maradt kvóta", + "quotaLeftHint": "A ma fennmaradó megosztott YouTube API-keret (éjfélkor nullázódik, USA csendes-óceáni idő szerint). A letöltés ennek alárendelődik.", + "loading": "Betöltés…", + "apiUsage": "Saját API-használat", + "today": "Ma", + "todayHint": "A saját műveleteid által ma használt YouTube API-egységek (éjfélkor nullázódik, USA csendes-óceáni idő szerint). A háttér-szinkronizálás itt nem számít bele.", + "last7d": "Utolsó 7 nap", + "last30d": "Utolsó 30 nap", + "allTime": "Mindenkori", + "byAction": "Művelet szerint (30 nap)", + "noUsage": "Még nincs használat.", + "actions": "Műveletek", + "syncSubscriptions": "Feliratkozások szinkronizálása", + "syncSubscriptionsHint": "Újraimportálja a YouTube-feliratkozásaidat és behúzza az újonnan követett csatornákat.", + "backfillEverything": "Minden letöltése", + "backfillEverythingHint": "Teljes archívum letöltésének kérése minden csatornához, amelyre fel vagy iratkozva. A régebbi videók és a teljes keresés annyira töltődnek fel, amennyit a megosztott napi kvóta enged.", + "synced": "{{count}} feliratkozás szinkronizálva", + "syncFailed": "A szinkronizálás sikertelen", + "fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához", + "fullHistoryFailed": "Nem sikerült teljes előzményt kérni", + "admin": "Adminisztrátor", + "adminPauseHint": "A háttér-szinkronizálás szüneteltetése vagy folytatása az egész alkalmazásra (minden felhasználóra).", + "resumeBackgroundSync": "Háttér-szinkronizálás folytatása", + "pauseBackgroundSync": "Háttér-szinkronizálás szüneteltetése" + } }