feat: show reels in the profile grid, as Instagram does
Docker Build and Publish / build-and-push (push) Failing after 10s
Docker Build and Publish / build-and-push (push) Failing after 10s
The Posts tab filtered reels out, so the grid was not the archive — it
was the archive minus its videos. For `for.heejin` that hid 533 of 1225
posts; for `loonatheworld`, 1100 of 3813. On Instagram the grid holds
everything and the Reels tab is a filtered view of that same set.
Extract the tab logic to src/lib/post-tabs.ts so the reel heuristic is
testable outside the component, and add dedupePostCopies: the jd2 flow
crawls the profile URL and the /reels URL separately because the profile
page misses some reels, so the two overlap and a reel can land on disk
twice. Those are two posts with distinct directory-scoped ids, which the
grid would now render side by side; the reels-source copy wins so the
survivor is still recognised as a reel.
Deciding what *is* a reel stays a guess for most archives. Instagram
marks it with product_type ("clips" vs "feed" vs "igtv" — all three are
GraphVideo, and aspect ratio does not separate them), but only newer
Instaloader captures carry it: 1101 of gibiofficial's 5919 sidecars, and
only 2 marked clips. JDownloader archives carry none, so those still
fall back to treating a lone video as a reel.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
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 `loonatheworld`'s 3813 posts and 533 of `for.heejin`'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 `gibiofficial` 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`)
|
### URL state (`src/App.tsx`, `src/lib/routing.ts`)
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "instaarchive-viewer",
|
"name": "instaarchive-viewer",
|
||||||
"version": "1.6.2",
|
"version": "1.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "instaarchive-viewer",
|
"name": "instaarchive-viewer",
|
||||||
"version": "1.6.2",
|
"version": "1.7.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
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "instaarchive-viewer",
|
"name": "instaarchive-viewer",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.6.2",
|
"version": "1.7.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",
|
||||||
|
|||||||
+5
-13
@@ -19,6 +19,7 @@ import { motion, AnimatePresence } from 'motion/react';
|
|||||||
import { cn } from './lib/utils';
|
import { cn } from './lib/utils';
|
||||||
import { PRESS, prefersReducedMotion } 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 { postsForTab } from './lib/post-tabs';
|
||||||
import { LocalArchiveFile, RemoteArchiveFile } from './lib/archive-files';
|
import { LocalArchiveFile, RemoteArchiveFile } from './lib/archive-files';
|
||||||
import {
|
import {
|
||||||
deleteCachedArchive,
|
deleteCachedArchive,
|
||||||
@@ -172,20 +173,11 @@ export default function App() {
|
|||||||
const clearCache = async (name: string) => { await deleteCachedArchive(name); await refreshCachedArchives(); };
|
const clearCache = async (name: string) => { await deleteCachedArchive(name); await refreshCachedArchives(); };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Archives with a `- reels` sidecar directory say outright which posts are
|
* The grid shows everything, reels included, and the Reels tab is a filtered
|
||||||
* reels; only fall back to the "lone video" heuristic for archives that have
|
* view of the same set — see src/lib/post-tabs.ts for the reel test and for
|
||||||
* no such directory.
|
* why a reel can arrive on disk twice.
|
||||||
*/
|
*/
|
||||||
const hasReelSource = useMemo(() => allPosts.some(p => p.source === 'reels'), [allPosts]);
|
const filteredPosts = useMemo(() => postsForTab(allPosts, activeTab), [allPosts, activeTab]);
|
||||||
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]);
|
|
||||||
|
|
||||||
/** Story highlights, grouped into the circles shown under the bio. */
|
/** Story highlights, grouped into the circles shown under the bio. */
|
||||||
const highlightGroups = useMemo(() => {
|
const highlightGroups = useMemo(() => {
|
||||||
|
|||||||
@@ -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> = {}): Post => ({
|
||||||
|
id, date: '2024-01-01', username: 'u', caption: '', media: [media('image')], thumbnail: '', ...opts,
|
||||||
|
});
|
||||||
|
|
||||||
|
const video = (id: string, opts: Partial<Post> = {}) => post(id, { media: [media('video')], ...opts });
|
||||||
|
const carousel = (id: string, opts: Partial<Post> = {}) =>
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
* (`0ct0ber19 - 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<SourceKind, number> = { 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<string, Post>();
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
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));
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user