lofi-app/README.md
Jarian Cottingham cb2459b495
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / docker-build (push) Waiting to run
CI / security (push) Waiting to run
CI / build-result (push) Blocked by required conditions
docs: add family link to lofi-android
2026-08-21 18:36:13 +00:00

232 lines
7.6 KiB
Markdown

# Lofi Radio
A web app that streams live audio from 20 popular lo-fi YouTube channels as a continuous "radio" feed. No YouTube UI, no downloads — just raw audio playback.
Part of the Lofi Radio project:
| Repo | What it is |
|------|------------|
| [lofi-android](https://git.jarianc.com/jarianc/lofi-android) | Android companion app for listening on the go |
## How It Works
1. **Discover** — queries the YouTube Data API v3 to find the current live broadcast for each of the 20 curated channels
2. **Extract** — uses yt-dlp to retrieve the HLS (m3u8) stream URL from the live video
3. **Validate** — checks that the stream is accessible before offering it for playback
4. **Play** — the frontend uses HLS.js to play the stream in-browser with full audio controls
## Features
- 20 curated lo-fi YouTube channels (Lofi Girl, Chillhop Music, The Japanese 100, and more)
- Live broadcast discovery via YouTube Data API v3
- HLS stream extraction via yt-dlp
- Stream validation before playback
- HLS-aware audio player (HLS.js with native fallback)
- Play/pause/stop, volume, next/previous channel controls
- Auto-advance to the next live channel when a stream ends
- Favorites list
- Dark-themed responsive UI
## Tech Stack
| Layer | Technology |
|---|---|
| Backend | Python 3.12, FastAPI, uv (package manager) |
| Stream extraction | yt-dlp |
| HTTP client | httpx |
| Frontend | React 18, TypeScript, Vite, Tailwind CSS |
| State management | Zustand |
| Audio playback | HLS.js |
| Testing (backend) | pytest, pytest-asyncio |
| Testing (frontend) | vitest, @testing-library/react |
| Linting | ruff (Python), ESLint (TypeScript) |
| Deployment | Docker Compose, nginx |
## Prerequisites
- Docker and Docker Compose (v2+)
- A YouTube Data API v3 key ([get one here](https://console.cloud.google.com/apis/credentials))
## Quick Start
```bash
# 1. Clone the repo
git clone <repo-url> && cd lofi-app
# 2. Configure environment
cp .env.example .env
# Edit .env and set your YOUTUBE_API_KEY
# 3. Build and start
cd docker
docker compose up -d --build
# 4. Open http://localhost:5173
```
The app runs two containers:
- **lofi-backend** — FastAPI server on port 8000
- **lofi-frontend** — Nginx serving the React build on port 5173 (reverse-proxies `/api` to backend)
## Environment Variables
| Variable | Required | Default | Description |
|---|---|---|---|
| `YOUTUBE_API_KEY` | Yes | — | YouTube Data API v3 key |
| `BACKEND_PORT` | No | `8000` | Backend listen port |
| `APP_ENV` | No | `development` | Runtime environment |
| `LOG_LEVEL` | No | `info` | Logging verbosity (debug/info/warning/error) |
| `VITE_API_URL` | No | `http://localhost:8000` | Backend API URL for frontend |
| `VITE_APP_TITLE` | No | `Lofi Radio` | App title shown in browser tab |
| `VITE_FRONTEND_PORT` | No | `5173` | Frontend exposed port |
| `COMPOSE_PROJECT_NAME` | No | `lofi-app` | Docker Compose project name |
## Project Structure
```
lofi-app/
├── .env # Environment variables (not in git)
├── .env.example # Template — copy to .env
├── .dockerignore # Docker build context exclusions
├── .gitignore
├── .hadolint.yaml # Dockerfile linting config
├── pyproject.toml # Python project + dependencies (uv)
├── uv.lock # uv dependency lock file
├── docker/ # Docker configuration
│ ├── docker-compose.yml # Service orchestration
│ └── Dockerfile.backend # Python 3.12 + uv backend image
├── src/ # Backend source
│ ├── main.py # FastAPI app (4 endpoints)
│ ├── config.py # Settings from environment
│ ├── channels.py # 20 channel definitions
│ └── modules/
│ ├── discovery.py # YouTube API live broadcast lookup
│ ├── stream_extractor.py # yt-dlp HLS stream extraction
│ └── validator.py # Stream URL validation
├── frontend/ # React frontend
│ ├── Dockerfile # Node 22 build → nginx runtime
│ ├── nginx.conf # Reverse proxy to backend /api
│ ├── package.json # pnpm dependencies
│ ├── pnpm-lock.yaml # pnpm lock file
│ ├── vite.config.ts # Vite build config + proxy
│ ├── tsconfig.json
│ ├── vitest.config.ts # Vitest test config
│ └── src/
│ ├── main.tsx # React entry point
│ ├── App.tsx # Root component
│ ├── types.ts # TypeScript interfaces
│ ├── components/ # UI components
│ │ ├── AudioPlayer.tsx # Player controls + HLS.js
│ │ ├── ChannelList.tsx # Channel grid
│ │ └── Header.tsx # App header
│ ├── hooks/
│ │ └── useAudioPlayer.ts # HLS.js audio player hook
│ ├── services/
│ │ └── api.ts # API client functions
│ ├── store/
│ │ └── audioStore.ts # Zustand state store
│ ├── styles/ # CSS / Tailwind
│ └__tests__/ # Vitest test suites
├── tests/ # Backend tests
│ ├── conftest.py # Pytest fixtures
│ ├── unit/ # Module unit tests
│ │ ├── test_discovery.py
│ │ ├── test_stream_extractor.py
│ │ └── test_validator.py
│ └── integration/
│ └── test_api.py # API endpoint tests
└── data/ # Runtime data (mounted as volume)
```
## API Endpoints
| Endpoint | Method | Description |
|---|---|---|
| `/api/channels` | GET | List all 20 channels with live status |
| `/api/channels/{id}/live` | GET | Check if a specific channel is currently live |
| `/api/stream/{videoId}` | GET | Get HLS/direct stream URL for a video |
| `/api/now-playing` | GET | Get the current active stream from any channel |
All responses use camelCase keys (`videoId`, `isLive`, `streamType`).
## Dockerfile Design
Both Dockerfiles follow a build-only philosophy — runtime configuration (ports, volumes, restart policies) lives in `docker-compose.yml` exclusively.
- **Backend**: Single-stage Python 3.12-slim image with uv-managed virtualenv
- **Frontend**: Multi-stage build (Node 22 for build → nginx:alpine for runtime)
- No `EXPOSE` directives — ports are mapped in Compose only
- All images lint clean with hadolint (warning-level failure threshold)
## Development
### Backend (local)
```bash
# Install dependencies
uv sync
# Run linter
ruff format .
ruff check --fix .
# Run tests
uv run pytest tests/ -v
```
### Frontend (local)
```bash
cd frontend
# Install dependencies
pnpm install
# Run linter
pnpm lint
# Type check
pnpm typecheck
# Run tests
pnpm test
# Development server (proxies /api to backend)
pnpm dev
```
### Docker Operations
```bash
cd docker
# Start everything
docker compose up -d --build
# View logs
docker compose logs -f backend
docker compose logs -f frontend
# Rebuild a single service
docker compose down backend
docker compose up -d backend --build
# Stop everything
docker compose down
# Cleanup unused images
docker system prune -f
```
## Testing
| Suite | Command | Tests | Coverage |
|---|---|---|---|
| Backend unit | `uv run pytest tests/unit/` | 30 | discovery, stream_extractor, validator |
| Backend integration | `uv run pytest tests/integration/` | 12 | API endpoints, CORS |
| Frontend unit | `pnpm --dir=frontend test` | 13 | audioStore, api service |
| Frontend component | `pnpm --dir=frontend test` | 7 | AudioPlayer, ChannelList |
## License
MIT