Merge bug/notif-context-and-api-proxy — notification context + YouTube API egress proxy

This commit is contained in:
npeter83 2026-06-26 00:36:22 +02:00
commit ec1e8c6e4f
22 changed files with 58 additions and 44 deletions

View file

@ -65,6 +65,10 @@ class Settings(BaseSettings):
# Optional API key for public reads (channels/videos/playlistItems). When set it is # Optional API key for public reads (channels/videos/playlistItems). When set it is
# preferred for shared backfill/enrichment so it doesn't depend on a specific user. # preferred for shared backfill/enrichment so it doesn't depend on a specific user.
youtube_api_key: str = "" youtube_api_key: str = ""
# Optional HTTP(S) proxy for outbound YouTube Data API calls. Set this to send the
# scheduler's googleapis traffic through a fixed-IP host (e.g. the VPS over WireGuard) so an
# IP-restricted API key keeps working even when this host's public IP is dynamic.
youtube_api_proxy: str = ""
# Daily quota budget (units). Default leaves headroom under the 10,000/day free limit. # Daily quota budget (units). Default leaves headroom under the 10,000/day free limit.
quota_daily_budget: int = 9000 quota_daily_budget: int = 9000
# Recent-first backfill: how far back to fetch on the first pass per channel. # Recent-first backfill: how far back to fetch on the first pass per channel.

View file

@ -50,6 +50,8 @@ SPECS: tuple[ConfigSpec, ...] = (
ConfigSpec("autotag_title_sample", "int", "batch", "autotag_title_sample", min=1, max=1_000), ConfigSpec("autotag_title_sample", "int", "batch", "autotag_title_sample", min=1, max=1_000),
# --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) --- # --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) ---
ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True), ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True),
# Optional egress proxy for YouTube API calls (fixed-IP host for an IP-restricted key).
ConfigSpec("youtube_api_proxy", "str", "youtube", "youtube_api_proxy"),
# --- Google OAuth client (Sign in with Google; both encrypted, env fallback). Set from the # --- Google OAuth client (Sign in with Google; both encrypted, env fallback). Set from the
# install wizard / Configuration page so no restart is needed when the creds change. --- # install wizard / Configuration page so no restart is needed when the creds change. ---
ConfigSpec("google_client_id", "str", "google", "google_client_id", secret=True), ConfigSpec("google_client_id", "str", "google", "google_client_id", secret=True),

View file

@ -44,7 +44,10 @@ class YouTubeClient:
def __init__(self, db, user: User): def __init__(self, db, user: User):
self.db = db self.db = db
self.user = user self.user = user
self._http = httpx.Client(timeout=30.0) # Optionally route all YouTube traffic through a fixed-IP egress proxy (e.g. the VPS
# over WireGuard) so an IP-restricted API key keeps working from a dynamic-IP host.
proxy = sysconfig.get_str(db, "youtube_api_proxy") or None
self._http = httpx.Client(timeout=30.0, proxy=proxy)
def __enter__(self) -> "YouTubeClient": def __enter__(self) -> "YouTubeClient":
return self return self

View file

