Compare commits

..
2 Commits
Author SHA1 Message Date
ergosteurandClaude Opus 5 e57be521a2 fix: read xz sidecars by buffer, page carousel with arrows, stop backdrop flash
Docker Build and Publish / build-and-push (push) Failing after 10s
Instaloader metadata was silently lost
Every .json.xz failed with "Failed to fetch" during a scan, though the same URL
fetched fine on its own. RemoteArchiveFile.stream() started a fetch, piped the
body into a TransformStream and returned the readable immediately — nothing
caught a fetch rejection, and the decompressor stops reading at the end of the
xz member, so the response body was never drained or cancelled. Across ~190
sidecars that exhausted the connection pool.

Everything Instaloader archives carry lives in those files, so the failure was
invisible but total. rivvsofficial reported 188 posts, no stories, 0 followers
and a placeholder bio; it now reports 68 posts, 120 stories, 10,337 followers
and the real name, bio and link — 68 + 120 = 188, matching the sidecars exactly
(106 GraphStoryVideo + 14 GraphStoryImage = 120).

These sidecars are a few KB, so they are now read into memory before
decompressing. stream() was left unused by that change and is removed from the
interface and both implementations rather than kept as a trap.

Arrow keys page the carousel
They moved between posts, which contradicted the arrows drawn on the carousel
itself. Arrows now page slides; , and . move between posts, alongside the side
buttons.

