Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
542d69e
Update README to current API surface; add audited Sonos integration spec
claude Jun 10, 2026
983cb7f
Fix four spec issues found in review: fMP4 container ordering, _looks…
Copilot Jun 10, 2026
351fac7
Fix internal inconsistencies left by the review-fix commit
claude Jun 10, 2026
e86ec7f
Scaffold hum-subsonic-shim: Phase 1 Subsonic endpoints
claude Jun 10, 2026
bcc9cc2
Shim startup UX: API_BEARER_TOKEN fallback + concise missing-settings…
claude Jun 10, 2026
0172eb9
Fix startup footguns: repo-root .env anchoring + hum stray-arg guard
betmoar Jun 10, 2026
7f0dea5
Phase 2 browsing: synthetic hierarchy + playlist drill-in (Search + P…
betmoar Jun 10, 2026
c2ffb61
Phase 5: favourites (star/unstar/getStarred2) + scrobble
betmoar Jun 10, 2026
a122239
Phase 5: cover-art sizing + richer error envelopes
betmoar Jun 10, 2026
4459dce
Phase 5: harden ffmpeg pipe lifecycle
betmoar Jun 10, 2026
270c3a1
Phase 5: seekable remux via cached temp file + Range (spec §4 mode b)
betmoar Jun 10, 2026
0d7ec44
Phase 5: opt-in live integration harness + fix unhandled Hum-transpor…
betmoar Jun 10, 2026
9ff0cbf
Spec: mark Phases 0-2 + code-able Phase 5 done; resolve hierarchy dec…
betmoar Jun 10, 2026
6417338
Serve 200 at / so Amperfy Auto-Detect probe doesn't 404
betmoar Jun 10, 2026
400bc71
Fix Amperfy login: emit XML when client doesn't request f=json
betmoar Jun 10, 2026
d364c94
Phase 2: populate Sonos Playlist shelf from pinned + starred playlists
betmoar Jun 10, 2026
f3fb241
Phase 2/5: Internet Radio shelf from Hum /api/radio (Sonos via bonob)
betmoar Jun 10, 2026
14faa01
Add Sonos shim validation runbook (pick-up guide)
betmoar Jun 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,21 @@ CORS_ORIGINS=http://127.0.0.1,http://localhost
LOG_LEVEL=INFO
LOG_JSON=false
DEBUG=false

# --- hum-subsonic-shim (optional sibling service; docs/SONOS_SPEC.md) ---
# Required to run the shim: the Subsonic password bonob/Amperfy log in with
SHIM_SUBSONIC_PASSWORD=
SHIM_SUBSONIC_USER=hum
# Token for the shim's calls to Hum; falls back to API_BEARER_TOKEN above.
# Leave commented unless the shim talks to a different Hum instance.
#SHIM_HUM_BEARER_TOKEN=
# Optional
SHIM_HUM_BASE_URL=http://127.0.0.1:8000
SHIM_HOST=127.0.0.1
SHIM_PORT=8001
SHIM_ALLOW_PLAIN_PASSWORD=false
SHIM_FFMPEG_PATH=ffmpeg
SHIM_MP3_BITRATE_KBPS=256
# Sonos-facing browse surfaces (via bonob)
SHIM_PINNED_PLAYLISTS= # comma-separated YouTube playlist IDs for the Playlist shelf
SHIM_PUBLIC_URL= # LAN-reachable base for radio streams, e.g. http://192.168.1.10:8001
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ jobs:
run: uv sync --extra dev --python ${{ matrix.python }}

- name: Lint (ruff)
run: uv run ruff check app/
run: uv run ruff check app/ shim/

- name: Type-check (mypy --strict)
run: uv run mypy app/ --strict
run: uv run mypy app/ shim/ --strict

- name: Test (pytest, unit only)
# Integration tests hit live YouTube and are deselected by default
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,6 @@ frontend/node_modules/
frontend/dist/
frontend/.svelte-kit/
frontend/coverage/

# Shim runtime state
.shim-data/
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,20 @@ All `/api/*` endpoints require `Authorization: Bearer <API_BEARER_TOKEN>`.

| Method | Path | Purpose |
|---|---|---|
| GET | `/api/search?q=...&limit=20` | Search videos |
| GET | `/api/search?q=...&limit=20&category=music&live=false` | Search videos (optional music/live filters) |
| GET | `/api/radio?limit=20` | Currently-live music streams (filtered live search) |
| GET | `/api/video/{id}` | Video metadata + signed proxy URLs |
| GET | `/api/channel/{id}` | Channel info |
| GET | `/api/playlist/{id}` | Playlist with items |
| GET | `/api/hls/{id}.m3u8?itag&exp&sig` | HLS byterange wrapper over fMP4/AAC audio (Safari seeking) |
| GET | `/api/live/{id}/manifest.m3u8?exp&sig` | Proxied live-stream HLS playlist (rewritten segment URIs) |
| GET | `/proxy/audio/{id}?itag&exp&sig` | Audio stream (signed, range-aware) |
| GET | `/proxy/stream/{id}?itag&exp&sig` | Video stream (signed, range-aware) |
| GET | `/proxy/thumbnail/{id}?itag=0&exp&sig` | Thumbnail (signed) |
| GET | `/proxy/live-segment/{id}?u&exp&sig` | Live HLS segment proxy (signed, host-allowlisted) |
| GET | `/health` | Health check |

Proxy URLs are minted by `/api/video/{id}` — call it first, hand the returned URLs to your player.
Proxy URLs are minted by `/api/video/{id}` — call it first, hand the returned URLs to your player. For AAC (`audio/mp4`) formats it additionally returns an `hls_url`; for live videos it returns a signed `live_stream_url` manifest. A bearer-protected `/api/debug/live/{id}/upstream` endpoint exposes the raw upstream playlists for live-stream debugging.

## Testing

Expand Down Expand Up @@ -118,8 +122,10 @@ app/
├── adapters/
│ ├── youtube.py Only file that imports pytubefix
│ └── upstream_http.py Shared httpx.AsyncClient + YouTube host allowlist
├── api/ GET routes: search, video, channel, playlist
└── proxy/ GET routes: audio, video, thumbnail (range pass-through)
├── api/ GET routes: search, radio, video, channel, playlist, hls, live
├── proxy/ GET routes: audio, video, thumbnail, live-segment (range pass-through)
├── hls/ sidx box parsing for the fMP4 byterange HLS wrapper
└── live/ HLS master/media playlist parsing + segment-URI rewriting
```

## Why pytubefix?
Expand All @@ -132,7 +138,7 @@ If pytubefix breaks (it eventually will), the fix lives in `app/adapters/youtube

- Single uvicorn worker is correct at this scale (single user)
- No rate limiting beyond the bearer token gate
- In-memory stream URL cache only (5-min TTL); restart loses it
- In-memory stream URL cache only (capped at 1h, bounded by YouTube's own `expire=`); restart loses it
- Not for public deployment without further hardening
- pytubefix is reverse-engineered; YouTube can break it without notice

Expand Down
7 changes: 6 additions & 1 deletion app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,20 @@
from __future__ import annotations

from functools import lru_cache
from pathlib import Path

from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

# Anchor .env to the repo root (this file is <root>/app/config.py), not the
# current working directory — launching from a subdir must not lose settings.
_ENV_FILE = str(Path(__file__).resolve().parents[1] / ".env")


class Settings(BaseSettings):
"""Single source of truth for runtime configuration."""

model_config = SettingsConfigDict(env_file=".env", case_sensitive=False, extra="ignore")
model_config = SettingsConfigDict(env_file=_ENV_FILE, case_sensitive=False, extra="ignore")

# App
app_name: str = "Hum"
Expand Down
9 changes: 9 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,15 @@ async def health() -> dict[str, str]:


def main() -> None:
import sys

# The `hum` script takes no subcommands; `hum shim` is a common slip — the
# shim has its own entry point. Fail loudly instead of silently starting Hum.
if len(sys.argv) > 1:
raise SystemExit(
f"hum: unknown argument {sys.argv[1]!r} — did you mean 'hum-shim'?"
)

import uvicorn

settings = get_settings()
Expand Down
Loading