@ -242,10 +242,10 @@ function AdminInvites() {
}); });
const add = useMutation({ const add = useMutation({
mutationFn: (email: string) => api.addInvite(email), mutationFn: (email: string) => api.addInvite(email),
onSuccess: () => { onSuccess: (_d, email) => {
setNewEmail(""); setNewEmail("");
refresh(); refresh();
notify({ level: "success", message: t("settings.invites.addedToWhitelist") }); notify({ level: "success", message: t("settings.invites.addedToWhitelist", { email }) });
}, },
onError: () => notify({ level: "error", message: t("settings.invites.addFailed") }), onError: () => notify({ level: "error", message: t("settings.invites.addFailed") }),
}); });
@ -324,10 +324,10 @@ function AdminDemo() {
const add = useMutation({ const add = useMutation({
mutationFn: (email: string) => api.addDemoWhitelist(email), mutationFn: (email: string) => api.addDemoWhitelist(email),
onSuccess: () => { onSuccess: (_d, email) => {
setNewEmail(""); setNewEmail("");
qc.invalidateQueries({ queryKey: ["demo-whitelist"] }); qc.invalidateQueries({ queryKey: ["demo-whitelist"] });
notify({ level: "success", message: t("settings.demo.added") }); notify({ level: "success", message: t("settings.demo.added", { email }) });
}, },
onError: () => notify({ level: "error", message: t("settings.demo.addFailed") }), onError: () => notify({ level: "error", message: t("settings.demo.addFailed") }),
}); });

View file

@ -157,20 +157,20 @@ export default function Channels({
onError: (e) => notifyActionError(e, "channels.notify.syncFailed"), onError: (e) => notifyActionError(e, "channels.notify.syncFailed"),
}); });
const unsubscribe = useMutation({ const unsubscribe = useMutation({
mutationFn: (id: string) => api.unsubscribeChannel(id), mutationFn: (v: { id: string; name: string }) => api.unsubscribeChannel(v.id),
onSuccess: () => { onSuccess: (_d, v) => {
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: ["my-status"] });
notify({ level: "success", message: t("channels.notify.unsubscribed") }); notify({ level: "success", message: t("channels.notify.unsubscribed", { name: v.name }) });
}, },
onError: (e) => notifyActionError(e, "channels.notify.unsubscribeFailed"), onError: (e) => notifyActionError(e, "channels.notify.unsubscribeFailed"),
}); });
const resetBackfill = useMutation({ const resetBackfill = useMutation({
mutationFn: (id: string) => api.resetChannelBackfill(id), mutationFn: (v: { id: string; name: string }) => api.resetChannelBackfill(v.id),
onSuccess: () => { onSuccess: (_d, v) => {
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: ["my-status"] });
notify({ level: "success", message: t("channels.notify.resetDone") }); notify({ level: "success", message: t("channels.notify.resetDone", { name: v.name }) });
}, },
onError: (e) => notifyActionError(e, "channels.notify.resetFailed"), onError: (e) => notifyActionError(e, "channels.notify.resetFailed"),
}); });
@ -210,7 +210,7 @@ export default function Channels({
confirmLabel: t("channels.row.unsubscribeOnYoutube"), confirmLabel: t("channels.row.unsubscribeOnYoutube"),
danger: true, danger: true,
}); });
if (ok) unsubscribe.mutate(c.id); if (ok) unsubscribe.mutate({ id: c.id, name: c.title ?? c.id });
}; };
const onReset = async (c: ManagedChannel) => { const onReset = async (c: ManagedChannel) => {
const ok = await confirm({ const ok = await confirm({
@ -218,7 +218,7 @@ export default function Channels({
message: t("channels.confirmReset", { name: c.title ?? c.id }), message: t("channels.confirmReset", { name: c.title ?? c.id }),
confirmLabel: t("channels.row.backfillThis"), confirmLabel: t("channels.row.backfillThis"),
}); });
if (ok) resetBackfill.mutate(c.id); if (ok) resetBackfill.mutate({ id: c.id, name: c.title ?? c.id });
}; };
const columns: Column<ManagedChannel>[] = [ const columns: Column<ManagedChannel>[] = [

View file

@ -353,11 +353,12 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
danger: true, danger: true,
}); });
if (!ok) return; if (!ok) return;
const plName = detail.kind === "watch_later" ? t("playlists.watchLater") : detail.name;
setReverting(true); setReverting(true);
try { try {
await api.revertPlaylist(selectedId); await api.revertPlaylist(selectedId);
refreshAll(); refreshAll();
notify({ level: "success", message: t("playlists.revertDone") }); notify({ level: "success", message: t("playlists.revertDone", { name: plName }) });
} catch { } catch {
notify({ level: "warning", message: t("playlists.revertFailed") }); notify({ level: "warning", message: t("playlists.revertFailed") });
} finally { } finally {
@ -367,11 +368,12 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
async function pushToYoutube() { async function pushToYoutube() {
if (selectedId == null || !detail || pushing) return; if (selectedId == null || !detail || pushing) return;
const plName = detail.kind === "watch_later" ? t("playlists.watchLater") : detail.name;
setPushing(true); setPushing(true);
try { try {
const plan = await api.playlistPushPlan(selectedId); const plan = await api.playlistPushPlan(selectedId);
if (plan.action === "update" && !plan.to_insert && !plan.to_delete && !plan.to_reorder) { if (plan.action === "update" && !plan.to_insert && !plan.to_delete && !plan.to_reorder) {
notify({ level: "info", message: t("playlists.pushUpToDate") }); notify({ level: "info", message: t("playlists.pushUpToDate", { name: plName }) });
return; return;
} }
if (!plan.affordable) { if (!plan.affordable) {
@ -410,7 +412,7 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
if (r.failures.length) { if (r.failures.length) {
notify({ level: "warning", message: t("playlists.pushPartial", { count: r.failures.length }) }); notify({ level: "warning", message: t("playlists.pushPartial", { count: r.failures.length }) });
} else { } else {
notify({ level: "success", message: t("playlists.pushDone") }); notify({ level: "success", message: t("playlists.pushDone", { name: plName }) });
} }
} catch { } catch {
notify({ level: "warning", message: t("playlists.pushFailed") }); notify({ level: "warning", message: t("playlists.pushFailed") });

View file

@ -137,10 +137,10 @@ export default function TagManager({
onSuccess: invalidate, onSuccess: invalidate,
}); });
const del = useMutation({ const del = useMutation({
mutationFn: (id: number) => api.deleteTag(id), mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id),
onSuccess: () => { onSuccess: (_d, v) => {
invalidate(); invalidate();
notify({ level: "success", message: t("tagManager.deleted") }); notify({ level: "success", message: t("tagManager.deleted", { name: v.name }) });
}, },
}); });
@ -179,7 +179,7 @@ export default function TagManager({
}); });
if (!ok) return; if (!ok) return;
} }
del.mutate(tag.id); del.mutate({ id: tag.id, name: tag.name });
}} }}
/> />
))} ))}

View file

