nibbles
Queue YouTube videos and file downloads on your phone, and fetch them with data that would otherwise expire.
The phone's data plan grants free data daily and wipes whatever is unused at the daily reset. In the window before that reset (an hour, by default), a scheduled job spends the leftover on downloads queued here, always leaving a floor for the morning (100 MB, by default) and never touching the paid reserve. Everything says what it costs before it spends anything.
How much there is to spend comes from a budget source: the zwana crew
portal (the default wherever the zwana-quota checkout is there), or a manual
daily allowance and reset time for a plan with no portal, spent down from the
runner's own count of what it moved. budget on the More tab chooses (or
nibbles settings budget);
docs/download-queue.md has the section on both.
ytq and dlq were two repos until 2026-10-10; this is both of them, with their histories, and one app: five tabs along the bottom — Search, Feed, Later, Queue, More — with buttons first and keys second, laid out for a phone in portrait. A tap on a button acts, a drag scrolls, and anything that spends data says what it costs on the button that spends it.
nibbles is the one command (nibbles help lists it all); it installs
no ytq or dlq.
nibblesopens the app, on Search;nibbles queueormoreon another tab. Feed and Later are a tap away.nibbles <url>takes whatever yt-dlp would — a link with or without its scheme, a bare video id,ytsearch:words— and a plain file URL too. A video page opens its sheet (--nowstarts it); a file is queued as a download. Which is which is the server's own answer, from its headers (~1 KB), unless a flag already says.nibbles search crust of rustsearches, andnibbles --list …prints with no screen at all.docs/ytq.mdis the guide.- The queue: every verb (
status,list,settings,arm,run-now,dump, …) is anibblesverb.docs/download-queue.mdis the guide;queue/README.mdis the item contract.
design/ holds the agreed design and its mockups, which are the spec the
screens follow; design/divergences.md lists where the build differs.
Layout
nibbles/is the package.app.py: the app's loop, andnibbles.main, the one command's door.tui.py: the terminal (blessed), read as taps, drags and keys.widgets.py: the buttons, sheets and bars, drawn with bewitch.shell.py: what the screens share.yt_screens.py: Search, Feed, Later and the video sheet.queue_screens.py: Queue and More.nibbles/ytq.py: the YouTube half's logic and its command line.ytdl_item.py: what the items it writes download through.nibbles/expire_runner.py: the scheduled runner.expire_dl.py: the shared downloader.expire_sched.py,expire_ui.py,dlq.py: the queue's command line and its actions.home.py: where the queue lives.expire_runner.py,expire_sched.py,ytdl_item.pyandexpire_dl.pyat the root are doors to the package's modules, kept for a phone armed, and holding items queued, before nibbles was a package. They are not shipped.queue/: the queue, with the item contract inqueue/README.md.~/zwana-quota($ZWANA_HOME, a clone beside this one, or under~): the portal client and quota widget the zwana budget source reads every guard figure through. Not needed with a manual budget.- Installed dependencies: blessed, which owns the terminal, and
bewitch, the styling library every screen is drawn with. A bewitch
checkout (
$BEWITCH_HOME, beside this one, or~/bewitch) wins over the installed one.
The queue root is $EXPIRE_HOME, else this checkout, else ~/nibbles — never
an installed copy's own directory, so an installed nibbles still works the
real queue. An installed copy makes ~/nibbles the first time it runs: the
item contract, and the expire_runner.py launcher nibbles arm registers
with the job scheduler. It writes into no checkout and into no folder that
already holds something else.
Install
From PyPI, on Termux (pkg install python termux-api, and yt-dlp on PATH):
uv tool install nibbles # or: pip install nibbles
nibbles # makes ~/nibbles on its first run
nibbles arm # register the nightly job
uv tool upgrade nibbles (or pip install -U nibbles) updates it, and the
next nibbles points the launcher at the new copy.
Or from a checkout, which is what the phone runs (git pull is the update):
git clone https://github.com/gsfernandes81/nibbles ~/nibbles
git clone https://github.com/gsfernandes81/bewitch ~/bewitch
uv tool install --editable ~/nibbles
ln -s ~/nibbles/completions/nibbles.fish ~/.config/fish/completions/
or, with nothing installed but pip install blessed, python3 -m nibbles
from ~/nibbles, which takes the same words.
On a phone that already runs a checkout, pulling the change that made nibbles
a package needs nothing else: the job is still armed at
~/nibbles/expire_runner.py, items queued before it still import
ytdl_item/expire_dl off ~/nibbles, and the editable install's nibbles
still finds nibbles.main — the doors at the root answer all three.
Releasing
A push to main that changes version in pyproject.toml to one PyPI does
not have yet is a release: .github/workflows/release.yml runs both suites,
builds, installs the wheel in a clean environment and makes a queue root with
it, then publishes by PyPI's trusted publishing (no token is stored anywhere)
and tags v<version>. Bump __version__ in nibbles/__init__.py with it; a
test holds the two together. bewitch is released the same way from its own
repo, and has to be on PyPI before a nibbles that needs it.
Moving the phone from ~/dlq and ~/ytq (one-time)
The queue's runtime state lives in the checkout, gitignored, so it moves by
hand. Do it outside the window, with nothing downloading (nibbles status says
so):
git clone https://github.com/gsfernandes81/nibbles ~/nibbles
git clone https://github.com/gsfernandes81/bewitch ~/bewitch # if not there yet
python3 ~/dlq/expire_sched.py cancel # stop the old job firing
mv ~/dlq/queue/*.py ~/nibbles/queue/ # pending items
mv ~/dlq/{work,out,done,failed,logs} ~/nibbles/ # partials + history
mv ~/dlq/{state.json,config.json} ~/nibbles/ 2>/dev/null
sed -i 's|/home/dlq|/home/nibbles|g; s|/home/ytq|/home/nibbles|g' ~/nibbles/queue/*.py
uv tool install --editable ~/nibbles
ln -sf ~/nibbles/completions/nibbles.fish ~/.config/fish/completions/
nibbles arm # register the runner in ~/nibbles
nibbles status # says what it found
The sed matters: an item spells the absolute paths of the modules it imports
into its own sys.path.insert lines, and an item that cannot import its
downloader fails every night without spending a byte. nibbles dump shows an
item's head if one does. Keep ~/dlq and ~/ytq until a night has gone
through, then delete them, along with the old completions (rm ~/.config/fish/completions/{dlq,ytq}.fish) if they are still there. (The old
commands are run by path above because they may already be uninstalled:
uv tool uninstall dlq; uv tool uninstall ytq if not.)
Checks
make dev (uv sync, once, networked) puts the locked pytest, pyte and
hypothesis into .venv; then make test or make check, both of which are
.githooks/checks.sh, the one copy of what runs. The pre-push hook
(git config core.hooksPath .githooks, once per clone) refuses a push that
fails it, because a push here is a deploy: the phone pulls it and the nightly
runner runs what landed.
There are two suites, tests/ytq and tests/dlq, from the two repos, run as
two pytest processes. Each builds its own queue root under a temporary
directory, copies this checkout's package into it, points EXPIRE_HOME and
HOME at it and imports from there, so the checks never read or write the
queue this checkout manages and never reach the network. The app is opened on a
pty that answers blessed's startup queries the way Termux does
(tests/termux_pty.py), tapped and dragged the way Termux sends a finger, and
read back with pyte; and every tab and sheet is composed at every width from
32 to 80 and every height from 11 to 30.
Two failures that are not regressions: a long checkout path (the screens check every line fits 32 columns, and the root is on the status screen), and a missing zwana-quota or bewitch checkout.
make mutants runs poodle; it is a ratchet, outside the checks, and
poodle_config.py says how it is arranged.
Scheduling on Android — findings the nightly job stands on
Measured on this phone in 2026-08, and still what nibbles arm relies on:
- JobScheduler, not runit/cron/nohup. A userspace poll loop lives only
while Termux does; JobScheduler is the system's own scheduler — it survives
Termux being killed and restores after a reboot.
termux-job-schedulerneeds the Termux:API app, not just the CLI package (without the app it hangs). - 15 minutes is Android's floor for a periodic job (clamped since N). The real uncertainty is Doze: screen off, unplugged and stationary, firings defer into maintenance windows; charging suppresses Doze entirely. The pre-reset window is a bias, not a guarantee — an accepted trade, since a missed night just means the job tries again the next evening. A night that matters: put the phone on charge over the reset.
- A job body must never take Termux's wake lock. JobScheduler holds one
across the job's execution, and Termux's own lock is a single global flag
with no reference counting —
termux-wake-unlockreleases it regardless of who acquired it, so a job that unlocked on its way out would silently drop a lock taken for something else.
Licence
AGPL-3.0-or-later; see LICENSE.
Metadata
Release files for nibbles 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| nibbles-0.4.0.tar.gz | 411.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| nibbles-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 643.3 kB
Release files / nibbles-0.4.0.tar.gz
| Download URL | nibbles-0.4.0.tar.gz |
|---|---|
| Size | 411.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
cb48306ec15473d12fec2a22c9962736b001c5fcb8ba5cf479439255ea3af84c
|
|
BLAKE2b-256 checksum How to use checksums |
2cec1be82b5d1ae5ef4ce0ab4d7425f26365dc83e1fb09bd9fe38dcf9329929b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 10, 2026.
Transparency logRelease files / nibbles-0.4.0-py3-none-any.whl
| Download URL | nibbles-0.4.0-py3-none-any.whl |
|---|---|
| Size | 232.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
200d0569fbdcfae8034a025471dfc4b7923f9ac177f6535704289df7c6919863
|
|
BLAKE2b-256 checksum How to use checksums |
30ee6cbcd74e12d91757749c2e1f6acb2287829b256b21df2420bef60484eb30
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 10, 2026.
Transparency log