diff --git a/CLAUDE.md b/CLAUDE.md index 803b448..ea033e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,6 +74,19 @@ 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. +### Profile tabs (`src/lib/post-tabs.ts`) + +The grid holds **everything**, reels included, and the Reels tab is a *filtered view* of that same set — as on Instagram. Only the Reels tab filters. The tabs were mutually exclusive until v1.7.0, which hid a lot: 1100 of `groupfandom`'s 3813 posts and 533 of `for.member`'s 1225 never appeared in the grid at all. + +Deciding *what is a reel* has no good answer for most archives. Instagram's own marker is `product_type` on the post's GraphQL node (`clips` = reel, `feed` = ordinary feed video, `igtv`, `story`) — `__typename` is `GraphVideo` for all three, and aspect ratio does not separate them either. But: + +- Only Instaloader archives carry that metadata, and only newer captures. A survey of `hazelofficial` found `product_type` on 1101 of 5919 sidecars, and just **2** posts marked `clips`. +- JDownloader archives carry none at all — media plus a `.txt` holding the bare caption. + +So the viewer believes a `- reels` sidecar directory when one exists, and otherwise falls back to treating a lone video as a reel. **The fallback is a guess**: it cannot tell a reel from a feed video or an old IGTV upload, and it misses videos inside carousels. + +`dedupePostCopies` exists because the JDownloader flow crawls the profile URL and the `/reels/` URL separately (the profile page misses some reels), so the two overlap and a reel can land on disk twice. Those become two posts with distinct directory-scoped ids, which the grid would otherwise render side by side. It dedupes by shortcode, preferring the reels-source copy. It is only safe over `allPosts` — stories and highlights are excluded there, and a shortcode may legitimately appear in both a profile and a highlight. + ### URL state (`src/App.tsx`, `src/lib/routing.ts`) Paths mirror Instagram: `//`, `//reels/`, `//p//`. 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. diff --git a/package-lock.json b/package-lock.json index 248d735..3ffc189 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "instaarchive-viewer", - "version": "1.6.2", + "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "instaarchive-viewer", - "version": "1.6.2", + "version": "1.7.0", "dependencies": { "@tailwindcss/vite": "^4.1.14", "@vitejs/plugin-react": "^5.0.4", diff --git a/package.json b/package.json index a157903..65b74e1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "instaarchive-viewer", "private": true, - "version": "1.6.2", + "version": "1.7.0", "type": "module", "scripts": { "dev": "vite --port=3000 --host=0.0.0.0", diff --git a/src/App.tsx b/src/App.tsx index ec918c0..290aac4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ import { motion, AnimatePresence } from 'motion/react'; import { cn } from './lib/utils'; import { PRESS, prefersReducedMotion } from './lib/motion'; import { buildPath, findPostBySlug, parseRoute, postSlug, tabForSource } from './lib/routing'; +import { postsForTab } from './lib/post-tabs'; import { LocalArchiveFile, RemoteArchiveFile } from './lib/archive-files'; import { deleteCachedArchive, @@ -172,20 +173,11 @@ export default function App() { const clearCache = async (name: string) => { await deleteCachedArchive(name); await refreshCachedArchives(); }; /** - * Archives with a `- reels` sidecar directory say outright which posts are - * reels; only fall back to the "lone video" heuristic for archives that have - * no such directory. + * The grid shows everything, reels included, and the Reels tab is a filtered + * view of the same set — see src/lib/post-tabs.ts for the reel test and for + * why a reel can arrive on disk twice. */ - const hasReelSource = useMemo(() => allPosts.some(p => p.source === 'reels'), [allPosts]); - const isReel = useCallback((p: Post) => ( - hasReelSource ? p.source === 'reels' : p.media.length === 1 && p.media[0].type === 'video' - ), [hasReelSource]); - - const filteredPosts = useMemo(() => { - if (activeTab === 'reels') return allPosts.filter(isReel); - if (activeTab === 'posts') return allPosts.filter(p => !isReel(p)); - return []; - }, [allPosts, activeTab, isReel]); + const filteredPosts = useMemo(() => postsForTab(allPosts, activeTab), [allPosts, activeTab]); /** Story highlights, grouped into the circles shown under the bio. */ const highlightGroups = useMemo(() => { diff --git a/src/lib/post-tabs.test.ts b/src/lib/post-tabs.test.ts new file mode 100644 index 0000000..9f0a780 --- /dev/null +++ b/src/lib/post-tabs.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { dedupePostCopies, hasReelSource, makeIsReel, postsForTab } from './post-tabs'; +import { MediaFile, Post } from '../types'; + +const media = (type: MediaFile['type'], index = 1): MediaFile => ({ + name: `f${index}.${type === 'video' ? 'mp4' : 'jpg'}`, + path: `d/f${index}`, url: '', type, index, +}); + +const post = (id: string, opts: Partial = {}): Post => ({ + id, date: '2024-01-01', username: 'u', caption: '', media: [media('image')], thumbnail: '', ...opts, +}); + +const video = (id: string, opts: Partial = {}) => post(id, { media: [media('video')], ...opts }); +const carousel = (id: string, opts: Partial = {}) => + post(id, { media: [media('image', 1), media('video', 2)], ...opts }); + +describe('hasReelSource', () => { + it('is false for an archive with no reels directory', () => { + expect(hasReelSource([post('A'), video('B')])).toBe(false); + }); + + it('is true once any post came from a reels directory', () => { + expect(hasReelSource([post('A'), video('u - reels/B', { source: 'reels' })])).toBe(true); + }); +}); + +describe('makeIsReel', () => { + it('believes the reels directory when there is one', () => { + const posts = [video('A'), video('u - reels/B', { source: 'reels' })]; + const isReel = makeIsReel(posts); + // A is a lone video too, but the archive states which posts are reels. + expect(isReel(posts[0])).toBe(false); + expect(isReel(posts[1])).toBe(true); + }); + + it('falls back to the lone-video heuristic without one', () => { + const posts = [post('A'), video('B'), carousel('C')]; + const isReel = makeIsReel(posts); + expect(posts.map(isReel)).toEqual([false, true, false]); + }); +}); + +describe('dedupePostCopies', () => { + it('leaves distinct posts alone', () => { + const posts = [post('A'), video('B')]; + expect(dedupePostCopies(posts).map(p => p.id)).toEqual(['A', 'B']); + }); + + it('collapses a reel fetched into both the profile and the reels directory', () => { + const posts = [video('B'), video('u - reels/B', { source: 'reels' })]; + const deduped = dedupePostCopies(posts); + expect(deduped).toHaveLength(1); + // The reels copy wins, so the survivor is still recognised as a reel. + expect(deduped[0].source).toBe('reels'); + }); + + it('picks the reels copy regardless of scan order', () => { + const profileCopy = video('B'); + const reelCopy = video('u - reels/B', { source: 'reels' }); + expect(dedupePostCopies([profileCopy, reelCopy])[0].source).toBe('reels'); + expect(dedupePostCopies([reelCopy, profileCopy])[0].source).toBe('reels'); + }); + + it('keeps the position of the first copy seen', () => { + const posts = [post('A'), video('B'), post('C'), video('u - reels/B', { source: 'reels' })]; + expect(dedupePostCopies(posts).map(p => p.id.split('/').pop())).toEqual(['A', 'B', 'C']); + }); +}); + +describe('postsForTab', () => { + it('shows reels in the profile grid, as Instagram does', () => { + const posts = [post('A'), video('u - reels/B', { source: 'reels' })]; + expect(postsForTab(posts, 'posts').map(p => p.id)).toEqual(['A', 'u - reels/B']); + }); + + it('shows the same reel in both tabs', () => { + const posts = [post('A'), video('u - reels/B', { source: 'reels' })]; + const inGrid = postsForTab(posts, 'posts').map(p => p.id); + const inReels = postsForTab(posts, 'reels').map(p => p.id); + expect(inReels).toEqual(['u - reels/B']); + expect(inGrid).toContain('u - reels/B'); + }); + + it('shows a duplicated reel once in the grid, not twice', () => { + const posts = [post('A'), video('B'), video('u - reels/B', { source: 'reels' })]; + expect(postsForTab(posts, 'posts')).toHaveLength(2); + expect(postsForTab(posts, 'reels')).toHaveLength(1); + }); + + it('treats lone videos as reels for archives with no reels directory', () => { + const posts = [post('A'), video('B'), carousel('C')]; + expect(postsForTab(posts, 'posts').map(p => p.id)).toEqual(['A', 'B', 'C']); + expect(postsForTab(posts, 'reels').map(p => p.id)).toEqual(['B']); + }); + + it('has nothing saved', () => { + expect(postsForTab([post('A')], 'saved')).toEqual([]); + }); +}); diff --git a/src/lib/post-tabs.ts b/src/lib/post-tabs.ts new file mode 100644 index 0000000..013c740 --- /dev/null +++ b/src/lib/post-tabs.ts @@ -0,0 +1,103 @@ +import { Post, SourceKind } from '../types'; +import { Tab } from './routing'; + +/** + * Which posts each profile tab shows. + * + * Instagram's profile grid holds everything the account posted — photos, + * carousels and reels alike — and the Reels tab is a *filtered view* of that + * same set rather than a separate one. So a reel belongs in both tabs, and + * only the Reels tab does any filtering. + * + * Kept pure and separate from App.tsx so the reel heuristic and the + * duplicate-copy rules can be tested directly. + */ + +/** + * The shortcode shared by every copy of a post, regardless of which source + * directory it came from. Sidecar ids are directory-scoped + * (`4utumn07 - reels/Cq8LrxSJAJE`); the trailing segment is the shortcode. + */ +const shortcode = (post: Post): string => post.id.split('/').pop() ?? post.id; + +/** + * True when the archive has a `- reels` sidecar directory, i.e. it states + * outright which posts are reels. + */ +export const hasReelSource = (posts: Post[]): boolean => posts.some(p => p.source === 'reels'); + +/** + * Build the reel test for an archive. + * + * Instagram's own marker is `product_type: "clips"` on the post's GraphQL + * node, but only Instaloader archives carry that metadata, and only on newer + * captures — JDownloader grabs are media plus a caption `.txt` and nothing + * else (see docs/jdownloader.md). So: + * + * - archives with a `- reels` directory are believed outright; + * - everything else falls back to treating a lone video as a reel. + * + * The fallback is a guess: it cannot tell a reel from an ordinary feed video + * or an old IGTV upload, all three of which are plain `GraphVideo` nodes + * distinguished only by `product_type`. + */ +export const makeIsReel = (posts: Post[]): ((post: Post) => boolean) => ( + hasReelSource(posts) + ? (post: Post) => post.source === 'reels' + : (post: Post) => post.media.length === 1 && post.media[0]?.type === 'video' +); + +/** Preference order when the same post was fetched into more than one directory. */ +const SOURCE_RANK: Record = { reels: 0, posts: 1, stories: 2, highlight: 3 }; + +const rankOf = (post: Post): number => SOURCE_RANK[post.source ?? 'posts']; + +/** + * Collapse copies of one post that were fetched into more than one directory. + * + * The JDownloader flow crawls a profile URL and its `/reels/` URL separately + * because the profile page misses some reels — so the two overlap, and a reel + * present in both lands on disk twice. Those become two posts with distinct + * directory-scoped ids, which the grid would happily render side by side. + * + * The reels-source copy wins, so the surviving post still reports + * `source: 'reels'` and both the Reels tab and `tabForSource` recognise it. + * + * Only safe because callers pass the grid's posts, which exclude stories and + * highlights — a shortcode may legitimately appear in both the profile and a + * highlight, and those must stay distinct. + */ +export const dedupePostCopies = (posts: Post[]): Post[] => { + const winners = new Map(); + + for (const post of posts) { + const code = shortcode(post); + const existing = winners.get(code); + if (!existing || rankOf(post) < rankOf(existing)) winners.set(code, post); + } + + // Preserve input order, keyed on the winner so ordering does not depend on + // which copy happened to be scanned first. + const emitted = new Set(); + const result: Post[] = []; + for (const post of posts) { + const code = shortcode(post); + if (emitted.has(code)) continue; + emitted.add(code); + result.push(winners.get(code)!); + } + return result; +}; + +/** + * The posts a tab displays. + * + * `posts` must already exclude stories and highlights (App passes `allPosts`). + */ +export const postsForTab = (posts: Post[], tab: Tab): Post[] => { + if (tab === 'saved') return []; + + const unique = dedupePostCopies(posts); + if (tab === 'posts') return unique; + return unique.filter(makeIsReel(posts)); +};