@ -111,14 +111,14 @@
"notify": { "notify": {
"synced": "{{count}} Abos synchronisiert", "synced": "{{count}} Abos synchronisiert",
"syncFailed": "Abo-Synchronisierung fehlgeschlagen", "syncFailed": "Abo-Synchronisierung fehlgeschlagen",
"unsubscribed": "Bei YouTube abbestellt", "unsubscribed": "{{name}} bei YouTube abbestellt",
"unsubscribeFailed": "Abbestellen fehlgeschlagen", "unsubscribeFailed": "Abbestellen fehlgeschlagen",
"fullHistoryRequested": "Vollständiger Verlauf für {{count}} Kanäle angefordert", "fullHistoryRequested": "Vollständiger Verlauf für {{count}} Kanäle angefordert",
"fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden", "fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden",
"needYouTube": "Verbinde dein YouTube-Konto, um dies zu tun.", "needYouTube": "Verbinde dein YouTube-Konto, um dies zu tun.",
"connect": "Verbinden", "connect": "Verbinden",
"filteredByTag": "Feed nach Tag gefiltert: {{name}}", "filteredByTag": "Feed nach Tag gefiltert: {{name}}",
"resetDone": "Kanal zurückgesetzt — wird neu geladen", "resetDone": "{{name}} zurückgesetzt — wird neu geladen",
"resetFailed": "Kanal konnte nicht zurückgesetzt werden" "resetFailed": "Kanal konnte nicht zurückgesetzt werden"
}, },
"confirmUnsubscribe": "Kanal „{{name}}“ bei YouTube abbestellen? Dies ändert dein echtes YouTube-Konto. Um ihn nur aus deinem Feed zu entfernen, blende ihn stattdessen aus.", "confirmUnsubscribe": "Kanal „{{name}}“ bei YouTube abbestellen? Dies ändert dein echtes YouTube-Konto. Um ihn nur aus deinem Feed zu entfernen, blende ihn stattdessen aus.",

View file

@ -26,6 +26,7 @@
"enrich_batch_size": { "label": "Anreicherungs-Stapelgröße", "hint": "videos.list-IDs pro Aufruf (YouTube begrenzt auf 50)." }, "enrich_batch_size": { "label": "Anreicherungs-Stapelgröße", "hint": "videos.list-IDs pro Aufruf (YouTube begrenzt auf 50)." },
"autotag_title_sample": { "label": "Auto-Tag-Titelstichprobe", "hint": "Pro Kanal abgetastete aktuelle Videotitel zur Spracherkennung." }, "autotag_title_sample": { "label": "Auto-Tag-Titelstichprobe", "hint": "Pro Kanal abgetastete aktuelle Videotitel zur Spracherkennung." },
"youtube_api_key": { "label": "YouTube-API-Schlüssel", "hint": "Optional. Für öffentliche Lesezugriffe (Kanäle/Videos), damit gemeinsames Nachladen nicht vom OAuth eines Nutzers abhängt. Verschlüsselt gespeichert; nur schreibbar." }, "youtube_api_key": { "label": "YouTube-API-Schlüssel", "hint": "Optional. Für öffentliche Lesezugriffe (Kanäle/Videos), damit gemeinsames Nachladen nicht vom OAuth eines Nutzers abhängt. Verschlüsselt gespeichert; nur schreibbar." },
"youtube_api_proxy": { "label": "YouTube-API-Egress-Proxy", "hint": "Optional. HTTP(S)-Proxy-URL für ausgehende YouTube-API-Aufrufe — über einen Host mit fester IP leiten, damit ein IP-beschränkter Schlüssel von einem Server mit dynamischer IP funktioniert. Leer = direkt." },
"google_client_id": { "label": "Google-Client-ID", "hint": "OAuth-2.0-Client-ID für die Google-Anmeldung. Verschlüsselt gespeichert; nur schreibbar. Beide Google-Felder leer lassen, um die Google-Anmeldung zu deaktivieren (E-Mail+Passwort funktioniert weiter)." }, "google_client_id": { "label": "Google-Client-ID", "hint": "OAuth-2.0-Client-ID für die Google-Anmeldung. Verschlüsselt gespeichert; nur schreibbar. Beide Google-Felder leer lassen, um die Google-Anmeldung zu deaktivieren (E-Mail+Passwort funktioniert weiter)." },
"google_client_secret": { "label": "Google-Client-Secret", "hint": "OAuth-2.0-Client-Secret. Verschlüsselt gespeichert; nur schreibbar." }, "google_client_secret": { "label": "Google-Client-Secret", "hint": "OAuth-2.0-Client-Secret. Verschlüsselt gespeichert; nur schreibbar." },
"allow_registration": { "label": "Registrierung erlauben", "hint": "Wenn aktiviert, kann jeder eine E-Mail+Passwort-Registrierung einreichen. Für die Anmeldung sind weiterhin E-Mail-Bestätigung und Admin-Freigabe nötig." } "allow_registration": { "label": "Registrierung erlauben", "hint": "Wenn aktiviert, kann jeder eine E-Mail+Passwort-Registrierung einreichen. Für die Anmeldung sind weiterhin E-Mail-Bestätigung und Admin-Freigabe nötig." }

