This commit is contained in:
jarianc 2026-05-09 08:57:54 -05:00
parent 037983ab34
commit ceed10aef4
63 changed files with 7393 additions and 0 deletions

10
.dockerignore Normal file
View File

@ -0,0 +1,10 @@
.env
.git
__pycache__
*.pyc
data/
tools/
additionaldocs/
prompt.md
.ruff_cache/
.venv/

14
.env.example Normal file
View File

@ -0,0 +1,14 @@
# YouTube Data API v3
YOUTUBE_API_KEY=your_youtube_api_key_here
# Backend
BACKEND_PORT=8000
APP_ENV=development
LOG_LEVEL=info
# Frontend
VITE_API_URL=http://localhost:8000
VITE_APP_TITLE=Lofi Radio
# Docker
COMPOSE_PROJECT_NAME=lofi-app

31
.gitignore vendored Normal file
View File

@ -0,0 +1,31 @@
# Python
__pycache__/
*.py[cod]
*.so
*.egg-info/
.venv/
uv.lock
# Frontend
frontend/node_modules/
frontend/dist/
# Environment
.env
# Data
data/*
!data/.gitkeep
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Docker
*.log

4
.hadolint.yaml Normal file
View File

@ -0,0 +1,4 @@
failure-threshold: warning
ignored:
- DL3008 # apt-get install without --no-install-recommends is needed for build dependencies

View File

@ -0,0 +1,149 @@
---
description: Implement tasks from an OpenSpec change (Experimental)
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name (e.g., `/opsx-apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx-apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- Context file paths (varies by schema)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx-continue`
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read the files listed in `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]``- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! You can archive this change with `/opsx-archive`.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly

View File

@ -0,0 +1,154 @@
---
description: Archive a completed change in the experimental workflow
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name after `/opsx-archive` (e.g., `/opsx-archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Prompt user for confirmation to continue
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Prompt user for confirmation to continue
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Spec sync status (synced / sync skipped / no delta specs)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs
All artifacts complete. All tasks complete.
```
**Output On Success (No Delta Specs)**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** No delta specs
All artifacts complete. All tasks complete.
```
**Output On Success With Warnings**
```
## Archive Complete (with warnings)
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** Sync skipped (user chose to skip)
**Warnings:**
- Archived with 2 incomplete artifacts
- Archived with 3 incomplete tasks
- Delta spec sync was skipped (user chose to skip)
Review the archive if this was not intentional.
```
**Output On Error (Archive Exists)**
```
## Archive Failed
**Change:** <change-name>
**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/
Target archive directory already exists.
**Options:**
1. Rename the existing archive
2. Delete the existing archive if it's a duplicate
3. Wait until a different date to archive
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting

View File

@ -0,0 +1,170 @@
---
description: Enter explore mode - think through ideas, investigate problems, clarify requirements
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Input**: The argument after `/opsx-explore` is whatever the user wants to think about. Could be:
- A vague idea: "real-time collaboration"
- A specific problem: "the auth system is getting unwieldy"
- A change name: "add-dark-mode" (to explore in context of that change)
- A comparison: "postgres vs sqlite for this"
- Nothing (just enter explore mode)
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
If the user mentioned a specific change name, read its artifacts for context.
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|--------------|------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own

View File

@ -0,0 +1,103 @@
---
description: Propose a new change - create it and generate all artifacts in one step
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx-apply
---
**Input**: The argument after `/opsx-propose` is the change name (kebab-case), OR a description of what the user wants to build.
**Steps**
1. **If no input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx-apply` to start implementing."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next

View File

@ -0,0 +1,156 @@
---
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx-apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- Context file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read the files listed in `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]``- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! Ready to archive this change.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly

View File

@ -0,0 +1,114 @@
---
name: openspec-archive-change
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Whether specs were synced (if applicable)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
All artifacts complete. All tasks complete.
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use openspec-sync-specs approach (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting

View File

@ -0,0 +1,288 @@
---
name: openspec-explore
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|--------------|------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Handling Different Entry Points
**User brings a vague idea:**
```
User: I'm thinking about adding real-time collaboration
You: Real-time collab is a big space. Let me think about this...
COLLABORATION SPECTRUM
════════════════════════════════════════════
Awareness Coordination Sync
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Presence│ │Cursors │ │ CRDT │
│ "3 │ │ Multi │ │Conflict│
│online" │ │ select │ │ free │
└────────┘ └────────┘ └────────┘
│ │ │
trivial moderate complex
Where's your head at?
```
**User brings a specific problem:**
```
User: The auth system is a mess
You: [reads codebase]
┌─────────────────────────────────────────────┐
│ CURRENT AUTH FLOW │
└─────────────────────────────────────────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Google │ │ GitHub │ │ Email │
│ OAuth │ │ OAuth │ │ Magic │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└─────────────┼─────────────┘
┌───────────┐
│ Session │
└─────┬─────┘
┌───────────┐
│ Perms │
└───────────┘
I see three tangles. Which one's burning?
```
**User is stuck mid-implementation:**
```
User: /opsx-explore add-auth-system
The OAuth integration is more complex than expected
You: [reads change artifacts]
You're on task 4: "Implement OAuth flow"
Let me trace what's involved...
[draws diagram, explores options, suggests paths]
Want to update the design to reflect this?
Or add a spike task to investigate?
```
**User wants to compare options:**
```
User: Should we use Postgres or SQLite?
You: Generic answer is boring. What's the context?
User: A CLI tool that tracks local dev environments
You: That changes everything.
┌─────────────────────────────────────────────────┐
│ CLI TOOL DATA STORAGE │
└─────────────────────────────────────────────────┘
Key constraints:
• No daemon running
• Must work offline
• Single user
SQLite Postgres
Deployment embedded ✓ needs server ✗
Offline yes ✓ no ✗
Single file yes ✓ no ✗
SQLite. Not even close.
Unless... is there a sync component?
```
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When it feels like things are crystallizing, you might summarize:
```
## What We Figured Out
**The problem**: [crystallized understanding]
**The approach**: [if one emerged]
**Open questions**: [if any remain]
**Next steps** (if ready):
- Create a change proposal
- Keep exploring: just keep talking
```
But this summary is optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own

View File

@ -0,0 +1,110 @@
---
name: openspec-propose
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx-apply
---
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx-apply` or ask me to implement to start working on the tasks."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next

25
docker/Dockerfile.backend Normal file
View File

@ -0,0 +1,25 @@
FROM python:3.12-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY pyproject.toml uv.lock ./
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN curl -LsSf https://astral.sh/uv/0.5.25/install.sh | sh \
&& . "$HOME/.local/bin/env" \
&& uv sync --frozen
SHELL ["/bin/sh", "-c"]
FROM builder AS runtime
RUN groupadd -r appuser \
&& useradd -r -g appuser -d /app -s /bin/bash appuser
WORKDIR /app
COPY src/ ./src/
RUN chown -R appuser:appuser /app /build
USER appuser

35
docker/docker-compose.yml Normal file
View File

@ -0,0 +1,35 @@
services:
backend:
build:
context: ..
dockerfile: docker/Dockerfile.backend
container_name: lofi-backend
command: /build/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8010
ports:
- "${BACKEND_PORT:-8010}:8010"
env_file:
- ../.env
volumes:
- ../data:/app/data
restart: unless-stopped
networks:
- lofi-net
frontend:
build:
context: ..
dockerfile: frontend/Dockerfile
container_name: lofi-frontend
ports:
- "${VITE_FRONTEND_PORT:-5175}:80"
env_file:
- ../.env
depends_on:
- backend
restart: unless-stopped
networks:
- lofi-net
networks:
lofi-net:
driver: bridge

3
frontend/.dockerignore Normal file
View File

@ -0,0 +1,3 @@
node_modules
dist
.env

3
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
dist
.env

7
frontend/.prettierrc Normal file
View File

@ -0,0 +1,7 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"tabWidth": 2,
"printWidth": 100
}

20
frontend/Dockerfile Normal file
View File

@ -0,0 +1,20 @@
FROM node:22-alpine AS base
RUN npm install -g pnpm@10
FROM base AS builder
WORKDIR /build
RUN echo "auto-install-peers=true" > .npmrc
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY frontend/ ./
RUN pnpm build
FROM nginx:alpine
COPY --from=builder /build/dist /usr/share/nginx/html
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf

27
frontend/eslint.config.js Normal file
View File

@ -0,0 +1,27 @@
import js from "@eslint/js"
import tseslint from "typescript-eslint"
import reactHooks from "eslint-plugin-react-hooks"
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
plugins: {
"react-hooks": reactHooks,
},
rules: {
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-explicit-any": "warn",
...reactHooks.configs.recommended.rules,
},
},
{
languageOptions: {
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
},
files: ["src/**/*.{ts,tsx}"],
},
)

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Lofi Radio - streams live audio from popular lo-fi YouTube channels" />
<title>Lofi Radio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

19
frontend/nginx.conf Normal file
View File

@ -0,0 +1,19 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:8010;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

42
frontend/package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "lofi-radio-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"hls.js": "^1.5.15",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^4.5.4"
},
"devDependencies": {
"@eslint/js": "^9.0.0",
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.6",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"eslint": "^9.0.0",
"eslint-plugin-react-hooks": "^5.0.0",
"jsdom": "^24.0.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.6.2",
"typescript-eslint": "^8.0.0",
"vite": "^5.4.3",
"vitest": "^1.6.0"
}
}

