Archive photo posts and carousels with gallery-dl - #17
Merged
Conversation
Instagram feed posts (/p/, /tv/) were routed to yt-dlp, which cannot
download photos: it fails with "There is no video in this post". In
production that meant 354 of 407 /p/ captures failed (87%), and because
the viewer picked the default tab by type preference without checking
status, every one of them landed visitors on a red "Archive Failed" page
while a working screenshot sat one tab over.
Add a gallery-dl archiver as the image-world counterpart to yt-dlp. It
covers ~300 sites; this routes the post-shaped URLs of 15 of them
(Instagram, X/Twitter, Reddit, Tumblr, Bluesky, Flickr, Imgur,
DeviantArt, ArtStation, Pixiv, Pinterest, Newgrounds, VSCO). Profile and
feed URLs are deliberately excluded — gallery-dl would pull down an
entire account. Instagram reels stay on yt-dlp, which handles them well.
Output is a streamed ZIP holding every downloaded file, gallery-dl's raw
per-file metadata sidecars, and a normalized metadata.json (author,
caption, date, likes, tags, per-file dimensions). A new viewer renders it
as the original post, served from /gallery/:shortid/{list,file/*}.
Rename the "youtube" archive type to "yt-dlp" so a stored artifact names
the tool that produced it. That string is in ~100k rows, in permalinks,
and in API clients, so the old name keeps resolving forever: requests and
job args are normalized on the way in, lookups match both spellings, and
a startup migration rewrites existing rows. The migration moves
archive_items and queued river_job args in one transaction — the cleanup
worker pairs an item with its job by comparing those two values, so
renaming only the items would make every in-flight item look orphaned and
get force-failed while its job was still queued.
Notable behavior choices, with reasoning in the code:
- No --sleep-request override. gallery-dl already waits a randomized
6-12s between Instagram API calls, and because that flag is a root
config key it replaces the per-site defaults rather than acting as a
floor; any value below Instagram's own would make throttling more
likely, not less.
- gallery-dl's exit status is a bitmask, not an enum, so a run can report
several causes at once (4 extraction, 8 challenge, 16 auth, 64 no
extractor). Decoded accordingly.
- Exit 0 does not imply files were written, so success requires at least
one downloaded media file.
- gallery-dl is installed into the same Python environment as yt-dlp: its
default Instagram video path hands DASH manifests to yt-dlp as an
importable module.
Incidental fixes found along the way:
- A failed archive no longer wins the default tab over a completed one.
- Screenshots that fell back to JPEG were served as image/webp.
- itch inherited the 2-minute browser timeout; give it its own.
- s3SeekableReader.Seek dropped its open body even when seeking to the
current offset, costing one ranged GetObject per ~32KB for random
access reads.
- saveArchiveData could return without closing the archiver's reader,
leaking the producing goroutine and its temp directory.
- An unreadable cookies file was fatal at startup; it now warns. Losing
logged-in archiving is bad, losing the whole server is worse.
zachlatta
added a commit
that referenced
this pull request
Aug 7, 2026
captureCoversTypes compares canonical type names so rows still carrying the retired 'youtube' spelling keep matching inside the freshness window, and dupmap's video-group filter matches both spellings. Re-apply alias integration to the surfaces PR #17 rewrote (NormalizeArchiveType serve path, BackfillMissingMediaItems alias exclusion).
zachlatta
added a commit
that referenced
this pull request
Aug 7, 2026
Instagram feed posts (/p/, /tv/) were routed to yt-dlp, which cannot
download photos: it fails with "There is no video in this post". In
production that meant 354 of 407 /p/ captures failed (87%), and because
the viewer picked the default tab by type preference without checking
status, every one of them landed visitors on a red "Archive Failed" page
while a working screenshot sat one tab over.
Add a gallery-dl archiver as the image-world counterpart to yt-dlp. It
covers ~300 sites; this routes the post-shaped URLs of 15 of them
(Instagram, X/Twitter, Reddit, Tumblr, Bluesky, Flickr, Imgur,
DeviantArt, ArtStation, Pixiv, Pinterest, Newgrounds, VSCO). Profile and
feed URLs are deliberately excluded — gallery-dl would pull down an
entire account. Instagram reels stay on yt-dlp, which handles them well.
Output is a streamed ZIP holding every downloaded file, gallery-dl's raw
per-file metadata sidecars, and a normalized metadata.json (author,
caption, date, likes, tags, per-file dimensions). A new viewer renders it
as the original post, served from /gallery/:shortid/{list,file/*}.
Rename the "youtube" archive type to "yt-dlp" so a stored artifact names
the tool that produced it. That string is in ~100k rows, in permalinks,
and in API clients, so the old name keeps resolving forever: requests and
job args are normalized on the way in, lookups match both spellings, and
a startup migration rewrites existing rows. The migration moves
archive_items and queued river_job args in one transaction — the cleanup
worker pairs an item with its job by comparing those two values, so
renaming only the items would make every in-flight item look orphaned and
get force-failed while its job was still queued.
Notable behavior choices, with reasoning in the code:
- No --sleep-request override. gallery-dl already waits a randomized
6-12s between Instagram API calls, and because that flag is a root
config key it replaces the per-site defaults rather than acting as a
floor; any value below Instagram's own would make throttling more
likely, not less.
- gallery-dl's exit status is a bitmask, not an enum, so a run can report
several causes at once (4 extraction, 8 challenge, 16 auth, 64 no
extractor). Decoded accordingly.
- Exit 0 does not imply files were written, so success requires at least
one downloaded media file.
- gallery-dl is installed into the same Python environment as yt-dlp: its
default Instagram video path hands DASH manifests to yt-dlp as an
importable module.
Incidental fixes found along the way:
- A failed archive no longer wins the default tab over a completed one.
- Screenshots that fell back to JPEG were served as image/webp.
- itch inherited the 2-minute browser timeout; give it its own.
- s3SeekableReader.Seek dropped its open body even when seeking to the
current offset, costing one ranged GetObject per ~32KB for random
access reads.
- saveArchiveData could return without closing the archiver's reader,
leaking the producing goroutine and its temp directory.
- An unreadable cookies file was fatal at startup; it now warns. Losing
logged-in archiving is bad, losing the whole server is worse.
zachlatta
added a commit
that referenced
this pull request
Aug 7, 2026
captureCoversTypes compares canonical type names so rows still carrying the retired 'youtube' spelling keep matching inside the freshness window, and dupmap's video-group filter matches both spellings. Re-apply alias integration to the surfaces PR #17 rewrote (NormalizeArchiveType serve path, BackfillMissingMediaItems alias exclusion).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Instagram feed posts (
/p/,/tv/) were routed to yt-dlp, which cannot download photos — it fails withThere is no video in this post. In production 354 of 407/p/captures failed (87%).Worse, the viewer chose its default tab by type preference without checking status, so every one of those captures opened on a red "Archive Failed" page while a perfectly good screenshot sat one tab over.
orpheus-engineis the main consumer of those captures.What this does
Adds a gallery-dl archiver — the image-world counterpart to yt-dlp. gallery-dl supports ~300 sites; this routes the post-shaped URLs of 15 of them: Instagram
/p/and/tv/, X/Twitter, Reddit, Tumblr, Bluesky, Flickr, Imgur, DeviantArt, ArtStation, Pixiv, Pinterest, Newgrounds, VSCO. Adding a site is one entry ingalleryDLSites.Profile and feed URLs are deliberately not routed — gallery-dl would happily download an entire account. Instagram reels stay on yt-dlp, which handles them well (~83%).
Output is a streamed ZIP: every downloaded file, gallery-dl's raw per-file metadata sidecars, and a normalized
metadata.json(author, caption, date, likes, tags, per-file dimensions). A new viewer renders it as the original post, served from/gallery/:shortid/listand/gallery/:shortid/file/*.Verified end-to-end against a real 10-slide Instagram carousel: all 10 images at full resolution, plus author, caption, hashtags and like count.
The rename
youtube→yt-dlp, so a stored artifact names the tool that produced it (alongside the newgallery-dl).That string lives in ~100k rows, in permalinks handed out long ago, and in API clients — so the old name keeps resolving forever. Requests and River job args are normalized on the way in, lookups match both spellings, and a startup migration rewrites existing rows.
The migration moves
archive_itemsand queuedriver_jobargs in one transaction. The cleanup worker pairs an item with its job by comparing those two values; renaming only the items would make every in-flight item look orphaned and get force-failed while its job was still queued and about to succeed.Decisions worth reviewing
--sleep-requestoverride. gallery-dl already waits a randomized 6–12s between Instagram API calls. Because that flag is a root config key, gallery-dl resolves it ahead of per-extractor settings — so setting it replaces those tuned defaults rather than acting as a floor, and any value below Instagram's own would make throttling more likely.Incidental fixes
Found while building this; each is independently defensible:
image/webp.itchinherited the 2-minute browser timeout; it now has its own.s3SeekableReader.Seekdropped its open body even when seeking to the current offset — one rangedGetObjectper ~32KB for random-access reads.saveArchiveDatacould return without closing the archiver's reader, leaking the producing goroutine and its temp directory.log.Fatalfat startup. Losing logged-in archiving is bad; crash-looping the whole server is worse (this caused a ~6 minute outage in July).Testing
43 new test cases: type normalization and legacy-permalink resolution, URL routing (including host-substring and query-string traps), metadata extraction from a real Instagram carousel sidecar, ZIP construction, exit-bitmask decoding, tab selection, and the viewer including path traversal, Range requests, and incomplete-archive rejection.
go build,go vet,gofmtclean; full suite green under-race.Deploy note
Instagram posts additionally require
YTDLP_COOKIES_FILE— gallery-dl gets redirected to the login page when logged out. Every other routed site works anonymously.