View file

@ -26,7 +26,7 @@
"revertTitle": "Lokale Änderungen verwerfen?", "revertTitle": "Lokale Änderungen verwerfen?",
"revertMsg": "Dies lädt die Wiedergabeliste von YouTube neu und verwirft deine nicht synchronisierten lokalen Änderungen (Reihenfolge, Hinzufügungen, Entfernungen, Umbenennung). Fortfahren?", "revertMsg": "Dies lädt die Wiedergabeliste von YouTube neu und verwirft deine nicht synchronisierten lokalen Änderungen (Reihenfolge, Hinzufügungen, Entfernungen, Umbenennung). Fortfahren?",
"revertConfirm": "Verwerfen & neu laden", "revertConfirm": "Verwerfen & neu laden",
"revertDone": "Von YouTube neu geladen ✓", "revertDone": "„{{name}}“ von YouTube neu geladen ✓",
"revertFailed": "Neuladen von YouTube fehlgeschlagen.", "revertFailed": "Neuladen von YouTube fehlgeschlagen.",
"pushTitle": "Mit YouTube synchronisieren", "pushTitle": "Mit YouTube synchronisieren",
"pushConfirm": "Synchronisieren", "pushConfirm": "Synchronisieren",
@ -34,8 +34,8 @@
"pushPlanUpdate": "Auf YouTube aktualisieren — {{insert}} hinzufügen, {{del}} entfernen, {{reorder}} neu anordnen? (~{{units}} Kontingenteinheiten; heute noch {{left}} übrig.)", "pushPlanUpdate": "Auf YouTube aktualisieren — {{insert}} hinzufügen, {{del}} entfernen, {{reorder}} neu anordnen? (~{{units}} Kontingenteinheiten; heute noch {{left}} übrig.)",
"pushDiverged": "{{count}} Video(s), die derzeit auf YouTube sind, werden entfernt.", "pushDiverged": "{{count}} Video(s), die derzeit auf YouTube sind, werden entfernt.",
"pushNoQuota": "Nicht genug YouTube-Kontingent für heute ({{left}}) für diese Synchronisierung (~{{units}} benötigt). Versuche es morgen erneut.", "pushNoQuota": "Nicht genug YouTube-Kontingent für heute ({{left}}) für diese Synchronisierung (~{{units}} benötigt). Versuche es morgen erneut.",
"pushUpToDate": "Bereits mit YouTube synchronisiert.", "pushUpToDate": "„{{name}}“ ist bereits mit YouTube synchronisiert.",
"pushDone": "Mit YouTube synchronisiert ✓", "pushDone": "„{{name}}“ mit YouTube synchronisiert ✓",
"pushPartial": "Synchronisiert, aber {{count}} Element(e) wurden übersprungen.", "pushPartial": "Synchronisiert, aber {{count}} Element(e) wurden übersprungen.",
"pushFailed": "Synchronisierung mit YouTube fehlgeschlagen.", "pushFailed": "Synchronisierung mit YouTube fehlgeschlagen.",
"deleteOnYoutubeTitle": "Auch auf YouTube löschen?", "deleteOnYoutubeTitle": "Auch auf YouTube löschen?",

View file

@ -93,7 +93,7 @@
"intro": "E-Mails hier können das gemeinsame Demo-Konto direkt von der Anmeldeseite aus betreten — ohne Google-Anmeldung. Sie tippen die Adresse einfach ins Feld und werden nach einem Moment eingelassen.", "intro": "E-Mails hier können das gemeinsame Demo-Konto direkt von der Anmeldeseite aus betreten — ohne Google-Anmeldung. Sie tippen die Adresse einfach ins Feld und werden nach einem Moment eingelassen.",
"addPlaceholder": "E-Mail für die Demo auf die Whitelist…", "addPlaceholder": "E-Mail für die Demo auf die Whitelist…",
"add": "Hinzufügen", "add": "Hinzufügen",
"added": "Zur Demo-Whitelist hinzugefügt", "added": "{{email}} zur Demo-Whitelist hinzugefügt",
"addFailed": "Diese E-Mail konnte nicht hinzugefügt werden", "addFailed": "Diese E-Mail konnte nicht hinzugefügt werden",
"removeFailed": "Diese E-Mail konnte nicht entfernt werden", "removeFailed": "Diese E-Mail konnte nicht entfernt werden",
"remove": "Entfernen", "remove": "Entfernen",
@ -115,7 +115,7 @@
"approved": "{{email}} genehmigt — sie können sich jetzt anmelden", "approved": "{{email}} genehmigt — sie können sich jetzt anmelden",
"approveFailed": "Genehmigung fehlgeschlagen", "approveFailed": "Genehmigung fehlgeschlagen",
"denyFailed": "Ablehnung fehlgeschlagen", "denyFailed": "Ablehnung fehlgeschlagen",
"addedToWhitelist": "Zur Whitelist hinzugefügt", "addedToWhitelist": "{{email}} zur Whitelist hinzugefügt",
"addFailed": "Diese E-Mail konnte nicht hinzugefügt werden", "addFailed": "Diese E-Mail konnte nicht hinzugefügt werden",
"requested": "angefragt {{time}}", "requested": "angefragt {{time}}",
"approveHint": "Genehmigen — setzt diese E-Mail auf die Whitelist und benachrichtigt sie per E-Mail, dass sie dabei sind.", "approveHint": "Genehmigen — setzt diese E-Mail auf die Whitelist und benachrichtigt sie per E-Mail, dass sie dabei sind.",