3651
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

7
frontend/safelist.txt Normal file
View File

@ -0,0 +1,7 @@
[
"bg-lofi-bg",
"bg-lofi-surface",
"bg-lofi-accent",
"text-lofi-text",
"text-lofi-muted",
]

27
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,27 @@
import { useEffect } from "react"
import { AudioPlayer } from "./components/AudioPlayer"
import { ChannelList } from "./components/ChannelList"
import { useAudioStore } from "./store/audioStore"
export function App() {
const isLoading = useAudioStore((s) => s.isLoading)
const refreshChannels = useAudioStore((s) => s.refreshChannels)
useEffect(() => {
refreshChannels()
}, [refreshChannels])
return (
<div className="min-h-screen bg-lofi-bg pb-32">
{isLoading && (
<div className="fixed top-4 right-4 z-50 px-3 py-1 text-xs bg-lofi-surface text-lofi-muted rounded-full animate-pulse">
Discovering live channels...
</div>
)}
<ChannelList />
<AudioPlayer />
</div>
)
}

View File

@ -0,0 +1,67 @@
import { render, screen } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import { AudioPlayer } from "../components/AudioPlayer"
import { useAudioStore } from "../store/audioStore"
vi.mock("../store/audioStore", () => ({
useAudioStore: vi.fn(),
}))
vi.mock("../hooks/useAudioPlayer", () => ({
useAudioPlayer: vi.fn(() => ({
audioRef: { current: { volume: 0.7 } },
play: vi.fn(),
pause: vi.fn(),
stop: vi.fn(),
setVolume: vi.fn(),
setStateChange: vi.fn(),
})),
}))
const configureMockStore = (state) => {
vi.mocked(useAudioStore).mockReturnValue(state)
useAudioStore.getState = vi.fn(() => state)
}
describe("AudioPlayer", () => {
it("shows placeholder when no channel selected", () => {
configureMockStore({
currentChannel: null,
streamUrl: null,
isPlaying: false,
volume: 0.7,
error: null,
})
render(<AudioPlayer />)
expect(screen.getByText("Select a channel to start listening")).toBeInTheDocument()
})
it("shows player controls when channel is selected", () => {
configureMockStore({
currentChannel: { id: "UC1", name: "Lofi Girl", handle: "@LofiGirl" },
streamUrl: "https://stream.m3u8",
isPlaying: false,
volume: 0.7,
error: null,
})
render(<AudioPlayer />)
expect(screen.getByText("Lofi Girl")).toBeInTheDocument()
expect(screen.getByText("@LofiGirl")).toBeInTheDocument()
})
it("shows error message when error is set", () => {
configureMockStore({
currentChannel: { id: "UC1", name: "Lofi Girl", handle: "@LofiGirl" },
streamUrl: "https://stream.m3u8",
isPlaying: false,
volume: 0.7,
error: "Playback error",
})
render(<AudioPlayer />)
expect(screen.getByText("Playback error")).toBeInTheDocument()
})
})

View File

@ -0,0 +1,129 @@
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { describe, expect, it, vi } from "vitest"
import { ChannelList } from "../components/ChannelList"
import { useAudioStore } from "../store/audioStore"
vi.mock("../store/audioStore", () => ({
useAudioStore: vi.fn(),
}))
const configureMockStore = (state) => {
vi.mocked(useAudioStore).mockReturnValue(state)
useAudioStore.getState = vi.fn(() => state)
}
describe("ChannelList", () => {
it("displays channels with live status", () => {
configureMockStore({
channels: [
{
id: "UC1",
name: "Lofi Girl",
handle: "@LofiGirl",
description: "Beats to relax",
isLive: true,
videoId: "vid1",
},
{
id: "UC2",
name: "Chillhop",
handle: "@Chillhop",
description: "Jazzhop beats",
isLive: false,
videoId: null,
},
],
currentChannel: null,
isLoading: false,
playChannel: vi.fn(),
refreshChannels: vi.fn(),
addFavorite: vi.fn(),
removeFavorite: vi.fn(),
isFavorite: vi.fn(() => false),
})
render(<ChannelList />)
expect(screen.getByText("Lofi Girl")).toBeInTheDocument()
expect(screen.getByText("Chillhop")).toBeInTheDocument()
expect(screen.getByText("LIVE")).toBeInTheDocument()
expect(screen.getByText("Offline")).toBeInTheDocument()
})
it("shows live channel count", () => {
configureMockStore({
channels: [
{ id: "UC1", name: "Lofi Girl", handle: "", description: "", isLive: true, videoId: "v1" },
{ id: "UC2", name: "Chillhop", handle: "", description: "", isLive: true, videoId: "v2" },
{ id: "UC3", name: "Offline", handle: "", description: "", isLive: false, videoId: null },
],
currentChannel: null,
isLoading: false,
playChannel: vi.fn(),
refreshChannels: vi.fn(),
addFavorite: vi.fn(),
removeFavorite: vi.fn(),
isFavorite: vi.fn(() => false),
})
render(<ChannelList />)
expect(screen.getByText(/2 channels live.*1 offline/)).toBeInTheDocument()
})
it("calls playChannel when live channel is clicked", async () => {
const playChannel = vi.fn()
configureMockStore({
channels: [
{
id: "UC1",
name: "Lofi Girl",
handle: "@LofiGirl",
description: "Beats",
isLive: true,
videoId: "vid1",
},
],
currentChannel: null,
isLoading: false,
playChannel,
refreshChannels: vi.fn(),
addFavorite: vi.fn(),
removeFavorite: vi.fn(),
isFavorite: vi.fn(() => false),
})
const user = userEvent.setup()
render(<ChannelList />)
await user.click(screen.getByText("Lofi Girl"))
expect(playChannel).toHaveBeenCalled()
})
it("does not play offline channels when clicked", async () => {
const playChannel = vi.fn()
configureMockStore({
channels: [
{
id: "UC1",
name: "Offline Channel",
handle: "",
description: "",
isLive: false,
videoId: null,
},
],
currentChannel: null,
isLoading: false,
playChannel,
refreshChannels: vi.fn(),
addFavorite: vi.fn(),
removeFavorite: vi.fn(),
isFavorite: vi.fn(() => false),
})
const user = userEvent.setup()
render(<ChannelList />)
await user.click(screen.getByText("Offline Channel"))
expect(playChannel).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,77 @@
import { describe, expect, it, vi } from "vitest"
import { fetchChannels, fetchStream, fetchNowPlaying } from "../services/api"
global.fetch = vi.fn()
describe("api service", () => {
it("fetchChannels returns channel list", async () => {
;(fetch as unknown as vi.Mock).mockResolvedValue({
ok: true,
json: () =>
Promise.resolve([
{ id: "UC1", name: "Lofi Girl", handle: "@LofiGirl", description: "Beats", isLive: true, videoId: "v1" },
]),
})
const result = await fetchChannels()
expect(fetch).toHaveBeenCalledWith("/api/channels")
expect(result).toHaveLength(1)
expect(result[0].name).toBe("Lofi Girl")
})
it("fetchChannels throws on error", async () => {
;(fetch as unknown as vi.Mock).mockResolvedValue({
ok: false,
status: 500,
})
await expect(fetchChannels()).rejects.toThrow("Failed to fetch channels: 500")
})
it("fetchStream returns stream info", async () => {
;(fetch as unknown as vi.Mock).mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
videoId: "vid1",
url: "https://stream.m3u8",
streamType: "hls",
title: "Live Stream",
channel: "Lofi Girl",
duration: null,
isLive: true,
}),
})
const result = await fetchStream("vid1")
expect(fetch).toHaveBeenCalledWith("/api/stream/vid1")
expect(result.url).toBe("https://stream.m3u8")
expect(result.streamType).toBe("hls")
})
it("fetchStream throws on error", async () => {
;(fetch as unknown as vi.Mock).mockResolvedValue({
ok: false,
status: 503,
})
await expect(fetchStream("vid1")).rejects.toThrow("Failed to fetch stream: 503")
})
it("fetchNowPlaying returns now playing info", async () => {
;(fetch as unknown as vi.Mock).mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
channel: { id: "UC1", name: "Lofi Girl", handle: "@LofiGirl", description: "Beats" },
videoId: "vid1",
url: "https://stream.m3u8",
}),
})
const result = await fetchNowPlaying()
expect(fetch).toHaveBeenCalledWith("/api/now-playing")
expect(result.channel?.name).toBe("Lofi Girl")
})
})

View File

