The .json sidecars published with the ARTMS fetch were inert: the scanner fed them through the Instaloader path, where `node.edge_media_to_caption` and `checkIsStory`'s `product_type` are both absent, so nothing happened. They are now recognised structurally -- flat, with post_shortcode and type, and none of the markers the other two JSON shapes carry -- and used for three things: - `type` sets post.isReel, which post-tabs prefers over every fallback. This is Instagram's own classification and it disagrees with ours a lot: of 781 items in "official_artms - reels", the sidecars say only 360 are reels. The other 421 are feed videos the clips endpoint returns via include_feed_video, and the directory-based rule counted them all. - `description` fills the caption where no .txt exists. - `date` dates a post whose filename could not. Also fixes date precedence. Only JDownloader highlights lack a date in the filename, so parseArchiveFilename now marks those as mtime-derived and the scanner lets any real date replace them -- previously the date depended on which file the scan reached first. Verified against real published files: a directory of three type=post and three type=reel renders 6 in the grid and exactly the 3 reels in the Reels tab. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
142 lines
5.7 KiB
TypeScript
142 lines
5.7 KiB
TypeScript
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([]);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Once an archive carries gallery-dl sidecars, the guesswork above is replaced
|
|
* by Instagram's own classification. These are the cases the heuristic got
|
|
* wrong (see docs/gallery-dl.md).
|
|
*/
|
|
describe('explicit isReel from a sidecar', () => {
|
|
it('beats the lone-video heuristic for an ordinary feed video', () => {
|
|
// A single mp4 that Instagram calls a post, not a reel — indistinguishable
|
|
// by shape alone.
|
|
const posts = [video('DbdG9L9jU4m', { isReel: false })];
|
|
expect(postsForTab(posts, 'reels')).toEqual([]);
|
|
expect(postsForTab(posts, 'posts')).toHaveLength(1);
|
|
});
|
|
|
|
it('recognises a reel that lives in the profile grid', () => {
|
|
// Shared to feed, so it sits in the base directory with source 'posts'.
|
|
const posts = [post('A'), video('C8FHM6EJl15', { source: 'posts', isReel: true })];
|
|
expect(postsForTab(posts, 'reels').map(p => p.id)).toEqual(['C8FHM6EJl15']);
|
|
expect(postsForTab(posts, 'posts')).toHaveLength(2);
|
|
});
|
|
|
|
it('beats the directory when both are present', () => {
|
|
const posts = [
|
|
video('u - reels/A', { source: 'reels', isReel: false }),
|
|
video('u - reels/B', { source: 'reels' }),
|
|
];
|
|
// A is a feed video that the reels tab happened to return; B is unlabelled
|
|
// and falls back to its directory.
|
|
expect(postsForTab(posts, 'reels').map(p => p.id)).toEqual(['u - reels/B']);
|
|
});
|
|
|
|
it('falls back per post, so a mixed archive still works', () => {
|
|
const posts = [
|
|
video('labelled', { isReel: true }),
|
|
video('unlabelled'),
|
|
carousel('C'),
|
|
];
|
|
expect(postsForTab(posts, 'reels').map(p => p.id)).toEqual(['labelled', 'unlabelled']);
|
|
});
|
|
});
|