View file

@ -3,7 +3,7 @@
"channels": "{{count}} Kan.", "channels": "{{count}} Kan.",
"onChannels": "Getaggte Kanäle", "onChannels": "Getaggte Kanäle",
"delete": "Löschen", "delete": "Löschen",
"deleted": "Tag gelöscht", "deleted": "Tag „{{name}}“ gelöscht",
"deleteTitle": "Tag löschen", "deleteTitle": "Tag löschen",
"confirmDelete": "Tag „{{name}}“ löschen? Er wird von allen Kanälen entfernt, auf denen er liegt.", "confirmDelete": "Tag „{{name}}“ löschen? Er wird von allen Kanälen entfernt, auf denen er liegt.",
"empty": "Noch keine Tags — füge unten einen hinzu.", "empty": "Noch keine Tags — füge unten einen hinzu.",

View file

@ -111,14 +111,14 @@
"notify": { "notify": {
"synced": "Synced {{count}} subscriptions", "synced": "Synced {{count}} subscriptions",
"syncFailed": "Subscription sync failed", "syncFailed": "Subscription sync failed",
"unsubscribed": "Unsubscribed on YouTube", "unsubscribed": "Unsubscribed from {{name}} on YouTube",
"unsubscribeFailed": "Unsubscribe failed", "unsubscribeFailed": "Unsubscribe failed",
"fullHistoryRequested": "Full history requested for {{count}} channels", "fullHistoryRequested": "Full history requested for {{count}} channels",
"fullHistoryFailed": "Couldn't request full history", "fullHistoryFailed": "Couldn't request full history",
"needYouTube": "Connect your YouTube account to do this.", "needYouTube": "Connect your YouTube account to do this.",
"connect": "Connect", "connect": "Connect",
"filteredByTag": "Feed filtered by tag: {{name}}", "filteredByTag": "Feed filtered by tag: {{name}}",
"resetDone": "Channel reset — re-fetching now", "resetDone": "{{name}} reset — re-fetching now",
"resetFailed": "Couldn't reset this channel" "resetFailed": "Couldn't reset this channel"
}, },
"confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead.", "confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead.",

View file

@ -26,6 +26,7 @@
"enrich_batch_size": { "label": "Enrichment batch size", "hint": "videos.list ids per call (YouTube caps this at 50)." }, "enrich_batch_size": { "label": "Enrichment batch size", "hint": "videos.list ids per call (YouTube caps this at 50)." },
"autotag_title_sample": { "label": "Auto-tag title sample", "hint": "Recent video titles sampled per channel for language detection." }, "autotag_title_sample": { "label": "Auto-tag title sample", "hint": "Recent video titles sampled per channel for language detection." },
"youtube_api_key": { "label": "YouTube API key", "hint": "Optional. Used for public reads (channels/videos) so shared backfill doesn't depend on a user's OAuth. Stored encrypted; write-only." }, "youtube_api_key": { "label": "YouTube API key", "hint": "Optional. Used for public reads (channels/videos) so shared backfill doesn't depend on a user's OAuth. Stored encrypted; write-only." },
"youtube_api_proxy": { "label": "YouTube API egress proxy", "hint": "Optional. HTTP(S) proxy URL for outbound YouTube API calls — route them through a fixed-IP host so an IP-restricted key keeps working from a dynamic-IP server. Leave empty for direct." },
"google_client_id": { "label": "Google client ID", "hint": "OAuth 2.0 client ID for Sign in with Google. Stored encrypted; write-only. Leave both Google fields empty to disable Google sign-in (email+password still works)." }, "google_client_id": { "label": "Google client ID", "hint": "OAuth 2.0 client ID for Sign in with Google. Stored encrypted; write-only. Leave both Google fields empty to disable Google sign-in (email+password still works)." },
"google_client_secret": { "label": "Google client secret", "hint": "OAuth 2.0 client secret. Stored encrypted; write-only." }, "google_client_secret": { "label": "Google client secret", "hint": "OAuth 2.0 client secret. Stored encrypted; write-only." },
"allow_registration": { "label": "Allow registration", "hint": "When on, anyone can submit an email+password registration. They still need to verify their email and be approved by an admin before they can sign in." } "allow_registration": { "label": "Allow registration", "hint": "When on, anyone can submit an email+password registration. They still need to verify their email and be approved by an admin before they can sign in." }

View file