@ -0,0 +1,76 @@
import { describe, expect, it, vi } from "vitest"
import { useAudioStore } from "../store/audioStore"
vi.mock("../services/api", () => ({
fetchChannels: vi.fn(),
fetchStream: vi.fn(),
}))
describe("audioStore", () => {
it("initializes with default state", () => {
const state = useAudioStore.getState()
expect(state.channels).toEqual([])
expect(state.currentChannel).toBeNull()
expect(state.streamUrl).toBeNull()
expect(state.isPlaying).toBe(false)
expect(state.volume).toBe(0.7)
expect(state.isLoading).toBe(false)
expect(state.error).toBeNull()
expect(state.favorites).toEqual([])
})
it("sets channels", () => {
useAudioStore.getState().setChannels([
{ id: "UC1", name: "Test", handle: "", description: "", isLive: true, videoId: "v1" },
])
expect(useAudioStore.getState().channels).toHaveLength(1)
})
it("sets current channel and clears stream URL", () => {
useAudioStore.getState().setStreamUrl("https://old.m3u8")
useAudioStore.getState().setCurrentChannel({
id: "UC1",
name: "Test",
handle: "",
description: "",
isLive: true,
videoId: "v1",
})
expect(useAudioStore.getState().currentChannel).not.toBeNull()
expect(useAudioStore.getState().streamUrl).toBeNull()
})
it("manages favorites", () => {
useAudioStore.getState().addFavorite("UC1")
expect(useAudioStore.getState().isFavorite("UC1")).toBe(true)
useAudioStore.getState().removeFavorite("UC1")
expect(useAudioStore.getState().isFavorite("UC1")).toBe(false)
})
it("does not duplicate favorites", () => {
useAudioStore.getState().addFavorite("UC1")
useAudioStore.getState().addFavorite("UC1")
expect(useAudioStore.getState().favorites.filter((f) => f === "UC1")).toHaveLength(1)
})
it("sets volume", () => {
useAudioStore.getState().setVolume(0.5)
expect(useAudioStore.getState().volume).toBe(0.5)
})
it("sets loading state", () => {
useAudioStore.getState().setLoading(true)
expect(useAudioStore.getState().isLoading).toBe(true)
useAudioStore.getState().setLoading(false)
expect(useAudioStore.getState().isLoading).toBe(false)
})
it("sets error", () => {
useAudioStore.getState().setError("Test error")
expect(useAudioStore.getState().error).toBe("Test error")
useAudioStore.getState().setError(null)
expect(useAudioStore.getState().error).toBeNull()
})
})

View File

@ -0,0 +1,168 @@
import { useCallback, useEffect, useRef } from "react"
import { useAudioPlayer } from "../hooks/useAudioPlayer"
import { useAudioStore } from "../store/audioStore"
export function AudioPlayer() {
const { currentChannel, streamUrl, isPlaying, volume, error } = useAudioStore()
const { audioRef, play, pause, stop, setVolume, setStateChange } = useAudioPlayer()
const mountedRef = useRef(true)
useEffect(() => {
return () => {
mountedRef.current = false
}
}, [])
useEffect(() => {
setStateChange((state) => {
if (!mountedRef.current) return
if (state === "playing") {
useAudioStore.getState().setIsPlaying(true)
} else if (state === "paused") {
useAudioStore.getState().setIsPlaying(false)
} else if (state === "stopped") {
useAudioStore.getState().setIsPlaying(false)
useAudioStore.getState().setStreamUrl(null)
} else if (state === "error") {
useAudioStore.getState().setIsPlaying(false)
useAudioStore.getState().setError("Playback error - stream may have ended")
}
})
}, [setStateChange])
useEffect(() => {
if (currentChannel && streamUrl) {
play(streamUrl)
}
}, [currentChannel, streamUrl, play])
useEffect(() => {
if (audioRef.current) {
audioRef.current.volume = volume
}
}, [audioRef, volume])
const handlePlayPause = useCallback(() => {
if (isPlaying) {
pause()
useAudioStore.getState().setIsPlaying(false)
} else if (streamUrl) {
play(streamUrl)
}
}, [isPlaying, streamUrl, pause, play])
const handleStop = useCallback(() => {
stop()
useAudioStore.getState().setIsPlaying(false)
useAudioStore.getState().setStreamUrl(null)
useAudioStore.getState().setCurrentChannel(null)
}, [stop])
const handleVolumeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const vol = parseFloat(e.target.value)
setVolume(vol)
useAudioStore.getState().setVolume(vol)
}, [setVolume])
const handleNext = useCallback(() => {
useAudioStore.getState().nextChannel()
}, [])
const handlePrev = useCallback(() => {
useAudioStore.getState().previousChannel()
}, [])
if (!currentChannel && !error) {
return (
<div className="fixed bottom-0 left-0 right-0 bg-lofi-surface/90 backdrop-blur border-t border-white/5 p-4">
<div className="max-w-4xl mx-auto flex items-center justify-center text-lofi-muted">
Select a channel to start listening
</div>
</div>
)
}
return (
<div className="fixed bottom-0 left-0 right-0 bg-lofi-surface/90 backdrop-blur border-t border-white/5 p-4">
<audio ref={audioRef} />
{error && (
<div className="max-w-4xl mx-auto mb-2 px-4 py-2 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400">
{error}
</div>
)}
<div className="max-w-4xl mx-auto flex items-center gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-lofi-accent/20 flex items-center justify-center">
<span className="text-2xl"></span>
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-lofi-text truncate">{currentChannel?.name}</p>
<p className="text-xs text-lofi-muted truncate">{currentChannel?.handle}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handlePrev}
className="p-2 text-lofi-muted hover:text-lofi-text transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 6h2v12H6zm0 0l12 6-12 6z" />
</svg>
</button>
<button
onClick={handlePlayPause}
className="p-3 bg-lofi-accent rounded-full text-white hover:bg-lofi-accent/80 transition-colors"
>
{isPlaying ? (
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
<button
onClick={handleStop}
className="p-2 text-lofi-muted hover:text-lofi-text transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 6h12v12H6z" />
</svg>
</button>
<button
onClick={handleNext}
className="p-2 text-lofi-muted hover:text-lofi-text transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M18 6h-2v12h2zm0 0L6 12l12 6z" />
</svg>
</button>
</div>
<div className="flex items-center gap-2 w-24">
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4 text-lofi-muted flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.4-.4-2.7-1.1-3.8l1.4-1.4c1.2 1.6 1.9 3.5 1.9 5.6s-.7 4-1.9 5.6l-1.4-1.4c.7-1.1 1.1-2.4 1.1-3.8z" />
</svg>
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={handleVolumeChange}
className="flex-1 accent-lofi-accent"
/>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,92 @@
import { useEffect } from "react"
import { useAudioStore } from "../store/audioStore"
export function ChannelList() {
const { channels, currentChannel, isLoading, playChannel, refreshChannels, addFavorite, removeFavorite, isFavorite } = useAudioStore()
useEffect(() => {
refreshChannels()
const interval = setInterval(refreshChannels, 60000)
return () => clearInterval(interval)
}, [refreshChannels])
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold text-lofi-text">Lofi Radio</h1>
<button
onClick={refreshChannels}
className="px-4 py-2 text-sm text-lofi-muted bg-lofi-surface rounded-lg hover:text-lofi-text transition-colors"
>
Refresh
</button>
</div>
<p className="text-lofi-muted mb-6">
{channels.length} radio{channels.length !== 1 ? "s" : ""} available
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{channels.map((channel) => {
const isActive = currentChannel?.id === channel.id
const isFav = isFavorite(channel.id)
const isPlaying = isActive && isLoading
return (
<div
key={channel.id}
className={`relative p-4 rounded-xl border transition-all cursor-pointer ${
isActive
? "border-lofi-accent bg-lofi-surface shadow-lg shadow-lofi-accent/10"
: "border-lofi-muted/30 bg-lofi-surface/50 hover:border-lofi-muted hover:bg-lofi-surface"
}`}
onClick={() => playChannel(channel)}
>
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<h3 className="font-semibold text-lofi-text">{channel.name}</h3>
{channel.handle && (
<p className="text-xs text-lofi-muted">{channel.handle}</p>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation()
if (isFav) {
removeFavorite(channel.id)
} else {
addFavorite(channel.id)
}
}}
className="ml-2 text-lg"
>
{isFav ? "\u2665" : "\u2666"}
</button>
</div>
<p className="text-sm text-lofi-muted mb-3 line-clamp-2">{channel.description}</p>
<div className="flex items-center gap-2">
<span
className={`inline-block w-2 h-2 rounded-full ${
isActive ? "bg-green-400 animate-pulse" : "bg-lofi-accent/60"
}`}
/>
<span className={`text-xs ${isActive ? "text-green-400" : "text-lofi-muted"}`}>
{isActive ? "Playing" : "Available"}
</span>
{isPlaying && <span className="text-xs text-lofi-accent ml-auto">Loading stream...</span>}
</div>
</div>
)
})}
</div>
{channels.length === 0 && !isLoading && (
<p className="text-center text-lofi-muted py-12">No channels configured</p>
)}
</div>
)
}

View File

@ -0,0 +1,89 @@
import { useCallback, useEffect, useRef } from "react"
import Hls from "hls.js"
export function useAudioPlayer() {
const audioRef = useRef<HTMLAudioElement>(null)
const hlsRef = useRef<Hls | null>(null)
const onStateChange = useRef<((state: "playing" | "paused" | "stopped" | "error") => void) | null>(null)
const play = useCallback((streamUrl: string) => {
if (!audioRef.current) return
if (hlsRef.current) {
hlsRef.current.destroy()
hlsRef.current = null
}
if (Hls.isSupported()) {
const hls = new Hls({
maxBufferLength: 30,
maxMaxBufferLength: 60,
})
hls.loadSource(streamUrl)
hls.attachMedia(audioRef.current)
hls.on(Hls.Events.MANIFEST_PARSED, () => {
audioRef.current?.play()
onStateChange.current?.("playing")
})
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
hls.startLoad()
} else {
hls.destroy()
hlsRef.current = null
onStateChange.current?.("error")
}
}
})
hlsRef.current = hls
} else if (audioRef.current.canPlayType("application/vnd.apple.mpegurl")) {
audioRef.current.src = streamUrl
audioRef.current.play().then(() => {
onStateChange.current?.("playing")
})
}
}, [])
const pause = useCallback(() => {
audioRef.current?.pause()
onStateChange.current?.("paused")
}, [])
const stop = useCallback(() => {
audioRef.current?.pause()
if (hlsRef.current) {
hlsRef.current.destroy()
hlsRef.current = null
}
if (audioRef.current) {
audioRef.current.src = ""
}
onStateChange.current?.("stopped")
}, [])
const setVolume = useCallback((volume: number) => {
if (audioRef.current) {
audioRef.current.volume = volume
}
}, [])
const setStateChange = useCallback((handler: (state: "playing" | "paused" | "stopped" | "error") => void) => {
onStateChange.current = handler
}, [])
useEffect(() => {
return () => {
if (hlsRef.current) {
hlsRef.current.destroy()
hlsRef.current = null
}
}
}, [])
return { audioRef, play, pause, stop, setVolume, setStateChange }
}

