Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
106d3f6691 | ||
|
|
92a4ada3c2 |
@@ -15,6 +15,8 @@ InstaArchive Viewer is a React 19 + Vite 6 PWA for browsing archived Instagram d
|
||||
- `npm run lint` — type-check only (`tsc --noEmit`)
|
||||
- `npm test` / `npm run test:watch` — vitest
|
||||
- `npx vitest run src/lib/archive-patterns.test.ts` — a single test file
|
||||
- `npm run jd2 -- --archives <dir> --dry-run` — generate JDownloader `.crawljob`
|
||||
files for every profile on disk (see `scripts/jd2-sync.ts`)
|
||||
|
||||
Local development usually needs both `npm run dev` and `npm run server`. Local-folder mode works without the backend; server-mode archives do not.
|
||||
|
||||
@@ -71,14 +73,21 @@ Restoring an archive **rehydrates URLs from `path`**: server archives rebuild HT
|
||||
|
||||
Images over 1MiB are downscaled in a Web Worker via `OffscreenCanvas`. The queue is **serial on purpose** — decoding several 50MP+ images at once OOMs the tab. `requestThumbnail` must keep a stable identity (it reads cache state through a ref), or every completed thumbnail re-runs the effect in all mounted thumbnails.
|
||||
|
||||
### URL state (`src/App.tsx`)
|
||||
### URL state (`src/App.tsx`, `src/lib/routing.ts`)
|
||||
|
||||
App state syncs to `?a=` / `?t=` / `?p=`. Two rules, both learned from real bugs:
|
||||
Paths mirror Instagram: `/<archive>/`, `/<archive>/reels/`, `/<archive>/p/<shortcode>/`. The old `?a=&t=&p=` form is still parsed for existing links but never written. Reserved prefixes (`api`, `archives`, `assets`…) can't be mistaken for a profile name.
|
||||
|
||||
- The initial query string is captured into a ref on first render; the URL is rewritten from state as soon as anything loads, so reading `window.location` later sees the rewrite, not the user's link.
|
||||
A post URL carries no tab, as on Instagram — the tab is re-derived from the post's `source`, so a reel link lands on the Reels tab and pages through reels. Sidecar posts keep directory-scoped ids internally but expose only the shortcode.
|
||||
|
||||
Three rules, all learned from real bugs:
|
||||
|
||||
- The initial route is captured into a ref on first render; the URL is rewritten from state as soon as anything loads, so reading `window.location` later sees the rewrite, not the user's link.
|
||||
- URL writing is gated on `hasInitialLoaded`, otherwise it erases the deep link before the loader consumes it.
|
||||
- Deep-link resolution waits on the archive fetch having *settled* (`archivesFetched`), not on `isServerMode`, which is still false while the request is in flight.
|
||||
|
||||
Deep-link resolution waits on the archive fetch having *settled*, not on `isServerMode` (which is still false while in flight).
|
||||
### Mobile feed (`src/components/PostFeed.tsx`)
|
||||
|
||||
Below `md`, opening a post renders a scrolling feed page rather than the modal (`useIsMobile` decides). Only a window of posts is mounted; it grows both ways, and prepending corrects `scrollTop` in a `useLayoutEffect` so content doesn't jump. Only the post crossing the viewport centre plays its video and drives the URL. Desktop keeps `PostModal`; both share `MediaCarousel`.
|
||||
|
||||
### Backend (`server.ts`)
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "instaarchive-viewer",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "instaarchive-viewer",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "instaarchive-viewer",
|
||||
"private": true,
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||
@@ -12,7 +12,8 @@
|
||||
"clean": "rm -rf dist",
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"jd2": "tsx scripts/jd2-sync.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isSystemDirectory } from './archive-index';
|
||||
|
||||
describe('isSystemDirectory', () => {
|
||||
it.each([
|
||||
['@eaDir', 'Synology thumbnail/index metadata, written inside every folder'],
|
||||
['@tmp', 'Synology scratch'],
|
||||
['.sync', 'Resilio state'],
|
||||
['.DS_Store', 'macOS'],
|
||||
['#recycle', 'Synology deletions'],
|
||||
['#snapshot', 'Synology snapshots'],
|
||||
])('skips %s (%s)', name => {
|
||||
expect(isSystemDirectory(name)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'4utumn07',
|
||||
'4utumn07 - reels',
|
||||
'story - dawn_petal',
|
||||
'story highlights - official_band - A.B.C',
|
||||
'story highlights - theoldlyricmuseinsta - 💙1999-2005 era',
|
||||
'Heejin_Bubble heejinmedia',
|
||||
'gallery-dl',
|
||||
'posts',
|
||||
])('keeps %s', name => {
|
||||
expect(isSystemDirectory(name)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat a leading underscore as a system directory', () => {
|
||||
// `_gemini-plans` is filtered separately at the archive root only; nothing
|
||||
// below the root should be excluded just for starting with an underscore.
|
||||
expect(isSystemDirectory('_gemini-plans')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,21 @@ interface DirIndex {
|
||||
const MEDIA_RE = /\.(jpg|jpeg|png|webp|gif|bmp|tiff|mp4|webm|ogv|mov)$/i;
|
||||
const STAT_CONCURRENCY = 16;
|
||||
|
||||
/**
|
||||
* Directories the walk must never descend into.
|
||||
*
|
||||
* NAS filesystems scatter sidecar metadata *inside* every folder, not just at
|
||||
* the share root: Synology writes `@eaDir` (thumbnails and indexing data),
|
||||
* `#recycle` holds deletions, and `.sync` is Resilio's state. Indexing those
|
||||
* would count NAS thumbnails as archive media and spend a stat on each one —
|
||||
* measured on a real share, `@eaDir` accounted for 12,516 of 123,023 files.
|
||||
*
|
||||
* The archive root is already filtered by prefix; this is the same rule applied
|
||||
* at every level below it.
|
||||
*/
|
||||
export const isSystemDirectory = (name: string): boolean =>
|
||||
name.startsWith('@') || name.startsWith('.') || name === '#recycle' || name === '#snapshot';
|
||||
|
||||
export class ArchiveIndex {
|
||||
private dirs = new Map<string, DirIndex>();
|
||||
private inFlight = new Map<string, Promise<DirIndex>>();
|
||||
@@ -43,7 +58,7 @@ export class ArchiveIndex {
|
||||
/** Visible (non-system) directories at the archive root. */
|
||||
private listRootDirs(): string[] {
|
||||
return fs.readdirSync(this.archivesDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && !/^[.@_]/.test(e.name))
|
||||
.filter(e => e.isDirectory() && !isSystemDirectory(e.name) && !e.name.startsWith('_'))
|
||||
.map(e => e.name);
|
||||
}
|
||||
|
||||
@@ -69,6 +84,7 @@ export class ArchiveIndex {
|
||||
return out;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (isSystemDirectory(entry.name)) continue;
|
||||
const rel = base ? `${base}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) out = out.concat(this.walk(path.join(absDir, entry.name), rel));
|
||||
else if (entry.isFile()) out.push(rel);
|
||||
|
||||
Reference in New Issue
Block a user