@ -26,7 +26,7 @@
"revertTitle": "Discard local changes?", "revertTitle": "Discard local changes?",
"revertMsg": "This reloads the playlist from YouTube and discards your unsynced local changes (order, additions, removals, rename). Continue?", "revertMsg": "This reloads the playlist from YouTube and discards your unsynced local changes (order, additions, removals, rename). Continue?",
"revertConfirm": "Discard & reload", "revertConfirm": "Discard & reload",
"revertDone": "Reloaded from YouTube ✓", "revertDone": "“{{name}}” reloaded from YouTube ✓",
"revertFailed": "Couldn't reload from YouTube.", "revertFailed": "Couldn't reload from YouTube.",
"pushTitle": "Sync to YouTube", "pushTitle": "Sync to YouTube",
"pushConfirm": "Sync", "pushConfirm": "Sync",
@ -34,8 +34,8 @@
"pushPlanUpdate": "Update on YouTube — add {{insert}}, remove {{del}}, reorder {{reorder}}? (~{{units}} quota units; {{left}} left today.)", "pushPlanUpdate": "Update on YouTube — add {{insert}}, remove {{del}}, reorder {{reorder}}? (~{{units}} quota units; {{left}} left today.)",
"pushDiverged": "{{count}} video(s) currently on YouTube will be removed.", "pushDiverged": "{{count}} video(s) currently on YouTube will be removed.",
"pushNoQuota": "Not enough YouTube quota left today ({{left}}) for this sync (~{{units}} needed). Try again tomorrow.", "pushNoQuota": "Not enough YouTube quota left today ({{left}}) for this sync (~{{units}} needed). Try again tomorrow.",
"pushUpToDate": "Already in sync with YouTube.", "pushUpToDate": "“{{name}}” is already in sync with YouTube.",
"pushDone": "Synced to YouTube ✓", "pushDone": "“{{name}}” synced to YouTube ✓",
"pushPartial": "Synced, but {{count}} item(s) were skipped.", "pushPartial": "Synced, but {{count}} item(s) were skipped.",
"pushFailed": "Couldn't sync to YouTube.", "pushFailed": "Couldn't sync to YouTube.",
"deleteOnYoutubeTitle": "Delete on YouTube too?", "deleteOnYoutubeTitle": "Delete on YouTube too?",

View file

@ -93,7 +93,7 @@
"intro": "Emails here can enter the shared demo account straight from the login page — no Google sign-in. They just type the address into the field and are let in after a moment.", "intro": "Emails here can enter the shared demo account straight from the login page — no Google sign-in. They just type the address into the field and are let in after a moment.",
"addPlaceholder": "Whitelist an email for demo…", "addPlaceholder": "Whitelist an email for demo…",
"add": "Add", "add": "Add",
"added": "Added to the demo whitelist", "added": "{{email}} added to the demo whitelist",
"addFailed": "Couldn't add that email", "addFailed": "Couldn't add that email",
"removeFailed": "Couldn't remove that email", "removeFailed": "Couldn't remove that email",
"remove": "Remove", "remove": "Remove",
@ -115,7 +115,7 @@
"approved": "Approved {{email}} — they can sign in now", "approved": "Approved {{email}} — they can sign in now",
"approveFailed": "Approve failed", "approveFailed": "Approve failed",
"denyFailed": "Deny failed", "denyFailed": "Deny failed",
"addedToWhitelist": "Added to the whitelist", "addedToWhitelist": "{{email}} added to the whitelist",
"addFailed": "Couldn't add that email", "addFailed": "Couldn't add that email",
"requested": "requested {{time}}", "requested": "requested {{time}}",
"approveHint": "Approve — whitelist this email and email them they're in.", "approveHint": "Approve — whitelist this email and email them they're in.",

View file

@ -3,7 +3,7 @@
"channels": "{{count}} ch.", "channels": "{{count}} ch.",
"onChannels": "Tagged channels", "onChannels": "Tagged channels",
"delete": "Delete", "delete": "Delete",
"deleted": "Tag deleted", "deleted": "Tag “{{name}}” deleted",
"deleteTitle": "Delete tag", "deleteTitle": "Delete tag",
"confirmDelete": "Delete the tag “{{name}}”? It will be removed from every channel it's on.", "confirmDelete": "Delete the tag “{{name}}”? It will be removed from every channel it's on.",
"empty": "No tags yet — add one below.", "empty": "No tags yet — add one below.",

View file