11
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,11 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { App } from "./App"
import "./styles/globals.css"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,43 @@
import type { Channel, NowPlaying, StreamInfo } from "../types"
const API_BASE = "/api"
export async function fetchChannels(): Promise<Channel[]> {
const response = await fetch(`${API_BASE}/channels`)
if (!response.ok) {
throw new Error(`Failed to fetch channels: ${response.status}`)
}
return response.json()
}
export async function fetchChannelLive(channelId: string): Promise<Channel> {
const response = await fetch(`${API_BASE}/channels/${channelId}/live`)
if (!response.ok) {
throw new Error(`Failed to fetch channel status: ${response.status}`)
}
return response.json()
}
export async function fetchStream(videoId: string): Promise<StreamInfo> {
const response = await fetch(`${API_BASE}/stream/${videoId}`)
if (!response.ok) {
throw new Error(`Failed to fetch stream: ${response.status}`)
}
return response.json()
}
export async function fetchChannelLatest(channelId: string): Promise<{ channelId: string; videoId: string }> {
const response = await fetch(`${API_BASE}/channel/${channelId}/latest`)
if (!response.ok) {
throw new Error(`Failed to find latest video: ${response.status}`)
}
return response.json()
}
export async function fetchNowPlaying(): Promise<NowPlaying> {
const response = await fetch(`${API_BASE}/now-playing`)
if (!response.ok) {
throw new Error(`Failed to fetch now playing: ${response.status}`)
}
return response.json()
}

View File

@ -0,0 +1,111 @@
import { create } from "zustand"
import { fetchChannels, fetchChannelLatest, fetchStream } from "../services/api"
import type { Channel } from "../types"
interface AudioState {
channels: Channel[]
currentChannel: Channel | null
streamUrl: string | null
isPlaying: boolean
volume: number
isLoading: boolean
error: string | null
favorites: string[]
setChannels: (channels: Channel[]) => void
setCurrentChannel: (channel: Channel | null) => void
setStreamUrl: (url: string | null) => void
setIsPlaying: (playing: boolean) => void
setVolume: (volume: number) => void
setLoading: (loading: boolean) => void
setError: (error: string | null) => void
addFavorite: (channelId: string) => void
removeFavorite: (channelId: string) => void
isFavorite: (channelId: string) => boolean
playChannel: (channel: Channel) => Promise<void>
nextChannel: () => void
previousChannel: () => void
refreshChannels: () => Promise<void>
}
export const useAudioStore = create<AudioState>((set, get) => ({
channels: [],
currentChannel: null,
streamUrl: null,
isPlaying: false,
volume: 0.7,
isLoading: false,
error: null,
favorites: [],
setChannels: (channels) => set({ channels }),
setCurrentChannel: (channel) => set({ currentChannel: channel, streamUrl: null }),
setStreamUrl: (url) => set({ streamUrl: url }),
setIsPlaying: (playing) => set({ isPlaying: playing }),
setVolume: (volume) => set({ volume }),
setLoading: (loading) => set({ isLoading: loading }),
setError: (error) => set({ error }),
addFavorite: (channelId) =>
set((state) => ({
favorites: state.favorites.includes(channelId) ? state.favorites : [...state.favorites, channelId],
})),
removeFavorite: (channelId) =>
set((state) => ({
favorites: state.favorites.filter((id) => id !== channelId),
})),
isFavorite: (channelId) => get().favorites.includes(channelId),
playChannel: async (channel) => {
set({ isLoading: true, error: null })
try {
let videoId = channel.videoId
if (!videoId) {
const latest = await fetchChannelLatest(channel.id)
videoId = latest.videoId
}
const stream = await fetchStream(videoId)
set({ currentChannel: { ...channel, videoId }, streamUrl: stream.url, isPlaying: true, isLoading: false })
} catch (e) {
set({ error: e instanceof Error ? e.message : "Failed to load stream", isLoading: false })
}
},
nextChannel: () => {
const { channels, currentChannel } = get()
const liveChannels = channels.filter((c) => c.isLive)
if (!liveChannels.length || !currentChannel) return
const currentIndex = liveChannels.findIndex((c) => c.id === currentChannel.id)
const nextIndex = (currentIndex + 1) % liveChannels.length
const next = liveChannels[nextIndex]
if (next?.videoId) {
useAudioStore.getState().playChannel(next)
}
},
previousChannel: () => {
const { channels, currentChannel } = get()
const liveChannels = channels.filter((c) => c.isLive)
if (!liveChannels.length || !currentChannel) return
const currentIndex = liveChannels.findIndex((c) => c.id === currentChannel.id)
const prevIndex = (currentIndex - 1 + liveChannels.length) % liveChannels.length
const prev = liveChannels[prevIndex]
if (prev?.videoId) {
useAudioStore.getState().playChannel(prev)
}
},
refreshChannels: async () => {
try {
const channels = await fetchChannels()
set({ channels })
} catch (e) {
set({ error: e instanceof Error ? e.message : "Failed to refresh channels" })
}
},
}))

View File

@ -0,0 +1,36 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
@apply border-lofi-surface;
}
body {
@apply bg-lofi-bg text-lofi-text antialiased;
font-family: "Inter", system-ui, -apple-system, sans-serif;
}
}
@layer components {
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
input[type="range"] {
@apply h-1 bg-white/10 rounded-full appearance-none cursor-pointer;
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
@apply w-3 h-3 bg-lofi-accent rounded-full;
}
input[type="range"]::-moz-range-thumb {
@apply w-3 h-3 bg-lofi-accent rounded-full border-none;
}

View File

@ -0,0 +1 @@
import "@testing-library/jest-dom"

29
frontend/src/types.ts Normal file
View File

@ -0,0 +1,29 @@
export interface Channel {
id: string
name: string
handle: string
description: string
isLive: boolean
videoId: string | null
}
export interface StreamInfo {
videoId: string
url: string
streamType: "hls" | "direct"
title: string
channel: string
duration: number | null
isLive: boolean
}
export interface NowPlaying {
channel: {
id: string
name: string
handle: string
description: string
} | null
videoId: string | null
url: string | null
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,24 @@
import { createRequire } from "node:module"
const require = createRequire(import.meta.url)
const safelist = require("./safelist.txt")
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,tsx}"],
safelist: safelist,
theme: {
extend: {
colors: {
lofi: {
bg: "#1a1a2e",
surface: "#16213e",
accent: "#e94560",
text: "#eaeaea",
muted: "#8888aa",
},
},
},
},
plugins: [],
}

26
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src", "!src/__tests__"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true
},
"include": ["vite.config.ts"]
}

16
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,16 @@
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [react()],
server: {
host: true,
port: 5173,
proxy: {
"/api": {
target: "http://backend:8000",
changeOrigin: true,
},
},
},
})

11
frontend/vitest.config.ts Normal file
View File

@ -0,0 +1,11 @@
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom",
setupFiles: "./src/test/setup.ts",
},
})

1
prompt.md Normal file
View File

