Skip to content

Development

oauramos edited this page Oct 21, 2026 · 2 revisions

Development

Everything you need to run tunetape from a checkout, run the tests, and understand how the pieces fit.

Run from source

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 queue

To have a global tunetape command that tracks your checkout without activating a venv:

uv tool install --editable .

Tests

pytest -q

The 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.py

CI 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).

Project layout

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.

How the interface works

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 ui function 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: q and ctrl+c make 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 raises SessionClosed, a BaseException that unwinds through the finally cleanup 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).

Playback pipeline

  1. A URL is matched to a source.

  2. The source fetches metadata (playlist titles, album track list) and, per track, resolves a stream URL: yt-dlp for YouTube and Spotify-via-YouTube, direct CDN links for KHInsider.

  3. MPVController launches mpv with no video, an IPC socket, the saved volume, and an audio filter chain:

    • dynaudnorm, when normalization is on;
    • last, a measuring-only astats filter labelled meter, which publishes per-channel peak and RMS levels.

    If mpv won't start with the filters, it retries once without any.

  4. 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.

  5. When mpv exits, playlist mode moves to the next track on the same screen; single tracks return to the menu.

Releasing

Releases are tagged on GitHub and shipped through the Homebrew tap oauramos/homebrew-tunetape. The checklist lives at the top of Formula/tunetape.rb:

  1. Bump version in pyproject.toml, commit, push.
  2. Tag (git tag -a vX.Y.Z), push the tag, create the GitHub release.
  3. Compute the tarball digest and update url and sha256 in Formula/tunetape.rb:
    curl -fsSL https://github.com/oauramos/tunetape/archive/refs/tags/vX.Y.Z.tar.gz | shasum -a 256
  4. Refresh the Python resource blocks if Textual's dependency tree changed: brew update-python-resources Formula/tunetape.rb.
  5. Copy the formula into the tap repo under Formula/ and push. That copy is what brew install uses.

__version__ is read from the installed package metadata, so pyproject.toml is the only place the version lives.

Screenshots

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.FakeController for 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.

Contributing

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.

Clone this wiki locally