Compare commits
26
Commits
v1.8.0
..
81498e3f75
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81498e3f75 | ||
|
|
d9b07f3d13 | ||
|
|
581d2f652d | ||
|
|
953c19db23 | ||
|
|
d77f81bc6d | ||
|
|
2beb0f0ec7 | ||
|
|
f151a33ef2 | ||
|
|
bd7441b366 | ||
|
|
314fa4c836 | ||
|
|
2501eb0f31 | ||
|
|
da287cac6c | ||
|
|
87b6107edd | ||
|
|
c42e20206b | ||
|
|
0f804d4008 | ||
|
|
faadc3e586 | ||
|
|
843607f47e | ||
|
|
0dc6a8372c | ||
|
|
ded2ad9e9f | ||
|
|
2e0d064eab | ||
|
|
c6f7ce6172 | ||
|
|
037013743a | ||
|
|
117731f67b | ||
|
|
b8ddf5cae2 | ||
|
|
6c051cfced | ||
|
|
5a1ea9e782 | ||
|
|
0db4274f46 |
@@ -9,3 +9,5 @@ coverage/
|
||||
!.env.example
|
||||
_sample-archives
|
||||
_gemini-plans
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -15,9 +15,6 @@ 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` 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.
|
||||
|
||||
@@ -37,10 +34,10 @@ Loading is unified behind the `ArchiveFile` interface (`src/types/index.ts`, imp
|
||||
An archive root holds one directory per profile plus *sidecars* that belong to it:
|
||||
|
||||
```
|
||||
4utumn07 -> posts (base)
|
||||
4utumn07 - reels -> reels
|
||||
story - 4utumn07 -> stories
|
||||
story highlights - 4utumn07 - Sunstory -> highlight "Sunstory"
|
||||
0ct0ber19 -> posts (base)
|
||||
0ct0ber19 - reels -> reels
|
||||
story - 0ct0ber19 -> stories
|
||||
story highlights - 0ct0ber19 - Heestory -> highlight "Heestory"
|
||||
```
|
||||
|
||||
`src/lib/archive-grouping.ts` (shared by server and tests) folds these into a single profile with a `sources` list. Sidecars never appear as standalone archives. Each file the server returns carries its `kind`, so the client routes posts / reels / story ring / highlight circles without re-deriving naming rules.
|
||||
@@ -72,7 +69,7 @@ Three different JSON shapes turn up as `.json`, so they are told apart structura
|
||||
| Instaloader `.json.xz` | GraphQL node under `node` / `__typename` |
|
||||
| gallery-dl sidecar | flat, `post_shortcode` + `type`, none of the above |
|
||||
|
||||
The gallery-dl sidecar is the only source that states what a post *is*: its `type` (`post` / `reel` / `story` / `highlight`) is Instagram's own classification, so `post.isReel` set from it beats every fallback in `post-tabs.ts`. This matters — of the 781 items in `official_band - reels`, the sidecars say only **360 are reels**; the other 421 are ordinary feed videos the clips endpoint returns via `include_feed_video`. Directory-based classification counted all 781.
|
||||
The gallery-dl sidecar is the only source that states what a post *is*: its `type` (`post` / `reel` / `story` / `highlight`) is Instagram's own classification, so `post.isReel` set from it beats every fallback in `post-tabs.ts`. This matters — of the 781 items in `official_artms - reels`, the sidecars say only **360 are reels**; the other 421 are ordinary feed videos the clips endpoint returns via `include_feed_video`. Directory-based classification counted all 781.
|
||||
|
||||
**Dates are ranked, not last-write-wins** (`src/lib/post-dates.ts`): sidecar (what Instagram reported) beats filename (what the fetcher wrote) beats mtime (when the file hit disk, and unrelated to when it was posted). Ties keep the incumbent. Several files describe one post and they are scanned in directory order, not in order of trustworthiness, so without the ranking the date was decided by whichever file came first. Only JDownloader highlights fall to mtime at all — `parseArchiveFilename` flags those via `dateFromMtime`.
|
||||
|
||||
@@ -88,15 +85,15 @@ Images over 1MiB are downscaled in a Web Worker via `OffscreenCanvas`. The queue
|
||||
|
||||
### 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. 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.
|
||||
The grid holds **everything**, reels included, and the Reels tab is a *filtered view* of that same set. 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.
|
||||
|
||||
This *approximates* Instagram rather than matching it. Instagram's grid includes a reel only if the creator shared it to feed — a per-post choice, measured live on 2026-08-16: `official_band` had 21 reels in its first 34 grid tiles, `4utumn07` just 1 in 214. That flag appears nowhere in an archive (JD2 stores no metadata, and Instaloader's `product_type` says what a post *is*, not whether it was shared to feed), so showing everything is the closest reachable behaviour. Instagram's "N posts" counter equals its grid, which is why the header counts `postsForTab(allPosts, 'posts')` and not `allPosts` — the raw list still holds both copies of a double-fetched post.
|
||||
This *approximates* Instagram rather than matching it. Instagram's grid includes a reel only if the creator shared it to feed — a per-post choice, measured live on 2026-08-16: `official_artms` had 21 reels in its first 34 grid tiles, `0ct0ber19` just 1 in 214. That flag appears nowhere in an archive (JD2 stores no metadata, and Instaloader's `product_type` says what a post *is*, not whether it was shared to feed), so showing everything is the closest reachable behaviour. Instagram's "N posts" counter equals its grid, which is why the header counts `postsForTab(allPosts, 'posts')` and not `allPosts` — the raw list still holds both copies of a double-fetched post.
|
||||
|
||||
When checking the live site, note that grid reels link to `/reel/<code>/` while ordinary posts link to `/<user>/p/<code>/`. Matching only `/p/` silently drops every reel, which once produced a confident and completely wrong conclusion that Instagram never shows reels in the grid.
|
||||
|
||||
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`.
|
||||
- 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.
|
||||
|
||||
+1
-2
@@ -12,8 +12,7 @@
|
||||
"clean": "rm -rf dist",
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"jd2": "tsx scripts/jd2-sync.ts"
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
|
||||
@@ -3,39 +3,39 @@ import { classifyDirectory, groupArchiveDirectories } from './archive-grouping';
|
||||
|
||||
describe('classifyDirectory', () => {
|
||||
it('treats a bare profile directory as the base', () => {
|
||||
expect(classifyDirectory('4utumn07')).toEqual({
|
||||
owner: '4utumn07',
|
||||
source: { kind: 'posts', dir: '4utumn07' },
|
||||
expect(classifyDirectory('0ct0ber19')).toEqual({
|
||||
owner: '0ct0ber19',
|
||||
source: { kind: 'posts', dir: '0ct0ber19' },
|
||||
});
|
||||
});
|
||||
|
||||
it('recognises a reels sidecar', () => {
|
||||
expect(classifyDirectory('4utumn07 - reels')).toEqual({
|
||||
owner: '4utumn07',
|
||||
source: { kind: 'reels', dir: '4utumn07 - reels' },
|
||||
expect(classifyDirectory('0ct0ber19 - reels')).toEqual({
|
||||
owner: '0ct0ber19',
|
||||
source: { kind: 'reels', dir: '0ct0ber19 - reels' },
|
||||
});
|
||||
});
|
||||
|
||||
it('recognises a stories sidecar', () => {
|
||||
expect(classifyDirectory('story - dawn_petal')).toEqual({
|
||||
owner: 'dawn_petal',
|
||||
source: { kind: 'stories', dir: 'story - dawn_petal' },
|
||||
expect(classifyDirectory('story - cher_ryppo')).toEqual({
|
||||
owner: 'cher_ryppo',
|
||||
source: { kind: 'stories', dir: 'story - cher_ryppo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('splits highlight owner from title', () => {
|
||||
const { owner, source } = classifyDirectory('story highlights - 4utumn07 - Sunstory');
|
||||
expect(owner).toBe('4utumn07');
|
||||
const { owner, source } = classifyDirectory('story highlights - 0ct0ber19 - Heestory');
|
||||
expect(owner).toBe('0ct0ber19');
|
||||
expect(source.kind).toBe('highlight');
|
||||
expect(source.title).toBe('Sunstory');
|
||||
expect(source.title).toBe('Heestory');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['story highlights - theoldlyricmuseinsta - 💙1999-2005 era', 'theoldlyricmuseinsta', '💙1999-2005 era'],
|
||||
['story highlights - member_theworld - [Bracket]', 'member_theworld', '[Bracket]'],
|
||||
['story highlights - official_band - Tour Schedule', 'official_band', 'Tour Schedule'],
|
||||
['story highlights - 4utumn07 - Sketching⠀', '4utumn07', 'Sketching⠀'],
|
||||
['story highlights - official_band - A.B.C', 'official_band', 'A.B.C'],
|
||||
['story highlights - theoldtaylorswiftinsta - 💙2014-1989 era', 'theoldtaylorswiftinsta', '💙2014-1989 era'],
|
||||
['story highlights - heejin_theworld - [Dall]', 'heejin_theworld', '[Dall]'],
|
||||
['story highlights - official_artms - Cosmo Schedule', 'official_artms', 'Cosmo Schedule'],
|
||||
['story highlights - 0ct0ber19 - Drawheeing⠀', '0ct0ber19', 'Drawheeing⠀'],
|
||||
['story highlights - official_artms - G.C.I', 'official_artms', 'G.C.I'],
|
||||
])('handles real-world title %s', (dir, owner, title) => {
|
||||
const result = classifyDirectory(dir);
|
||||
expect(result.owner).toBe(owner);
|
||||
@@ -57,25 +57,25 @@ describe('classifyDirectory', () => {
|
||||
|
||||
describe('groupArchiveDirectories', () => {
|
||||
const dirs = [
|
||||
'4utumn07',
|
||||
'4utumn07 - reels',
|
||||
'story - 4utumn07',
|
||||
'story highlights - 4utumn07 - Sunstory',
|
||||
'story highlights - 4utumn07 - Sketching⠀',
|
||||
'kestrelsings',
|
||||
'0ct0ber19',
|
||||
'0ct0ber19 - reels',
|
||||
'story - 0ct0ber19',
|
||||
'story highlights - 0ct0ber19 - Heestory',
|
||||
'story highlights - 0ct0ber19 - Drawheeing⠀',
|
||||
'carlyraejepsen',
|
||||
];
|
||||
|
||||
it('folds sidecars into their base profile', () => {
|
||||
const groups = groupArchiveDirectories(dirs);
|
||||
expect([...groups.keys()].sort()).toEqual(['4utumn07', 'kestrelsings']);
|
||||
expect(groups.get('4utumn07')).toHaveLength(5);
|
||||
expect(groups.get('kestrelsings')).toHaveLength(1);
|
||||
expect([...groups.keys()].sort()).toEqual(['0ct0ber19', 'carlyraejepsen']);
|
||||
expect(groups.get('0ct0ber19')).toHaveLength(5);
|
||||
expect(groups.get('carlyraejepsen')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('orders sources posts, reels, stories, then highlights by title', () => {
|
||||
const sources = groupArchiveDirectories(dirs).get('4utumn07')!;
|
||||
const sources = groupArchiveDirectories(dirs).get('0ct0ber19')!;
|
||||
expect(sources.map(s => s.kind)).toEqual(['posts', 'reels', 'stories', 'highlight', 'highlight']);
|
||||
expect(sources.slice(3).map(s => s.title)).toEqual(['Sketching⠀', 'Sunstory']);
|
||||
expect(sources.slice(3).map(s => s.title)).toEqual(['Drawheeing⠀', 'Heestory']);
|
||||
});
|
||||
|
||||
it('still groups a sidecar whose base profile is missing', () => {
|
||||
|
||||
@@ -18,10 +18,10 @@ export interface ArchiveSource {
|
||||
/**
|
||||
* Sidecar directories sit next to the profile directory they belong to:
|
||||
*
|
||||
* 4utumn07 -> posts (base)
|
||||
* 4utumn07 - reels -> reels
|
||||
* story - 4utumn07 -> stories
|
||||
* story highlights - 4utumn07 - Sunstory -> highlight "Sunstory"
|
||||
* 0ct0ber19 -> posts (base)
|
||||
* 0ct0ber19 - reels -> reels
|
||||
* story - 0ct0ber19 -> stories
|
||||
* story highlights - 0ct0ber19 - Heestory -> highlight "Heestory"
|
||||
*
|
||||
* Instagram usernames cannot contain spaces, so matching the username as a
|
||||
* run of non-space characters reliably separates it from a highlight title
|
||||
|
||||
@@ -14,11 +14,11 @@ describe('isSystemDirectory', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
'4utumn07',
|
||||
'4utumn07 - reels',
|
||||
'story - dawn_petal',
|
||||
'story highlights - official_band - A.B.C',
|
||||
'story highlights - theoldlyricmuseinsta - 💙1999-2005 era',
|
||||
'0ct0ber19',
|
||||
'0ct0ber19 - reels',
|
||||
'story - cher_ryppo',
|
||||
'story highlights - official_artms - G.C.I',
|
||||
'story highlights - theoldtaylorswiftinsta - 💙2014-1989 era',
|
||||
'Heejin_Bubble heejinmedia',
|
||||
'gallery-dl',
|
||||
'posts',
|
||||
|
||||
@@ -3,10 +3,10 @@ import { canonicalItemId, parseArchiveFilename, scopedPostId } from './archive-p
|
||||
|
||||
describe('parseArchiveFilename — Instagram export format', () => {
|
||||
it('parses a single-image post', () => {
|
||||
expect(parseArchiveFilename('2023-04-19_4utumn07 - CrORBIcJJbM.mp4')).toEqual({
|
||||
expect(parseArchiveFilename('2023-04-19_0ct0ber19 - CrORBIcJJbM.mp4')).toEqual({
|
||||
postId: 'CrORBIcJJbM',
|
||||
date: '2023-04-19',
|
||||
username: '4utumn07',
|
||||
username: '0ct0ber19',
|
||||
index: 1,
|
||||
ext: 'mp4',
|
||||
isStory: false,
|
||||
@@ -15,7 +15,7 @@ describe('parseArchiveFilename — Instagram export format', () => {
|
||||
});
|
||||
|
||||
it('parses a carousel slide index', () => {
|
||||
const parsed = parseArchiveFilename('2023-04-12_4utumn07 - Cq8LrxSJAJE - 3.jpg');
|
||||
const parsed = parseArchiveFilename('2023-04-12_0ct0ber19 - Cq8LrxSJAJE - 3.jpg');
|
||||
expect(parsed).toMatchObject({ postId: 'Cq8LrxSJAJE', index: 3, ext: 'jpg' });
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('parseArchiveFilename — Instagram export format', () => {
|
||||
});
|
||||
|
||||
it('parses caption sidecar files', () => {
|
||||
expect(parseArchiveFilename('2023-04-12_4utumn07 - Cq8LrxSJAJE.txt')).toMatchObject({
|
||||
expect(parseArchiveFilename('2023-04-12_0ct0ber19 - Cq8LrxSJAJE.txt')).toMatchObject({
|
||||
postId: 'Cq8LrxSJAJE',
|
||||
ext: 'txt',
|
||||
});
|
||||
@@ -39,8 +39,8 @@ describe('parseArchiveFilename — Instagram export format', () => {
|
||||
|
||||
it('parses the story sidecar layout (date_user - N - shortcode)', () => {
|
||||
// Files in `story - <user>` carry a per-day ordinal before the shortcode.
|
||||
const parsed = parseArchiveFilename('2025-10-26_4utumn07 - 2 - DQRuDx9iW5Q.jpg', 'stories');
|
||||
expect(parsed).toMatchObject({ date: '2025-10-26', username: '4utumn07', ext: 'jpg' });
|
||||
const parsed = parseArchiveFilename('2025-10-26_0ct0ber19 - 2 - DQRuDx9iW5Q.jpg', 'stories');
|
||||
expect(parsed).toMatchObject({ date: '2025-10-26', username: '0ct0ber19', ext: 'jpg' });
|
||||
expect(parsed!.postId).toContain('DQRuDx9iW5Q');
|
||||
});
|
||||
|
||||
@@ -71,9 +71,9 @@ describe('parseArchiveFilename — Instaloader format', () => {
|
||||
|
||||
describe('parseArchiveFilename — story highlights', () => {
|
||||
it('parses the dateless highlight layout', () => {
|
||||
expect(parseArchiveFilename('4utumn07 - C5dQPEYpd9W.mp4', 'highlight')).toMatchObject({
|
||||
expect(parseArchiveFilename('0ct0ber19 - C5dQPEYpd9W.mp4', 'highlight')).toMatchObject({
|
||||
postId: 'C5dQPEYpd9W',
|
||||
username: '4utumn07',
|
||||
username: '0ct0ber19',
|
||||
ext: 'mp4',
|
||||
isStory: false,
|
||||
});
|
||||
@@ -95,7 +95,7 @@ describe('parseArchiveFilename — story highlights', () => {
|
||||
});
|
||||
|
||||
describe('parseArchiveFilename — non-matching files', () => {
|
||||
it.each(['4utumn07.jpg', 'profile_pic.jpg', 'README.md', 'no-separator.png'])(
|
||||
it.each(['0ct0ber19.jpg', 'profile_pic.jpg', 'README.md', 'no-separator.png'])(
|
||||
'returns null for %s',
|
||||
name => expect(parseArchiveFilename(name)).toBeNull(),
|
||||
);
|
||||
@@ -107,8 +107,8 @@ describe('scopedPostId', () => {
|
||||
});
|
||||
|
||||
it('namespaces sidecar ids by directory', () => {
|
||||
expect(scopedPostId('C5dQ', 'highlight', 'story highlights - u - Sunstory'))
|
||||
.toBe('story highlights - u - Sunstory/C5dQ');
|
||||
expect(scopedPostId('C5dQ', 'highlight', 'story highlights - u - Heestory'))
|
||||
.toBe('story highlights - u - Heestory/C5dQ');
|
||||
});
|
||||
|
||||
it('keeps the same shortcode distinct across sources', () => {
|
||||
@@ -125,8 +125,8 @@ describe('scopedPostId', () => {
|
||||
*/
|
||||
describe('gallery-dl / JDownloader naming interop', () => {
|
||||
it('treats a single-media post the same with or without an index', () => {
|
||||
const jd2 = parseArchiveFilename('2023-04-19_4utumn07 - CrORBIcJJbM.mp4')!;
|
||||
const gdl = parseArchiveFilename('2023-04-19_4utumn07 - CrORBIcJJbM - 1.mp4')!;
|
||||
const jd2 = parseArchiveFilename('2023-04-19_0ct0ber19 - CrORBIcJJbM.mp4')!;
|
||||
const gdl = parseArchiveFilename('2023-04-19_0ct0ber19 - CrORBIcJJbM - 1.mp4')!;
|
||||
expect(jd2.postId).toBe(gdl.postId);
|
||||
expect(jd2.index).toBe(gdl.index);
|
||||
expect(jd2.index).toBe(1);
|
||||
@@ -136,21 +136,21 @@ describe('gallery-dl / JDownloader naming interop', () => {
|
||||
// JD2 pads to the width of the media count (10+ items -> "01"), and
|
||||
// gallery-dl's count can be one higher, so the same post may be padded
|
||||
// by one tool and not the other.
|
||||
expect(parseArchiveFilename('2024-04-17_4utumn07 - C53YPQzp7Wj - 09.jpg')!.index).toBe(9);
|
||||
expect(parseArchiveFilename('2024-04-17_4utumn07 - C53YPQzp7Wj - 9.jpg')!.index).toBe(9);
|
||||
expect(parseArchiveFilename('2023-11-03_4utumn07 - CzM8Uf6B6H_ - 01.jpg')!.index).toBe(1);
|
||||
expect(parseArchiveFilename('2024-04-17_0ct0ber19 - C53YPQzp7Wj - 09.jpg')!.index).toBe(9);
|
||||
expect(parseArchiveFilename('2024-04-17_0ct0ber19 - C53YPQzp7Wj - 9.jpg')!.index).toBe(9);
|
||||
expect(parseArchiveFilename('2023-11-03_0ct0ber19 - CzM8Uf6B6H_ - 01.jpg')!.index).toBe(1);
|
||||
});
|
||||
|
||||
it('reads a gallery-dl story name, which carries a per-item shortcode', () => {
|
||||
const p = parseArchiveFilename('2026-08-16_official_band - DcF9OyhBJ1H.jpg', 'stories')!;
|
||||
const p = parseArchiveFilename('2026-08-16_official_artms - DcF9OyhBJ1H.jpg', 'stories')!;
|
||||
expect(p.postId).toBe('DcF9OyhBJ1H');
|
||||
expect(p.date).toBe('2026-08-16');
|
||||
});
|
||||
|
||||
it('gives a dated highlight a real date instead of the mtime fallback', () => {
|
||||
const mtime = Date.parse('2026-08-17T00:00:00Z');
|
||||
const undated = parseArchiveFilename('4utumn07 - C-IImhvpFuk.jpg', 'highlight', mtime)!;
|
||||
const dated = parseArchiveFilename('2024-08-04_4utumn07 - C-IImhvpFuk.jpg', 'highlight', mtime)!;
|
||||
const undated = parseArchiveFilename('0ct0ber19 - C-IImhvpFuk.jpg', 'highlight', mtime)!;
|
||||
const dated = parseArchiveFilename('2024-08-04_0ct0ber19 - C-IImhvpFuk.jpg', 'highlight', mtime)!;
|
||||
// Same item either way, so re-fetching cannot split it into two posts.
|
||||
expect(dated.postId).toBe(undated.postId);
|
||||
expect(undated.date).toBe('2026-08-17');
|
||||
@@ -167,13 +167,13 @@ describe('dateFromMtime', () => {
|
||||
const mtime = Date.parse('2026-08-17T00:00:00Z');
|
||||
|
||||
it('flags an undated highlight name as mtime-dated', () => {
|
||||
const p = parseArchiveFilename('4utumn07 - C-IImhvpFuk.jpg', 'highlight', mtime)!;
|
||||
const p = parseArchiveFilename('0ct0ber19 - C-IImhvpFuk.jpg', 'highlight', mtime)!;
|
||||
expect(p.date).toBe('2026-08-17');
|
||||
expect(p.dateFromMtime).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag a highlight that carries its own date', () => {
|
||||
const p = parseArchiveFilename('2024-08-04_4utumn07 - C-IImhvpFuk.jpg', 'highlight', mtime)!;
|
||||
const p = parseArchiveFilename('2024-08-04_0ct0ber19 - C-IImhvpFuk.jpg', 'highlight', mtime)!;
|
||||
expect(p.date).toBe('2024-08-04');
|
||||
expect(p.dateFromMtime).toBe(false);
|
||||
});
|
||||
@@ -184,7 +184,7 @@ describe('dateFromMtime', () => {
|
||||
});
|
||||
|
||||
it('leaves the date empty rather than guessing when no mtime is given', () => {
|
||||
const p = parseArchiveFilename('4utumn07 - C-IImhvpFuk.jpg', 'highlight')!;
|
||||
const p = parseArchiveFilename('0ct0ber19 - C-IImhvpFuk.jpg', 'highlight')!;
|
||||
expect(p.date).toBe('');
|
||||
expect(p.dateFromMtime).toBe(false);
|
||||
});
|
||||
@@ -196,9 +196,9 @@ describe('dateFromMtime', () => {
|
||||
*/
|
||||
describe('canonicalItemId', () => {
|
||||
it('collapses the two highlight naming conventions onto one id', () => {
|
||||
const dir = 'story highlights - 4utumn07 - Sunstory';
|
||||
const undated = parseArchiveFilename('4utumn07 - C5dQPEYpd9W.mp4', 'highlight', 1)!;
|
||||
const dated = parseArchiveFilename('2024-04-07_4utumn07 - 01 - C5dQPEYpd9W.mp4', 'highlight')!;
|
||||
const dir = 'story highlights - 0ct0ber19 - Heestory';
|
||||
const undated = parseArchiveFilename('0ct0ber19 - C5dQPEYpd9W.mp4', 'highlight', 1)!;
|
||||
const dated = parseArchiveFilename('2024-04-07_0ct0ber19 - 01 - C5dQPEYpd9W.mp4', 'highlight')!;
|
||||
expect(scopedPostId(dated.postId, 'highlight', dir))
|
||||
.toBe(scopedPostId(undated.postId, 'highlight', dir));
|
||||
});
|
||||
@@ -216,7 +216,7 @@ describe('canonicalItemId', () => {
|
||||
});
|
||||
|
||||
it('leaves a shortcode that merely starts with digits alone', () => {
|
||||
expect(canonicalItemId('4utumn07')).toBe('4utumn07');
|
||||
expect(canonicalItemId('0ct0ber19')).toBe('0ct0ber19');
|
||||
expect(canonicalItemId('C5dQPEYpd9W')).toBe('C5dQPEYpd9W');
|
||||
expect(canonicalItemId('12345')).toBe('12345');
|
||||
});
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
const REEL: GalleryDlSidecar = {
|
||||
post_shortcode: 'Db-lNCoib9m', post_id: '3962768346034323302', type: 'reel',
|
||||
date: '2026-08-13 11:00:44', post_date: '2026-08-13 11:00:44',
|
||||
username: 'official_band', fullname: 'Official ARTMS',
|
||||
username: 'official_artms', fullname: 'Official ARTMS',
|
||||
description: 'Dancing in the spotlight', count: 1, likes: 22914,
|
||||
};
|
||||
const FEED_VIDEO: GalleryDlSidecar = { ...REEL, post_shortcode: 'DbdG9L9jU4m', type: 'post', count: 2 };
|
||||
const HIGHLIGHT: GalleryDlSidecar = {
|
||||
post_shortcode: 'BATVdRZi_3', post_id: '18099435932626935', type: 'highlight',
|
||||
date: '2026-08-08 16:22:09', username: 'official_band', count: 154,
|
||||
date: '2026-08-08 16:22:09', username: 'official_artms', count: 154,
|
||||
};
|
||||
|
||||
describe('isGalleryDlSidecar', () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Tab } from './routing';
|
||||
/**
|
||||
* 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.
|
||||
* (`0ct0ber19 - reels/Cq8LrxSJAJE`); the trailing segment is the shortcode.
|
||||
*/
|
||||
const shortcode = (post: Post): string => post.id.split('/').pop() ?? post.id;
|
||||
|
||||
|
||||
+16
-16
@@ -12,21 +12,21 @@ describe('parseRoute', () => {
|
||||
});
|
||||
|
||||
it('reads a profile', () => {
|
||||
expect(parseRoute('/4utumn07/')).toEqual({ archive: '4utumn07', tab: 'posts', post: null });
|
||||
expect(parseRoute('/0ct0ber19/')).toEqual({ archive: '0ct0ber19', tab: 'posts', post: null });
|
||||
});
|
||||
|
||||
it('reads a profile without a trailing slash', () => {
|
||||
expect(parseRoute('/4utumn07')).toEqual({ archive: '4utumn07', tab: 'posts', post: null });
|
||||
expect(parseRoute('/0ct0ber19')).toEqual({ archive: '0ct0ber19', tab: 'posts', post: null });
|
||||
});
|
||||
|
||||
it('reads a tab', () => {
|
||||
expect(parseRoute('/4utumn07/reels/').tab).toBe('reels');
|
||||
expect(parseRoute('/4utumn07/saved/').tab).toBe('saved');
|
||||
expect(parseRoute('/0ct0ber19/reels/').tab).toBe('reels');
|
||||
expect(parseRoute('/0ct0ber19/saved/').tab).toBe('saved');
|
||||
});
|
||||
|
||||
it('reads a post in Instagram form', () => {
|
||||
expect(parseRoute('/4utumn07/p/Db5tIoRCcvm/')).toEqual({
|
||||
archive: '4utumn07', tab: 'posts', post: 'Db5tIoRCcvm',
|
||||
expect(parseRoute('/0ct0ber19/p/Db5tIoRCcvm/')).toEqual({
|
||||
archive: '0ct0ber19', tab: 'posts', post: 'Db5tIoRCcvm',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,8 +41,8 @@ describe('parseRoute', () => {
|
||||
});
|
||||
|
||||
it('still understands the legacy query form', () => {
|
||||
expect(parseRoute('/', '?a=4utumn07&t=reels&p=ABC')).toEqual({
|
||||
archive: '4utumn07', tab: 'reels', post: 'ABC',
|
||||
expect(parseRoute('/', '?a=0ct0ber19&t=reels&p=ABC')).toEqual({
|
||||
archive: '0ct0ber19', tab: 'reels', post: 'ABC',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,9 +54,9 @@ describe('parseRoute', () => {
|
||||
describe('buildPath', () => {
|
||||
it.each([
|
||||
[{ archive: null, tab: 'posts', post: null }, '/'],
|
||||
[{ archive: '4utumn07', tab: 'posts', post: null }, '/4utumn07/'],
|
||||
[{ archive: '4utumn07', tab: 'reels', post: null }, '/4utumn07/reels/'],
|
||||
[{ archive: '4utumn07', tab: 'posts', post: 'Db5tIoRCcvm' }, '/4utumn07/p/Db5tIoRCcvm/'],
|
||||
[{ archive: '0ct0ber19', tab: 'posts', post: null }, '/0ct0ber19/'],
|
||||
[{ archive: '0ct0ber19', tab: 'reels', post: null }, '/0ct0ber19/reels/'],
|
||||
[{ archive: '0ct0ber19', tab: 'posts', post: 'Db5tIoRCcvm' }, '/0ct0ber19/p/Db5tIoRCcvm/'],
|
||||
] as const)('builds %j', (route, expected) => {
|
||||
expect(buildPath(route as any)).toBe(expected);
|
||||
});
|
||||
@@ -71,8 +71,8 @@ describe('buildPath', () => {
|
||||
|
||||
it('round-trips through parseRoute', () => {
|
||||
for (const route of [
|
||||
{ archive: '4utumn07', tab: 'posts' as const, post: null },
|
||||
{ archive: '4utumn07', tab: 'reels' as const, post: null },
|
||||
{ archive: '0ct0ber19', tab: 'posts' as const, post: null },
|
||||
{ archive: '0ct0ber19', tab: 'reels' as const, post: null },
|
||||
{ archive: 'Heejin_Bubble heejinmedia', tab: 'posts' as const, post: null },
|
||||
]) {
|
||||
expect(parseRoute(buildPath(route))).toEqual(route);
|
||||
@@ -86,12 +86,12 @@ describe('postSlug / findPostBySlug', () => {
|
||||
});
|
||||
|
||||
it('strips the sidecar directory from the slug', () => {
|
||||
expect(postSlug(post('story highlights - u - Sunstory/C5dQPEYpd9W'))).toBe('C5dQPEYpd9W');
|
||||
expect(postSlug(post('story highlights - u - Heestory/C5dQPEYpd9W'))).toBe('C5dQPEYpd9W');
|
||||
});
|
||||
|
||||
it('resolves a slug back to its post', () => {
|
||||
const posts = [post('AAA'), post('4utumn07 - reels/BBB', 'reels')];
|
||||
expect(findPostBySlug(posts, 'BBB')?.id).toBe('4utumn07 - reels/BBB');
|
||||
const posts = [post('AAA'), post('0ct0ber19 - reels/BBB', 'reels')];
|
||||
expect(findPostBySlug(posts, 'BBB')?.id).toBe('0ct0ber19 - reels/BBB');
|
||||
expect(findPostBySlug(posts, 'AAA')?.id).toBe('AAA');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user