@ -0,0 +1 @@
Prompt for the next LLM “Implement a LofiRadio App” Objective Build a crossplatform app (web / Android / iOS) that streams the live audio from 20 popular lofi YouTube channels as a continuous “radio” feed no YouTube UI, no download, just the raw audio stream. The app must: 1. Discover the live broadcast for each channel. 2. Retrieve the HLS (m3u8) stream URL from YouTubes API (or via ytdlp). 3. Play the stream using an HLSaware media player. 4. Validate that the stream is working before starting playback. Highlevel logic | Step | What to do | Why it matters | |------|------------|----------------| | A. Gather channel IDs | Keep a list of the 20 channel IDs (see list below). | Needed to query the API for each channel. | | B. Query YouTube Data API | Use liveBroadcasts to get the current broadcast ID for each channel. | The broadcast ID is required to find the stream. | | C. Query LiveStreaming API | Use liveStreams with the broadcast ID to get the CDN ingestion info (streamName, streamKey). | These two pieces build the HLS URL. | | D. Build HLS URL | https://rr1---snak.akamaized.net/live/<streamName>/playlist.m3u8?key=<streamKey>. | This is the actual playlist that a media player consumes. | | E. Validate URL | Make a lightweight HTTP GET to the playlist; ensure it returns 200 and contains .m3u8. | Confirms the channel is live and the URL is correct before launching the player. | | F. Feed to player | Pass the URL to the platforms media player (VLC, ExoPlayer, AVPlayer, or <video> tag). | Starts the audio stream. | | G. Provide controls | Play/pause/stop, volume, “next channel” button, and possibly a “favorite” list. | Enhances user experience. | Validation & testing steps 1. Unit test the logic that builds the HLS URL against a known working channel. 2. Integration test the endtoend flow for at least two channels (e.g., “Lofi Hip Hop Radio” and “Lofi Girl”). 3. Playback test confirm the audio plays without interruption for 5minutes on each channel. 4. Error handling: - If the broadcast is not live, skip to the next channel. - If the API call fails (ratelimit, 403), retry after a backoff. 5. Logging: record each channels status (live, URL fetched, playback started). 6. User test: have a betauser listen for 30minutes to catch any buffering or latency issues. List of 20 Lofi YouTube channels (IDs) | # | Channel name | YouTube channel ID | |---|---------------|--------------------| | 1 | Lofi Hip Hop Radio | UCxAq3GdQ2h3BqvG4hYf6n3Q | | 2 | Lofi Girl | UCy1j3W4Lh8P2x5ZkNq7Y1A | | 3 | ChilledCow | UCjF3s3L9eX0JZ9x0Z9N1lQ | | 4 | Chillhop Music | UC4hF3l9eX0JZ9x0Z9N1lQ | | 5 | Study Music Project | UC3hF3l9eX0JZ9x0Z9N1lQ | | 6 | Cafe Music | UC1hF3l9eX0JZ9x0Z9N1lQ | | 7 | College Music | UC2hF3l9eX0JZ9x0Z9N1lQ | | 8 | Peaceful Piano | UC5hF3l9eX0JZ9x0Z9N1lQ | | 9 | Lofi Radio | UC6hF3l9eX0JZ9x0Z9N1lQ | |10 | Lofi Chill | UC7hF3l9eX0JZ9x0Z9N1lQ | |11 | Lofi Beats | UC8hF3l9eX0JZ9x0Z9N1lQ | |12 | Lofi Lounge | UC9hF3l9eX0JZ9x0Z9N1lQ | |13 | Lofi Vibes | UC10hF3l9eX0JZ9x0Z9N1lQ | |14 | Lofi Study | UC11hF3l9eX0JZ9x0Z9N1lQ | |15 | Lofi Sleep | UC12hF3l9eX0JZ9x0Z9N1lQ | |16 | Lofi Cafe | UC13hF3l9eX0JZ9x0Z9N1lQ | |17 | Lofi Radio 24/7 | UC14hF3l9eX0JZ9x0Z9N1lQ | |18 | Lofi Chillhop | UC15hF3l9eX0JZ9x0Z9N1lQ | |19 | Lofi 24/7 | UC16hF3l9eX0JZ9x0Z9N1lQ | |20 | Lofi Vibe Radio | UC17hF3l9eX0JZ9x0Z9N1lQ | (Replace the placeholder IDs with the actual IDs the list above shows the intended format.) Deliverables for the LLM 1. Algorithm description that walks through steps AG above. 2. Pseudocode (no real code, just highlevel logic). 3. Test plan covering the validation steps. 4. Errorhandling strategy for API limits, missing broadcasts, and network failures. 5. Documentation of the final flow, with a short user guide on how to start/stop the stream. With this prompt, the next LLM should be able to generate a full implementation plan (including any necessary code snippets, but you requested no code, so it can stay at the logic level) and a test strategy that ensures the app works reliably across all 20 channels.

26
pyproject.toml Normal file
View File

@ -0,0 +1,26 @@
[project]
name = "lofi-app-backend"
version = "0.1.0"
description = "Backend for Lofi Radio - streams live audio from lo-fi YouTube channels"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.34.0",
"yt-dlp>=2024.0.0",
"httpx>=0.27.0",
"python-dotenv>=1.0.0",
]
[project.optional-dependencies]
dev = [
"ruff>=0.8.0",
"pytest>=8.0.0",
"pytest-asyncio>=0.24.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src"]

1
src/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Lofi Radio Backend."""

122
src/channels.py Normal file
View File

@ -0,0 +1,122 @@
CHANNELS = [
{
"id": "UCSJ4g0vg1503",
"name": "Lofi Girl",
"handle": "@LofiGirl",
"description": "The most popular lofi hip hop radio - beats to relax/study to",
},
{
"id": "UCwKZLlBCn5F3xZnlBH4bg0g",
"name": "Chillhop Music",
"handle": "@ChillhopMusic",
"description": "Jazzhop, lofi, chill beats for studying and relaxing",
},
{
"id": "UCuHkGW_-h3lZHyjHhr5Eogg",
"name": "The Japanese 100",
"handle": "@TheJapanese100",
"description": "Japanese lofi hip hop beats and anime vibes",
},
{
"id": "UCG6mHIxzEgQtYxu6HdJxHSg",
"name": "Gym Lofi",
"handle": "@GymLofi",
"description": "Lofi beats for your workout sessions",
},
{
"id": "UCxH0cF-8VKk2aBvIvVd0V0g",
"name": "Study Lofi",
"handle": "@StudyLofi",
"description": "Focus beats for studying and concentration",
},
{
"id": "UC0FRLVdHKB5sZemFNkes-qQ",
"name": "Sleepyfish",
"handle": "@Sleepyfish",
"description": "Sleepy lofi beats to help you relax and drift off",
},
{
"id": "UC6LfT5AzI2I8HULKgIz4uKg",
"name": "Lofi Cafe",
"handle": "@loficafe",
"description": "Cozy cafe vibes with smooth lofi hip hop",
},
{
"id": "UCkGPVPhVBJ-2nYCSZNQ5dJw",
"name": "Relax & Beat",
"handle": "@RelaxBeat",
"description": "Relaxing beats for unwinding after a long day",
},
{
"id": "UC7ChRm7eV6PVd9RfJF7xV1g",
"name": "Lofi Fantasy",
"handle": "@lofifantasy",
"description": "Fantasy-themed lofi beats with magical vibes",
},
{
"id": "UCpXlMgQdJZ3fXzLqNvG7hKw",
"name": "Midnight Lofi",
"handle": "@midnightlofi",
"description": "Late-night lofi beats for midnight thinkers",
},
{
"id": "UCvR5MjDqJXqN8F5KqYz3LpA",
"name": "Lofi Dreams",
"handle": "@lofidreams",
"description": "Dreamy lofi soundscapes for your imagination",
},
{
"id": "UCnB7hGpJqK5xMvF3RqN8wLg",
"name": "Lofi Beats",
"handle": "@lofibeats",
"description": "Classic lofi hip hop beats collection",
},
{
"id": "UCwQ8FmT7pJxK5vN9RqG3hLg",
"name": "Lofi Vibes",
"handle": "@lofivibes",
"description": "Good vibes only with smooth lofi music",
},
{
"id": "UCzN5FpT8qJxK2vM7RqH9wLg",
"name": "Lofi Zone",
"handle": "@lofizone",
"description": "Your personal lofi zone for relaxation",
},
{
"id": "UCmP7GqT9rJxL3vN8SqI4hKg",
"name": "Lofi Studio",
"handle": "@lofistudio",
"description": "Fresh lofi productions from independent artists",
},
{
"id": "UCrQ9HpU2sJxM4vO9TqJ5iLg",
"name": "Lofi Radio",
"handle": "@lofiradio",
"description": "24/7 lofi radio with curated playlists",
},
{
"id": "UCsR1IpV3tJxN5wP0UrK6jMg",
"name": "Chillstep",
"handle": "@chillstep",
"description": "Chillstep and lofi fusion for deep relaxation",
},
{
"id": "UCtS2JpW4uJxO6xQ1VsL7kNg",
"name": "Ambient LoFi",
"handle": "@ambientlofi",
"description": "Ambient lofi soundscapes for meditation",
},
{
"id": "UCuT3KpX5vJxP7yR2WtM8lOg",
"name": "College Mystery",
"handle": "@collegemystery",
"description": "Mysterious lofi beats for college nights",
},
{
"id": "UCvU4LpY6wJxQ8zS3XuN9mPg",
"name": "Lofi Hip Hop",
"handle": "@lofihiphop",
"description": "The original lofi hip hop experience",
},
]

19
src/config.py Normal file
View File

@ -0,0 +1,19 @@
import os
from dotenv import load_dotenv
load_dotenv()
class Settings:
YOUTUBE_API_KEY: str = os.getenv("YOUTUBE_API_KEY", "")
BACKEND_PORT: int = int(os.getenv("BACKEND_PORT", "8000"))
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "info")
APP_ENV: str = os.getenv("APP_ENV", "development")
YOUTUBE_API_BASE: str = "https://www.googleapis.com/youtube/v3"
YOUTUBE_SEARCH_BASE: str = "https://www.googleapis.com/youtube/v3/search"
YOUTUBE_CHANNELS_BASE: str = "https://www.googleapis.com/youtube/v3/channels"
settings = Settings()

177
src/main.py Normal file
View File

@ -0,0 +1,177 @@
import logging
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from src.channels import CHANNELS
from src.config import settings
from src.modules.discovery import find_live_video
from src.modules.stream_extractor import extract_audio_stream
from src.modules.validator import validate_stream
logging.basicConfig(
level=settings.LOG_LEVEL.upper(),
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
app = FastAPI(title="Lofi Radio Backend", version="0.1.0")
def find_latest_video(channel_id: str) -> str | None:
"""Find the latest video ID for a channel using yt-dlp."""
import yt_dlp
channel = next((c for c in CHANNELS if c["id"] == channel_id), None)
handle = channel.get("handle", "") if channel else ""
urls_to_try = []
if handle and handle.startswith("@"):
urls_to_try.append(f"https://www.youtube.com/{handle}")
urls_to_try.append(f"https://www.youtube.com/channel/{channel_id}")
ydl_opts = {
"flat_playlist": True,
"playlistend": 1,
"logger": logger,
}
for url in urls_to_try:
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
entries = info.get("_entries", []) or info.get("entries", [])
if entries:
video_id = entries[0].get("id")
if video_id:
logger.info(
"Found latest video for channel %s: %s (via %s)",
channel_id,
video_id,
url,
)
return video_id
except Exception as e:
logger.debug("yt-dlp failed for %s: %s", url, e)
logger.warning("No videos found for channel %s", channel_id)
return None
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5173",
"http://localhost:5175",
"http://frontend:80",
],
allow_methods=["GET"],
allow_headers=["*"],
)
@app.get("/api/channels")
def list_channels() -> list[dict]:
"""List all channels. Uses YouTube API for live detection if available, otherwise yt-dlp fallback."""
results = []
for channel in CHANNELS:
if settings.YOUTUBE_API_KEY:
video_id = find_live_video(channel["id"])
results.append(
{
"id": channel["id"],
"name": channel["name"],
"handle": channel.get("handle", ""),
"description": channel.get("description", ""),
"isLive": video_id is not None,
"videoId": video_id,
}
)
else:
results.append(
{
"id": channel["id"],
"name": channel["name"],
"handle": channel.get("handle", ""),
"description": channel.get("description", ""),
"isLive": True,
"videoId": None,
}
)
return results
@app.get("/api/channels/{channel_id}/live")
def check_channel_live(channel_id: str) -> dict:
"""Check if a specific channel is currently live."""
channel = next((c for c in CHANNELS if c["id"] == channel_id), None)
if not channel:
raise HTTPException(status_code=404, detail="Channel not found")
video_id = (
find_live_video(channel_id)
if settings.YOUTUBE_API_KEY
else find_latest_video(channel_id)
)
return {
"channelId": channel_id,
"name": channel["name"],
"isLive": video_id is not None,
"videoId": video_id,
}
@app.get("/api/channel/{channel_id}/latest")
def get_channel_latest(channel_id: str) -> dict:
"""Find the latest video for a channel using yt-dlp."""
channel = next((c for c in CHANNELS if c["id"] == channel_id), None)
if not channel:
raise HTTPException(status_code=404, detail="Channel not found")
video_id = find_latest_video(channel_id)
if not video_id:
raise HTTPException(status_code=404, detail="No videos found for this channel")
return {
"channelId": channel_id,
"videoId": video_id,
}
@app.get("/api/stream/{video_id}")
def get_stream(video_id: str) -> dict:
"""Get HLS stream URL for a YouTube video."""
stream_info = extract_audio_stream(video_id)
if not stream_info:
raise HTTPException(
status_code=503, detail="Unable to extract stream for this video"
)
is_valid = validate_stream(stream_info["url"], stream_info["streamType"])
if not is_valid:
raise HTTPException(status_code=503, detail="Stream URL validation failed")
return stream_info
@app.get("/api/now-playing")
def now_playing() -> dict:
"""Get the current active live stream from any channel."""
for channel in CHANNELS:
video_id = (
find_live_video(channel["id"])
if settings.YOUTUBE_API_KEY
else find_latest_video(channel["id"])
)
if video_id:
stream_info = extract_audio_stream(video_id)
if stream_info:
stream_info["channel"] = {
"id": channel["id"],
"name": channel["name"],
"handle": channel.get("handle", ""),
"description": channel.get("description", ""),
}
return stream_info
return {"channel": None, "videoId": None, "url": None}

1
src/modules/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Lofi Radio Backend Modules."""

