Repository navigation
Development
Everything you need to run tunetape from a checkout, run the tests, and understand how the pieces fit.
git clone https://github.com/oauramos/tunetape.git
cd tunetape
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[test]"
tunetape-e makes the install editable: changes in tunetape/ take effect on the next launch. You still need mpv and yt-dlp on your PATH (see Installation).
To try the interface without mpv or a network, run the player on fake data:
python -m tunetape.demo # a single track
python -m tunetape.demo --album # a 24-track album with its queueTo have a global tunetape command that tracks your checkout without activating a venv:
uv tool install --editable .pytest -qThe suite is offline: URL detection, the Spotify and KHInsider page parsers (against fixture HTML in tests/fixtures/), yt-dlp argument building, history and config persistence, playlist logic, the AI reply parser, and how the level meter and stream stats are read from mpv.
The interface is covered by tests/test_tui.py, which runs the real app headless with Textual's run_test. A session flow runs on its thread while a script presses keys, clicks and scrolls. The tests cover every screen's answers, the universal navigation keys, the player's bindings and mouse actions, the layout at sizes down to 60×20, and a full session from the menu to the player and back, with a fake mpv. Nothing in the default run talks to the network or starts mpv. A set of live playback tests exists for checking real URLs end to end; they are skipped unless you opt in:
TUNETAPE_NETWORK_TESTS=1 pytest tests/test_network.pyCI runs the same suite on every push and pull request across Ubuntu, macOS and Windows on Python 3.9 and 3.13 (macOS on 3.10 and 3.13, since the arm64 runners have no 3.9 build).
| Module | Role |
|---|---|
tunetape/__main__.py |
Entry point and the session flow: menu, play, history, discover, settings, written as plain blocking code |
tunetape/ui.py |
The Textual app (TunetapeApp), its CSS, and the functions the flow calls (main_menu, prompt_url, show_history, play, …) |
tunetape/screens.py |
Every screen but the player: menu, prompts, lists, settings, theme picker, loading, help, debug, dialogs |
tunetape/player_screen.py |
The player dashboard: now playing, level, stream and queue panels, and the controller polling |
tunetape/widgets.py |
Shared widgets: the key caps, the mascot, the braille graph, the level meter, the scrolling title, the top bar |
tunetape/art.py |
The tunetape theme and the mascot: the ASCII tuna and the pixel-art tunas, as hand-edited grids |
tunetape/demo.py |
FakeController and demo data for trying the player and for the tests and screenshots |
tunetape/player.py |
YouTube URL detection, yt-dlp resolution, dependency checks, and MPVController (drives mpv over IPC) |
tunetape/ipc.py |
The IPC transport: Unix socket on macOS/Linux, named pipe on Windows |
tunetape/terminal.py |
Emergency terminal restore for crashes and signals |
tunetape/playlist.py |
Playlist position, next/prev/jump, and a temporary track cache |
tunetape/models.py |
Track and Album, the source-agnostic data model |
tunetape/spotify.py |
Spotify link parsing, public embed-page scraping, YouTube search resolution |
tunetape/khinsider.py |
KHInsider album page parsing and CDN track resolution |
tunetape/ai.py |
Discover: provider config, request building, reply parsing |
tunetape/history.py |
Recently played persistence |
tunetape/config.py |
Settings persistence and defaults |
tunetape/paths.py |
Data directory resolution and atomic JSON writes |
tunetape/debug.py |
The in-memory session log behind the Debug screen |
Source adapters build Album/Track objects; everything downstream (Playlist, MPVController, the player screen) is shared. Adding a new source means: a URL matcher, a fetcher that returns an Album, and a resolver that turns a Track into a stream URL.
The whole session is one Textual app. The session flow in __main__ stays ordinary blocking code (show the menu, fetch, play, come back) and runs on a worker thread.
-
Screens as function calls: each
uifunction shows a screen and blocks the flow until that screen answers. It returns what the screen answered, so the flow reads like a series of prompts. - Slow work: fetches and AI calls run on the flow thread, while the app keeps drawing and animating on the main thread.
- Next track: moving to the next track of an album reuses the same player screen.
-
Quitting:
qandctrl+cmake the waiting screen answer its "quit" value, so the flow winds down normally: mpv stops and the volume is saved. If the app closes while the flow is busy, the flow's next call raisesSessionClosed, aBaseExceptionthat unwinds through thefinallycleanup blocks.
Navigation is shared: every full screen inherits b/esc (back), q (quit) and h/? (help) from screens.FlowScreen, and only declares what "back" answers. Key caps are widgets.KeyCaps, and every screen's caps end with screens.NAV (h help, b back, q quit).
-
A URL is matched to a source.
-
The source fetches metadata (playlist titles, album track list) and, per track, resolves a stream URL:
yt-dlpfor YouTube and Spotify-via-YouTube, direct CDN links for KHInsider. -
MPVControllerlaunchesmpvwith no video, an IPC socket, the saved volume, and an audio filter chain:-
dynaudnorm, when normalization is on; - last, a measuring-only
astatsfilter labelledmeter, which publishes per-channel peak and RMS levels.
If mpv won't start with the filters, it retries once without any.
-
-
The player polls over IPC:
- every 100 ms, the position and the levels (
af-metadata/meter); - every 0.3 s, duration, pause, volume and mute;
- every second, the stream stats: codec, bitrate, audio params, container, cache ahead and download speed.
Seek, volume and pause commands go the other way.
- every 100 ms, the position and the levels (
-
When mpv exits, playlist mode moves to the next track on the same screen; single tracks return to the menu.
Releases are tagged on GitHub and shipped through the Homebrew tap oauramos/homebrew-tunetape. The checklist lives at the top of Formula/tunetape.rb:
- Bump
versioninpyproject.toml, commit, push. - Tag (
git tag -a vX.Y.Z), push the tag, create the GitHub release. - Compute the tarball digest and update
urlandsha256inFormula/tunetape.rb:curl -fsSL https://github.com/oauramos/tunetape/archive/refs/tags/vX.Y.Z.tar.gz | shasum -a 256 - Refresh the Python resource blocks if Textual's dependency tree changed:
brew update-python-resources Formula/tunetape.rb. - Copy the formula into the tap repo under
Formula/and push. That copy is whatbrew installuses.
__version__ is read from the installed package metadata, so pyproject.toml is the only place the version lives.
The images in the README and this wiki are not hand-taken. A script runs the real screens through ui on fake data in TunetapeApp.run_test(size=(108, 32)):
-
Fake data:
demo.FakeControllerfor the player, plus fake history, AI suggestions and config, so nothing real leaks into the images. - Graphs: player shots wait about ten seconds so the level and download graphs have filled.
-
Export: each screen is saved with
app.save_screenshot()as SVG, rasterised at 2x with headless Chrome, and quantized to a 256-color PNG. -
Small terminals: the 60-column shots use
size=(60, 24).
Regenerate them the same way after a UI change so the docs never drift from the app.
Issues and pull requests are welcome at https://github.com/oauramos/tunetape. Keep changes small and focused, add or update a test where it makes sense, and make sure pytest -q passes on your platform before opening a PR. CI covers the other two.