Compare commits

..

2 Commits

Author SHA1 Message Date
Mattias Tall
c7ec8c21f2 Add paste-from-YouTube import to subscription section
Alongside the CSV option, add a text area that accepts copy-pasted text
from the YouTube subscriptions page. Extracts @handle from lines like
'@handle•N subscribers' using regex, deduplicates, then calls follow-bulk.
Button live-counts how many handles are detected as you paste.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:47:16 +02:00
Mattias Tall
83e1b18c5b Fix max_comments format: use full 4-tuple for yt-dlp YouTube extractor
max_comments takes thread_count,total,replies_per_thread,reply_pages.
Passing just one value left the rest unset which caused yt-dlp to fetch
only 1 comment. Now passes 20,20,0,0 to fetch 20 top-level comments
with no replies. Also switch --no-download to --skip-download.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:45:15 +02:00
2 changed files with 78 additions and 33 deletions

View File

@@ -398,10 +398,10 @@ def fetch_video_comments(youtube_video_id: str, max_comments: int = 20) -> list[
"yt-dlp", url,
"--write-info-json",
"--write-comments",
"--extractor-args", f"youtube:max_comments={max_comments};comment_sort=top",
"--no-download",
# Format: thread_count,total_count,replies_per_thread,reply_pages
"--extractor-args", f"youtube:max_comments={max_comments},{max_comments},0,0;comment_sort=top",
"--skip-download",
"--no-playlist",
"--quiet",
"--output", out_tmpl,
*_cookie_args(),
]

View File

@@ -339,68 +339,113 @@ function DiagnosticSection() {
}
function SubscriptionImportSection() {
const [status, setStatus] = useState(null); // null | { imported, skipped, errors }
const [status, setStatus] = useState(null);
const [loading, setLoading] = useState(false);
const [pasteText, setPasteText] = useState("");
const [showPaste, setShowPaste] = useState(false);
const fileRef = useRef(null);
const handleFile = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const doImport = async (handles) => {
if (!handles.length) {
setStatus({ error: "No channel handles found." });
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);
const res = await followBulk(handles);
setStatus(res.data);
} catch (err) {
setStatus({ error: err.response?.data?.detail || err.message });
} finally {
setLoading(false);
if (fileRef.current) fileRef.current.value = "";
}
};
const handleFile = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (fileRef.current) fileRef.current.value = "";
const text = await file.text();
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
const dataLines = lines.slice(1);
const ids = dataLines
.map((l) => l.split(",")[0]?.replace(/^"|"$/g, "").trim())
.filter((id) => id?.startsWith("UC"));
await doImport(ids);
};
const handlePaste = async () => {
// Extract @handles from YouTube subscription page text — lines like "@handle•N subscribers"
const handles = [...new Set(
(pasteText.match(/@[\w.-]+(?=•)/g) || [])
)];
await doImport(handles);
setPasteText("");
setShowPaste(false);
};
return (
<Section title="Subscriptions">
<div className="px-5 py-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="px-5 py-4 flex flex-col gap-4">
{/* CSV import row */}
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-zinc-200">Import from Google Takeout</p>
<p className="text-xs text-zinc-500 mt-0.5">
Upload the <span className="font-mono">subscriptions.csv</span> from a YouTube Google Takeout export.
Channels already followed are skipped.
</p>
<p className="text-sm font-medium text-zinc-200">Import from Google Takeout CSV</p>
<p className="text-xs text-zinc-500 mt-0.5">Upload the <span className="font-mono">subscriptions.csv</span> file.</p>
</div>
<button
onClick={() => fileRef.current?.click()}
disabled={loading}
className="shrink-0 px-4 py-2 rounded-lg bg-accent text-black text-sm font-medium hover:bg-yellow-300 transition-colors disabled:opacity-50"
className="shrink-0 px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm hover:bg-zinc-700 transition-colors disabled:opacity-50"
>
{loading ? "Importing…" : "Import CSV"}
</button>
<input ref={fileRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleFile} />
</div>
{/* Paste from YouTube row */}
<div className="flex flex-col gap-2 border-t border-zinc-800 pt-4">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-zinc-200">Paste from YouTube</p>
<p className="text-xs text-zinc-500 mt-0.5">Copy your subscriptions list from YouTube and paste it here handles starting with @ are extracted automatically.</p>
</div>
<button
onClick={() => setShowPaste((v) => !v)}
className="shrink-0 px-4 py-2 rounded-lg bg-accent text-black text-sm font-medium hover:bg-yellow-300 transition-colors"
>
{showPaste ? "Cancel" : "Paste list"}
</button>
</div>
{showPaste && (
<div className="flex flex-col gap-2">
<textarea
value={pasteText}
onChange={(e) => setPasteText(e.target.value)}
placeholder="Paste your YouTube subscription list here…"
rows={6}
className="w-full bg-zinc-950 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-zinc-300 font-mono resize-none focus:outline-none focus:border-accent"
/>
<button
onClick={handlePaste}
disabled={loading || !pasteText.trim()}
className="self-end px-4 py-2 rounded-lg bg-accent text-black text-sm font-medium hover:bg-yellow-300 transition-colors disabled:opacity-50"
>
{loading ? "Importing…" : `Import ${(pasteText.match(/@[\w.-]+(?=•)/g) || []).length} channels`}
</button>
</div>
)}
</div>
{status && !status.error && (
<p className="text-sm text-zinc-300">
Followed <span className="text-accent font-semibold">{status.followed ?? 0}</span> new channels
{status.already_following > 0 && <span className="text-zinc-500"> · {status.already_following} already following</span>}
{status.new_channels > 0 && <span className="text-zinc-500"> · {status.new_channels} stubs created (index to fetch metadata)</span>}
{status.new_channels > 0 && <span className="text-zinc-500"> · {status.new_channels} stubs created</span>}
</p>
)}
{status?.error && (
<p className="text-sm text-red-400">{status.error}</p>
)}
{status?.error && <p className="text-sm text-red-400">{status.error}</p>}
</div>
</Section>
);