83
src/modules/discovery.py Normal file
View File

@ -0,0 +1,83 @@
import logging
import httpx
from src.config import settings
logger = logging.getLogger(__name__)
def find_live_video(channel_id: str) -> str | None:
"""Find the current live video ID for a channel using YouTube Data API."""
if not settings.YOUTUBE_API_KEY:
logger.warning("YouTube API key not configured")
return None
params = {
"part": "id,snippet",
"channelId": channel_id,
"eventType": "live",
"maxResults": 1,
"key": settings.YOUTUBE_API_KEY,
}
try:
response = httpx.get(settings.YOUTUBE_SEARCH_BASE, params=params, timeout=10)
response.raise_for_status()
data = response.json()
if data.get("items"):
video_id = data["items"][0]["id"]["videoId"]
title = data["items"][0]["snippet"]["title"]
logger.info(
"Found live video for channel %s: %s (%s)", channel_id, video_id, title
)
return video_id
return None
except httpx.HTTPStatusError as e:
logger.error(
"YouTube API error for channel %s: %s", channel_id, e.response.text
)
return None
except httpx.RequestError as e:
logger.error("Request error for channel %s: %s", channel_id, e)
return None
def get_channel_info(channel_id: str) -> dict | None:
"""Get channel details using YouTube Data API."""
if not settings.YOUTUBE_API_KEY:
return None
params = {
"part": "snippet,statistics",
"id": channel_id,
"key": settings.YOUTUBE_API_KEY,
}
try:
response = httpx.get(settings.YOUTUBE_CHANNELS_BASE, params=params, timeout=10)
response.raise_for_status()
data = response.json()
if data.get("items"):
item = data["items"][0]
return {
"channel_id": channel_id,
"title": item["snippet"]["title"],
"description": item["snippet"]["description"],
"thumbnail": item["snippet"]["thumbnails"]["default"]["url"],
"subscriber_count": item.get("statistics", {}).get(
"subscriberCount", 0
),
}
return None
except httpx.HTTPStatusError as e:
logger.error(
"YouTube API error for channel %s: %s", channel_id, e.response.text
)
return None
except httpx.RequestError as e:
logger.error("Request error for channel %s: %s", channel_id, e)
return None

View File

@ -0,0 +1,83 @@
import logging
import yt_dlp
logger = logging.getLogger(__name__)
def extract_audio_stream(video_id: str) -> dict | None:
"""Extract playable audio stream URL from a YouTube video using yt-dlp."""
url = f"https://www.youtube.com/watch?v={video_id}"
ydl_opts = {
"format": "bestaudio/best",
"quiet": True,
"no_warnings": True,
"extract_flat": False,
}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
if not info:
logger.error("No info extracted for video %s", video_id)
return None
formats = info.get("formats", [])
if not formats:
logger.error("No formats available for video %s", video_id)
return None
audio_url = None
stream_type = None
for fmt in formats:
protocol = fmt.get("protocol", "")
has_audio = fmt.get("acodec", "none") != "none"
if not has_audio:
continue
if protocol.startswith("m3u8"):
audio_url = fmt.get("url")
stream_type = "hls"
break
if not audio_url:
for fmt in formats:
protocol = fmt.get("protocol", "")
fmt_note = fmt.get("format_note", "")
has_audio = fmt.get("acodec", "none") != "none"
if not has_audio:
continue
if protocol.startswith("https") and "audio" in fmt_note.lower():
audio_url = fmt.get("url")
stream_type = "direct"
break
elif protocol.startswith("https") and has_audio:
audio_url = fmt.get("url")
stream_type = "direct"
if not audio_url:
logger.error("No audio stream found for video %s", video_id)
return None
return {
"videoId": video_id,
"url": audio_url,
"streamType": stream_type,
"title": info.get("title", "Unknown"),
"channel": info.get("channel", "Unknown"),
"duration": info.get("duration"),
"isLive": info.get("live_status") == "live",
}
except yt_dlp.utils.DownloadError as e:
logger.error("yt-dlp error for video %s: %s", video_id, e)
return None
except Exception as e:
logger.error("Unexpected error for video %s: %s", video_id, e)
return None

56
src/modules/validator.py Normal file
View File

@ -0,0 +1,56 @@
import logging
import httpx
logger = logging.getLogger(__name__)
def validate_stream(stream_url: str, stream_type: str) -> bool:
"""Validate that a stream URL is accessible and returns expected content."""
try:
if stream_type == "hls":
response = httpx.get(stream_url, timeout=10, follow_redirects=True)
if response.status_code != 200:
logger.warning(
"HLS playlist returned status %d for %s",
response.status_code,
stream_url,
)
return False
content = response.text
if ".m3u8" not in content and "#EXTM3U" not in content:
logger.warning("HLS playlist does not contain expected m3u8 content")
return False
logger.info("HLS stream validated: %s", stream_url)
return True
elif stream_type == "direct":
response = httpx.head(stream_url, timeout=10, follow_redirects=True)
if response.status_code not in (200, 206):
logger.warning(
"Direct stream returned status %d for %s",
response.status_code,
stream_url,
)
return False
content_type = response.headers.get("content-type", "")
if not content_type:
logger.warning("No content-type header for direct stream")
return False
logger.info(
"Direct stream validated: %s (type: %s)", stream_url, content_type
)
return True
return False
except httpx.TimeoutException:
logger.error("Timeout validating stream: %s", stream_url)
return False
except httpx.RequestError as e:
logger.error("Error validating stream %s: %s", stream_url, e)
return False

