Lofi app
This commit is contained in:
parent
037983ab34
commit
ceed10aef4
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
.env
|
||||||
|
.git
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
data/
|
||||||
|
tools/
|
||||||
|
additionaldocs/
|
||||||
|
prompt.md
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
14
.env.example
Normal file
14
.env.example
Normal 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
31
.gitignore
vendored
Normal 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
4
.hadolint.yaml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
failure-threshold: warning
|
||||||
|
|
||||||
|
ignored:
|
||||||
|
- DL3008 # apt-get install without --no-install-recommends is needed for build dependencies
|
||||||
149
.opencode/command/opsx-apply.md
Normal file
149
.opencode/command/opsx-apply.md
Normal 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
|
||||||
154
.opencode/command/opsx-archive.md
Normal file
154
.opencode/command/opsx-archive.md
Normal 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
|
||||||
170
.opencode/command/opsx-explore.md
Normal file
170
.opencode/command/opsx-explore.md
Normal 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
|
||||||
103
.opencode/command/opsx-propose.md
Normal file
103
.opencode/command/opsx-propose.md
Normal 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
|
||||||
156
.opencode/skills/openspec-apply-change/SKILL.md
Normal file
156
.opencode/skills/openspec-apply-change/SKILL.md
Normal 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
|
||||||
114
.opencode/skills/openspec-archive-change/SKILL.md
Normal file
114
.opencode/skills/openspec-archive-change/SKILL.md
Normal 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
|
||||||
288
.opencode/skills/openspec-explore/SKILL.md
Normal file
288
.opencode/skills/openspec-explore/SKILL.md
Normal 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
|
||||||
110
.opencode/skills/openspec-propose/SKILL.md
Normal file
110
.opencode/skills/openspec-propose/SKILL.md
Normal 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
25
docker/Dockerfile.backend
Normal 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
35
docker/docker-compose.yml
Normal 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
3
frontend/.dockerignore
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
3
frontend/.gitignore
vendored
Normal file
3
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
7
frontend/.prettierrc
Normal file
7
frontend/.prettierrc
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
20
frontend/Dockerfile
Normal file
20
frontend/Dockerfile
Normal 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
27
frontend/eslint.config.js
Normal 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
13
frontend/index.html
Normal 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
19
frontend/nginx.conf
Normal 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
42
frontend/package.json
Normal 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
3651
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
7
frontend/safelist.txt
Normal file
7
frontend/safelist.txt
Normal 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
27
frontend/src/App.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
frontend/src/__tests__/AudioPlayer.test.tsx
Normal file
67
frontend/src/__tests__/AudioPlayer.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
129
frontend/src/__tests__/ChannelList.test.tsx
Normal file
129
frontend/src/__tests__/ChannelList.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
77
frontend/src/__tests__/api.test.ts
Normal file
77
frontend/src/__tests__/api.test.ts
Normal 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")
|
||||||
|
})
|
||||||
|
})
|
||||||
76
frontend/src/__tests__/audioStore.test.ts
Normal file
76
frontend/src/__tests__/audioStore.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
168
frontend/src/components/AudioPlayer.tsx
Normal file
168
frontend/src/components/AudioPlayer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
92
frontend/src/components/ChannelList.tsx
Normal file
92
frontend/src/components/ChannelList.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
89
frontend/src/hooks/useAudioPlayer.ts
Normal file
89
frontend/src/hooks/useAudioPlayer.ts
Normal 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
11
frontend/src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
43
frontend/src/services/api.ts
Normal file
43
frontend/src/services/api.ts
Normal 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()
|
||||||
|
}
|
||||||
111
frontend/src/store/audioStore.ts
Normal file
111
frontend/src/store/audioStore.ts
Normal 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" })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
36
frontend/src/styles/globals.css
Normal file
36
frontend/src/styles/globals.css
Normal 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;
|
||||||
|
}
|
||||||
1
frontend/src/test/setup.ts
Normal file
1
frontend/src/test/setup.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/jest-dom"
|
||||||
29
frontend/src/types.ts
Normal file
29
frontend/src/types.ts
Normal 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
24
frontend/tailwind.config.js
Normal file
24
frontend/tailwind.config.js
Normal 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
26
frontend/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
19
frontend/tsconfig.node.json
Normal file
19
frontend/tsconfig.node.json
Normal 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
16
frontend/vite.config.ts
Normal 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
11
frontend/vitest.config.ts
Normal 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
1
prompt.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
Prompt for the next LLM – “Implement a Lofi‑Radio App” Objective Build a cross‑platform app (web / Android / iOS) that streams the live audio from 20 popular lo‑fi 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 YouTube’s API (or via yt‑dlp). 3. Play the stream using an HLS‑aware media player. 4. Validate that the stream is working before starting playback. High‑level 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 Live‑Streaming 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 platform’s 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 end‑to‑end 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 5 minutes on each channel. 4. Error handling: - If the broadcast is not live, skip to the next channel. - If the API call fails (rate‑limit, 403), retry after a back‑off. 5. Logging: record each channel’s status (live, URL fetched, playback started). 6. User test: have a beta‑user listen for 30 minutes to catch any buffering or latency issues. List of 20 Lo‑fi 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 A–G above. 2. Pseudo‑code (no real code, just high‑level logic). 3. Test plan covering the validation steps. 4. Error‑handling 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
26
pyproject.toml
Normal 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
1
src/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Lofi Radio Backend."""
|
||||||
122
src/channels.py
Normal file
122
src/channels.py
Normal 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
19
src/config.py
Normal 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
177
src/main.py
Normal 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
1
src/modules/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Lofi Radio Backend Modules."""
|
||||||
83
src/modules/discovery.py
Normal file
83
src/modules/discovery.py
Normal 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
|
||||||
83
src/modules/stream_extractor.py
Normal file
83
src/modules/stream_extractor.py
Normal 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
56
src/modules/validator.py
Normal 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
0
tests/__init__.py
Normal file
5
tests/conftest.py
Normal file
5
tests/conftest.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
os.environ["YOUTUBE_API_KEY"] = "test-fake-key"
|
||||||
|
os.environ["APP_ENV"] = "test"
|
||||||
0
tests/integration/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
168
tests/integration/test_api.py
Normal file
168
tests/integration/test_api.py
Normal 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
0
tests/unit/__init__.py
Normal file
158
tests/unit/test_discovery.py
Normal file
158
tests/unit/test_discovery.py
Normal 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
|
||||||
163
tests/unit/test_stream_extractor.py
Normal file
163
tests/unit/test_stream_extractor.py
Normal 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
|
||||||
116
tests/unit/test_validator.py
Normal file
116
tests/unit/test_validator.py
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user