Build a Textual-based TUI accessible via ./manage.py tui that provides an interactive
interface for managing stashed media. No web server or Huey worker needed — direct
database access with synchronous operations and progress display.
Textual (Python TUI framework by Textualize)
- Add
textualto pyproject.toml dependencies - Provides DataTable, Input, ProgressBar, Header, Footer, screens, key bindings
- Built-in test harness (
app.run_test()/pilot) integrates with pytest
manage.py tui
└── TuiCommand (Django management command)
└── StashCastApp (textual.App)
├── ItemListScreen (default) ── reads MediaItem via Django ORM
├── ItemDetailScreen ── reads single MediaItem
└── StashScreen ── calls processing.py synchronously
└── progress bar driven by worker thread + callbacks
The TUI is a thin presentation layer. All business logic stays in the existing service/processing modules. Django ORM is used directly (DJANGO_SETTINGS_MODULE is already set by manage.py).
- DataTable with columns: Title, Status, Type, Duration, Date Added
- Sorted by most recent first
- Footer with key bindings:
sStash,enterDetail,dDelete,aArchive,rRetry,fFilter,qQuit - Filter bar (toggled with
f): filter by status (READY/ERROR/ARCHIVED/all) and text search on title - Color-code status: READY=green, ERROR=red, DOWNLOADING/PROCESSING=yellow, ARCHIVED=dim
- Shows all metadata for a selected MediaItem: title, source URL, slug, status, media type, duration, file size, content path, thumbnail path, subtitle path, created/updated timestamps
- Key bindings:
escapeBack,dDelete,aArchive/Unarchive,oOpen source URL
- Input field for URL
- RadioSet for type: Auto / Audio / Video
- On submit:
- Run the stash pipeline synchronously in a Textual worker thread
- Display a ProgressBar (0-100%) updated via callbacks
- Show status label: "Prefetching..." → "Downloading..." → "Processing..." → "Ready!"
- On completion, return to Item List (refreshed)
- On error, show error message with option to dismiss
- Delete: confirm dialog →
MediaItem.delete()+ optionallyshutil.rmtree()on the item's directory - Archive: toggle
statusto ARCHIVED / back to READY, set/cleararchived_at - Retry: for ERROR items, reset status to PREFETCHING and re-run stash pipeline (same as Stash Screen but for existing item)
Reuse the existing pipeline from stash.py management command and processing.py:
# In a Textual worker thread (non-blocking to the UI):
def stash_with_progress(url, requested_type, progress_callback):
item = MediaItem.objects.create(source_url=url, requested_type=requested_type)
tmp_dir = create_tmp_dir(item)
log_path = tmp_dir / 'log.txt'
progress_callback(0, "Prefetching...")
strategy = determine_strategy(url)
prefetch_X(item, tmp_dir, log_path) # existing processing.py functions
progress_callback(10, "Downloading...")
download_X(item, tmp_dir, log_path) # existing processing.py functions
progress_callback(40, "Processing...")
process_files(item, tmp_dir, log_path) # existing processing.py function
move_to_final_dir(item, tmp_dir)
item.status = MediaItem.STATUS_READY
item.save()
progress_callback(100, "Ready!")The progress_callback posts messages to the Textual app via app.call_from_thread()
to update the ProgressBar widget.
media/
├── management/commands/
│ └── tui.py # Django management command entry point
├── tui/
│ ├── __init__.py
│ ├── app.py # StashCastApp (textual.App subclass)
│ ├── screens/
│ │ ├── __init__.py
│ │ ├── item_list.py # Item list screen
│ │ ├── item_detail.py # Item detail screen
│ │ └── stash.py # Stash URL screen with progress
│ ├── widgets/
│ │ ├── __init__.py
│ │ └── filter_bar.py # Status/text filter widget
│ └── styles.tcss # Textual CSS stylesheet
├── tests/
│ └── test_tui.py # TUI tests
Use Textual's built-in run_test() harness with pytest:
import pytest
from media.tui.app import StashCastApp
@pytest.mark.django_db
async def test_item_list_shows_items():
# Create test data
MediaItem.objects.create(title="Test Episode", status=MediaItem.STATUS_READY, ...)
async with StashCastApp().run_test() as pilot:
table = pilot.app.query_one(DataTable)
assert table.row_count == 1
assert "Test Episode" in str(table.get_row_at(0))@pytest.mark.django_db
async def test_delete_item():
item = MediaItem.objects.create(...)
async with StashCastApp().run_test() as pilot:
await pilot.press("enter") # select item
await pilot.press("d") # delete
await pilot.press("y") # confirm
assert MediaItem.objects.count() == 0Textual supports SVG snapshot comparison — render a screen and compare against a saved reference. Useful for catching unintended layout changes.
Test the stash-with-progress function independently by mocking the processing functions and verifying the callback sequence:
@patch('media.processing.prefetch_ytdlp')
@patch('media.processing.download_ytdlp')
@patch('media.processing.process_files')
def test_stash_progress_callbacks(mock_process, mock_download, mock_prefetch):
callbacks = []
stash_with_progress("https://example.com", "auto", lambda pct, msg: callbacks.append((pct, msg)))
assert callbacks[0] == (0, "Prefetching...")
assert callbacks[-1] == (100, "Ready!")- Add
textualdependency to pyproject.toml - Create
media/tui/package with app.py and basic screen scaffolding - Item List Screen — read-only list of MediaItems from DB
- Item Detail Screen — view metadata for selected item
- Delete & Archive — mutations with confirmation dialogs
- Stash Screen — URL input + synchronous stash with progress bar
- Filter bar — status and text filtering on item list
- Retry — re-stash errored items
manage.py tuicommand — wire it all up- Tests — pilot tests for each screen, unit tests for stash logic
- Styles — polish colors, layout, keybinding footer
- Should the TUI support batch stashing (multiple URLs at once)? → Defer to v2
- Should we add a log viewer panel? → Defer to v2
- Inline audio playback via terminal? → Probably not practical, defer