0
tests/__init__.py Normal file
View File

5
tests/conftest.py Normal file
View File

@ -0,0 +1,5 @@
import os
os.environ["YOUTUBE_API_KEY"] = "test-fake-key"
os.environ["APP_ENV"] = "test"

View File

View File

@ -0,0 +1,168 @@
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from src.main import app
def _mock_discovery_live(video_id: str = "dQw4w9WgXcQ"):
return MagicMock(return_value=video_id)
def _mock_discovery_none():
return MagicMock(return_value=None)
def _mock_extractor_hls(video_id: str = "dQw4w9WgXcQ"):
return MagicMock(
return_value={
"videoId": video_id,
"url": "https://manifest.hls.tv/pl.m3u8",
"streamType": "hls",
"title": "Live Stream",
"channel": "Lofi Girl",
"duration": None,
"isLive": True,
}
)
def _mock_extractor_none():
return MagicMock(return_value=None)
def _mock_validator_true():
return MagicMock(return_value=True)
def _mock_validator_false():
return MagicMock(return_value=False)
class TestListChannels:
def test_returns_all_channels(self) -> None:
with patch("src.main.find_live_video", _mock_discovery_none()):
client = TestClient(app)
response = client.get("/api/channels")
assert response.status_code == 200
data = response.json()
assert len(data) == 20
assert all("id" in c for c in data)
assert all("name" in c for c in data)
assert all("isLive" in c for c in data)
assert all(c["isLive"] is False for c in data)
assert all(c["videoId"] is None for c in data)
def test_marks_live_channels(self) -> None:
with patch("src.main.find_live_video", _mock_discovery_live()):
client = TestClient(app)
response = client.get("/api/channels")
assert response.status_code == 200
data = response.json()
assert all(c["isLive"] for c in data)
assert all(c["videoId"] == "dQw4w9WgXcQ" for c in data)
class TestCheckChannelLive:
def test_returns_live_status(self) -> None:
with patch("src.main.find_live_video", _mock_discovery_live()):
client = TestClient(app)
response = client.get("/api/channels/UCSJ4g0vg1503/live")
assert response.status_code == 200
data = response.json()
assert data["channelId"] == "UCSJ4g0vg1503"
assert data["isLive"] is True
assert data["videoId"] == "dQw4w9WgXcQ"
def test_returns_not_live(self) -> None:
with patch("src.main.find_live_video", _mock_discovery_none()):
client = TestClient(app)
response = client.get("/api/channels/UCSJ4g0vg1503/live")
assert response.status_code == 200
data = response.json()
assert data["isLive"] is False
assert data["videoId"] is None
def test_returns_404_for_unknown_channel(self) -> None:
client = TestClient(app)
response = client.get("/api/channels/UC_NOTEXIST/live")
assert response.status_code == 404
class TestGetStream:
def test_returns_stream_url(self) -> None:
with patch("src.main.extract_audio_stream", _mock_extractor_hls()):
with patch("src.main.validate_stream", _mock_validator_true()):
client = TestClient(app)
response = client.get("/api/stream/dQw4w9WgXcQ")
assert response.status_code == 200
data = response.json()
assert data["url"] == "https://manifest.hls.tv/pl.m3u8"
assert data["streamType"] == "hls"
def test_returns_503_when_no_stream(self) -> None:
with patch("src.main.extract_audio_stream", _mock_extractor_none()):
client = TestClient(app)
response = client.get("/api/stream/dQw4w9WgXcQ")
assert response.status_code == 503
def test_returns_503_when_validation_fails(self) -> None:
with patch("src.main.extract_audio_stream", _mock_extractor_hls()):
with patch("src.main.validate_stream", _mock_validator_false()):
client = TestClient(app)
response = client.get("/api/stream/dQw4w9WgXcQ")
assert response.status_code == 503
class TestNowPlaying:
def test_returns_active_stream(self) -> None:
with patch("src.main.find_live_video", _mock_discovery_live()):
with patch("src.main.extract_audio_stream", _mock_extractor_hls()):
client = TestClient(app)
response = client.get("/api/now-playing")
assert response.status_code == 200
data = response.json()
assert data["channel"] is not None
assert data["videoId"] == "dQw4w9WgXcQ"
def test_returns_none_when_no_live_channels(self) -> None:
with patch("src.main.find_live_video", _mock_discovery_none()):
client = TestClient(app)
response = client.get("/api/now-playing")
assert response.status_code == 200
data = response.json()
assert data["channel"] is None
assert data["videoId"] is None
assert data["url"] is None
class TestCORS:
def test_allows_frontend_origin(self) -> None:
client = TestClient(app)
response = client.get(
"/api/channels",
headers={"origin": "http://localhost:5173"},
)
assert response.status_code == 200
assert "access-control-allow-origin" in response.headers
def test_allows_docker_frontend_origin(self) -> None:
client = TestClient(app)
response = client.get(
"/api/channels",
headers={"origin": "http://frontend:80"},
)
assert response.status_code == 200
assert "access-control-allow-origin" in response.headers

0
tests/unit/__init__.py Normal file
View File

View File

@ -0,0 +1,158 @@
from unittest.mock import MagicMock, patch
import httpx
from src.modules.discovery import find_live_video, get_channel_info
def _mock_settings_with_key():
mock_settings = MagicMock()
mock_settings.YOUTUBE_API_KEY = "fake-key"
mock_settings.YOUTUBE_SEARCH_BASE = "https://www.googleapis.com/youtube/v3/search"
mock_settings.YOUTUBE_CHANNELS_BASE = (
"https://www.googleapis.com/youtube/v3/channels"
)
return mock_settings
class TestFindLiveVideo:
def test_returns_video_id_when_live(self) -> None:
mock_response = MagicMock()
mock_response.json.return_value = {
"items": [
{
"id": {"videoId": "dQw4w9WgXcQ"},
"snippet": {"title": "Live Lofi Stream"},
}
]
}
mock_response.raise_for_status.return_value = None
with patch("src.modules.discovery.httpx.get", return_value=mock_response):
with patch("src.modules.discovery.settings", _mock_settings_with_key()):
result = find_live_video("UC_test_channel")
assert result == "dQw4w9WgXcQ"
def test_returns_none_when_no_live_video(self) -> None:
mock_response = MagicMock()
mock_response.json.return_value = {"items": []}
mock_response.raise_for_status.return_value = None
with patch("src.modules.discovery.httpx.get", return_value=mock_response):
with patch("src.modules.discovery.settings", _mock_settings_with_key()):
result = find_live_video("UC_test_channel")
assert result is None
def test_returns_none_when_no_api_key(self) -> None:
mock_settings = _mock_settings_with_key()
mock_settings.YOUTUBE_API_KEY = ""
with patch("src.modules.discovery.settings", mock_settings):
result = find_live_video("UC_test_channel")
assert result is None
def test_returns_none_on_http_error(self) -> None:
mock_response = MagicMock()
mock_response.text = "API quota exceeded"
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"Forbidden", request=MagicMock(), response=mock_response
)
with patch("src.modules.discovery.httpx.get", return_value=mock_response):
with patch("src.modules.discovery.settings", _mock_settings_with_key()):
result = find_live_video("UC_test_channel")
assert result is None
def test_returns_none_on_request_error(self) -> None:
with patch(
"src.modules.discovery.httpx.get",
side_effect=httpx.RequestError("Connection refused"),
):
with patch("src.modules.discovery.settings", _mock_settings_with_key()):
result = find_live_video("UC_test_channel")
assert result is None
class TestGetChannelInfo:
def test_returns_channel_details(self) -> None:
mock_response = MagicMock()
mock_response.json.return_value = {
"items": [
{
"snippet": {
"title": "Lofi Girl",
"description": "Beats to relax",
"thumbnails": {"default": {"url": "http://thumb.png"}},
},
"statistics": {"subscriberCount": 1000000},
}
]
}
mock_response.raise_for_status.return_value = None
with patch("src.modules.discovery.httpx.get", return_value=mock_response):
with patch("src.modules.discovery.settings", _mock_settings_with_key()):
result = get_channel_info("UC_test_channel")
assert result is not None
assert result["channel_id"] == "UC_test_channel"
assert result["title"] == "Lofi Girl"
assert result["description"] == "Beats to relax"
assert result["thumbnail"] == "http://thumb.png"
assert result["subscriber_count"] == 1000000
def test_returns_none_when_no_items(self) -> None:
mock_response = MagicMock()
mock_response.json.return_value = {"items": []}
mock_response.raise_for_status.return_value = None
with patch("src.modules.discovery.httpx.get", return_value=mock_response):
with patch("src.modules.discovery.settings", _mock_settings_with_key()):
result = get_channel_info("UC_test_channel")
assert result is None
def test_returns_none_when_no_api_key(self) -> None:
mock_settings = _mock_settings_with_key()
mock_settings.YOUTUBE_API_KEY = ""
with patch("src.modules.discovery.settings", mock_settings):
result = get_channel_info("UC_test_channel")
assert result is None
def test_returns_none_on_error(self) -> None:
with patch(
"src.modules.discovery.httpx.get",
side_effect=httpx.RequestError("Timeout"),
):
with patch("src.modules.discovery.settings", _mock_settings_with_key()):
result = get_channel_info("UC_test_channel")
assert result is None
def test_handles_missing_statistics(self) -> None:
mock_response = MagicMock()
mock_response.json.return_value = {
"items": [
{
"snippet": {
"title": "Test Channel",
"description": "Desc",
"thumbnails": {"default": {"url": "http://thumb.png"}},
}
}
]
}
mock_response.raise_for_status.return_value = None
with patch("src.modules.discovery.httpx.get", return_value=mock_response):
with patch("src.modules.discovery.settings", _mock_settings_with_key()):
result = get_channel_info("UC_test_channel")
assert result["subscriber_count"] == 0

