ME EAT DVD. ME MAKE ISO. OM NOM NOM.
Feed it discs, it eats them. Watches every optical drive it can see, works out what each disc is — film, album or data — rips it, burps, ejects, and waits for the next one. Several drives at once. Runs in a container, so the only thing you install is Docker.
🍪 ISOHungry — me eat DVD! (updated 14:22:07)
========================================================
sr0 | 🍪 NOM NOM NOM! Eating BLADE_RUN | 00:12:41
sr1 | 🎵 NOM NOM! Slurping CD | 00:04:52
sr2 | 🤤 BUUURP! Me ate THE_MATRIX | 00:44:52
sr3 | 🍪 Me hungry... feed me disc! | --:--:--
There's a web UI too, on :8080 — the same information plus a log viewer, a
settings panel, and the ability to name a disc while it's still ripping or
identify an album the lookup missed.
Linux — the easy case, drives just work:
git clone <this-repo> && cd ISOHungry
./setup.sh
docker compose up -d
docker compose logs -fWindows — needs a one-time kernel swap (see why):
git clone <this-repo>; cd ISOHungry
powershell -ExecutionPolicy Bypass -File scripts\setup-windows.ps1 # once, ~20 min
.\scripts\attach-drives.ps1 # after each reboot
docker compose up -dmacOS — not possible. Docker Desktop on macOS has no USB or optical
passthrough and there's no usbipd equivalent. ./setup.sh explains your
options: run the script natively via Homebrew, or point a Linux box at it.
Then open http://localhost:8080, or watch the terminal display with
docker compose logs -f. Output is sorted by what the disc turned out to be:
out/
├── movies/ THE_MATRIX.iso, THE_MATRIX_disc2.iso, …
├── music/ Radiohead/OK Computer/01 - Airbag.mp3, …
├── data/ BACKUP_2019.iso, …
├── logs/ sr0.log, sr1.log, …
└── .ripped/ one marker per disc already eaten
Docker Desktop runs containers inside the WSL2 VM, and Microsoft's stock WSL2 kernel is built without optical-drive support:
# CONFIG_BLK_DEV_SR is not set <- no /dev/sr* can ever appear
# CONFIG_USB_STORAGE is not set <- USB mass storage not recognised
No container flag works around a missing driver — not --privileged, not
--device, not bind-mounting /dev. setup-windows.ps1 builds a WSL2 kernel
from Microsoft's own source with exactly three symbols added
(BLK_DEV_SR, USB_STORAGE, USB_UAS), points .wslconfig at it, and
installs usbipd-win. Everything else the ripper needs — SCSI core, sg,
ISO9660, UDF, the usbip virtual hub — is already enabled upstream.
USB drives only on Windows. Internal SATA optical drives cannot be forwarded into WSL2; there is no SATA passthrough. Linux has no such limit.
Discs are identified automatically and sorted into subdirectories.
| Disc | Goes to | How |
|---|---|---|
| Video DVD | out/movies/ |
dvdbackup + genisoimage, CSS handled by libdvdcss |
| Audio CD | out/music/ |
abcde: MusicBrainz lookup, cdparanoia read, tagged MP3 (LAME -V0) or FLAC |
| Data disc | out/data/ |
Sector-for-sector image, bounded by the volume size |
Audio is checked first, so an enhanced CD (audio tracks plus a data session)
is ripped as music rather than as a data image. Music lands as
music/Artist/Album/01 - Track.mp3. Choose FLAC instead of MP3, or turn data
discs off, from the web UI's Settings panel — or with AUDIO_FORMAT and
RIP_DATA_DISCS if you'd rather set them in docker-compose.yml.
While an audio CD rips, the display shows the album as soon as the lookup
resolves and then follows the track count — Track 3/12: Artist / Album.
Tags are written by ISOHungry, not by abcde. abcde's own tagging step
reports success while leaving the files untagged: it sends the tagger's output
to /dev/null and only checks the exit status, and the tagger exits 0 having
done nothing. So the files are tagged afterwards from the CDDB data abcde
fetched, which also carries properly spaced titles where the filenames have
had spaces munged to underscores. Various-artists discs get the right
per-track artist.
If a disc won't give up every track, the readable ones are still kept and
UNREADABLE_TRACKS.txt in the album folder records what's missing.
Nothing ever silently overwrites or gets skipped as a duplicate.
| Disc label | Result |
|---|---|
Distinctive (THE_MATRIX) |
THE_MATRIX.iso |
Generic (DVD_VIDEO, WB_DVD, UNTITLED…) |
DVD_VIDEO_2026-07-27_150000.iso |
| No label at all | unknown_sr0_2026-07-27_150000.iso |
| Distinctive, already seen | THE_MATRIX_disc2.iso, _disc3, … |
That last row is the box-set case. Multi-disc sets routinely stamp every disc
with the same volume label, so a repeated distinctive label is treated as
another disc in the set and numbered, keeping the set together on disk. A
repeated generic label is treated as an unrelated film and timestamped
instead — half of Warner's catalogue is stamped WB_DVD, and those discs have
nothing to do with each other. GENERIC_LABELS controls that list.
Duplicate detection doesn't use the label. Each disc is fingerprinted —
ISO9660 metadata for discs, the TOC for audio CDs — so two different films both
labelled DVD_VIDEO are seen as different discs, while the same disc
reinserted next week is recognised and skipped rather than becoming _disc2.
Markers live in out/.ripped/<fp>; delete one to make ISOHungry hungry for
that disc again.
http://localhost:8080 — a status page that mirrors the terminal display:
every drive, what it's eating, progress bars, finished items with download
links, and a log viewer. The terminal display stays exactly as it was; the web
UI is additive and reads the same state files.
Progress is measured in whatever unit is honest for the disc: bytes for films and data images, tracks for audio CDs. Encoded audio is a small fraction of the raw disc, so measuring bytes against CD size would show a finished album sitting at about 10%.
Three things it can change, and nothing else. Name a rip while it's running
— applied when the ISO is finalised, so you can type the real film title while
the disc spins. Rename a finished item, keeping its fingerprint marker in
step so the disc stays recognised. And identify an album the disc lookup
missed: anything that landed as Unknown Artist or Track 1..N is marked
not identified, and the Identify button searches MusicBrainz by name,
shows the candidates with a * beside those whose track count agrees, then
tags, renames and files the album under Artist/Album. There is no eject and
no delete.
It also carries a Settings panel for the options you actually change disc
to disc — music format, whether to rip data discs, how hard to fight a
scratched CD, how many discs at once, and when to give up. Changes are written
to out/settings.conf and picked up before the next scan, so they apply to the
next disc without a restart; a rip already running is unaffected.
Those settings override the environment, so docker-compose.yml still sets
the defaults. Values are whitelisted on both sides — the file is writable from
the web UI and one of its values reaches a command line, so the ripper
validates every key it reads rather than sourcing the file.
WEB_UI=0 disables it entirely.
It binds to 127.0.0.1 by default, so only the host can see it. To open it to
your LAN, create a .env file next to docker-compose.yml:
WEB_BIND=0.0.0.0then docker compose up -d --force-recreate. It's now at
http://<host-lan-ip>:8080 from any device on the network — phone included,
which is the point.
On Windows you also need a firewall rule, once:
New-NetFirewallRule -DisplayName "ISOHungry web UI" -Direction Inbound `
-LocalPort 8080 -Protocol TCP -Action Allow -Profile PrivateNote -Profile Private. If your network is classified Public, either change
the network to Private or add Public here — but understand what that means
before you do it on a network you don't control.
Know what you're exposing. There is no authentication and no TLS. Anyone who can reach the port can browse your library, download whole ISOs, and rename things. That's fine on a home LAN and a bad idea anywhere else.
If you run Tailscale (or another WAN), bind to its interface rather than opening a LAN port. You get device-authenticated access from anywhere, no firewall rule, and nothing exposed to other machines on the local network:
# .env — substitute your own tailnet address
WEB_BIND=100.x.y.zThen reach it at http://100.x.y.z:8080 from any device on your tailnet.
tailscale ip -4 prints the address.
Or tunnel over SSH and skip network exposure altogether:
ssh -L 8080:127.0.0.1:8080 you@host # then use http://localhost:8080| Status | Meaning |
|---|---|
🍪 Me hungry... feed me disc! |
Drive empty, waiting |
🍪 NOM NOM NOM! Eating X |
Ripping a video DVD |
🎵 NOM NOM! Slurping X |
Ripping an audio CD, before it's identified |
🎵 NOM NOM! Artist / Album |
The disc has been identified |
🎵 Track 3/12: Artist / Album |
Ripping that track |
🎵 No match — slurping untagged |
No metadata found; ripping anyway |
🍪 NOM NOM! Eating data X |
Imaging a data disc |
😋 Om nom nom... chewing into ISO |
Building the ISO |
🤤 BUUURP! Me ate X |
Done |
💨 BURP! Spit out X |
Ejected, ready for the next |
🤕 Ate 13/14 — track 1 no good |
Some tracks were unreadable; the rest were kept |
🙃 Me already ate dis! (X) |
Seen before, ejected untouched |
😋 Me full! Waiting (2/2 eating) |
At MAX_PARALLEL, queued |
😢 Tummy full! Need NNNMB |
Not enough disk space; waits |
🙅 Me no eat data discs |
A data disc, and RIP_DATA_DISCS=0 |
🤮 Me no like dis disc! |
Video/data rip failed — check out/logs/ |
🤮 Me no like dis CD! |
Audio rip failed — check out/logs/ |
🤮 Me choke on dis! |
ISO build failed — check out/logs/ |
😝 Dis not movie! |
Claimed to have VIDEO_TS, but the extract had none |
😝 No music came out! |
Audio CD produced no encoded tracks |
😤 Disc stuck in me teeth! |
Eject failed, pull it out yourself |
😵 Drive no talk to me! |
Drive stopped responding |
Set these in docker-compose.yml under environment:, except WEB_BIND,
which belongs in a .env file beside it because it's machine-specific.
The five marked UI can also be changed from the web UI's Settings panel,
which writes out/settings.conf and overrides the environment without a
restart.
| Variable | Default | Meaning |
|---|---|---|
BASE_OUTPUT_DIR |
/output |
Where output is written |
POLL_INTERVAL |
5 |
Seconds between drive scans |
MAX_PARALLEL (UI) |
2 |
Concurrent rips — USB drives on one controller thrash above this |
SPACE_FACTOR |
22 |
Free space required, in tenths of disc size (2.2×) |
PROBE_TIMEOUT |
30 |
Seconds before a wedged drive is given up on |
GENERIC_LABELS |
see script | Labels too common to trust as filenames |
AUDIO_FORMAT (UI) |
mp3 |
mp3 (LAME -V0, ~245 kbps VBR) or flac |
AUDIO_RIP_TIMEOUT (UI) |
5400 |
Seconds before an audio rip gives up; 0 disables |
AUDIO_CDPARANOIA_OPTS (UI) |
— | Extra cdparanoia flags; -Y or -Z for damaged discs |
RIP_DATA_DISCS (UI) |
1 |
0 to ignore non-video, non-audio discs |
WEB_UI / WEB_PORT |
1 / 8080 |
Web status page |
WEB_BIND (.env) |
127.0.0.1 |
Interface the UI listens on |
DEVICE_GLOB |
/dev/sr* |
Which devices to watch |
TZ |
— | Timezone for the clock |
Disk space. Video DVDs are the hungry case: dvdbackup extracts the disc
and then genisoimage builds the ISO from the extract, so roughly 2× disc
size is needed while working — the default SPACE_FACTOR=22 reserves 2.2×.
Data discs need about 1×, and audio CDs need far less once encoded. If there
isn't room, the drive waits rather than failing partway through.
.\scripts\attach-drives.ps1 finds every USB optical drive, binds it (one UAC
prompt) and attaches it into the VM. Re-run after every reboot or
wsl --shutdown — binding persists, attaching doesn't. -Detach gives the
drives back to Windows.
Drives are matched by USB instance ID, not vendor/product ID, so two identical drives are never mixed up.
Why not just usbipd attach --wsl?
It fails here. usbipd insists on modprobe vhci_hcd, which can't succeed when
the driver is built into the kernel rather than a module — and our kernel
builds it in. The script drives the usbip client directly instead.
It must run with --network host. The TCP socket backing the virtual USB port
lives in the caller's network namespace; in a normal container namespace that
socket dies with the container, leaving the device present but wedged in
unkillable I/O that survives timeout and needs wsl --shutdown to clear.
No drives listed — on Windows, did you run attach-drives.ps1? Check with
docker run --rm --privileged -v /dev:/dev alpine ls /dev/sr0.
🤮 Me no like dis disc! / Me no like dis CD! — usually a scratched or
unusually protected disc. The real error is in out/logs/<device>.log.
😝 Dis not movie! — the disc's index advertised a VIDEO_TS directory,
but the extraction produced none. That means a failed or partial dvdbackup
run, not a data disc: discs without VIDEO_TS are detected up front and
imaged into out/data/ instead. Check the log.
😝 No music came out! — abcde finished but wrote no tracks. Usually a
MusicBrainz lookup that failed alongside a read error; the log has both.
Music has no artist or album name — lookup at rip time is by disc TOC, so
a disc that isn't in the database becomes Unknown Artist / Unknown Album with
Track 1..N. You don't need the disc back to fix it: search MusicBrainz by
name instead and match on track count.
Easiest from the web UI — such albums are badged not identified and have an Identify button. From the command line:
docker exec isohungry python3 /opt/isohungry/identify-album.py \
--dir "/output/music/Unknown_Artist/Some_Album" # dry run
docker exec isohungry python3 /opt/isohungry/identify-album.py \
--dir "/output/music/Unknown_Artist/Some_Album" --applyIt lists candidate releases, marking those whose track count agrees, tags the
files, renames them to the real titles, and moves the album to
Artist/Album. Use --query if the folder name isn't a good search term,
--pick N to choose a different match, and --no-move to tag in place.
🤕 Ate 13/14 — track 1 no good — the disc wouldn't give up every track.
What was readable is kept, and UNREADABLE_TRACKS.txt in the album folder
lists what's missing. The read errors are in the log. Usually a scratch or a
failing drive: try the disc in another drive, or push past the damage with
AUDIO_CDPARANOIA_OPTS=-Z (faster, less accurate). To retry, delete the
disc's marker in out/.ripped/ and reinsert it.
A rip grinds for hours on one track — cdparanoia retries a bad sector
hard, and on a failing drive each retry costs seconds. AUDIO_RIP_TIMEOUT
(default 90 min) caps it and keeps whatever was read.
Music files have no embedded tags — for albums ripped before this was fixed, retag them from their filenames:
docker exec isohungry /opt/isohungry/retag-music.sh --dry-run # preview
docker exec isohungry /opt/isohungry/retag-music.sh # applyIt skips files that already have tags unless you pass --force.
Rename says "file is in use" — something has the file open. On Windows the usual culprit is the ISO being mounted as a virtual drive: double-clicking an ISO in Explorer mounts it, and Windows then holds an exclusive lock, so neither the container nor Windows itself can rename it. Eject the virtual drive and try again. To check:
Get-DiskImage -ImagePath C:\path\to\out\movies\YOUR.iso | Select-Object Attached
Dismount-DiskImage -ImagePath C:\path\to\out\movies\YOUR.iso😵 Drive no talk to me! — the drive stopped responding. On Windows this
usually means the usbip attachment dropped; re-run attach-drives.ps1.
A drive disappears from the display — a badly damaged disc can knock a USB
drive off the bus entirely; usbipd list still shows it Attached but the
device behind it is gone. Detach and re-attach it:
usbipd detach --busid <id>
.\scripts\attach-drives.ps1The drive reappears within one scan cycle.
Rips are slow — USB optical drives read at 2–10 MB/s, so a full disc takes 20–60 minutes. That's the drive, not the software.
Kernel didn't take — uname -r inside a container should end in +.
Docker Desktop pins the VM alive, so wsl --shutdown without quitting Docker
Desktop first often leaves the old kernel running.
Delete the kernel= line from %USERPROFILE%\.wslconfig and run
wsl --shutdown. Optionally winget uninstall dorssel.usbipd-win and delete
%USERPROFILE%\wsl-kernel.
| Path | Purpose |
|---|---|
isohungry.sh |
The ripper |
web/server.py, web/index.html |
Web status page (stdlib only) |
entrypoint.sh |
Starts the web UI, then runs the terminal display |
Dockerfile |
Two stages: libdvdcss + gum, then a Debian runtime |
docker-compose.yml |
Privileged service, /dev and ./out mounts |
.env (optional, untracked) |
Machine-specific settings — currently just WEB_BIND |
setup.sh |
Linux/macOS setup |
scripts/setup-windows.ps1 |
Windows one-time setup |
scripts/attach-drives.ps1 |
Per-boot USB attach/detach |
scripts/retag-music.sh |
Retro-tags albums ripped before tagging worked |
scripts/identify-album.py |
Identifies an album MusicBrainz missed at rip time |
kernel/build-wsl-kernel.sh |
Reproducible WSL2 kernel build |
Commercial DVDs are CSS-scrambled; libdvdcss is built from VideoLAN source in
a throwaway stage, so libdvdread finds it at rip time with nothing extra to
install. Audio CDs go through abcde, which wraps cdparanoia, MusicBrainz and
LAME/FLAC.
PolyForm Noncommercial License 1.0.0 — free for personal, hobby, research and educational use. Commercial use or resale requires a separate written licence from the author.
ISOHungry ships no third-party code itself. The container image installs
dvdbackup, cdrkit, libdvdcss, abcde, cdparanoia, LAME, FLAC and usbip at build
time, and each stays under its own terms (mostly GPL/LGPL); gum is MIT, and
the custom WSL2 kernel remains GPLv2.
Rip only discs you're entitled to copy — rules vary by jurisdiction, and what you do with this is on you.