Backdrop cross-fade
AnimatePresence had no exit variant, so the outgoing scan backdrop was removed
instantly while its replacement faded in over 1.5s, exposing the pale page
behind it as a white flash. Layers now stack: the outgoing image holds full
opacity until covered, and the 0.4 moved onto the group so overlapping layers
don't darken as they cross. Measured over a real scan: 152 cross-fades with a
layer always opaque, except the opening fade-in where nothing is underneath.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uBWhwV3wFQ5MBCcMHHem7
2026-08-14 12:28:26 -04:00
ergosteurandClaude Opus 5 792b834cbe docs: add a JDownloader quick reference
Covers why fetching goes through JDownloader rather than Instaloader (the
instagram.com vs CDN split, and what the metadata gap actually costs), the
settings that matter, cookie handling, the two-URL workflow, jd2-sync usage,
the expected on-disk layout, and what to do when something breaks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uBWhwV3wFQ5MBCcMHHem7
2026-08-14 12:02:41 -04:00
8 changed files with 71 additions and 31 deletions
+2 -1
View File
@@ -16,7 +16,8 @@ InstaArchive Viewer is a React 19 + Vite 6 PWA for browsing archived Instagram d
- `npm test` / `npm run test:watch` — vitest - `npm test` / `npm run test:watch` — vitest
- `npx vitest run src/lib/archive-patterns.test.ts` — a single test file - `npx vitest run src/lib/archive-patterns.test.ts` — a single test file
- `npm run jd2 -- --archives <dir> --dry-run` — generate JDownloader `.crawljob` - `npm run jd2 -- --archives <dir> --dry-run` — generate JDownloader `.crawljob`
files for every profile on disk (see `scripts/jd2-sync.ts`) files for every profile on disk (see `scripts/jd2-sync.ts` and
`docs/jdownloader.md`)
Local development usually needs both `npm run dev` and `npm run server`. Local-folder mode works without the backend; server-mode archives do not. Local development usually needs both `npm run dev` and `npm run server`. Local-folder mode works without the backend; server-mode archives do not.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "instaarchive-viewer", "name": "instaarchive-viewer",
"version": "1.5.1", "version": "1.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "instaarchive-viewer", "name": "instaarchive-viewer",
"version": "1.5.1", "version": "1.6.0",
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "instaarchive-viewer", "name": "instaarchive-viewer",
"private": true, "private": true,
"version": "1.5.1", "version": "1.6.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --port=3000 --host=0.0.0.0", "dev": "vite --port=3000 --host=0.0.0.0",
+44 -9
View File
@@ -17,7 +17,7 @@ import {
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { cn } from './lib/utils'; import { cn } from './lib/utils';
import { PRESS } from './lib/motion'; import { PRESS, prefersReducedMotion } from './lib/motion';
import { buildPath, findPostBySlug, parseRoute, postSlug, tabForSource } from './lib/routing'; import { buildPath, findPostBySlug, parseRoute, postSlug, tabForSource } from './lib/routing';
import { LocalArchiveFile, RemoteArchiveFile } from './lib/archive-files'; import { LocalArchiveFile, RemoteArchiveFile } from './lib/archive-files';
import { import {
@@ -111,6 +111,30 @@ export default function App() {
const [lastLoadedScanningImage, setLastLoadedScanningImage] = useState<string | null>(null); const [lastLoadedScanningImage, setLastLoadedScanningImage] = useState<string | null>(null);
/**
* Blurred backdrops behind the scanning UI, newest last.
*
* Each new image is stacked *over* the previous one and fades in; the one
* underneath stays fully opaque until it's covered. Cross-fading by swapping
* a single element left the pale backdrop showing through mid-transition,
* which read as a white flash between every image.
*/
const [scanBackdrops, setScanBackdrops] = useState<string[]>([]);
useEffect(() => {
if (!lastLoadedScanningImage) return;
setScanBackdrops(prev =>
prev[prev.length - 1] === lastLoadedScanningImage
? prev
: [...prev, lastLoadedScanningImage].slice(-3),
);
}, [lastLoadedScanningImage]);
// Don't carry one archive's backdrops into the next scan.
useEffect(() => {
if (!isScanning) { setScanBackdrops([]); setLastLoadedScanningImage(null); }
}, [isScanning]);
const { const {
username, username,
fullName, fullName,
@@ -460,17 +484,28 @@ export default function App() {
onLoad={() => setLastLoadedScanningImage(currentScanningImage)} onLoad={() => setLastLoadedScanningImage(currentScanningImage)}
/> />
)} )}
<div className="absolute inset-0 z-0"> {/*
<AnimatePresence initial={false}> The 0.4 lives on the group, not the images: two layers overlap
<motion.img during a cross-fade, and fading them individually would darken the
key={lastLoadedScanningImage} backdrop as they cross. Inside the group each layer goes to full
src={lastLoadedScanningImage || undefined} opacity, so the stack is always completely covered.
*/}
<div className="absolute inset-0 z-0 opacity-40">
{scanBackdrops.map(src => (
<motion.img
key={src}
src={src}
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 0.4 }} animate={{ opacity: 1 }}
transition={{ duration: 1.5 }} transition={prefersReducedMotion() ? { duration: 0 } : { duration: 0.9, ease: 'easeInOut' }}
onAnimationComplete={() => setScanBackdrops(prev => {
// Once this layer is opaque it hides everything below it.
const i = prev.indexOf(src);
return i > 0 ? prev.slice(i) : prev;
})}
className="absolute inset-0 w-full h-full object-cover blur-[60px] scale-110" className="absolute inset-0 w-full h-full object-cover blur-[60px] scale-110"
/> />
</AnimatePresence> ))}
</div> </div>
<div className="absolute inset-0 bg-white/40 z-1" /> <div className="absolute inset-0 bg-white/40 z-1" />
<div className="relative z-10 w-full max-w-4xl px-4 flex flex-col items-center gap-8 text-black"> <div className="relative z-10 w-full max-w-4xl px-4 flex flex-col items-center gap-8 text-black">
+7 -4
View File
@@ -82,11 +82,14 @@ export const PostModal: React.FC<PostModalProps> = ({
useEffect(() => setCurrentIndex(0), [post.id]); useEffect(() => setCurrentIndex(0), [post.id]);
useEffect(() => { useEffect(() => {
// Arrows page within the carousel — the thing the arrows visually point at.
// Moving between posts stays on the side buttons, with , and . as keyboard
// equivalents.
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight') goToPost(1, 'x'); if (e.key === 'ArrowRight') paginate(1);
else if (e.key === 'ArrowLeft') goToPost(-1, 'x'); else if (e.key === 'ArrowLeft') paginate(-1);
else if (e.key === '.') paginate(1); else if (e.key === '.') goToPost(1, 'x');
else if (e.key === ',') paginate(-1); else if (e.key === ',') goToPost(-1, 'x');
else if (e.key === 'Escape') onClose(); else if (e.key === 'Escape') onClose();
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
+15 -3
View File
@@ -107,11 +107,23 @@ export const useArchiveScanner = (
/** Stable identity for a media file, used to rehydrate URLs after a reload. */ /** Stable identity for a media file, used to rehydrate URLs after a reload. */
const mediaPath = (file: ArchiveFile) => file.webkitRelativePath || file.name; const mediaPath = (file: ArchiveFile) => file.webkitRelativePath || file.name;
/**
* Decompress an `.xz` metadata sidecar.
*
* Read fully into memory first rather than handing the live HTTP body to
* the decompressor. These sidecars are a few KB, so buffering costs
* nothing, and streaming was actively harmful: the decompressor stops
* reading at the end of the xz member, leaving the response body neither
* drained nor cancelled. Across a couple of hundred sidecars that exhausts
* the connection pool and every later fetch fails with "Failed to fetch" —
* which silently cost Instaloader archives their captions, story flags and
* profile metadata, since all of it lives in these files.
*/
const parseXZFile = async (file: ArchiveFile) => { const parseXZFile = async (file: ArchiveFile) => {
try { try {
const stream = new XzReadableStream(file.stream()); const compressed = await file.arrayBuffer();
const response = new Response(stream); const stream = new XzReadableStream(new Blob([compressed]).stream());
return await response.json(); return await new Response(stream).json();
} catch (e) { console.error(`[Scanner] XZ Parse Error:`, file.name, e); return null; } } catch (e) { console.error(`[Scanner] XZ Parse Error:`, file.name, e); return null; }
}; };
-10
View File
@@ -14,7 +14,6 @@ export class LocalArchiveFile implements ArchiveFile {
get size() { return this.file.size; } get size() { return this.file.size; }
text() { return this.file.text(); } text() { return this.file.text(); }
arrayBuffer() { return this.file.arrayBuffer(); } arrayBuffer() { return this.file.arrayBuffer(); }
stream() { return this.file.stream(); }
/** /**
* A blob: URL backed directly by the on-disk File. * A blob: URL backed directly by the on-disk File.
@@ -55,15 +54,6 @@ export class RemoteArchiveFile implements ArchiveFile {
const res = await fetch(this.url); const res = await fetch(this.url);
return res.arrayBuffer(); return res.arrayBuffer();
} }
stream() {
const transform = new TransformStream();
fetch(this.url).then(res => {
if (res.body) res.body.pipeTo(transform.writable);
else transform.writable.getWriter().close();
});
return transform.readable;
}
createObjectUrl() { createObjectUrl() {
return this.url; return this.url;
} }
-1
View File
@@ -50,7 +50,6 @@ export interface ArchiveFile {
size: number; size: number;
text(): Promise<string>; text(): Promise<string>;
arrayBuffer(): Promise<ArrayBuffer>; arrayBuffer(): Promise<ArrayBuffer>;
stream(): ReadableStream<Uint8Array>;
url?: string; url?: string;
/** /**
* A URL pointing at this file's contents. Local files mint a disk-backed * A URL pointing at this file's contents. Local files mint a disk-backed