From a4cd32da4aaddb13a85ac3f7253c7c892d96ae13 Mon Sep 17 00:00:00 2001 From: Mattias Tall Date: Tue, 26 May 2026 11:04:42 +0200 Subject: [PATCH] Add PWA support and Google Takeout subscription import - manifest.json + icon.svg for installability on mobile (standalone mode) - index.html: theme-color, apple-mobile-web-app meta tags, manifest link - Settings: Import CSV section reads Google Takeout subscriptions.csv, extracts UC... channel IDs, calls follow-bulk to follow them all at once Co-Authored-By: Claude Sonnet 4.6 --- frontend/index.html | 11 ++++- frontend/public/icon.svg | 4 ++ frontend/public/manifest.json | 27 +++++++++++++ frontend/src/pages/Settings.jsx | 71 ++++++++++++++++++++++++++++++++- 4 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 frontend/public/icon.svg create mode 100644 frontend/public/manifest.json diff --git a/frontend/index.html b/frontend/index.html index 73f306e..ee45b78 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,8 +2,15 @@ - - YouTube Hub + + + + + + + + + YT Hub + + YT + diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..616af31 --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,27 @@ +{ + "name": "YT Hub", + "short_name": "YT Hub", + "description": "Your personal YouTube archive", + "start_url": "/", + "display": "standalone", + "background_color": "#09090b", + "theme_color": "#09090b", + "icons": [ + { + "src": "/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + }, + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 5df18f3..1f98968 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -1,6 +1,6 @@ import { useState, useRef, useEffect } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { getSettings, updateSettings, exportData, uploadCookiesFile, deleteCookiesFile, getAdminUsers, deleteAdminUser, getAdminConfig, updateAdminConfig, initOAuth2, getOAuth2Status, enableOAuth2, disableOAuth2, testYtdlp } from "../api"; +import { getSettings, updateSettings, exportData, uploadCookiesFile, deleteCookiesFile, getAdminUsers, deleteAdminUser, getAdminConfig, updateAdminConfig, initOAuth2, getOAuth2Status, enableOAuth2, disableOAuth2, testYtdlp, followBulk } from "../api"; import { useAuth } from "../hooks/useAuth"; const REGION_OPTIONS = [ @@ -338,6 +338,74 @@ function DiagnosticSection() { ); } +function SubscriptionImportSection() { + const [status, setStatus] = useState(null); // null | { imported, skipped, errors } + const [loading, setLoading] = useState(false); + const fileRef = useRef(null); + + const handleFile = async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + setLoading(true); + setStatus(null); + try { + const text = await file.text(); + const lines = text.split("\n").map((l) => l.trim()).filter(Boolean); + // Skip header row (Kanal-id or Channel ID or similar) + const dataLines = lines.slice(1); + // First column is the channel ID (UCxxxxxxx) + const ids = dataLines + .map((l) => l.split(",")[0]?.replace(/^"|"$/g, "").trim()) + .filter((id) => id?.startsWith("UC")); + if (!ids.length) { + setStatus({ error: "No channel IDs found. Expected UC... IDs in the first column." }); + return; + } + const res = await followBulk(ids); + setStatus(res.data); + } catch (err) { + setStatus({ error: err.response?.data?.detail || err.message }); + } finally { + setLoading(false); + if (fileRef.current) fileRef.current.value = ""; + } + }; + + return ( +
+
+
+
+

Import from Google Takeout

+

+ Upload the subscriptions.csv from a YouTube Google Takeout export. + Channels already followed are skipped. +

+
+ + +
+ {status && !status.error && ( +

+ Followed {status.followed ?? 0} new channels + {status.already_following > 0 && · {status.already_following} already following} + {status.new_channels > 0 && · {status.new_channels} stubs created (index to fetch metadata)} +

+ )} + {status?.error && ( +

{status.error}

+ )} +
+
+ ); +} + function OAuth2Section({ s, qc }) { const [flowState, setFlowState] = useState(null); // null | {status, device_url, code} const [polling, setPolling] = useState(false); @@ -535,6 +603,7 @@ export default function SettingsPage() { + {/* Download quality */}