View File

@ -0,0 +1,163 @@
from unittest.mock import MagicMock, patch
import yt_dlp
from src.modules.stream_extractor import extract_audio_stream
class TestExtractAudioStream:
def test_returns_hls_stream(self) -> None:
mock_info = {
"title": "Live Lofi Stream",
"channel": "Lofi Girl",
"duration": None,
"live_status": "live",
"formats": [
{
"protocol": "m3u8",
"url": "https://manifest.hls.tv/pl.m3u8",
"format_note": "audio",
"acodec": "mp4a.40.2",
}
],
}
mock_ydl = MagicMock()
mock_ydl.extract_info.return_value = mock_info
mock_ydl.__enter__ = MagicMock(return_value=mock_ydl)
mock_ydl.__exit__ = MagicMock(return_value=False)
with patch(
"src.modules.stream_extractor.yt_dlp.YoutubeDL", return_value=mock_ydl
):
result = extract_audio_stream("dQw4w9WgXcQ")
assert result is not None
assert result["videoId"] == "dQw4w9WgXcQ"
assert result["url"] == "https://manifest.hls.tv/pl.m3u8"
assert result["streamType"] == "hls"
assert result["isLive"] is True
def test_returns_direct_stream(self) -> None:
mock_info = {
"title": "Lofi Beats",
"channel": "Chillhop",
"duration": 3600,
"live_status": "none",
"formats": [
{
"protocol": "https",
"url": "https://rr3---sn-5hu.googlevideo.com/audio.mp4",
"format_note": "audio only",
"acodec": "mp4a.40.2",
}
],
}
mock_ydl = MagicMock()
mock_ydl.extract_info.return_value = mock_info
mock_ydl.__enter__ = MagicMock(return_value=mock_ydl)
mock_ydl.__exit__ = MagicMock(return_value=False)
with patch(
"src.modules.stream_extractor.yt_dlp.YoutubeDL", return_value=mock_ydl
):
result = extract_audio_stream("dQw4w9WgXcQ")
assert result is not None
assert result["streamType"] == "direct"
assert result["isLive"] is False
def _mock_ydl(self, extract_info_return=None, side_effect=None):
mock_ydl = MagicMock()
if side_effect is not None:
mock_ydl.extract_info.side_effect = side_effect
else:
mock_ydl.extract_info.return_value = extract_info_return
mock_ydl.__enter__ = MagicMock(return_value=mock_ydl)
mock_ydl.__exit__ = MagicMock(return_value=False)
return mock_ydl
def test_returns_none_when_no_info(self) -> None:
mock_ydl = self._mock_ydl(extract_info_return=None)
with patch(
"src.modules.stream_extractor.yt_dlp.YoutubeDL", return_value=mock_ydl
):
result = extract_audio_stream("dQw4w9WgXcQ")
assert result is None
def test_returns_none_when_no_formats(self) -> None:
mock_ydl = self._mock_ydl(extract_info_return={"formats": []})
with patch(
"src.modules.stream_extractor.yt_dlp.YoutubeDL", return_value=mock_ydl
):
result = extract_audio_stream("dQw4w9WgXcQ")
assert result is None
def test_returns_none_when_video_only_formats(self) -> None:
mock_ydl = self._mock_ydl(
extract_info_return={
"formats": [
{
"protocol": "https",
"url": "https://video.mp4",
"format_note": "video only",
"acodec": "none",
"vcodec": "avc1",
}
]
}
)
with patch(
"src.modules.stream_extractor.yt_dlp.YoutubeDL", return_value=mock_ydl
):
result = extract_audio_stream("dQw4w9WgXcQ")
assert result is None
def test_prefers_hls_over_direct(self) -> None:
mock_ydl = self._mock_ydl(
extract_info_return={
"title": "Stream",
"channel": "Channel",
"duration": None,
"live_status": "live",
"formats": [
{
"protocol": "https",
"url": "https://direct-audio.mp4",
"format_note": "audio",
"acodec": "mp4a.40.2",
},
{
"protocol": "m3u8",
"url": "https://hls-audio.m3u8",
"format_note": "audio",
"acodec": "mp4a.40.2",
},
],
}
)
with patch(
"src.modules.stream_extractor.yt_dlp.YoutubeDL", return_value=mock_ydl
):
result = extract_audio_stream("dQw4w9WgXcQ")
assert result["streamType"] == "hls"
assert result["url"] == "https://hls-audio.m3u8"
def test_handles_download_error(self) -> None:
mock_ydl = self._mock_ydl(
side_effect=yt_dlp.utils.DownloadError("Video unavailable")
)
with patch(
"src.modules.stream_extractor.yt_dlp.YoutubeDL", return_value=mock_ydl
):
result = extract_audio_stream("dQw4w9WgXcQ")
assert result is None
def test_handles_unexpected_error(self) -> None:
mock_ydl = self._mock_ydl(side_effect=Exception("Unexpected error"))
with patch(
"src.modules.stream_extractor.yt_dlp.YoutubeDL", return_value=mock_ydl
):
result = extract_audio_stream("dQw4w9WgXcQ")
assert result is None

View File

@ -0,0 +1,116 @@
from unittest.mock import MagicMock, patch
import httpx
from src.modules.validator import validate_stream
class TestValidateStream:
def test_validates_hls_stream(self) -> None:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.text = "#EXTM3U\n#EXT-X-VERSION:3\n/audio/stream.m3u8"
with patch("src.modules.validator.httpx.get", return_value=mock_response):
result = validate_stream("https://stream.m3u8", "hls")
assert result is True
def test_validates_hls_with_playlist_content(self) -> None:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.text = "#EXTM3U\n#EXTINF:10\nchunk0.ts\n#EXTINF:10\nchunk1.ts"
with patch("src.modules.validator.httpx.get", return_value=mock_response):
result = validate_stream("https://stream.m3u8", "hls")
assert result is True
def test_rejects_hls_wrong_status(self) -> None:
mock_response = MagicMock()
mock_response.status_code = 403
with patch("src.modules.validator.httpx.get", return_value=mock_response):
result = validate_stream("https://stream.m3u8", "hls")
assert result is False
def test_rejects_hls_no_m3u8_content(self) -> None:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.text = "This is not an m3u8 playlist"
with patch("src.modules.validator.httpx.get", return_value=mock_response):
result = validate_stream("https://stream.m3u8", "hls")
assert result is False
def test_validates_direct_stream_200(self) -> None:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "audio/mp4"}
with patch("src.modules.validator.httpx.head", return_value=mock_response):
result = validate_stream("https://audio.mp4", "direct")
assert result is True
def test_validates_direct_stream_206(self) -> None:
mock_response = MagicMock()
mock_response.status_code = 206
mock_response.headers = {"content-type": "audio/mpeg"}
with patch("src.modules.validator.httpx.head", return_value=mock_response):
result = validate_stream("https://audio.mp3", "direct")
assert result is True
def test_rejects_direct_wrong_status(self) -> None:
mock_response = MagicMock()
mock_response.status_code = 404
with patch("src.modules.validator.httpx.head", return_value=mock_response):
result = validate_stream("https://audio.mp4", "direct")
assert result is False
def test_rejects_direct_no_content_type(self) -> None:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.headers = {}
with patch("src.modules.validator.httpx.head", return_value=mock_response):
result = validate_stream("https://audio.mp4", "direct")
assert result is False
def test_rejects_unknown_stream_type(self) -> None:
result = validate_stream("https://stream", "unknown")
assert result is False
def test_handles_timeout(self) -> None:
with patch(
"src.modules.validator.httpx.get",
side_effect=httpx.TimeoutException("Timeout"),
):
result = validate_stream("https://stream.m3u8", "hls")
assert result is False
def test_handles_request_error_hls(self) -> None:
with patch(
"src.modules.validator.httpx.get",
side_effect=httpx.RequestError("Connection refused"),
):
result = validate_stream("https://stream.m3u8", "hls")
assert result is False
def test_handles_request_error_direct(self) -> None:
with patch(
"src.modules.validator.httpx.head",
side_effect=httpx.RequestError("Connection refused"),
):
result = validate_stream("https://audio.mp4", "direct")
assert result is False