@ -111,14 +111,14 @@
"notify": { "notify": {
"synced": "{{count}} feliratkozás szinkronizálva", "synced": "{{count}} feliratkozás szinkronizálva",
"syncFailed": "A feliratkozások szinkronizálása sikertelen", "syncFailed": "A feliratkozások szinkronizálása sikertelen",
"unsubscribed": "Leiratkozva a YouTube-on", "unsubscribed": "Leiratkozva a(z) {{name}} csatornáról a YouTube-on",
"unsubscribeFailed": "A leiratkozás sikertelen", "unsubscribeFailed": "A leiratkozás sikertelen",
"fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához", "fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához",
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni", "fullHistoryFailed": "Nem sikerült teljes előzményt kérni",
"needYouTube": "Ehhez csatlakoztasd a YouTube-fiókod.", "needYouTube": "Ehhez csatlakoztasd a YouTube-fiókod.",
"connect": "Csatlakoztatás", "connect": "Csatlakoztatás",
"filteredByTag": "Hírfolyam szűrve címkére: {{name}}", "filteredByTag": "Hírfolyam szűrve címkére: {{name}}",
"resetDone": "Csatorna resetelve — újraletöltés folyamatban", "resetDone": "{{name}} resetelve — újraletöltés folyamatban",
"resetFailed": "Nem sikerült resetelni a csatornát" "resetFailed": "Nem sikerült resetelni a csatornát"
}, },
"confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a hírfolyamodból szeretnéd eltávolítani, inkább rejtsd el.", "confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a hírfolyamodból szeretnéd eltávolítani, inkább rejtsd el.",

View file

@ -26,6 +26,7 @@
"enrich_batch_size": { "label": "Gazdagítási kötegméret", "hint": "videos.list azonosítók hívásonként (a YouTube 50-ben maximálja)." }, "enrich_batch_size": { "label": "Gazdagítási kötegméret", "hint": "videos.list azonosítók hívásonként (a YouTube 50-ben maximálja)." },
"autotag_title_sample": { "label": "Auto-címke címmintavétel", "hint": "Csatornánként ennyi friss videócímet mintázunk a nyelvfelismeréshez." }, "autotag_title_sample": { "label": "Auto-címke címmintavétel", "hint": "Csatornánként ennyi friss videócímet mintázunk a nyelvfelismeréshez." },
"youtube_api_key": { "label": "YouTube API-kulcs", "hint": "Opcionális. Publikus olvasásokhoz (csatornák/videók), hogy a megosztott letöltés ne függjön egy felhasználó OAuth-jától. Titkosítva tárolva; csak írható." }, "youtube_api_key": { "label": "YouTube API-kulcs", "hint": "Opcionális. Publikus olvasásokhoz (csatornák/videók), hogy a megosztott letöltés ne függjön egy felhasználó OAuth-jától. Titkosítva tárolva; csak írható." },
"youtube_api_proxy": { "label": "YouTube API egress proxy", "hint": "Opcionális. HTTP(S) proxy URL a kimenő YouTube API-hívásokhoz — fix IP-jű hoston átengedve egy IP-korlátozott kulcs dinamikus IP-jű szerverről is működik. Üresen = közvetlen." },
"google_client_id": { "label": "Google kliens-azonosító", "hint": "OAuth 2.0 kliens-azonosító a Google-bejelentkezéshez. Titkosítva tárolva; csak írható. Hagyd üresen mindkét Google-mezőt a Google-bejelentkezés kikapcsolásához (az e-mail+jelszó továbbra is működik)." }, "google_client_id": { "label": "Google kliens-azonosító", "hint": "OAuth 2.0 kliens-azonosító a Google-bejelentkezéshez. Titkosítva tárolva; csak írható. Hagyd üresen mindkét Google-mezőt a Google-bejelentkezés kikapcsolásához (az e-mail+jelszó továbbra is működik)." },
"google_client_secret": { "label": "Google kliens-titok", "hint": "OAuth 2.0 kliens-titok. Titkosítva tárolva; csak írható." }, "google_client_secret": { "label": "Google kliens-titok", "hint": "OAuth 2.0 kliens-titok. Titkosítva tárolva; csak írható." },
"allow_registration": { "label": "Regisztráció engedélyezése", "hint": "Bekapcsolva bárki beküldhet email+jelszó regisztrációt. Belépéshez továbbra is email-igazolás és admin-jóváhagyás szükséges." } "allow_registration": { "label": "Regisztráció engedélyezése", "hint": "Bekapcsolva bárki beküldhet email+jelszó regisztrációt. Belépéshez továbbra is email-igazolás és admin-jóváhagyás szükséges." }

View file

@ -26,7 +26,7 @@
"revertTitle": "Eldobod a helyi módosításokat?", "revertTitle": "Eldobod a helyi módosításokat?",
"revertMsg": "Ez újratölti a listát a YouTube-ról, és eldobja a nem szinkronizált helyi módosításaidat (sorrend, hozzáadás, eltávolítás, átnevezés). Folytatod?", "revertMsg": "Ez újratölti a listát a YouTube-ról, és eldobja a nem szinkronizált helyi módosításaidat (sorrend, hozzáadás, eltávolítás, átnevezés). Folytatod?",
"revertConfirm": "Eldobás és újratöltés", "revertConfirm": "Eldobás és újratöltés",
"revertDone": "Újratöltve a YouTube-ról ✓", "revertDone": "„{{name}}” újratöltve a YouTube-ról ✓",
"revertFailed": "Nem sikerült újratölteni a YouTube-ról.", "revertFailed": "Nem sikerült újratölteni a YouTube-ról.",
"pushTitle": "Szinkron YouTube-ra", "pushTitle": "Szinkron YouTube-ra",
"pushConfirm": "Szinkron", "pushConfirm": "Szinkron",
@ -34,8 +34,8 @@
"pushPlanUpdate": "Frissítés a YouTube-on — {{insert}} hozzáadása, {{del}} eltávolítása, {{reorder}} átrendezése? (~{{units}} kvótaegység; ma még {{left}} maradt.)", "pushPlanUpdate": "Frissítés a YouTube-on — {{insert}} hozzáadása, {{del}} eltávolítása, {{reorder}} átrendezése? (~{{units}} kvótaegység; ma még {{left}} maradt.)",
"pushDiverged": "{{count}} videó, amely jelenleg a YouTube-on van, el lesz távolítva.", "pushDiverged": "{{count}} videó, amely jelenleg a YouTube-on van, el lesz távolítva.",
"pushNoQuota": "Nincs elég YouTube-kvóta mára ({{left}}) ehhez a szinkronhoz (~{{units}} kell). Próbáld újra holnap.", "pushNoQuota": "Nincs elég YouTube-kvóta mára ({{left}}) ehhez a szinkronhoz (~{{units}} kell). Próbáld újra holnap.",
"pushUpToDate": "Már szinkronban van a YouTube-bal.", "pushUpToDate": "A(z) „{{name}}” már szinkronban van a YouTube-bal.",
"pushDone": "Szinkronizálva a YouTube-ra ✓", "pushDone": "„{{name}}” szinkronizálva a YouTube-ra ✓",
"pushPartial": "Szinkronizálva, de {{count}} elem kimaradt.", "pushPartial": "Szinkronizálva, de {{count}} elem kimaradt.",
"pushFailed": "Nem sikerült a YouTube-ra szinkronizálás.", "pushFailed": "Nem sikerült a YouTube-ra szinkronizálás.",
"deleteOnYoutubeTitle": "Törlöd a YouTube-on is?", "deleteOnYoutubeTitle": "Törlöd a YouTube-on is?",

View file

@ -93,7 +93,7 @@
"intro": "Az itt szereplő e-mailek a bejelentkező oldalról közvetlenül beléphetnek a közös demo fiókba — Google-bejelentkezés nélkül. Csak beírják a címet a mezőbe, és pár pillanat múlva bekerülnek.", "intro": "Az itt szereplő e-mailek a bejelentkező oldalról közvetlenül beléphetnek a közös demo fiókba — Google-bejelentkezés nélkül. Csak beírják a címet a mezőbe, és pár pillanat múlva bekerülnek.",
"addPlaceholder": "E-mail fehérlistára a demóhoz…", "addPlaceholder": "E-mail fehérlistára a demóhoz…",
"add": "Hozzáadás", "add": "Hozzáadás",
"added": "Hozzáadva a demo fehérlistához", "added": "{{email}} hozzáadva a demo fehérlistához",
"addFailed": "Nem sikerült hozzáadni ezt az e-mailt", "addFailed": "Nem sikerült hozzáadni ezt az e-mailt",
"removeFailed": "Nem sikerült eltávolítani ezt az e-mailt", "removeFailed": "Nem sikerült eltávolítani ezt az e-mailt",
"remove": "Eltávolítás", "remove": "Eltávolítás",
@ -115,7 +115,7 @@
"approved": "Jóváhagyva: {{email}} — most már be tud jelentkezni", "approved": "Jóváhagyva: {{email}} — most már be tud jelentkezni",
"approveFailed": "A jóváhagyás sikertelen", "approveFailed": "A jóváhagyás sikertelen",
"denyFailed": "Az elutasítás sikertelen", "denyFailed": "Az elutasítás sikertelen",
"addedToWhitelist": "Hozzáadva a fehérlistához", "addedToWhitelist": "{{email}} hozzáadva a fehérlistához",
"addFailed": "Nem sikerült hozzáadni ezt az e-mailt", "addFailed": "Nem sikerült hozzáadni ezt az e-mailt",
"requested": "kérve: {{time}}", "requested": "kérve: {{time}}",
"approveHint": "Jóváhagyás — fehérlistára teszi ezt az e-mailt és e-mailben értesíti őket, hogy bekerültek.", "approveHint": "Jóváhagyás — fehérlistára teszi ezt az e-mailt és e-mailben értesíti őket, hogy bekerültek.",

View file

@ -3,7 +3,7 @@
"channels": "{{count}} csat.", "channels": "{{count}} csat.",
"onChannels": "Címkézett csatornák", "onChannels": "Címkézett csatornák",
"delete": "Törlés", "delete": "Törlés",
"deleted": "Címke törölve", "deleted": "„{{name}}” címke törölve",
"deleteTitle": "Címke törlése", "deleteTitle": "Címke törlése",
"confirmDelete": "Törlöd a(z) „{{name}}” címkét? Minden csatornáról lekerül, amin rajta van.", "confirmDelete": "Törlöd a(z) „{{name}}” címkét? Minden csatornáról lekerül, amin rajta van.",
"empty": "Még nincs címke — adj hozzá lentebb.", "empty": "Még nincs címke — adj hozzá lentebb.",