Compare commits
111 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b32b71db5 | ||
|
|
46cf45ad33 | ||
|
|
10b15fd521 | ||
|
|
3d090082fb | ||
|
|
da72e89fd6 | ||
|
|
9c0abc12e3 | ||
|
|
59099b2b66 | ||
|
|
696d317c6c | ||
|
|
7edec61af1 | ||
|
|
6b9d778b4d | ||
|
|
c9d4266b77 | ||
|
|
bfcc94a67f | ||
|
|
9e28fc168a | ||
|
|
8d55b1303b | ||
|
|
0c80904661 | ||
|
|
18b102558d | ||
|
|
f10f8ad465 | ||
|
|
29cc0289d6 | ||
|
|
1d8eb36034 | ||
|
|
dfeeb2ae81 | ||
|
|
4795d624cb | ||
|
|
f1bb59afb0 | ||
|
|
379e0eff85 | ||
|
|
de2dd0a69d | ||
|
|
dd5d513534 | ||
|
|
be5921b6ee | ||
|
|
f026cb2423 | ||
|
|
429858287f | ||
|
|
60456c4737 | ||
|
|
2941fa482e | ||
|
|
3aaf35d43b | ||
|
|
c0e3e2a7e2 | ||
|
|
c860075681 | ||
|
|
c090a395dc | ||
|
|
8e7be96991 | ||
|
|
8d7339b950 | ||
|
|
6d3b30ed6f | ||
|
|
1cb08bc778 | ||
|
|
7d266208ae | ||
|
|
265200b6ad | ||
|
|
b1eed75b31 | ||
|
|
6cf9a100d4 | ||
|
|
1652dd230d | ||
|
|
5eb8edf209 | ||
|
|
7d10d195f3 | ||
|
|
bac4d3cff6 | ||
|
|
fb34db3ea3 | ||
|
|
1d9e56a2da | ||
|
|
d4f6f60df6 | ||
|
|
e68486ddd2 | ||
|
|
0f571864e0 | ||
|
|
0c7df04b92 | ||
|
|
212082050c | ||
|
|
e07c23aceb | ||
|
|
a87c0cc8a3 | ||
|
|
d20cc75085 | ||
|
|
97417ee03c | ||
|
|
58f077268f | ||
|
|
bd4c5bf215 | ||
|
|
0e67d7b53d | ||
|
|
ae3741c9ee | ||
|
|
c4ba926a74 | ||
|
|
7b8ba4b54c | ||
|
|
413430b700 | ||
|
|
b6337f4c85 | ||
|
|
7c9bcd81a6 | ||
|
|
af9fcd4060 | ||
|
|
c59c06915b | ||
|
|
7886095342 | ||
|
|
5b8cfffcf9 | ||
|
|
f966ef7c2e | ||
|
|
b38da24055 | ||
|
|
2c7ff6207a | ||
|
|
af6a4ae5d3 | ||
|
|
60e2abfb65 | ||
|
|
cf7748a980 | ||
|
|
762d2b81ff | ||
|
|
1a9c89e1f1 | ||
|
|
1d1e1e84e9 | ||
|
|
1dab125396 | ||
|
|
9f57f4f3c2 | ||
|
|
9e927c114e | ||
|
|
b2c1ce6ef5 | ||
|
|
9914fdb802 | ||
|
|
d3557fedbb | ||
|
|
c026af2720 | ||
|
|
619c91e2f5 | ||
|
|
f2ad02df81 | ||
|
|
afb55153e2 | ||
|
|
5ee6735a99 | ||
|
|
d11878fdcd | ||
|
|
73df24f28f | ||
|
|
cdaeec1280 | ||
|
|
ee89fcef7e | ||
|
|
0bdb7ca8f6 | ||
|
|
fae5349f9c | ||
|
|
c8e5a48d57 | ||
|
|
a9eb35fa75 | ||
|
|
99e5924588 | ||
|
|
fe81271ab3 | ||
|
|
8718fc663a | ||
|
|
465017cda9 | ||
|
|
35ce12bc84 | ||
|
|
159cbc853c | ||
|
|
5b236608f8 | ||
|
|
19c5e97e7f | ||
|
|
704a8d9a0b | ||
|
|
5ced96f4e6 | ||
|
|
c1c041bbe9 | ||
|
|
d8b949a965 | ||
|
|
5c1b05a169 |
143 changed files with 25066 additions and 925 deletions
111
.agent/workflows/dev-environment.md
Normal file
111
.agent/workflows/dev-environment.md
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
---
|
||||
description: fediversion development environment and workflow requirements
|
||||
---
|
||||
|
||||
# Fediversion Development Workflow
|
||||
|
||||
## CRITICAL: Development Environment
|
||||
|
||||
**All development and testing happens on nexus-vector.** Do NOT use local SQLite for dev/testing.
|
||||
|
||||
### Why nexus-vector?
|
||||
|
||||
- Production-like PostgreSQL database
|
||||
- All imported band data lives there (10,605+ shows, 139k+ performances)
|
||||
- Proper Docker environment matching production
|
||||
- Local SQLite is stale/empty and should NOT be used
|
||||
|
||||
### Development Servers
|
||||
|
||||
| Environment | Server | Path | URL |
|
||||
|-------------|--------|------|-----|
|
||||
| **Staging** | nexus-vector | `/srv/containers/fediversion` | fediversion.runfoo.run |
|
||||
| **Production** | tangible-aacorn | `/srv/containers/fediversion` | (domain TBD) |
|
||||
|
||||
---
|
||||
|
||||
## SSH Access
|
||||
|
||||
```bash
|
||||
# Connect to staging dev environment
|
||||
ssh nexus-vector
|
||||
|
||||
# Navigate to project
|
||||
cd /srv/containers/fediversion
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
### Query Data
|
||||
|
||||
```bash
|
||||
# On nexus-vector
|
||||
docker compose exec db psql -U fediversion -d fediversion
|
||||
```
|
||||
|
||||
### Check Band Data
|
||||
|
||||
```bash
|
||||
docker compose exec db psql -U fediversion -d fediversion -c "
|
||||
SELECT v.name, COUNT(s.id) as shows
|
||||
FROM vertical v
|
||||
LEFT JOIN show s ON s.vertical_id = v.id
|
||||
GROUP BY v.id
|
||||
ORDER BY shows DESC;"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running Importers
|
||||
|
||||
Importers run inside the backend container to access the PostgreSQL database:
|
||||
|
||||
```bash
|
||||
# On nexus-vector
|
||||
docker compose exec backend python -m importers.setlistfm deadco
|
||||
docker compose exec backend python -m importers.setlistfm bmfs
|
||||
docker compose exec backend python -m importers.phish
|
||||
docker compose exec backend python -m importers.grateful_dead
|
||||
```
|
||||
|
||||
### API Keys
|
||||
|
||||
Importers require API keys set in `.env`:
|
||||
|
||||
- `SETLISTFM_API_KEY` - For Dead & Co, Billy Strings, JRAD, Eggy, etc.
|
||||
- `PHISHNET_API_KEY` - For Phish data
|
||||
- `GRATEFULSTATS_API_KEY` - For Grateful Dead (may not be required)
|
||||
|
||||
---
|
||||
|
||||
## Cache
|
||||
|
||||
API responses are cached in `backend/importers/.cache/` (4,800+ files).
|
||||
|
||||
- Cache TTL: 1 hour
|
||||
- Cache persists across runs
|
||||
- Re-import uses cache first → no API calls wasted
|
||||
|
||||
---
|
||||
|
||||
## Imported Band Data (Dec 2025)
|
||||
|
||||
| Band | Shows | Performances |
|
||||
|------|-------|--------------|
|
||||
| DSO | 4,414 | 65,172 |
|
||||
| SCI | 1,916 | 27,225 |
|
||||
| Disco Biscuits | 1,860 | 19,935 |
|
||||
| Phish | 4,266 | (in progress) |
|
||||
| MSI | 758 | 9,501 |
|
||||
| Eggy | 666 | 4,705 |
|
||||
| Dogs in a Pile | 601 | 7,558 |
|
||||
| JRAD | 390 | 5,452 |
|
||||
|
||||
### Still Need Import
|
||||
|
||||
- Goose (El Goose API)
|
||||
- Grateful Dead (Grateful Stats API)
|
||||
- Dead & Company (Setlist.fm)
|
||||
- Billy Strings (Setlist.fm)
|
||||
46
.claude/agents/verify_fediversion.md
Normal file
46
.claude/agents/verify_fediversion.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
name: verify_fediversion
|
||||
description: Verifies Fediversion after changes.
|
||||
|
||||
You are a verification agent for Fediversion (Python backend + Next.js frontend).
|
||||
|
||||
Given a description of changes and relevant context:
|
||||
1. Decide the minimal but sufficient set of checks:
|
||||
- Backend tests: `cd backend && pytest`
|
||||
- Frontend tests: `cd frontend && npm test`
|
||||
- Frontend build: `cd frontend && npm run build`
|
||||
- Lint checks if applicable
|
||||
- VPS deployment verification if deploying
|
||||
2. Run or suggest those checks.
|
||||
3. Report pass/fail and any issues.
|
||||
4. If issues are found, propose follow-up changes.
|
||||
|
||||
## Backend Verification
|
||||
|
||||
For Python backend changes:
|
||||
- Run tests: `cd backend && pytest`
|
||||
- Check imports: `python -m py_compile main.py`
|
||||
- Verify Alembic migrations if schema changed: `cd backend && alembic check`
|
||||
|
||||
## Frontend Verification
|
||||
|
||||
For Next.js frontend changes:
|
||||
- Run tests: `cd frontend && npm test`
|
||||
- Build check: `cd frontend && npm run build`
|
||||
- Type check: `cd frontend && npx tsc --noEmit`
|
||||
|
||||
## Full-Stack Verification
|
||||
|
||||
For changes affecting both:
|
||||
- Start backend: `cd backend && uvicorn main:app --reload`
|
||||
- Start frontend: `cd frontend && npm run dev`
|
||||
- Test critical flows (view shows, rate shows, search)
|
||||
|
||||
## VPS Verification
|
||||
|
||||
When verifying on the VPS (nexus-vector):
|
||||
- Check container status: `ssh admin@nexus-vector 'docker compose ps'`
|
||||
- View logs: `ssh admin@nexus-vector 'docker compose logs --tail 50'`
|
||||
- Health check backend: `curl http://nexus-vector-url.com:8000/docs`
|
||||
- Health check frontend: `curl http://nexus-vector-url.com:3000/`
|
||||
|
||||
Be explicit and concise.
|
||||
206
.claude/claude.md
Normal file
206
.claude/claude.md
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
# Project Overview
|
||||
|
||||
**Name**: Fediversion
|
||||
**Type**: Full-stack application (Python backend + Next.js frontend)
|
||||
**Purpose**: The ultimate HeadyVersion platform for ALL jam bands
|
||||
**Primary Languages**: Python (FastAPI), TypeScript (Next.js 16)
|
||||
|
||||
## High-Level Description
|
||||
|
||||
Fediversion is a unified setlist tracking, rating, and community platform supporting multiple jam bands from a single account.
|
||||
|
||||
**Supported Bands:**
|
||||
- 🦆 Goose (El Goose API) - Active
|
||||
- 🐟 Phish (Phish.net API v5) - Ready
|
||||
- 💀 Grateful Dead (Grateful Stats API) - Ready
|
||||
- ⚡ Dead & Company (Setlist.fm) - Ready
|
||||
- 🎸 Billy Strings (Setlist.fm) - Ready
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
fediversion/
|
||||
├── backend/ # Python FastAPI backend
|
||||
│ ├── main.py # FastAPI app entry point
|
||||
│ ├── models/ # SQLModel database models
|
||||
│ ├── routers/ # API route handlers
|
||||
│ ├── services/ # Business logic
|
||||
│ └── alembic/ # Database migrations
|
||||
├── frontend/ # Next.js 16 frontend
|
||||
│ ├── app/ # Next.js App Router
|
||||
│ ├── components/ # React components
|
||||
│ └── lib/ # Utilities and API client
|
||||
├── email/ # Email templates
|
||||
├── docs/ # Documentation
|
||||
├── docker-compose.yml # Local development stack
|
||||
└── database.db # SQLite database (dev)
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
### Backend (Python)
|
||||
- **Framework**: FastAPI with uvicorn server
|
||||
- **ORM**: SQLModel (built on SQLAlchemy + Pydantic)
|
||||
- **Migrations**: Alembic
|
||||
- **Auth**: JWT tokens (python-jose), bcrypt hashing
|
||||
- **Validation**: Pydantic models
|
||||
- **Testing**: pytest
|
||||
|
||||
### Frontend (Next.js)
|
||||
- **Version**: Next.js 16 with App Router
|
||||
- **React**: Version 19
|
||||
- **Styling**: Tailwind CSS v4
|
||||
- **Components**: Radix UI primitives
|
||||
- **State**: React hooks, server components
|
||||
- **Testing**: Jest + Testing Library
|
||||
|
||||
### Database
|
||||
- **Development**: SQLite (`database.db`)
|
||||
- **Production**: PostgreSQL
|
||||
- **Migrations**: Alembic in `backend/alembic/`
|
||||
|
||||
### Error Handling
|
||||
- Backend: Return proper HTTP status codes with error messages
|
||||
- Frontend: Display user-friendly error messages
|
||||
- Never expose sensitive data (API keys, passwords)
|
||||
|
||||
## Patterns & Playbooks
|
||||
|
||||
### How to Run Locally
|
||||
|
||||
**Backend (port 8000):**
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
uvicorn main:app --reload --port 8000
|
||||
```
|
||||
|
||||
**Frontend (port 3000):**
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Full stack (Docker):**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### How to Run Database Migrations
|
||||
|
||||
**Create new migration:**
|
||||
```bash
|
||||
cd backend
|
||||
alembic revision --autogenerate -m "description"
|
||||
```
|
||||
|
||||
**Apply migrations:**
|
||||
```bash
|
||||
cd backend
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
**Rollback migration:**
|
||||
```bash
|
||||
cd backend
|
||||
alembic downgrade -1
|
||||
```
|
||||
|
||||
### How to Import Band Data
|
||||
|
||||
Set API keys and run import:
|
||||
```bash
|
||||
export PHISHNET_API_KEY="your-key"
|
||||
export SETLISTFM_API_KEY="your-key"
|
||||
|
||||
# Run import scripts (check backend/services/ for import scripts)
|
||||
python -m backend.services.import_phish
|
||||
python -m backend.services.import_goose
|
||||
```
|
||||
|
||||
### How to Add New Band Support
|
||||
|
||||
1. Identify data source (API, scraping, etc.)
|
||||
2. Create import service in `backend/services/import_[band].py`
|
||||
3. Add models if needed
|
||||
4. Create API routes in `backend/routers/`
|
||||
5. Add frontend components to display band data
|
||||
6. Add tests for import logic
|
||||
|
||||
### VPS Deployment (nexus-vector)
|
||||
|
||||
**Deploy to VPS:**
|
||||
```bash
|
||||
# SSH to nexus-vector
|
||||
ssh admin@nexus-vector
|
||||
|
||||
# Navigate to project
|
||||
cd /path/to/fediversion
|
||||
|
||||
# Pull latest changes
|
||||
git pull
|
||||
|
||||
# Restart services
|
||||
docker compose down && docker compose up -d --build
|
||||
```
|
||||
|
||||
**Or automate:**
|
||||
```bash
|
||||
ssh admin@nexus-vector 'cd /path/to/fediversion && git pull && docker compose down && docker compose up -d --build'
|
||||
```
|
||||
|
||||
## Important Environment Variables
|
||||
|
||||
### Backend
|
||||
- `DATABASE_URL`: Database connection string (SQLite or PostgreSQL)
|
||||
- `SECRET_KEY`: JWT secret key
|
||||
- `PHISHNET_API_KEY`: Phish.net API key
|
||||
- `SETLISTFM_API_KEY`: Setlist.fm API key
|
||||
- `GRATEFUL_STATS_API_KEY`: Grateful Stats API key (if applicable)
|
||||
|
||||
### Frontend
|
||||
- `NEXT_PUBLIC_API_URL`: Backend API URL
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Backend tests**: pytest with test database
|
||||
- **Frontend tests**: Jest + Testing Library
|
||||
- **E2E tests**: Playwright for critical flows (view shows, rate shows)
|
||||
- **API tests**: Test CRUD operations for shows, songs, ratings
|
||||
|
||||
## Data Source Integration
|
||||
|
||||
### Current Integrations
|
||||
- **Goose**: El Goose API (proprietary)
|
||||
- **Phish**: Phish.net API v5 ( documented at phish.net/api)
|
||||
- **Grateful Dead**: Grateful Stats API
|
||||
- **Dead & Company**: Setlist.fm
|
||||
- **Billy Strings**: Setlist.fm
|
||||
|
||||
### Integration Patterns
|
||||
1. Fetch data from external API
|
||||
2. Transform to internal data models
|
||||
3. Store in database (backend/models/)
|
||||
4. Expose via API endpoints (backend/routers/)
|
||||
5. Display in frontend (frontend/app/)
|
||||
|
||||
## PR & Learning Workflow
|
||||
|
||||
- When a PR introduces a new pattern or fixes a subtle issue:
|
||||
1. Summarize the lesson in 1-2 bullets
|
||||
2. Append under "Patterns & Playbooks" above
|
||||
3. Consider updating relevant documentation in `docs/`
|
||||
|
||||
## Multi-Band Architecture
|
||||
|
||||
### Band-Agnostic Design
|
||||
- Core models (Show, Song, Venue) are band-agnostic
|
||||
- Band-specific data stored in band_* tables
|
||||
- API endpoints support `?band=goose|phish|dead` filtering
|
||||
- Frontend adapts UI based on selected band
|
||||
|
||||
### User Accounts
|
||||
- Single account supports all bands
|
||||
- Ratings and favorites are per-user, per-band
|
||||
- Unified activity feed across all bands
|
||||
39
.claude/commands/deploy.md
Normal file
39
.claude/commands/deploy.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
You are the VPS deployment assistant for Fediversion.
|
||||
|
||||
## VPS Information
|
||||
- Host: nexus-vector
|
||||
- User: admin
|
||||
- Project path on VPS: Confirm path with user (typically `/home/admin/fediversion` or similar)
|
||||
|
||||
## Deployment Process
|
||||
|
||||
1. **Pre-deployment checks**:
|
||||
- `git status` to check for uncommitted changes
|
||||
- `git log -1 --oneline` to show what will be deployed
|
||||
- Ask if database migration is needed
|
||||
|
||||
2. **Deploy to VPS**:
|
||||
```bash
|
||||
# SSH to nexus-vector and deploy
|
||||
ssh admin@nexus-vector 'cd /path/to/fediversion && git pull && docker compose down && docker compose up -d --build 2>&1 | tail -50'
|
||||
```
|
||||
|
||||
3. **Run migrations if needed**:
|
||||
```bash
|
||||
ssh admin@nexus-vector 'cd /path/to/fediversion/backend && alembic upgrade head'
|
||||
```
|
||||
|
||||
4. **Post-deployment verification**:
|
||||
- Check container status: `ssh admin@nexus-vector 'docker compose ps'`
|
||||
- View logs: `ssh admin@nexus-vector 'docker compose logs --tail 50'`
|
||||
- Health check: `curl http://nexus-vector-url.com:8000/docs` (FastAPI docs)
|
||||
|
||||
## Output
|
||||
|
||||
Report:
|
||||
- What was deployed (commit hash, message)
|
||||
- Build output summary
|
||||
- Container status (running/not running)
|
||||
- Any errors or warnings in logs
|
||||
- Whether migrations were run
|
||||
- Suggested manual verification steps (URLs to check)
|
||||
57
.claude/commands/import-data.md
Normal file
57
.claude/commands/import-data.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
You are the data import assistant for Fediversion.
|
||||
|
||||
## Supported Bands
|
||||
|
||||
| Band | Data Source | Status |
|
||||
|------|-------------|--------|
|
||||
| Goose | El Goose API | Active |
|
||||
| Phish | Phish.net API v5 | Ready |
|
||||
| Grateful Dead | Grateful Stats API | Ready |
|
||||
| Dead & Company | Setlist.fm | Ready |
|
||||
| Billy Strings | Setlist.fm | Ready |
|
||||
|
||||
## Import Process
|
||||
|
||||
1. **Check API keys**:
|
||||
```bash
|
||||
# Verify required API keys are set
|
||||
echo $PHISHNET_API_KEY
|
||||
echo $SETLISTFM_API_KEY
|
||||
```
|
||||
|
||||
2. **Ask which band(s) to import**:
|
||||
- Single band or all bands?
|
||||
- Date range (optional)?
|
||||
- Import shows, songs, or both?
|
||||
|
||||
3. **Run import scripts** (examples):
|
||||
```bash
|
||||
# Phish import
|
||||
cd backend
|
||||
python -m services.import_phish
|
||||
|
||||
# Goose import
|
||||
python -m services.import_goose
|
||||
|
||||
# Grateful Dead import
|
||||
python -m services.import_dead
|
||||
```
|
||||
|
||||
4. **Verify import**:
|
||||
- Check database for new records
|
||||
- Verify API endpoints return new data
|
||||
- Spot-check in frontend
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Required API keys must be set in environment
|
||||
- Backend database must be running
|
||||
- Sufficient API rate limits (don't spam APIs)
|
||||
|
||||
## Output
|
||||
|
||||
Report:
|
||||
- Which band(s) were imported
|
||||
- Number of shows/songs imported
|
||||
- Any API errors or rate limiting issues
|
||||
- Verification steps to confirm import success
|
||||
51
.claude/commands/migrate.md
Normal file
51
.claude/commands/migrate.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
You are the database migration assistant for Fediversion.
|
||||
|
||||
## Database Information
|
||||
- ORM: SQLModel (SQLAlchemy + Pydantic)
|
||||
- Migration tool: Alembic
|
||||
- Backend language: Python
|
||||
- Dev database: SQLite
|
||||
- Production database: PostgreSQL
|
||||
|
||||
## Migration Process
|
||||
|
||||
1. **Check current status**:
|
||||
```bash
|
||||
cd backend
|
||||
alembic current
|
||||
alembic history
|
||||
```
|
||||
|
||||
2. **Create new migration**:
|
||||
- Ask for a description of the schema change
|
||||
- Modify models in `backend/models/` first
|
||||
- Run: `alembic revision --autogenerate -m "description"`
|
||||
- Review generated migration in `backend/alembic/versions/`
|
||||
|
||||
3. **Apply migration**:
|
||||
```bash
|
||||
cd backend
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
4. **Verify**:
|
||||
- Show the generated SQL
|
||||
- Check if any data migration is needed
|
||||
- Test with dev database first
|
||||
|
||||
## Rollback
|
||||
|
||||
If something goes wrong:
|
||||
```bash
|
||||
cd backend
|
||||
alembic downgrade -1 # Rollback one migration
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
Report:
|
||||
- Migration revision ID and description
|
||||
- SQL changes summary
|
||||
- Whether data migration is needed
|
||||
- Rollback instructions if needed
|
||||
- Next steps (test, deploy to production)
|
||||
|
|
@ -36,7 +36,7 @@ jobs:
|
|||
script: |
|
||||
# Clone or pull repo
|
||||
if [ ! -d "${{ steps.target.outputs.deploy_path }}" ]; then
|
||||
git clone https://git.runfoo.run/runfoo/fediversion.git ${{ steps.target.outputs.deploy_path }}
|
||||
git clone https://git.runfoo.run/runfoo-org/fediversion.git ${{ steps.target.outputs.deploy_path }}
|
||||
fi
|
||||
cd ${{ steps.target.outputs.deploy_path }}
|
||||
git fetch origin ${{ github.ref_name }}
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -7,3 +7,4 @@ node_modules/
|
|||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
backend/importers/.cache/
|
||||
|
|
|
|||
24
README.md
24
README.md
|
|
@ -76,6 +76,30 @@ python -m importers.setlistfm bmfs
|
|||
| Setlist.fm | <https://api.setlist.fm> | D&C, Billy Strings |
|
||||
| Grateful Stats | <https://gratefulstats.com> | Grateful Dead |
|
||||
|
||||
## Git Repository
|
||||
|
||||
**Repo**: <https://git.runfoo.run/runfoo-org/fediversion>
|
||||
|
||||
### Push via Tailscale
|
||||
|
||||
SSH port 2222 blocked externally. Use Tailscale IP for nexus-vector:
|
||||
|
||||
```bash
|
||||
# Set remote to Tailscale IP (nexus-vector = 100.95.3.92)
|
||||
git remote set-url origin ssh://git@100.95.3.92:2222/runfoo-org/fediversion.git
|
||||
|
||||
# Push
|
||||
git push origin main
|
||||
git push origin testing # Triggers CI/CD deploy to fediversion.runfoo.run
|
||||
```
|
||||
|
||||
### CI/CD Branches
|
||||
|
||||
| Branch | Server | URL |
|
||||
|--------|--------|-----|
|
||||
| `testing` | nexus-vector | fediversion.runfoo.run |
|
||||
| `production` | tangible-aacorn | (when domain ready) |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
|
|
|
|||
132
backend/alembic/versions/0b6d33dcfe94_add_video_tables.py
Normal file
132
backend/alembic/versions/0b6d33dcfe94_add_video_tables.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"""add_video_tables
|
||||
|
||||
Revision ID: 0b6d33dcfe94
|
||||
Revises: ad5a56553d20
|
||||
Create Date: 2025-12-30 19:23:49.165420
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0b6d33dcfe94'
|
||||
down_revision: Union[str, Sequence[str], None] = 'ad5a56553d20'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('video',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('url', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('platform', sa.Enum('YOUTUBE', 'VIMEO', 'NUGS', 'BANDCAMP', 'ARCHIVE', 'OTHER', name='videoplatform'), nullable=False),
|
||||
sa.Column('video_type', sa.Enum('FULL_SHOW', 'SINGLE_SONG', 'SEQUENCE', 'INTERVIEW', 'DOCUMENTARY', 'LIVE_STREAM', 'OTHER', name='videotype'), nullable=False),
|
||||
sa.Column('duration_seconds', sa.Integer(), nullable=True),
|
||||
sa.Column('thumbnail_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('external_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('recorded_date', sa.DateTime(), nullable=True),
|
||||
sa.Column('published_date', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('vertical_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['vertical_id'], ['vertical.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('video', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_video_url'), ['url'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_video_vertical_id'), ['vertical_id'], unique=False)
|
||||
|
||||
op.create_table('videomusician',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('video_id', sa.Integer(), nullable=False),
|
||||
sa.Column('musician_id', sa.Integer(), nullable=False),
|
||||
sa.Column('role', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['musician_id'], ['musician.id'], ),
|
||||
sa.ForeignKeyConstraint(['video_id'], ['video.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('videomusician', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_videomusician_musician_id'), ['musician_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_videomusician_video_id'), ['video_id'], unique=False)
|
||||
|
||||
op.create_table('videoshow',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('video_id', sa.Integer(), nullable=False),
|
||||
sa.Column('show_id', sa.Integer(), nullable=False),
|
||||
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['show_id'], ['show.id'], ),
|
||||
sa.ForeignKeyConstraint(['video_id'], ['video.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('videoshow', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_videoshow_show_id'), ['show_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_videoshow_video_id'), ['video_id'], unique=False)
|
||||
|
||||
op.create_table('videosong',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('video_id', sa.Integer(), nullable=False),
|
||||
sa.Column('song_id', sa.Integer(), nullable=False),
|
||||
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['song_id'], ['song.id'], ),
|
||||
sa.ForeignKeyConstraint(['video_id'], ['video.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('videosong', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_videosong_song_id'), ['song_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_videosong_video_id'), ['video_id'], unique=False)
|
||||
|
||||
op.create_table('videoperformance',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('video_id', sa.Integer(), nullable=False),
|
||||
sa.Column('performance_id', sa.Integer(), nullable=False),
|
||||
sa.Column('timestamp_start', sa.Integer(), nullable=True),
|
||||
sa.Column('timestamp_end', sa.Integer(), nullable=True),
|
||||
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['performance_id'], ['performance.id'], ),
|
||||
sa.ForeignKeyConstraint(['video_id'], ['video.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('videoperformance', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_videoperformance_performance_id'), ['performance_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_videoperformance_video_id'), ['video_id'], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('videoperformance', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_videoperformance_video_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_videoperformance_performance_id'))
|
||||
|
||||
op.drop_table('videoperformance')
|
||||
with op.batch_alter_table('videosong', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_videosong_video_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_videosong_song_id'))
|
||||
|
||||
op.drop_table('videosong')
|
||||
with op.batch_alter_table('videoshow', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_videoshow_video_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_videoshow_show_id'))
|
||||
|
||||
op.drop_table('videoshow')
|
||||
with op.batch_alter_table('videomusician', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_videomusician_video_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_videomusician_musician_id'))
|
||||
|
||||
op.drop_table('videomusician')
|
||||
with op.batch_alter_table('video', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_video_vertical_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_video_url'))
|
||||
|
||||
op.drop_table('video')
|
||||
# ### end Alembic commands ###
|
||||
45
backend/alembic/versions/409112776ded_add_social_handles.py
Normal file
45
backend/alembic/versions/409112776ded_add_social_handles.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""add_social_handles
|
||||
|
||||
Revision ID: 409112776ded
|
||||
Revises: b1ca95289d88
|
||||
Create Date: 2025-12-29 20:46:30.443972
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel.sql.sqltypes
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '409112776ded'
|
||||
down_revision: Union[str, Sequence[str], None] = 'b1ca95289d88'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('bluesky_handle', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||
batch_op.add_column(sa.Column('mastodon_handle', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||
batch_op.add_column(sa.Column('instagram_handle', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||
batch_op.add_column(sa.Column('x_handle', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||
batch_op.add_column(sa.Column('location', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.drop_column('location')
|
||||
batch_op.drop_column('x_handle')
|
||||
batch_op.drop_column('instagram_handle')
|
||||
batch_op.drop_column('mastodon_handle')
|
||||
batch_op.drop_column('bluesky_handle')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
"""Add Scene and new vertical fields
|
||||
|
||||
Revision ID: 4f14be7d0551
|
||||
Revises: 65c515b4722a
|
||||
Create Date: 2025-12-29 01:15:06.570017
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '4f14be7d0551'
|
||||
down_revision: Union[str, Sequence[str], None] = '65c515b4722a'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('musician',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('bio', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('image_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('primary_instrument', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('musician', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_musician_name'), ['name'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_musician_slug'), ['slug'], unique=True)
|
||||
|
||||
op.create_table('scene',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('scene', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_scene_name'), ['name'], unique=True)
|
||||
batch_op.create_index(batch_op.f('ix_scene_slug'), ['slug'], unique=True)
|
||||
|
||||
op.create_table('sequence',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('sequence', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_sequence_name'), ['name'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sequence_slug'), ['slug'], unique=True)
|
||||
|
||||
op.create_table('bandmembership',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('musician_id', sa.Integer(), nullable=False),
|
||||
sa.Column('artist_id', sa.Integer(), nullable=False),
|
||||
sa.Column('role', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('start_date', sa.DateTime(), nullable=True),
|
||||
sa.Column('end_date', sa.DateTime(), nullable=True),
|
||||
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['artist_id'], ['artist.id'], ),
|
||||
sa.ForeignKeyConstraint(['musician_id'], ['musician.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('reaction',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('entity_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('entity_id', sa.Integer(), nullable=False),
|
||||
sa.Column('emoji', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('reaction', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_reaction_entity_id'), ['entity_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_reaction_entity_type'), ['entity_type'], unique=False)
|
||||
|
||||
op.create_table('songcanon',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('original_artist', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('original_artist_id', sa.Integer(), nullable=True),
|
||||
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['original_artist_id'], ['artist.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('songcanon', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_songcanon_slug'), ['slug'], unique=True)
|
||||
batch_op.create_index(batch_op.f('ix_songcanon_title'), ['title'], unique=False)
|
||||
|
||||
op.create_table('userverticalpreference',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('vertical_id', sa.Integer(), nullable=False),
|
||||
sa.Column('display_mode', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('priority', sa.Integer(), nullable=False),
|
||||
sa.Column('notify_on_show', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.ForeignKeyConstraint(['vertical_id'], ['vertical.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('userverticalpreference', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_userverticalpreference_user_id'), ['user_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_userverticalpreference_vertical_id'), ['vertical_id'], unique=False)
|
||||
|
||||
op.create_table('verticalscene',
|
||||
sa.Column('vertical_id', sa.Integer(), nullable=False),
|
||||
sa.Column('scene_id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['scene_id'], ['scene.id'], ),
|
||||
sa.ForeignKeyConstraint(['vertical_id'], ['vertical.id'], ),
|
||||
sa.PrimaryKeyConstraint('vertical_id', 'scene_id')
|
||||
)
|
||||
op.create_table('chasesong',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('song_id', sa.Integer(), nullable=False),
|
||||
sa.Column('priority', sa.Integer(), nullable=False),
|
||||
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('caught_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('caught_show_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['caught_show_id'], ['show.id'], ),
|
||||
sa.ForeignKeyConstraint(['song_id'], ['song.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('chasesong', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_chasesong_song_id'), ['song_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_chasesong_user_id'), ['user_id'], unique=False)
|
||||
|
||||
op.create_table('sequencesong',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('sequence_id', sa.Integer(), nullable=False),
|
||||
sa.Column('song_id', sa.Integer(), nullable=False),
|
||||
sa.Column('position', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['sequence_id'], ['sequence.id'], ),
|
||||
sa.ForeignKeyConstraint(['song_id'], ['song.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('performanceguest',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('performance_id', sa.Integer(), nullable=False),
|
||||
sa.Column('musician_id', sa.Integer(), nullable=False),
|
||||
sa.Column('instrument', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['musician_id'], ['musician.id'], ),
|
||||
sa.ForeignKeyConstraint(['performance_id'], ['performance.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('artist', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=False))
|
||||
batch_op.add_column(sa.Column('bio', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||
batch_op.add_column(sa.Column('image_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||
batch_op.create_index(batch_op.f('ix_artist_slug'), ['slug'], unique=True)
|
||||
|
||||
with op.batch_alter_table('badge', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('tier', sqlmodel.sql.sqltypes.AutoString(), nullable=False))
|
||||
batch_op.add_column(sa.Column('category', sqlmodel.sql.sqltypes.AutoString(), nullable=False))
|
||||
batch_op.add_column(sa.Column('xp_reward', sa.Integer(), nullable=False))
|
||||
|
||||
with op.batch_alter_table('group', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('vertical_id', sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column('image_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||
batch_op.create_index(batch_op.f('ix_group_vertical_id'), ['vertical_id'], unique=False)
|
||||
batch_op.create_foreign_key(None, 'vertical', ['vertical_id'], ['id'])
|
||||
|
||||
with op.batch_alter_table('performance', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('bandcamp_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||
batch_op.add_column(sa.Column('nugs_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||
|
||||
with op.batch_alter_table('show', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('relisten_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||
|
||||
with op.batch_alter_table('song', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('canon_id', sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column('artist_id', sa.Integer(), nullable=True))
|
||||
batch_op.create_foreign_key(None, 'artist', ['artist_id'], ['id'])
|
||||
batch_op.create_foreign_key(None, 'songcanon', ['canon_id'], ['id'])
|
||||
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('avatar_bg_color', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||
batch_op.add_column(sa.Column('avatar_text', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||
batch_op.add_column(sa.Column('profile_public', sa.Boolean(), nullable=False))
|
||||
batch_op.add_column(sa.Column('show_attendance_public', sa.Boolean(), nullable=False))
|
||||
batch_op.add_column(sa.Column('appear_in_leaderboards', sa.Boolean(), nullable=False))
|
||||
|
||||
with op.batch_alter_table('userpreferences', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('theme', sqlmodel.sql.sqltypes.AutoString(), nullable=False))
|
||||
batch_op.add_column(sa.Column('email_on_reply', sa.Boolean(), nullable=False))
|
||||
batch_op.add_column(sa.Column('email_on_chase', sa.Boolean(), nullable=False))
|
||||
batch_op.add_column(sa.Column('email_digest', sa.Boolean(), nullable=False))
|
||||
|
||||
with op.batch_alter_table('vertical', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('primary_artist_id', sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column('setlistfm_mbid', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||
batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=False))
|
||||
batch_op.add_column(sa.Column('is_featured', sa.Boolean(), nullable=False))
|
||||
batch_op.add_column(sa.Column('logo_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||
batch_op.add_column(sa.Column('accent_color', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||
batch_op.create_foreign_key(None, 'artist', ['primary_artist_id'], ['id'])
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('vertical', schema=None) as batch_op:
|
||||
batch_op.drop_constraint(None, type_='foreignkey')
|
||||
batch_op.drop_column('accent_color')
|
||||
batch_op.drop_column('logo_url')
|
||||
batch_op.drop_column('is_featured')
|
||||
batch_op.drop_column('is_active')
|
||||
batch_op.drop_column('setlistfm_mbid')
|
||||
batch_op.drop_column('primary_artist_id')
|
||||
|
||||
with op.batch_alter_table('userpreferences', schema=None) as batch_op:
|
||||
batch_op.drop_column('email_digest')
|
||||
batch_op.drop_column('email_on_chase')
|
||||
batch_op.drop_column('email_on_reply')
|
||||
batch_op.drop_column('theme')
|
||||
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.drop_column('appear_in_leaderboards')
|
||||
batch_op.drop_column('show_attendance_public')
|
||||
batch_op.drop_column('profile_public')
|
||||
batch_op.drop_column('avatar_text')
|
||||
batch_op.drop_column('avatar_bg_color')
|
||||
|
||||
with op.batch_alter_table('song', schema=None) as batch_op:
|
||||
batch_op.drop_constraint(None, type_='foreignkey')
|
||||
batch_op.drop_constraint(None, type_='foreignkey')
|
||||
batch_op.drop_column('artist_id')
|
||||
batch_op.drop_column('canon_id')
|
||||
|
||||
with op.batch_alter_table('show', schema=None) as batch_op:
|
||||
batch_op.drop_column('relisten_link')
|
||||
|
||||
with op.batch_alter_table('performance', schema=None) as batch_op:
|
||||
batch_op.drop_column('nugs_link')
|
||||
batch_op.drop_column('bandcamp_link')
|
||||
|
||||
with op.batch_alter_table('group', schema=None) as batch_op:
|
||||
batch_op.drop_constraint(None, type_='foreignkey')
|
||||
batch_op.drop_index(batch_op.f('ix_group_vertical_id'))
|
||||
batch_op.drop_column('image_url')
|
||||
batch_op.drop_column('vertical_id')
|
||||
|
||||
with op.batch_alter_table('badge', schema=None) as batch_op:
|
||||
batch_op.drop_column('xp_reward')
|
||||
batch_op.drop_column('category')
|
||||
batch_op.drop_column('tier')
|
||||
|
||||
with op.batch_alter_table('artist', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_artist_slug'))
|
||||
batch_op.drop_column('image_url')
|
||||
batch_op.drop_column('bio')
|
||||
batch_op.drop_column('slug')
|
||||
|
||||
op.drop_table('performanceguest')
|
||||
op.drop_table('sequencesong')
|
||||
with op.batch_alter_table('chasesong', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_chasesong_user_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_chasesong_song_id'))
|
||||
|
||||
op.drop_table('chasesong')
|
||||
op.drop_table('verticalscene')
|
||||
with op.batch_alter_table('userverticalpreference', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_userverticalpreference_vertical_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_userverticalpreference_user_id'))
|
||||
|
||||
op.drop_table('userverticalpreference')
|
||||
with op.batch_alter_table('songcanon', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_songcanon_title'))
|
||||
batch_op.drop_index(batch_op.f('ix_songcanon_slug'))
|
||||
|
||||
op.drop_table('songcanon')
|
||||
with op.batch_alter_table('reaction', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_reaction_entity_type'))
|
||||
batch_op.drop_index(batch_op.f('ix_reaction_entity_id'))
|
||||
|
||||
op.drop_table('reaction')
|
||||
op.drop_table('bandmembership')
|
||||
with op.batch_alter_table('sequence', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_sequence_slug'))
|
||||
batch_op.drop_index(batch_op.f('ix_sequence_name'))
|
||||
|
||||
op.drop_table('sequence')
|
||||
with op.batch_alter_table('scene', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_scene_slug'))
|
||||
batch_op.drop_index(batch_op.f('ix_scene_name'))
|
||||
|
||||
op.drop_table('scene')
|
||||
with op.batch_alter_table('musician', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_musician_slug'))
|
||||
batch_op.drop_index(batch_op.f('ix_musician_name'))
|
||||
|
||||
op.drop_table('musician')
|
||||
# ### end Alembic commands ###
|
||||
36
backend/alembic/versions/ad5a56553d20_remove_x_handle.py
Normal file
36
backend/alembic/versions/ad5a56553d20_remove_x_handle.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"""remove_x_handle
|
||||
|
||||
Revision ID: ad5a56553d20
|
||||
Revises: 409112776ded
|
||||
Create Date: 2025-12-29 21:01:08.011913
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'ad5a56553d20'
|
||||
down_revision: Union[str, Sequence[str], None] = '409112776ded'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.drop_column('x_handle')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('x_handle', sa.VARCHAR(), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
"""manual_notification_fix
|
||||
|
||||
Revision ID: b1ca95289d88
|
||||
Revises: b83b61f15175
|
||||
Create Date: 2025-12-29 13:14:38.291752
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b1ca95289d88'
|
||||
down_revision: Union[str, Sequence[str], None] = 'b83b61f15175'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Create Notification Table if not exists
|
||||
# Use SQLAlchemy inspector to check table existence
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
if 'notification' not in tables:
|
||||
op.create_table('notification',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('type', sa.Enum('SHOW_ALERT', 'SIT_IN_ALERT', 'CHASE_SONG_ALERT', name='notificationtype'), nullable=False),
|
||||
sa.Column('title', sa.String(), nullable=False),
|
||||
sa.Column('message', sa.String(), nullable=False),
|
||||
sa.Column('link', sa.String(), nullable=True),
|
||||
sa.Column('is_read', sa.Boolean(), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_notification_user_id'), 'notification', ['user_id'], unique=False)
|
||||
op.create_foreign_key('fk_notification_user', 'notification', 'user', ['user_id'], ['id'])
|
||||
|
||||
# 2. Add missing columns to UserVerticalPreference if they don't exist
|
||||
columns = [c['name'] for c in inspector.get_columns('userverticalpreference')]
|
||||
|
||||
# Explicitly create type execution for Postgres
|
||||
from sqlalchemy.dialects import postgresql
|
||||
enum_type = postgresql.ENUM('HEADLINER', 'MAIN_STAGE', 'SUPPORTING', name='preferencetier')
|
||||
enum_type.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
with op.batch_alter_table('userverticalpreference', schema=None) as batch_op:
|
||||
if 'tier' not in columns:
|
||||
batch_op.add_column(sa.Column('tier', sa.Enum('HEADLINER', 'MAIN_STAGE', 'SUPPORTING', name='preferencetier'), server_default='MAIN_STAGE', nullable=False))
|
||||
if 'notify_on_show' not in columns:
|
||||
batch_op.add_column(sa.Column('notify_on_show', sa.Boolean(), server_default=sa.text('1'), nullable=False))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Downgrade logic
|
||||
with op.batch_alter_table('userverticalpreference', schema=None) as batch_op:
|
||||
batch_op.drop_column('notify_on_show')
|
||||
batch_op.drop_column('tier')
|
||||
|
||||
op.drop_index(op.f('ix_notification_user_id'), table_name='notification')
|
||||
op.drop_table('notification')
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
"""add notification model
|
||||
|
||||
Revision ID: b83b61f15175
|
||||
Revises: bc26bfdca841
|
||||
Create Date: 2025-12-29 13:09:49.487765
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b83b61f15175'
|
||||
down_revision: Union[str, Sequence[str], None] = 'bc26bfdca841'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
"""add venue canon and preferences
|
||||
|
||||
Revision ID: bc26bfdca841
|
||||
Revises: 81e183e75ff5
|
||||
Create Date: 2025-12-29 12:57:55.838639
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'bc26bfdca841'
|
||||
down_revision: Union[str, Sequence[str], None] = '4f14be7d0551'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('venuecanon',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('city', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('state', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('country', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('latitude', sa.Float(), nullable=True),
|
||||
sa.Column('longitude', sa.Float(), nullable=True),
|
||||
sa.Column('capacity', sa.Integer(), nullable=True),
|
||||
sa.Column('website_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('venuecanon', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_venuecanon_name'), ['name'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_venuecanon_slug'), ['slug'], unique=True)
|
||||
|
||||
op.create_table('userplaylist',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('is_public', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('userplaylist', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_userplaylist_name'), ['name'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_userplaylist_slug'), ['slug'], unique=False)
|
||||
|
||||
op.create_table('festival',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('year', sa.Integer(), nullable=True),
|
||||
sa.Column('start_date', sa.DateTime(), nullable=True),
|
||||
sa.Column('end_date', sa.DateTime(), nullable=True),
|
||||
sa.Column('venue_id', sa.Integer(), nullable=True),
|
||||
sa.Column('website_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['venue_id'], ['venue.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('festival', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_festival_name'), ['name'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_festival_slug'), ['slug'], unique=True)
|
||||
|
||||
op.create_table('showfestival',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('show_id', sa.Integer(), nullable=False),
|
||||
sa.Column('festival_id', sa.Integer(), nullable=False),
|
||||
sa.Column('stage', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('set_time', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['festival_id'], ['festival.id'], ),
|
||||
sa.ForeignKeyConstraint(['show_id'], ['show.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('playlistperformance',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('playlist_id', sa.Integer(), nullable=False),
|
||||
sa.Column('performance_id', sa.Integer(), nullable=False),
|
||||
sa.Column('position', sa.Integer(), nullable=False),
|
||||
sa.Column('added_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['performance_id'], ['performance.id'], ),
|
||||
sa.ForeignKeyConstraint(['playlist_id'], ['userplaylist.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# with op.batch_alter_table('userverticalpreference', schema=None) as batch_op:
|
||||
# batch_op.add_column(sa.Column('tier', sa.Enum('HEADLINER', 'MAIN_STAGE', 'SUPPORTING', name='preferencetier'), nullable=False))
|
||||
|
||||
with op.batch_alter_table('venue', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('canon_id', sa.Integer(), nullable=True))
|
||||
batch_op.create_foreign_key('fk_venue_canon_id_venuecanon', 'venuecanon', ['canon_id'], ['id'])
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('venue', schema=None) as batch_op:
|
||||
batch_op.drop_constraint(None, type_='foreignkey')
|
||||
batch_op.drop_column('canon_id')
|
||||
|
||||
with op.batch_alter_table('userverticalpreference', schema=None) as batch_op:
|
||||
batch_op.drop_column('tier')
|
||||
|
||||
op.drop_table('playlistperformance')
|
||||
op.drop_table('showfestival')
|
||||
with op.batch_alter_table('festival', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_festival_slug'))
|
||||
batch_op.drop_index(batch_op.f('ix_festival_name'))
|
||||
|
||||
op.drop_table('festival')
|
||||
with op.batch_alter_table('userplaylist', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_userplaylist_slug'))
|
||||
batch_op.drop_index(batch_op.f('ix_userplaylist_name'))
|
||||
|
||||
op.drop_table('userplaylist')
|
||||
with op.batch_alter_table('venuecanon', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_venuecanon_slug'))
|
||||
batch_op.drop_index(batch_op.f('ix_venuecanon_name'))
|
||||
|
||||
op.drop_table('venuecanon')
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -59,3 +59,26 @@ async def get_current_superuser(current_user: User = Depends(get_current_user)):
|
|||
detail="The user doesn't have enough privileges"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
# Optional OAuth scheme that doesn't require auth
|
||||
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="auth/token", auto_error=False)
|
||||
|
||||
async def get_current_user_optional(
|
||||
token: Optional[str] = Depends(oauth2_scheme_optional),
|
||||
session: Session = Depends(get_session)
|
||||
) -> Optional[User]:
|
||||
"""Get current user if authenticated, otherwise return None (for public endpoints)"""
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
email: str = payload.get("sub")
|
||||
if email is None:
|
||||
return None
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
user = session.exec(select(User).where(User.email == email)).first()
|
||||
return user
|
||||
|
||||
|
|
|
|||
BIN
backend/database.db.bak_1767032775
Normal file
BIN
backend/database.db.bak_1767032775
Normal file
Binary file not shown.
12313
backend/elmeg_dump.sql
Normal file
12313
backend/elmeg_dump.sql
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -5,7 +5,7 @@ Fetches ALL Goose data from El Goose API and populates demo database
|
|||
import requests
|
||||
import time
|
||||
from datetime import datetime
|
||||
from sqlmodel import Session, select
|
||||
from sqlmodel import Session, select, or_
|
||||
from database import engine
|
||||
from models import (
|
||||
Vertical, Venue, Tour, Show, Song, Performance, Artist,
|
||||
|
|
@ -114,6 +114,10 @@ def create_users(session):
|
|||
print(f"✓ Created/Found {len(users)} users")
|
||||
return users
|
||||
|
||||
from sqlmodel import Session, select, or_
|
||||
|
||||
# ... (imports)
|
||||
|
||||
def import_venues(session):
|
||||
"""Import all venues"""
|
||||
print("\n🏛️ Importing venues...")
|
||||
|
|
@ -123,8 +127,14 @@ def import_venues(session):
|
|||
|
||||
venue_map = {}
|
||||
for v in venues_data:
|
||||
slug = generate_slug(v['venuename'])
|
||||
existing = session.exec(
|
||||
select(Venue).where(Venue.name == v['venuename'])
|
||||
select(Venue).where(
|
||||
or_(
|
||||
Venue.name == v['venuename'],
|
||||
Venue.slug == slug
|
||||
)
|
||||
)
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
|
|
@ -132,7 +142,7 @@ def import_venues(session):
|
|||
else:
|
||||
venue = Venue(
|
||||
name=v['venuename'],
|
||||
slug=generate_slug(v['venuename']),
|
||||
slug=slug,
|
||||
city=v.get('city'),
|
||||
state=v.get('state'),
|
||||
country=v.get('country'),
|
||||
|
|
@ -155,10 +165,14 @@ def import_songs(session, vertical_id):
|
|||
|
||||
song_map = {}
|
||||
for s in songs_data:
|
||||
slug = generate_slug(s['name'])
|
||||
# Check if song exists
|
||||
existing = session.exec(
|
||||
select(Song).where(
|
||||
Song.title == s['name'],
|
||||
or_(
|
||||
Song.title == s['name'],
|
||||
Song.slug == slug
|
||||
),
|
||||
Song.vertical_id == vertical_id
|
||||
)
|
||||
).first()
|
||||
|
|
@ -168,7 +182,7 @@ def import_songs(session, vertical_id):
|
|||
else:
|
||||
song = Song(
|
||||
title=s['name'],
|
||||
slug=generate_slug(s['name']),
|
||||
slug=slug,
|
||||
original_artist=s.get('original_artist'),
|
||||
vertical_id=vertical_id
|
||||
# API doesn't include debut_date or times_played in base response
|
||||
|
|
@ -207,8 +221,14 @@ def import_shows(session, vertical_id, venue_map):
|
|||
if s.get('tour_id') and s['tour_id'] != 1: # 1 = "Not Part of a Tour"
|
||||
if s['tour_id'] not in tour_map:
|
||||
# Check if tour exists
|
||||
slug = generate_slug(s['tourname'])
|
||||
existing_tour = session.exec(
|
||||
select(Tour).where(Tour.name == s['tourname'])
|
||||
select(Tour).where(
|
||||
or_(
|
||||
Tour.name == s['tourname'],
|
||||
Tour.slug == slug
|
||||
)
|
||||
)
|
||||
).first()
|
||||
|
||||
if existing_tour:
|
||||
|
|
@ -216,7 +236,7 @@ def import_shows(session, vertical_id, venue_map):
|
|||
else:
|
||||
tour = Tour(
|
||||
name=s['tourname'],
|
||||
slug=generate_slug(s['tourname'])
|
||||
slug=slug
|
||||
)
|
||||
session.add(tour)
|
||||
session.commit()
|
||||
|
|
@ -343,53 +363,67 @@ def import_setlists(session, show_map, song_map):
|
|||
|
||||
print(f"✓ Imported {performance_count} new performances")
|
||||
|
||||
def run_import(session: Session, with_users: bool = False):
|
||||
"""Run the import process programmatically"""
|
||||
# 1. Get or create vertical
|
||||
print("\n🦆 Creating Goose vertical...")
|
||||
vertical = session.exec(
|
||||
select(Vertical).where(Vertical.slug == "goose")
|
||||
).first()
|
||||
|
||||
if not vertical:
|
||||
vertical = Vertical(
|
||||
name="Goose",
|
||||
slug="goose",
|
||||
description="Goose is a jam band from Connecticut"
|
||||
)
|
||||
session.add(vertical)
|
||||
session.commit()
|
||||
session.refresh(vertical)
|
||||
print(f"✓ Created vertical (ID: {vertical.id})")
|
||||
else:
|
||||
print(f"✓ Using existing vertical (ID: {vertical.id})")
|
||||
|
||||
users = []
|
||||
if with_users:
|
||||
# 2. Create users
|
||||
users = create_users(session)
|
||||
|
||||
# 3. Import base data
|
||||
venue_map = import_venues(session)
|
||||
song_map = import_songs(session, vertical.id)
|
||||
|
||||
# 4. Import shows
|
||||
show_map, tour_map = import_shows(session, vertical.id, venue_map)
|
||||
|
||||
# 5. Import setlists
|
||||
import_setlists(session, show_map, song_map)
|
||||
|
||||
return {
|
||||
"venues": len(venue_map),
|
||||
"tours": len(tour_map),
|
||||
"songs": len(song_map),
|
||||
"shows": len(show_map),
|
||||
"users": len(users)
|
||||
}
|
||||
|
||||
def main():
|
||||
print("="*60)
|
||||
print("EL GOOSE DATA IMPORTER")
|
||||
print("="*60)
|
||||
|
||||
with Session(engine) as session:
|
||||
# 1. Get or create vertical
|
||||
print("\n🦆 Creating Goose vertical...")
|
||||
vertical = session.exec(
|
||||
select(Vertical).where(Vertical.slug == "goose")
|
||||
).first()
|
||||
|
||||
if not vertical:
|
||||
vertical = Vertical(
|
||||
name="Goose",
|
||||
slug="goose",
|
||||
description="Goose is a jam band from Connecticut"
|
||||
)
|
||||
session.add(vertical)
|
||||
session.commit()
|
||||
session.refresh(vertical)
|
||||
print(f"✓ Created vertical (ID: {vertical.id})")
|
||||
else:
|
||||
print(f"✓ Using existing vertical (ID: {vertical.id})")
|
||||
|
||||
# 2. Create users
|
||||
users = create_users(session)
|
||||
|
||||
# 3. Import base data
|
||||
venue_map = import_venues(session)
|
||||
song_map = import_songs(session, vertical.id)
|
||||
|
||||
# 4. Import shows
|
||||
show_map, tour_map = import_shows(session, vertical.id, venue_map)
|
||||
|
||||
# 5. Import setlists
|
||||
import_setlists(session, show_map, song_map)
|
||||
stats = run_import(session, with_users=True)
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("✓ IMPORT COMPLETE!")
|
||||
print("="*60)
|
||||
print(f"\nImported:")
|
||||
print(f" • {len(venue_map)} venues")
|
||||
print(f" • {len(tour_map)} tours")
|
||||
print(f" • {len(song_map)} songs")
|
||||
print(f" • {len(show_map)} shows")
|
||||
print(f" • {len(users)} demo users")
|
||||
print(f" • {stats['venues']} venues")
|
||||
print(f" • {stats['tours']} tours")
|
||||
print(f" • {stats['songs']} songs")
|
||||
print(f" • {stats['shows']} shows")
|
||||
print(f" • {stats['users']} demo users")
|
||||
print(f"\nAll passwords: demo123")
|
||||
print(f"\nStart demo servers:")
|
||||
print(f" Backend: DATABASE_URL='sqlite:///./elmeg-demo.db' uvicorn main:app --reload --port 8001")
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class ImporterBase(ABC):
|
|||
VERTICAL_DESCRIPTION: str = ""
|
||||
|
||||
# Rate limiting
|
||||
REQUEST_DELAY: float = 0.5 # seconds between requests
|
||||
REQUEST_DELAY: float = 2.0 # seconds between requests (setlist.fm is strict)
|
||||
|
||||
# Cache settings
|
||||
CACHE_DIR: Path = Path(__file__).parent / ".cache"
|
||||
|
|
@ -145,9 +145,11 @@ class ImporterBase(ABC):
|
|||
if existing:
|
||||
venue_id = existing.id
|
||||
else:
|
||||
# Include city in slug for uniqueness (e.g., "Private Venue" in multiple cities)
|
||||
venue_slug = f"{generate_slug(name)}-{generate_slug(city)}"
|
||||
venue = Venue(
|
||||
name=name,
|
||||
slug=generate_slug(name),
|
||||
slug=venue_slug,
|
||||
city=city,
|
||||
state=state,
|
||||
country=country,
|
||||
|
|
@ -178,16 +180,27 @@ class ImporterBase(ABC):
|
|||
if existing:
|
||||
song_id = existing.id
|
||||
else:
|
||||
song = Song(
|
||||
title=title,
|
||||
slug=generate_slug(title),
|
||||
original_artist=original_artist,
|
||||
vertical_id=vertical.id
|
||||
)
|
||||
self.session.add(song)
|
||||
self.session.commit()
|
||||
self.session.refresh(song)
|
||||
song_id = song.id
|
||||
# Include vertical slug in song slug for cross-band uniqueness
|
||||
song_slug = f"{vertical.slug}-{generate_slug(title)}"
|
||||
|
||||
# Check if slug exists (handle simple case variations)
|
||||
existing_slug = self.session.exec(
|
||||
select(Song).where(Song.slug == song_slug)
|
||||
).first()
|
||||
|
||||
if existing_slug:
|
||||
song_id = existing_slug.id
|
||||
else:
|
||||
song = Song(
|
||||
title=title,
|
||||
slug=song_slug,
|
||||
original_artist=original_artist,
|
||||
vertical_id=vertical.id
|
||||
)
|
||||
self.session.add(song)
|
||||
self.session.commit()
|
||||
self.session.refresh(song)
|
||||
song_id = song.id
|
||||
|
||||
if external_id:
|
||||
self.song_map[external_id] = song_id
|
||||
|
|
@ -249,9 +262,13 @@ class ImporterBase(ABC):
|
|||
venue = self.session.get(Venue, venue_id)
|
||||
venue_name = venue.name if venue else "unknown"
|
||||
|
||||
# Include vertical slug for cross-band uniqueness (same venue/date possible)
|
||||
base_slug = generate_show_slug(date.strftime("%Y-%m-%d"), venue_name)
|
||||
show_slug = f"{vertical.slug}-{base_slug}"
|
||||
|
||||
show = Show(
|
||||
date=date,
|
||||
slug=generate_show_slug(date.strftime("%Y-%m-%d"), venue_name),
|
||||
slug=show_slug,
|
||||
vertical_id=vertical.id,
|
||||
venue_id=venue_id,
|
||||
tour_id=tour_id,
|
||||
|
|
|
|||
14
backend/importers/dso.py
Normal file
14
backend/importers/dso.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from .setlistfm import SetlistFmImporter
|
||||
|
||||
class DsoImporter(SetlistFmImporter):
|
||||
"""Import Dark Star Orchestra data from Setlist.fm"""
|
||||
|
||||
VERTICAL_NAME = "Dark Star Orchestra"
|
||||
VERTICAL_SLUG = "dark-star-orchestra"
|
||||
VERTICAL_DESCRIPTION = "Recreating the Grateful Dead concert experience."
|
||||
|
||||
# Dark Star Orchestra MusicBrainz ID
|
||||
ARTIST_MBID = "e477d9c0-1f35-40f7-ad1a-b915d2523b84"
|
||||
|
||||
def __init__(self, session):
|
||||
super().__init__(session)
|
||||
|
|
@ -63,6 +63,10 @@ class GratefulDeadImporter(ImporterBase):
|
|||
print(f" • {len(self.song_map)} songs")
|
||||
print(f" • {len(self.show_map)} shows")
|
||||
|
||||
def import_venues(self) -> Dict[str, int]:
|
||||
"""Import venues (handled during show import for GD)"""
|
||||
return self.venue_map
|
||||
|
||||
def import_songs(self) -> Dict[str, int]:
|
||||
"""Import all Grateful Dead songs"""
|
||||
print("\n🎵 Importing songs...")
|
||||
|
|
|
|||
|
|
@ -246,6 +246,72 @@ class BillyStringsImporter(SetlistFmImporter):
|
|||
ARTIST_MBID = "640db492-34c4-47df-be14-96e2cd4b9fe4"
|
||||
|
||||
|
||||
class JoeRussosAlmostDeadImporter(SetlistFmImporter):
|
||||
"""Import Joe Russo's Almost Dead data from Setlist.fm"""
|
||||
|
||||
VERTICAL_NAME = "Joe Russo's Almost Dead"
|
||||
VERTICAL_SLUG = "jrad"
|
||||
VERTICAL_DESCRIPTION = "Joe Russo's Almost Dead is an American rock band formed in 2013 that interprets the music of the Grateful Dead."
|
||||
|
||||
# JRAD MusicBrainz ID
|
||||
ARTIST_MBID = "84a69823-3d4f-4ede-b43f-17f85513181a"
|
||||
|
||||
|
||||
class EggyImporter(SetlistFmImporter):
|
||||
"""Import Eggy data from Setlist.fm"""
|
||||
|
||||
VERTICAL_NAME = "Eggy"
|
||||
VERTICAL_SLUG = "eggy"
|
||||
VERTICAL_DESCRIPTION = "Connecticut jam band formed in 2014. Known for improvisational rock and explosive live shows."
|
||||
|
||||
# Eggy MusicBrainz ID
|
||||
ARTIST_MBID = "ba0b9dc6-bd61-42c7-a28f-5179b1c04391"
|
||||
|
||||
|
||||
class DogsInAPileImporter(SetlistFmImporter):
|
||||
"""Import Dogs in a Pile data from Setlist.fm"""
|
||||
|
||||
VERTICAL_NAME = "Dogs in a Pile"
|
||||
VERTICAL_SLUG = "dogs-in-a-pile"
|
||||
VERTICAL_DESCRIPTION = "New Jersey jam band. Young and energetic."
|
||||
|
||||
# Dogs in a Pile MusicBrainz ID
|
||||
ARTIST_MBID = "a05236ee-3fac-45d7-96f5-b2cd6d03fda9"
|
||||
|
||||
|
||||
class TheDiscoBiscuitsImporter(SetlistFmImporter):
|
||||
"""Import The Disco Biscuits data from Setlist.fm"""
|
||||
|
||||
VERTICAL_NAME = "The Disco Biscuits"
|
||||
VERTICAL_SLUG = "disco-biscuits"
|
||||
VERTICAL_DESCRIPTION = "Philadelphia trance-fusion jam band. Pioneers of livetronica."
|
||||
|
||||
# The Disco Biscuits MusicBrainz ID
|
||||
ARTIST_MBID = "4e43632a-afef-4b54-a822-26311110d5c5"
|
||||
|
||||
|
||||
class TheStringCheeseIncidentImporter(SetlistFmImporter):
|
||||
"""Import The String Cheese Incident data from Setlist.fm"""
|
||||
|
||||
VERTICAL_NAME = "The String Cheese Incident"
|
||||
VERTICAL_SLUG = "sci"
|
||||
VERTICAL_DESCRIPTION = "Colorado jam band formed in 1993. Known for bluegrass-infused improvisational rock."
|
||||
|
||||
# SCI MusicBrainz ID
|
||||
ARTIST_MBID = "cff95140-6d57-498a-8834-10eb72865b29"
|
||||
|
||||
|
||||
class MindlessSelfIndulgenceImporter(SetlistFmImporter):
|
||||
"""Import Mindless Self Indulgence data from Setlist.fm"""
|
||||
|
||||
VERTICAL_NAME = "Mindless Self Indulgence"
|
||||
VERTICAL_SLUG = "msi"
|
||||
VERTICAL_DESCRIPTION = "New York City electronic rock band. Known for their high-energy, chaotic style."
|
||||
|
||||
# MSI MusicBrainz ID
|
||||
ARTIST_MBID = "44f42386-a733-4b51-8298-fe5c807d03aa"
|
||||
|
||||
|
||||
def main_dead_and_company():
|
||||
"""Run Dead & Company import"""
|
||||
from database import engine
|
||||
|
|
@ -264,6 +330,60 @@ def main_billy_strings():
|
|||
importer.import_all()
|
||||
|
||||
|
||||
def main_jrad():
|
||||
"""Run JRAD import"""
|
||||
from database import engine
|
||||
|
||||
with Session(engine) as session:
|
||||
importer = JoeRussosAlmostDeadImporter(session)
|
||||
importer.import_all()
|
||||
|
||||
|
||||
def main_eggy():
|
||||
"""Run Eggy import"""
|
||||
from database import engine
|
||||
|
||||
with Session(engine) as session:
|
||||
importer = EggyImporter(session)
|
||||
importer.import_all()
|
||||
|
||||
|
||||
def main_dogs():
|
||||
"""Run Dogs in a Pile import"""
|
||||
from database import engine
|
||||
|
||||
with Session(engine) as session:
|
||||
importer = DogsInAPileImporter(session)
|
||||
importer.import_all()
|
||||
|
||||
|
||||
def main_biscuits():
|
||||
"""Run The Disco Biscuits import"""
|
||||
from database import engine
|
||||
|
||||
with Session(engine) as session:
|
||||
importer = TheDiscoBiscuitsImporter(session)
|
||||
importer.import_all()
|
||||
|
||||
|
||||
def main_sci():
|
||||
"""Run SCI import"""
|
||||
from database import engine
|
||||
|
||||
with Session(engine) as session:
|
||||
importer = TheStringCheeseIncidentImporter(session)
|
||||
importer.import_all()
|
||||
|
||||
|
||||
def main_msi():
|
||||
"""Run Mindless Self Indulgence import"""
|
||||
from database import engine
|
||||
|
||||
with Session(engine) as session:
|
||||
importer = MindlessSelfIndulgenceImporter(session)
|
||||
importer.import_all()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
if len(sys.argv) > 1:
|
||||
|
|
@ -271,5 +391,17 @@ if __name__ == "__main__":
|
|||
main_dead_and_company()
|
||||
elif sys.argv[1] == "bmfs":
|
||||
main_billy_strings()
|
||||
elif sys.argv[1] == "jrad":
|
||||
main_jrad()
|
||||
elif sys.argv[1] == "eggy":
|
||||
main_eggy()
|
||||
elif sys.argv[1] == "dogs":
|
||||
main_dogs()
|
||||
elif sys.argv[1] == "biscuits":
|
||||
main_biscuits()
|
||||
elif sys.argv[1] == "sci":
|
||||
main_sci()
|
||||
elif sys.argv[1] == "msi":
|
||||
main_msi()
|
||||
else:
|
||||
print("Usage: python -m importers.setlistfm [deadco|bmfs]")
|
||||
print("Usage: python -m importers.setlistfm [deadco|bmfs|jrad|eggy|dogs|biscuits|sci|msi]")
|
||||
|
|
|
|||
159
backend/link_canon_songs.py
Normal file
159
backend/link_canon_songs.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""
|
||||
Auto-linker script to find and link shared songs across bands.
|
||||
|
||||
This script identifies songs with matching titles across different verticals
|
||||
and creates SongCanon entries to link them together.
|
||||
|
||||
Common shared songs in the jam scene:
|
||||
- Grateful Dead covers (Friend of the Devil, Dark Star, Scarlet Begonias)
|
||||
- Traditional songs (Amazing Grace, etc.)
|
||||
- Songs that multiple bands cover
|
||||
"""
|
||||
|
||||
from sqlmodel import Session, select
|
||||
from database import engine
|
||||
from models import Song, SongCanon, Vertical
|
||||
import re
|
||||
|
||||
|
||||
def normalize_title(title: str) -> str:
|
||||
"""Normalize song title for matching"""
|
||||
# Lowercase
|
||||
t = title.lower()
|
||||
# Remove common suffixes
|
||||
t = re.sub(r'\s*\(.*\)$', '', t) # Remove parenthetical notes
|
||||
t = re.sub(r'\s*->.*$', '', t) # Remove segue indicators
|
||||
t = re.sub(r'\s*>.*$', '', t) # Remove segue indicators
|
||||
# Remove special characters
|
||||
t = re.sub(r'[^\w\s]', '', t)
|
||||
# Normalize whitespace
|
||||
t = ' '.join(t.split())
|
||||
return t
|
||||
|
||||
|
||||
def generate_slug(title: str) -> str:
|
||||
"""Generate URL-safe slug from title"""
|
||||
slug = title.lower()
|
||||
slug = re.sub(r'[^\w\s-]', '', slug)
|
||||
slug = re.sub(r'[\s_]+', '-', slug)
|
||||
slug = re.sub(r'-+', '-', slug)
|
||||
return slug.strip('-')
|
||||
|
||||
|
||||
def find_shared_songs():
|
||||
"""Find songs that appear in multiple verticals"""
|
||||
print("Finding shared songs across bands...\n")
|
||||
|
||||
with Session(engine) as session:
|
||||
# Get all songs grouped by normalized title
|
||||
all_songs = session.exec(select(Song)).all()
|
||||
|
||||
# Group by normalized title
|
||||
title_groups = {}
|
||||
for song in all_songs:
|
||||
norm = normalize_title(song.title)
|
||||
if norm not in title_groups:
|
||||
title_groups[norm] = []
|
||||
title_groups[norm].append(song)
|
||||
|
||||
# Find songs that appear in multiple verticals
|
||||
shared = {}
|
||||
for norm_title, songs in title_groups.items():
|
||||
vertical_ids = set(s.vertical_id for s in songs)
|
||||
if len(vertical_ids) > 1:
|
||||
shared[norm_title] = songs
|
||||
|
||||
print(f"Found {len(shared)} songs shared across bands:\n")
|
||||
|
||||
for norm_title, songs in sorted(shared.items()):
|
||||
# Get band names
|
||||
bands = []
|
||||
for song in songs:
|
||||
vertical = session.get(Vertical, song.vertical_id)
|
||||
if vertical:
|
||||
bands.append(f"{vertical.name} ({song.title})")
|
||||
|
||||
print(f" {norm_title}")
|
||||
for band in bands:
|
||||
print(f" - {band}")
|
||||
print()
|
||||
|
||||
return shared
|
||||
|
||||
|
||||
def create_canon_links(dry_run: bool = True):
|
||||
"""Create SongCanon entries and link songs to them"""
|
||||
print(f"{'[DRY RUN] ' if dry_run else ''}Creating SongCanon links...\n")
|
||||
|
||||
with Session(engine) as session:
|
||||
shared = find_shared_songs()
|
||||
|
||||
created = 0
|
||||
linked = 0
|
||||
|
||||
for norm_title, songs in shared.items():
|
||||
# Use the most common title as the canonical title
|
||||
title_counts = {}
|
||||
for song in songs:
|
||||
t = song.title
|
||||
title_counts[t] = title_counts.get(t, 0) + 1
|
||||
|
||||
canonical_title = max(title_counts, key=title_counts.get)
|
||||
slug = generate_slug(canonical_title)
|
||||
|
||||
# Check if canon already exists
|
||||
existing = session.exec(
|
||||
select(SongCanon).where(SongCanon.slug == slug)
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
canon = existing
|
||||
print(f" Found existing: {canonical_title}")
|
||||
else:
|
||||
# Determine original artist
|
||||
original_artist = None
|
||||
for song in songs:
|
||||
if song.original_artist:
|
||||
original_artist = song.original_artist
|
||||
break
|
||||
|
||||
canon = SongCanon(
|
||||
title=canonical_title,
|
||||
slug=slug,
|
||||
original_artist=original_artist
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
session.add(canon)
|
||||
session.commit()
|
||||
session.refresh(canon)
|
||||
|
||||
created += 1
|
||||
print(f" Created canon: {canonical_title}")
|
||||
|
||||
# Link songs to canon
|
||||
for song in songs:
|
||||
if song.canon_id != (canon.id if canon.id else None):
|
||||
if not dry_run:
|
||||
song.canon_id = canon.id
|
||||
session.add(song)
|
||||
linked += 1
|
||||
|
||||
if not dry_run:
|
||||
session.commit()
|
||||
|
||||
print(f"\n{'Would create' if dry_run else 'Created'}: {created} canonical songs")
|
||||
print(f"{'Would link' if dry_run else 'Linked'}: {linked} songs")
|
||||
|
||||
if dry_run:
|
||||
print("\nRun with dry_run=False to apply changes.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "--apply":
|
||||
create_canon_links(dry_run=False)
|
||||
else:
|
||||
create_canon_links(dry_run=True)
|
||||
print("\nTo apply changes, run: python link_canon_songs.py --apply")
|
||||
|
|
@ -1,14 +1,20 @@
|
|||
from fastapi import FastAPI
|
||||
import os
|
||||
from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats, admin, chase, gamification, videos, musicians, sequences
|
||||
from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats, admin, chase, gamification, videos, musicians, sequences, verticals, canon, on_this_day, discover, bands, festivals, playlists, analytics, recommendations
|
||||
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
# Feature flags - set to False to disable features
|
||||
ENABLE_BUG_TRACKER = os.getenv("ENABLE_BUG_TRACKER", "true").lower() == "true"
|
||||
|
||||
from services.scheduler import start_scheduler
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
start_scheduler()
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # In production, set this to the frontend domain
|
||||
|
|
@ -44,6 +50,14 @@ app.include_router(gamification.router)
|
|||
app.include_router(videos.router)
|
||||
app.include_router(musicians.router)
|
||||
app.include_router(sequences.router)
|
||||
app.include_router(verticals.router)
|
||||
app.include_router(canon.router)
|
||||
app.include_router(on_this_day.router)
|
||||
app.include_router(discover.router)
|
||||
app.include_router(bands.router)
|
||||
app.include_router(festivals.router)
|
||||
app.include_router(playlists.router)
|
||||
app.include_router(analytics.router)
|
||||
|
||||
|
||||
# Optional features - can be disabled via env vars
|
||||
|
|
@ -51,6 +65,8 @@ if ENABLE_BUG_TRACKER:
|
|||
from routers import tickets
|
||||
app.include_router(tickets.router)
|
||||
|
||||
app.include_router(recommendations.router)
|
||||
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
return {"Hello": "World"}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from typing import List, Optional
|
||||
from sqlmodel import Field, Relationship, SQLModel
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
# --- Join Tables ---
|
||||
class Performance(SQLModel, table=True):
|
||||
|
|
@ -21,6 +22,7 @@ class Performance(SQLModel, table=True):
|
|||
nicknames: List["PerformanceNickname"] = Relationship(back_populates="performance")
|
||||
show: "Show" = Relationship(back_populates="performances")
|
||||
song: "Song" = Relationship()
|
||||
video_links: List["VideoPerformance"] = Relationship(back_populates="performance")
|
||||
|
||||
class ShowArtist(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
|
|
@ -54,6 +56,23 @@ class EntityTag(SQLModel, table=True):
|
|||
|
||||
# --- Core Entities ---
|
||||
|
||||
class VerticalScene(SQLModel, table=True):
|
||||
"""Join table linking verticals to scenes (many-to-many)"""
|
||||
vertical_id: int = Field(foreign_key="vertical.id", primary_key=True)
|
||||
scene_id: int = Field(foreign_key="scene.id", primary_key=True)
|
||||
|
||||
|
||||
class Scene(SQLModel, table=True):
|
||||
"""Genre/scene categorization for bands (e.g., 'Jam', 'Bluegrass', 'Dead Family')"""
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
name: str = Field(unique=True, index=True)
|
||||
slug: str = Field(unique=True, index=True)
|
||||
description: Optional[str] = Field(default=None)
|
||||
|
||||
# Relationships
|
||||
verticals: List["Vertical"] = Relationship(back_populates="scenes", link_model=VerticalScene)
|
||||
|
||||
|
||||
class Vertical(SQLModel, table=True):
|
||||
"""Represents a Fandom Vertical (e.g., 'Phish', 'Goose', 'Star Wars')"""
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
|
|
@ -64,12 +83,55 @@ class Vertical(SQLModel, table=True):
|
|||
# Link to primary artist/band for this vertical
|
||||
primary_artist_id: Optional[int] = Field(default=None, foreign_key="artist.id")
|
||||
|
||||
# Theming
|
||||
color: Optional[str] = Field(default=None, description="Hex color for branding")
|
||||
emoji: Optional[str] = Field(default=None, description="Display emoji")
|
||||
# Setlist.fm integration for universal import
|
||||
setlistfm_mbid: Optional[str] = Field(default=None, description="MusicBrainz ID for Setlist.fm")
|
||||
|
||||
# Admin/status fields
|
||||
is_active: bool = Field(default=True, description="Show in band selector")
|
||||
is_featured: bool = Field(default=False, description="Highlight in discovery")
|
||||
|
||||
# Branding
|
||||
logo_url: Optional[str] = Field(default=None, description="Band logo URL for UI")
|
||||
accent_color: Optional[str] = Field(default=None, description="Hex color for accents")
|
||||
|
||||
# Rich profile fields
|
||||
formed_year: Optional[int] = Field(default=None, description="Year band was formed")
|
||||
origin_city: Optional[str] = Field(default=None, description="City of origin")
|
||||
origin_state: Optional[str] = Field(default=None, description="State/province")
|
||||
origin_country: Optional[str] = Field(default=None, description="Country")
|
||||
long_description: Optional[str] = Field(default=None, description="Full band biography")
|
||||
|
||||
# Social/external links
|
||||
website_url: Optional[str] = Field(default=None)
|
||||
wikipedia_url: Optional[str] = Field(default=None)
|
||||
bandcamp_url: Optional[str] = Field(default=None)
|
||||
nugs_url: Optional[str] = Field(default=None)
|
||||
relisten_url: Optional[str] = Field(default=None)
|
||||
spotify_url: Optional[str] = Field(default=None)
|
||||
|
||||
# Relationships
|
||||
shows: List["Show"] = Relationship(back_populates="vertical")
|
||||
songs: List["Song"] = Relationship(back_populates="vertical")
|
||||
scenes: List["Scene"] = Relationship(back_populates="verticals", link_model=VerticalScene)
|
||||
user_preferences: List["UserVerticalPreference"] = Relationship(back_populates="vertical")
|
||||
|
||||
class VenueCanon(SQLModel, table=True):
|
||||
"""Canonical venue independent of band - enables cross-band venue linking (like SongCanon for songs)"""
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
name: str = Field(index=True)
|
||||
slug: str = Field(unique=True, index=True)
|
||||
city: str
|
||||
state: Optional[str] = Field(default=None)
|
||||
country: str = Field(default="USA")
|
||||
latitude: Optional[float] = Field(default=None)
|
||||
longitude: Optional[float] = Field(default=None)
|
||||
capacity: Optional[int] = Field(default=None)
|
||||
website_url: Optional[str] = Field(default=None)
|
||||
notes: Optional[str] = Field(default=None)
|
||||
|
||||
# All venue records that point to this canonical venue
|
||||
venues: List["Venue"] = Relationship(back_populates="canon")
|
||||
|
||||
|
||||
class Venue(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
|
|
@ -81,8 +143,13 @@ class Venue(SQLModel, table=True):
|
|||
capacity: Optional[int] = Field(default=None)
|
||||
notes: Optional[str] = Field(default=None)
|
||||
|
||||
# Link to canonical venue for cross-band deduplication
|
||||
canon_id: Optional[int] = Field(default=None, foreign_key="venuecanon.id")
|
||||
canon: Optional[VenueCanon] = Relationship(back_populates="venues")
|
||||
|
||||
shows: List["Show"] = Relationship(back_populates="venue")
|
||||
|
||||
|
||||
class Tour(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
name: str = Field(index=True)
|
||||
|
|
@ -93,6 +160,33 @@ class Tour(SQLModel, table=True):
|
|||
|
||||
shows: List["Show"] = Relationship(back_populates="tour")
|
||||
|
||||
|
||||
class Festival(SQLModel, table=True):
|
||||
"""Multi-band festivals that span multiple shows/dates (Bonnaroo, Lockn, etc.)"""
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
name: str = Field(index=True)
|
||||
slug: str = Field(unique=True, index=True)
|
||||
year: Optional[int] = Field(default=None, description="Festival year/edition")
|
||||
start_date: Optional[datetime] = None
|
||||
end_date: Optional[datetime] = None
|
||||
venue_id: Optional[int] = Field(default=None, foreign_key="venue.id")
|
||||
website_url: Optional[str] = Field(default=None)
|
||||
description: Optional[str] = Field(default=None)
|
||||
|
||||
# Relationships
|
||||
shows: List["ShowFestival"] = Relationship(back_populates="festival")
|
||||
|
||||
|
||||
class ShowFestival(SQLModel, table=True):
|
||||
"""Link table for shows at festivals (many-to-many)"""
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
show_id: int = Field(foreign_key="show.id")
|
||||
festival_id: int = Field(foreign_key="festival.id")
|
||||
stage: Optional[str] = Field(default=None, description="Which stage (Main, Second, etc.)")
|
||||
set_time: Optional[str] = Field(default=None, description="Scheduled time slot")
|
||||
|
||||
festival: Festival = Relationship(back_populates="shows")
|
||||
|
||||
class Artist(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
name: str = Field(index=True)
|
||||
|
|
@ -114,6 +208,17 @@ class Musician(SQLModel, table=True):
|
|||
primary_instrument: Optional[str] = Field(default=None)
|
||||
notes: Optional[str] = Field(default=None)
|
||||
|
||||
# Rich profile fields
|
||||
birth_year: Optional[int] = Field(default=None)
|
||||
origin_city: Optional[str] = Field(default=None)
|
||||
origin_state: Optional[str] = Field(default=None)
|
||||
origin_country: Optional[str] = Field(default=None)
|
||||
|
||||
# Social links
|
||||
website_url: Optional[str] = Field(default=None)
|
||||
wikipedia_url: Optional[str] = Field(default=None)
|
||||
instagram_url: Optional[str] = Field(default=None)
|
||||
|
||||
# Relationships
|
||||
memberships: List["BandMembership"] = Relationship(back_populates="musician")
|
||||
guest_appearances: List["PerformanceGuest"] = Relationship(back_populates="musician")
|
||||
|
|
@ -155,6 +260,7 @@ class Show(SQLModel, table=True):
|
|||
bandcamp_link: Optional[str] = Field(default=None)
|
||||
nugs_link: Optional[str] = Field(default=None)
|
||||
youtube_link: Optional[str] = Field(default=None)
|
||||
relisten_link: Optional[str] = Field(default=None, description="Link to Relisten.net or archive.org")
|
||||
|
||||
vertical: Vertical = Relationship(back_populates="shows")
|
||||
venue: Optional[Venue] = Relationship(back_populates="shows")
|
||||
|
|
@ -218,6 +324,29 @@ class Tag(SQLModel, table=True):
|
|||
name: str = Field(unique=True, index=True)
|
||||
slug: str = Field(unique=True, index=True)
|
||||
|
||||
class PreferenceTier(str, Enum):
|
||||
HEADLINER = "headliner"
|
||||
MAIN_STAGE = "main_stage"
|
||||
SUPPORTING = "supporting"
|
||||
IGNORED = "ignored" # Exclude from feeds, keep attribution mentions
|
||||
|
||||
class UserVerticalPreference(SQLModel, table=True):
|
||||
"""User preferences for which bands to display prominently vs. attribution-only"""
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id", index=True)
|
||||
vertical_id: int = Field(foreign_key="vertical.id", index=True)
|
||||
|
||||
# Preferences
|
||||
display_mode: str = Field(default="standard") # compact, standard, expanded
|
||||
priority: int = Field(default=0) # 0-100 sorting
|
||||
tier: PreferenceTier = Field(default=PreferenceTier.MAIN_STAGE)
|
||||
notify_on_show: bool = Field(default=True)
|
||||
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
user: "User" = Relationship(back_populates="vertical_preferences")
|
||||
vertical: "Vertical" = Relationship(back_populates="user_preferences")
|
||||
|
||||
class Attendance(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
|
|
@ -256,6 +385,23 @@ class Rating(SQLModel, table=True):
|
|||
|
||||
user: "User" = Relationship(back_populates="ratings")
|
||||
|
||||
class NotificationType(str, Enum):
|
||||
SHOW_ALERT = "SHOW_ALERT"
|
||||
SIT_IN_ALERT = "SIT_IN_ALERT"
|
||||
CHASE_SONG_ALERT = "CHASE_SONG_ALERT"
|
||||
|
||||
class Notification(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id", index=True)
|
||||
type: NotificationType
|
||||
title: str
|
||||
message: str
|
||||
link: Optional[str] = None
|
||||
is_read: bool = Field(default=False)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
user: "User" = Relationship(back_populates="notifications")
|
||||
|
||||
class User(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
email: str = Field(unique=True, index=True)
|
||||
|
|
@ -279,6 +425,12 @@ class User(SQLModel, table=True):
|
|||
streak_days: int = Field(default=0, description="Consecutive days active")
|
||||
last_activity: Optional[datetime] = Field(default=None)
|
||||
|
||||
# Social Identity
|
||||
bluesky_handle: Optional[str] = Field(default=None)
|
||||
mastodon_handle: Optional[str] = Field(default=None)
|
||||
instagram_handle: Optional[str] = Field(default=None)
|
||||
location: Optional[str] = Field(default=None, description="User's local scene/city")
|
||||
|
||||
# Custom Titles & Flair (tracker forum style)
|
||||
custom_title: Optional[str] = Field(default=None, description="Custom title chosen by user")
|
||||
title_color: Optional[str] = Field(default=None, description="Hex color for username display")
|
||||
|
|
@ -306,6 +458,36 @@ class User(SQLModel, table=True):
|
|||
preferences: Optional["UserPreferences"] = Relationship(back_populates="user", sa_relationship_kwargs={"uselist": False})
|
||||
reports: List["Report"] = Relationship(back_populates="user")
|
||||
notifications: List["Notification"] = Relationship(back_populates="user")
|
||||
playlists: List["UserPlaylist"] = Relationship(back_populates="user")
|
||||
vertical_preferences: List["UserVerticalPreference"] = Relationship(back_populates="user")
|
||||
|
||||
|
||||
class UserPlaylist(SQLModel, table=True):
|
||||
"""User-created curated collections of performances"""
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
name: str = Field(index=True)
|
||||
slug: str = Field(index=True)
|
||||
description: Optional[str] = Field(default=None)
|
||||
is_public: bool = Field(default=True)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
user: "User" = Relationship(back_populates="playlists")
|
||||
performances: List["PlaylistPerformance"] = Relationship(back_populates="playlist")
|
||||
|
||||
|
||||
class PlaylistPerformance(SQLModel, table=True):
|
||||
"""Link table for performances in playlists with ordering"""
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
playlist_id: int = Field(foreign_key="userplaylist.id")
|
||||
performance_id: int = Field(foreign_key="performance.id")
|
||||
position: int = Field(description="Order in playlist, 1-indexed")
|
||||
added_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
notes: Optional[str] = Field(default=None, description="User notes about this performance")
|
||||
|
||||
playlist: UserPlaylist = Relationship(back_populates="performances")
|
||||
|
||||
class Report(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
|
|
@ -371,18 +553,7 @@ class UserPreferences(SQLModel, table=True):
|
|||
|
||||
user: "User" = Relationship(back_populates="preferences")
|
||||
|
||||
class UserVerticalPreference(SQLModel, table=True):
|
||||
"""User preferences for which bands to display prominently vs. attribution-only"""
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id", index=True)
|
||||
vertical_id: int = Field(foreign_key="vertical.id", index=True)
|
||||
display_mode: str = Field(default="primary", description="primary, secondary, attribution_only, hidden")
|
||||
priority: int = Field(default=0, description="Sort order - lower = higher priority")
|
||||
notify_on_show: bool = Field(default=True, description="Notify when this band plays a show")
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
user: "User" = Relationship()
|
||||
vertical: "Vertical" = Relationship()
|
||||
|
||||
|
||||
class Profile(SQLModel, table=True):
|
||||
"""A user's identity within a specific context or global"""
|
||||
|
|
@ -398,10 +569,14 @@ class Group(SQLModel, table=True):
|
|||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
name: str = Field(index=True, unique=True)
|
||||
description: Optional[str] = None
|
||||
privacy: str = Field(default="public") # public, private
|
||||
privacy: str = Field(default="public") # public, private, invite_only
|
||||
created_by: int = Field(foreign_key="user.id")
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
# Vertical scoping (optional - null means cross-band group)
|
||||
vertical_id: Optional[int] = Field(default=None, foreign_key="vertical.id", index=True)
|
||||
image_url: Optional[str] = Field(default=None, description="Group logo/image URL")
|
||||
|
||||
members: List["GroupMember"] = Relationship(back_populates="group")
|
||||
posts: List["GroupPost"] = Relationship(back_populates="group")
|
||||
|
||||
|
|
@ -425,17 +600,6 @@ class GroupPost(SQLModel, table=True):
|
|||
group: Group = Relationship(back_populates="posts")
|
||||
user: User = Relationship()
|
||||
|
||||
class Notification(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id", index=True)
|
||||
type: str = Field(description="reply, mention, system")
|
||||
title: str
|
||||
message: str
|
||||
link: Optional[str] = None
|
||||
is_read: bool = Field(default=False)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
user: User = Relationship(back_populates="notifications")
|
||||
|
||||
class Reaction(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
|
|
@ -461,3 +625,99 @@ class ChaseSong(SQLModel, table=True):
|
|||
user: User = Relationship()
|
||||
song: "Song" = Relationship()
|
||||
|
||||
|
||||
# --- Video System ---
|
||||
|
||||
class VideoType(str, Enum):
|
||||
FULL_SHOW = "full_show" # Complete show recording
|
||||
SINGLE_SONG = "single_song" # Individual song performance
|
||||
SEQUENCE = "sequence" # Multi-song sequence
|
||||
INTERVIEW = "interview" # Artist interview
|
||||
DOCUMENTARY = "documentary" # Documentary/behind the scenes
|
||||
LIVE_STREAM = "live_stream" # Live stream recording
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class VideoPlatform(str, Enum):
|
||||
YOUTUBE = "youtube"
|
||||
VIMEO = "vimeo"
|
||||
NUGS = "nugs"
|
||||
BANDCAMP = "bandcamp"
|
||||
ARCHIVE = "archive" # archive.org
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class Video(SQLModel, table=True):
|
||||
"""Modular video entity that can be linked to multiple entities"""
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
url: str = Field(index=True, description="Full video URL")
|
||||
title: Optional[str] = Field(default=None, description="Video title")
|
||||
description: Optional[str] = Field(default=None)
|
||||
platform: VideoPlatform = Field(default=VideoPlatform.YOUTUBE)
|
||||
video_type: VideoType = Field(default=VideoType.SINGLE_SONG)
|
||||
|
||||
# Metadata
|
||||
duration_seconds: Optional[int] = Field(default=None)
|
||||
thumbnail_url: Optional[str] = Field(default=None)
|
||||
external_id: Optional[str] = Field(default=None, description="Platform-specific ID (e.g., YouTube video ID)")
|
||||
|
||||
# Timestamps
|
||||
recorded_date: Optional[datetime] = Field(default=None, description="When the video was recorded")
|
||||
published_date: Optional[datetime] = Field(default=None, description="When published to platform")
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
# Optional vertical scoping
|
||||
vertical_id: Optional[int] = Field(default=None, foreign_key="vertical.id", index=True)
|
||||
|
||||
# Relationships
|
||||
shows: List["VideoShow"] = Relationship(back_populates="video")
|
||||
performances: List["VideoPerformance"] = Relationship(back_populates="video")
|
||||
songs: List["VideoSong"] = Relationship(back_populates="video")
|
||||
musicians: List["VideoMusician"] = Relationship(back_populates="video")
|
||||
|
||||
|
||||
class VideoShow(SQLModel, table=True):
|
||||
"""Junction table linking videos to shows"""
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
video_id: int = Field(foreign_key="video.id", index=True)
|
||||
show_id: int = Field(foreign_key="show.id", index=True)
|
||||
notes: Optional[str] = Field(default=None, description="Context for this link")
|
||||
|
||||
video: Video = Relationship(back_populates="shows")
|
||||
show: "Show" = Relationship()
|
||||
|
||||
|
||||
class VideoPerformance(SQLModel, table=True):
|
||||
"""Junction table linking videos to specific performances"""
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
video_id: int = Field(foreign_key="video.id", index=True)
|
||||
performance_id: int = Field(foreign_key="performance.id", index=True)
|
||||
timestamp_start: Optional[int] = Field(default=None, description="Start time in seconds for this performance in the video")
|
||||
timestamp_end: Optional[int] = Field(default=None, description="End time in seconds")
|
||||
notes: Optional[str] = Field(default=None)
|
||||
|
||||
video: Video = Relationship(back_populates="performances")
|
||||
performance: "Performance" = Relationship(back_populates="video_links")
|
||||
|
||||
|
||||
class VideoSong(SQLModel, table=True):
|
||||
"""Junction table linking videos to songs (general, not performance-specific)"""
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
video_id: int = Field(foreign_key="video.id", index=True)
|
||||
song_id: int = Field(foreign_key="song.id", index=True)
|
||||
notes: Optional[str] = Field(default=None)
|
||||
|
||||
video: Video = Relationship(back_populates="songs")
|
||||
song: "Song" = Relationship()
|
||||
|
||||
|
||||
class VideoMusician(SQLModel, table=True):
|
||||
"""Junction table linking videos to musicians (for interviews, documentaries, etc.)"""
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
video_id: int = Field(foreign_key="video.id", index=True)
|
||||
musician_id: int = Field(foreign_key="musician.id", index=True)
|
||||
role: Optional[str] = Field(default=None, description="Role in video: 'featured', 'interview', 'performance'")
|
||||
notes: Optional[str] = Field(default=None)
|
||||
|
||||
video: Video = Relationship(back_populates="musicians")
|
||||
musician: "Musician" = Relationship()
|
||||
|
|
|
|||
|
|
@ -13,3 +13,5 @@ requests
|
|||
beautifulsoup4
|
||||
boto3
|
||||
email-validator
|
||||
apscheduler
|
||||
python-slugify
|
||||
|
|
|
|||
423
backend/routers/analytics.py
Normal file
423
backend/routers/analytics.py
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
"""
|
||||
Analytics API - Charts, Trends, Velocity, Gap Analysis.
|
||||
Deep insights into song performance patterns and band statistics.
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlmodel import Session, select, func, desc
|
||||
from pydantic import BaseModel
|
||||
from database import get_session
|
||||
from models import Song, Show, Performance, Vertical
|
||||
|
||||
router = APIRouter(prefix="/analytics", tags=["analytics"])
|
||||
|
||||
|
||||
class SongGapAnalysis(BaseModel):
|
||||
"""Gap analysis for a song - days since last played"""
|
||||
song_id: int
|
||||
song_title: str
|
||||
song_slug: str
|
||||
last_played: Optional[str]
|
||||
days_since_played: Optional[int]
|
||||
total_plays: int
|
||||
average_gap_days: Optional[float]
|
||||
|
||||
|
||||
class SongTrend(BaseModel):
|
||||
"""Play count trend over time periods"""
|
||||
period: str # "2024-Q1", "2024-06", etc.
|
||||
play_count: int
|
||||
|
||||
|
||||
class SongVelocity(BaseModel):
|
||||
"""Song velocity - frequency and recency metrics"""
|
||||
song_id: int
|
||||
song_title: str
|
||||
song_slug: str
|
||||
plays_last_30_days: int
|
||||
plays_last_90_days: int
|
||||
plays_last_year: int
|
||||
total_plays: int
|
||||
velocity_score: float # Higher = more frequently played recently
|
||||
|
||||
|
||||
class BandStats(BaseModel):
|
||||
"""Aggregate statistics for a band"""
|
||||
vertical_id: int
|
||||
vertical_name: str
|
||||
vertical_slug: str
|
||||
total_shows: int
|
||||
total_songs: int
|
||||
total_performances: int
|
||||
unique_songs_played: int
|
||||
avg_songs_per_show: float
|
||||
first_show: Optional[str]
|
||||
last_show: Optional[str]
|
||||
|
||||
|
||||
class MonthlyActivity(BaseModel):
|
||||
"""Monthly show/performance counts"""
|
||||
month: str
|
||||
show_count: int
|
||||
performance_count: int
|
||||
|
||||
|
||||
@router.get("/gaps/{vertical_slug}", response_model=List[SongGapAnalysis])
|
||||
def get_song_gaps(
|
||||
vertical_slug: str,
|
||||
min_plays: int = Query(default=5, description="Minimum plays to include"),
|
||||
limit: int = Query(default=50, le=200),
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""
|
||||
Get gap analysis for songs - how long since each song was last played.
|
||||
Useful for identifying songs that are "due" to be played.
|
||||
"""
|
||||
vertical = session.exec(
|
||||
select(Vertical).where(Vertical.slug == vertical_slug)
|
||||
).first()
|
||||
if not vertical:
|
||||
raise HTTPException(status_code=404, detail="Band not found")
|
||||
|
||||
# Get all songs for this vertical with play counts
|
||||
songs = session.exec(
|
||||
select(Song).where(Song.vertical_id == vertical.id)
|
||||
).all()
|
||||
|
||||
today = datetime.now().date()
|
||||
results = []
|
||||
|
||||
for song in songs:
|
||||
# Get performances for this song
|
||||
performances = session.exec(
|
||||
select(Performance)
|
||||
.join(Show)
|
||||
.where(Performance.song_id == song.id)
|
||||
.where(Show.date.isnot(None))
|
||||
.order_by(Show.date.desc())
|
||||
).all()
|
||||
|
||||
if len(performances) < min_plays:
|
||||
continue
|
||||
|
||||
# Get show dates for gap calculation
|
||||
show_dates = []
|
||||
for perf in performances:
|
||||
show = session.get(Show, perf.show_id)
|
||||
if show and show.date:
|
||||
show_dates.append(show.date.date() if hasattr(show.date, 'date') else show.date)
|
||||
|
||||
if not show_dates:
|
||||
continue
|
||||
|
||||
show_dates.sort(reverse=True)
|
||||
last_played = show_dates[0]
|
||||
days_since = (today - last_played).days
|
||||
|
||||
# Calculate average gap between plays
|
||||
avg_gap = None
|
||||
if len(show_dates) > 1:
|
||||
gaps = [(show_dates[i] - show_dates[i+1]).days for i in range(len(show_dates)-1)]
|
||||
avg_gap = sum(gaps) / len(gaps)
|
||||
|
||||
results.append(SongGapAnalysis(
|
||||
song_id=song.id,
|
||||
song_title=song.title,
|
||||
song_slug=song.slug or "",
|
||||
last_played=last_played.strftime("%Y-%m-%d"),
|
||||
days_since_played=days_since,
|
||||
total_plays=len(performances),
|
||||
average_gap_days=round(avg_gap, 1) if avg_gap else None
|
||||
))
|
||||
|
||||
# Sort by days since played (longest gaps first)
|
||||
results.sort(key=lambda x: x.days_since_played or 0, reverse=True)
|
||||
return results[:limit]
|
||||
|
||||
|
||||
@router.get("/velocity/{vertical_slug}", response_model=List[SongVelocity])
|
||||
def get_song_velocity(
|
||||
vertical_slug: str,
|
||||
limit: int = Query(default=50, le=200),
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""
|
||||
Get song velocity - which songs are hot right now vs cooling down.
|
||||
Higher velocity score = more frequently played recently.
|
||||
"""
|
||||
vertical = session.exec(
|
||||
select(Vertical).where(Vertical.slug == vertical_slug)
|
||||
).first()
|
||||
if not vertical:
|
||||
raise HTTPException(status_code=404, detail="Band not found")
|
||||
|
||||
today = datetime.now()
|
||||
thirty_days_ago = today - timedelta(days=30)
|
||||
ninety_days_ago = today - timedelta(days=90)
|
||||
one_year_ago = today - timedelta(days=365)
|
||||
|
||||
songs = session.exec(
|
||||
select(Song).where(Song.vertical_id == vertical.id)
|
||||
).all()
|
||||
|
||||
results = []
|
||||
for song in songs:
|
||||
# Get all performances with show dates
|
||||
performances = session.exec(
|
||||
select(Performance, Show)
|
||||
.join(Show)
|
||||
.where(Performance.song_id == song.id)
|
||||
.where(Show.date.isnot(None))
|
||||
).all()
|
||||
|
||||
if not performances:
|
||||
continue
|
||||
|
||||
plays_30 = 0
|
||||
plays_90 = 0
|
||||
plays_year = 0
|
||||
total = len(performances)
|
||||
|
||||
for perf, show in performances:
|
||||
if show.date >= thirty_days_ago:
|
||||
plays_30 += 1
|
||||
if show.date >= ninety_days_ago:
|
||||
plays_90 += 1
|
||||
if show.date >= one_year_ago:
|
||||
plays_year += 1
|
||||
|
||||
# Velocity score: weighted recent plays (30d = 3x, 90d = 2x, year = 1x)
|
||||
velocity = (plays_30 * 3) + (plays_90 * 2) + plays_year
|
||||
|
||||
results.append(SongVelocity(
|
||||
song_id=song.id,
|
||||
song_title=song.title,
|
||||
song_slug=song.slug or "",
|
||||
plays_last_30_days=plays_30,
|
||||
plays_last_90_days=plays_90,
|
||||
plays_last_year=plays_year,
|
||||
total_plays=total,
|
||||
velocity_score=velocity
|
||||
))
|
||||
|
||||
# Sort by velocity (hottest songs first)
|
||||
results.sort(key=lambda x: x.velocity_score, reverse=True)
|
||||
return results[:limit]
|
||||
|
||||
|
||||
@router.get("/trends/{vertical_slug}")
|
||||
def get_show_trends(
|
||||
vertical_slug: str,
|
||||
period: str = Query(default="month", description="month or quarter"),
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""
|
||||
Get show activity trends over time - monthly or quarterly aggregates.
|
||||
"""
|
||||
vertical = session.exec(
|
||||
select(Vertical).where(Vertical.slug == vertical_slug)
|
||||
).first()
|
||||
if not vertical:
|
||||
raise HTTPException(status_code=404, detail="Band not found")
|
||||
|
||||
shows = session.exec(
|
||||
select(Show)
|
||||
.where(Show.vertical_id == vertical.id)
|
||||
.where(Show.date.isnot(None))
|
||||
.order_by(Show.date)
|
||||
).all()
|
||||
|
||||
# Group by period
|
||||
trends = {}
|
||||
for show in shows:
|
||||
if period == "quarter":
|
||||
q = (show.date.month - 1) // 3 + 1
|
||||
key = f"{show.date.year}-Q{q}"
|
||||
else:
|
||||
key = show.date.strftime("%Y-%m")
|
||||
|
||||
if key not in trends:
|
||||
trends[key] = {"shows": 0, "performances": 0}
|
||||
trends[key]["shows"] += 1
|
||||
|
||||
# Count performances in this show
|
||||
perf_count = len(session.exec(
|
||||
select(Performance).where(Performance.show_id == show.id)
|
||||
).all())
|
||||
trends[key]["performances"] += perf_count
|
||||
|
||||
return {
|
||||
"vertical": vertical.name,
|
||||
"period_type": period,
|
||||
"trends": [
|
||||
{"period": k, "shows": v["shows"], "performances": v["performances"]}
|
||||
for k, v in sorted(trends.items())
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats/{vertical_slug}", response_model=BandStats)
|
||||
def get_band_stats(
|
||||
vertical_slug: str,
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""Get aggregate statistics for a band."""
|
||||
vertical = session.exec(
|
||||
select(Vertical).where(Vertical.slug == vertical_slug)
|
||||
).first()
|
||||
if not vertical:
|
||||
raise HTTPException(status_code=404, detail="Band not found")
|
||||
|
||||
# Total shows
|
||||
shows = session.exec(
|
||||
select(Show)
|
||||
.where(Show.vertical_id == vertical.id)
|
||||
.order_by(Show.date)
|
||||
).all()
|
||||
|
||||
# Total unique songs
|
||||
songs = session.exec(
|
||||
select(Song).where(Song.vertical_id == vertical.id)
|
||||
).all()
|
||||
|
||||
# Total performances
|
||||
show_ids = [s.id for s in shows]
|
||||
total_perfs = 0
|
||||
unique_songs_played = set()
|
||||
|
||||
if show_ids:
|
||||
all_perfs = session.exec(
|
||||
select(Performance).where(Performance.show_id.in_(show_ids))
|
||||
).all()
|
||||
total_perfs = len(all_perfs)
|
||||
unique_songs_played = set(p.song_id for p in all_perfs if p.song_id)
|
||||
|
||||
# Date range
|
||||
dated_shows = [s for s in shows if s.date]
|
||||
first_show = min(s.date for s in dated_shows).strftime("%Y-%m-%d") if dated_shows else None
|
||||
last_show = max(s.date for s in dated_shows).strftime("%Y-%m-%d") if dated_shows else None
|
||||
|
||||
avg_songs = total_perfs / len(shows) if shows else 0
|
||||
|
||||
return BandStats(
|
||||
vertical_id=vertical.id,
|
||||
vertical_name=vertical.name,
|
||||
vertical_slug=vertical.slug,
|
||||
total_shows=len(shows),
|
||||
total_songs=len(songs),
|
||||
total_performances=total_perfs,
|
||||
unique_songs_played=len(unique_songs_played),
|
||||
avg_songs_per_show=round(avg_songs, 1),
|
||||
first_show=first_show,
|
||||
last_show=last_show
|
||||
)
|
||||
|
||||
|
||||
@router.get("/bustouts/{vertical_slug}")
|
||||
def get_bustouts(
|
||||
vertical_slug: str,
|
||||
days: int = Query(default=365, description="Look back period in days"),
|
||||
gap_threshold: int = Query(default=180, description="Minimum gap days to count as bustout"),
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""
|
||||
Find bustouts - songs that returned after a long gap.
|
||||
"""
|
||||
vertical = session.exec(
|
||||
select(Vertical).where(Vertical.slug == vertical_slug)
|
||||
).first()
|
||||
if not vertical:
|
||||
raise HTTPException(status_code=404, detail="Band not found")
|
||||
|
||||
cutoff_date = datetime.now() - timedelta(days=days)
|
||||
|
||||
songs = session.exec(
|
||||
select(Song).where(Song.vertical_id == vertical.id)
|
||||
).all()
|
||||
|
||||
bustouts = []
|
||||
for song in songs:
|
||||
# Get performances ordered by date
|
||||
perfs_with_shows = session.exec(
|
||||
select(Performance, Show)
|
||||
.join(Show)
|
||||
.where(Performance.song_id == song.id)
|
||||
.where(Show.date.isnot(None))
|
||||
.order_by(Show.date)
|
||||
).all()
|
||||
|
||||
if len(perfs_with_shows) < 2:
|
||||
continue
|
||||
|
||||
# Look for gaps > threshold followed by a play in the period
|
||||
for i in range(1, len(perfs_with_shows)):
|
||||
prev_show = perfs_with_shows[i-1][1]
|
||||
curr_show = perfs_with_shows[i][1]
|
||||
|
||||
gap = (curr_show.date - prev_show.date).days
|
||||
|
||||
if gap >= gap_threshold and curr_show.date >= cutoff_date:
|
||||
bustouts.append({
|
||||
"song_title": song.title,
|
||||
"song_slug": song.slug,
|
||||
"bustout_date": curr_show.date.strftime("%Y-%m-%d"),
|
||||
"show_slug": curr_show.slug,
|
||||
"gap_days": gap,
|
||||
"previous_play": prev_show.date.strftime("%Y-%m-%d")
|
||||
})
|
||||
|
||||
# Sort by gap (biggest bustouts first)
|
||||
bustouts.sort(key=lambda x: x["gap_days"], reverse=True)
|
||||
return {"vertical": vertical.name, "threshold_days": gap_threshold, "bustouts": bustouts}
|
||||
|
||||
|
||||
@router.get("/debut-songs/{vertical_slug}")
|
||||
def get_debut_songs(
|
||||
vertical_slug: str,
|
||||
days: int = Query(default=365, description="Look back period"),
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""Find songs that debuted (first ever play) within the period."""
|
||||
vertical = session.exec(
|
||||
select(Vertical).where(Vertical.slug == vertical_slug)
|
||||
).first()
|
||||
if not vertical:
|
||||
raise HTTPException(status_code=404, detail="Band not found")
|
||||
|
||||
cutoff_date = datetime.now() - timedelta(days=days)
|
||||
|
||||
songs = session.exec(
|
||||
select(Song).where(Song.vertical_id == vertical.id)
|
||||
).all()
|
||||
|
||||
debuts = []
|
||||
for song in songs:
|
||||
# Find first performance
|
||||
first_perf = session.exec(
|
||||
select(Performance, Show)
|
||||
.join(Show)
|
||||
.where(Performance.song_id == song.id)
|
||||
.where(Show.date.isnot(None))
|
||||
.order_by(Show.date)
|
||||
).first()
|
||||
|
||||
if first_perf:
|
||||
perf, show = first_perf
|
||||
if show.date >= cutoff_date:
|
||||
# Count total plays
|
||||
total = len(session.exec(
|
||||
select(Performance).where(Performance.song_id == song.id)
|
||||
).all())
|
||||
|
||||
debuts.append({
|
||||
"song_title": song.title,
|
||||
"song_slug": song.slug,
|
||||
"debut_date": show.date.strftime("%Y-%m-%d"),
|
||||
"show_slug": show.slug,
|
||||
"times_played_since": total
|
||||
})
|
||||
|
||||
# Sort by debut date (newest first)
|
||||
debuts.sort(key=lambda x: x["debut_date"], reverse=True)
|
||||
return {"vertical": vertical.name, "period_days": days, "debuts": debuts}
|
||||
|
|
@ -82,3 +82,45 @@ def get_show_attendance(
|
|||
.offset(offset)
|
||||
.limit(limit)
|
||||
).all()
|
||||
|
||||
|
||||
@router.get("/me/stats")
|
||||
def get_my_attendance_stats(
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get attendance statistics grouped by band"""
|
||||
from models import Vertical
|
||||
|
||||
attendances = session.exec(
|
||||
select(Attendance).where(Attendance.user_id == current_user.id)
|
||||
).all()
|
||||
|
||||
total = len(attendances)
|
||||
by_vertical = {}
|
||||
years = set()
|
||||
|
||||
for a in attendances:
|
||||
show = session.get(Show, a.show_id)
|
||||
if not show:
|
||||
continue
|
||||
|
||||
if show.date:
|
||||
years.add(show.date.year)
|
||||
|
||||
vertical = session.get(Vertical, show.vertical_id)
|
||||
if vertical:
|
||||
if vertical.slug not in by_vertical:
|
||||
by_vertical[vertical.slug] = {
|
||||
"name": vertical.name,
|
||||
"count": 0
|
||||
}
|
||||
by_vertical[vertical.slug]["count"] += 1
|
||||
|
||||
return {
|
||||
"total_shows": total,
|
||||
"by_vertical": by_vertical,
|
||||
"years_attended": sorted(years, reverse=True),
|
||||
"year_count": len(years)
|
||||
}
|
||||
|
||||
|
|
|
|||
112
backend/routers/bands.py
Normal file
112
backend/routers/bands.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select, func
|
||||
from typing import List, Optional
|
||||
from database import get_session
|
||||
from models import (
|
||||
Vertical, Artist, Musician, BandMembership,
|
||||
PerformanceGuest, Performance, Show, Song, Venue
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/bands", tags=["bands"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_bands(
|
||||
scene: Optional[str] = None,
|
||||
is_featured: Optional[bool] = None,
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""List all active bands, optionally filtered"""
|
||||
query = select(Vertical).where(Vertical.is_active == True)
|
||||
|
||||
if is_featured is not None:
|
||||
query = query.where(Vertical.is_featured == is_featured)
|
||||
|
||||
bands = session.exec(query.order_by(Vertical.name)).all()
|
||||
return bands
|
||||
|
||||
|
||||
@router.get("/{slug}")
|
||||
async def get_band_profile(slug: str, session: Session = Depends(get_session)):
|
||||
"""Get comprehensive band profile including members and stats"""
|
||||
vertical = session.exec(
|
||||
select(Vertical).where(Vertical.slug == slug)
|
||||
).first()
|
||||
|
||||
if not vertical:
|
||||
raise HTTPException(status_code=404, detail="Band not found")
|
||||
|
||||
# Get members via BandMembership if primary_artist_id exists
|
||||
current_members = []
|
||||
past_members = []
|
||||
|
||||
if vertical.primary_artist_id:
|
||||
# Get all memberships for this band's artist
|
||||
memberships = session.exec(
|
||||
select(BandMembership, Musician)
|
||||
.join(Musician, BandMembership.musician_id == Musician.id)
|
||||
.where(BandMembership.artist_id == vertical.primary_artist_id)
|
||||
.order_by(BandMembership.start_date)
|
||||
).all()
|
||||
|
||||
for membership, musician in memberships:
|
||||
member_data = {
|
||||
"id": musician.id,
|
||||
"name": musician.name,
|
||||
"slug": musician.slug,
|
||||
"image_url": musician.image_url,
|
||||
"role": membership.role,
|
||||
"primary_instrument": musician.primary_instrument,
|
||||
"start_date": membership.start_date,
|
||||
"end_date": membership.end_date,
|
||||
"notes": membership.notes,
|
||||
}
|
||||
if membership.end_date is None:
|
||||
current_members.append(member_data)
|
||||
else:
|
||||
past_members.append(member_data)
|
||||
|
||||
# Get stats
|
||||
show_count = session.exec(
|
||||
select(func.count(Show.id)).where(Show.vertical_id == vertical.id)
|
||||
).one()
|
||||
|
||||
song_count = session.exec(
|
||||
select(func.count(Song.id)).where(Song.vertical_id == vertical.id)
|
||||
).one()
|
||||
|
||||
# Get venue count (distinct venues from shows)
|
||||
venue_count = session.exec(
|
||||
select(func.count(func.distinct(Show.venue_id)))
|
||||
.where(Show.vertical_id == vertical.id)
|
||||
).one()
|
||||
|
||||
# Get first and last show dates
|
||||
first_show = session.exec(
|
||||
select(Show.date)
|
||||
.where(Show.vertical_id == vertical.id)
|
||||
.order_by(Show.date.asc())
|
||||
.limit(1)
|
||||
).first()
|
||||
|
||||
last_show = session.exec(
|
||||
select(Show.date)
|
||||
.where(Show.vertical_id == vertical.id)
|
||||
.order_by(Show.date.desc())
|
||||
.limit(1)
|
||||
).first()
|
||||
|
||||
stats = {
|
||||
"total_shows": show_count,
|
||||
"total_songs": song_count,
|
||||
"total_venues": venue_count,
|
||||
"first_show": first_show,
|
||||
"last_show": last_show,
|
||||
}
|
||||
|
||||
return {
|
||||
"band": vertical,
|
||||
"current_members": current_members,
|
||||
"past_members": past_members,
|
||||
"stats": stats,
|
||||
}
|
||||
142
backend/routers/canon.py
Normal file
142
backend/routers/canon.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
from typing import List
|
||||
from database import get_session
|
||||
from models import SongCanon, Song, Vertical
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/canon", tags=["canon"])
|
||||
|
||||
|
||||
class SongVersionRead(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
slug: str | None
|
||||
vertical_id: int
|
||||
vertical_name: str
|
||||
vertical_slug: str
|
||||
|
||||
|
||||
class SongCanonRead(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
slug: str
|
||||
original_artist: str | None
|
||||
notes: str | None
|
||||
versions: List[SongVersionRead]
|
||||
|
||||
|
||||
class SongCanonCreate(BaseModel):
|
||||
title: str
|
||||
slug: str
|
||||
original_artist: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
@router.get("/", response_model=List[SongCanonRead])
|
||||
def list_canon_songs(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""List all canonical songs with their cross-band versions"""
|
||||
canons = session.exec(
|
||||
select(SongCanon).offset(offset).limit(limit)
|
||||
).all()
|
||||
|
||||
result = []
|
||||
for canon in canons:
|
||||
versions = []
|
||||
songs = session.exec(
|
||||
select(Song).where(Song.canon_id == canon.id)
|
||||
).all()
|
||||
|
||||
for song in songs:
|
||||
vertical = session.get(Vertical, song.vertical_id)
|
||||
versions.append({
|
||||
"id": song.id,
|
||||
"title": song.title,
|
||||
"slug": song.slug,
|
||||
"vertical_id": song.vertical_id,
|
||||
"vertical_name": vertical.name if vertical else "Unknown",
|
||||
"vertical_slug": vertical.slug if vertical else "unknown"
|
||||
})
|
||||
|
||||
result.append({
|
||||
"id": canon.id,
|
||||
"title": canon.title,
|
||||
"slug": canon.slug,
|
||||
"original_artist": canon.original_artist,
|
||||
"notes": canon.notes,
|
||||
"versions": versions
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{slug}", response_model=SongCanonRead)
|
||||
def get_canon_song(slug: str, session: Session = Depends(get_session)):
|
||||
"""Get a canonical song with all its band-specific versions"""
|
||||
canon = session.exec(
|
||||
select(SongCanon).where(SongCanon.slug == slug)
|
||||
).first()
|
||||
|
||||
if not canon:
|
||||
raise HTTPException(status_code=404, detail="Canonical song not found")
|
||||
|
||||
versions = []
|
||||
songs = session.exec(
|
||||
select(Song).where(Song.canon_id == canon.id)
|
||||
).all()
|
||||
|
||||
for song in songs:
|
||||
vertical = session.get(Vertical, song.vertical_id)
|
||||
versions.append({
|
||||
"id": song.id,
|
||||
"title": song.title,
|
||||
"slug": song.slug,
|
||||
"vertical_id": song.vertical_id,
|
||||
"vertical_name": vertical.name if vertical else "Unknown",
|
||||
"vertical_slug": vertical.slug if vertical else "unknown"
|
||||
})
|
||||
|
||||
return {
|
||||
"id": canon.id,
|
||||
"title": canon.title,
|
||||
"slug": canon.slug,
|
||||
"original_artist": canon.original_artist,
|
||||
"notes": canon.notes,
|
||||
"versions": versions
|
||||
}
|
||||
|
||||
|
||||
@router.get("/song/{song_id}/related", response_model=List[SongVersionRead])
|
||||
def get_related_versions(song_id: int, session: Session = Depends(get_session)):
|
||||
"""Get all versions of the same song across bands"""
|
||||
song = session.get(Song, song_id)
|
||||
if not song:
|
||||
raise HTTPException(status_code=404, detail="Song not found")
|
||||
|
||||
if not song.canon_id:
|
||||
return []
|
||||
|
||||
# Get all songs with same canon_id (excluding this one)
|
||||
related = session.exec(
|
||||
select(Song)
|
||||
.where(Song.canon_id == song.canon_id)
|
||||
.where(Song.id != song_id)
|
||||
).all()
|
||||
|
||||
result = []
|
||||
for s in related:
|
||||
vertical = session.get(Vertical, s.vertical_id)
|
||||
result.append({
|
||||
"id": s.id,
|
||||
"title": s.title,
|
||||
"slug": s.slug,
|
||||
"vertical_id": s.vertical_id,
|
||||
"vertical_name": vertical.name if vertical else "Unknown",
|
||||
"vertical_slug": vertical.slug if vertical else "unknown"
|
||||
})
|
||||
|
||||
return result
|
||||
190
backend/routers/discover.py
Normal file
190
backend/routers/discover.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
"""
|
||||
Show Discovery API - smart routing for finding shows.
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlmodel import Session, select, desc
|
||||
from pydantic import BaseModel
|
||||
from database import get_session
|
||||
from models import Show, Venue, Vertical, Tour
|
||||
|
||||
router = APIRouter(prefix="/discover", tags=["discover"])
|
||||
|
||||
|
||||
class DiscoverShow(BaseModel):
|
||||
id: int
|
||||
date: str
|
||||
slug: str | None
|
||||
venue_name: str | None
|
||||
venue_city: str | None
|
||||
vertical_name: str
|
||||
vertical_slug: str
|
||||
tour_name: str | None = None
|
||||
|
||||
|
||||
class DiscoverResponse(BaseModel):
|
||||
shows: List[DiscoverShow]
|
||||
total: int
|
||||
filters_applied: dict
|
||||
|
||||
|
||||
@router.get("/shows", response_model=DiscoverResponse)
|
||||
def discover_shows(
|
||||
vertical: Optional[str] = None,
|
||||
year: Optional[int] = None,
|
||||
month: Optional[int] = None,
|
||||
venue: Optional[str] = None,
|
||||
tour: Optional[str] = None,
|
||||
city: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
sort: str = "date_desc",
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""
|
||||
Discover shows with smart filtering.
|
||||
|
||||
Sort options: date_desc, date_asc
|
||||
"""
|
||||
query = select(Show).where(Show.date.isnot(None))
|
||||
|
||||
filters = {}
|
||||
|
||||
# Filter by vertical (band)
|
||||
if vertical:
|
||||
v = session.exec(select(Vertical).where(Vertical.slug == vertical)).first()
|
||||
if v:
|
||||
query = query.where(Show.vertical_id == v.id)
|
||||
filters["vertical"] = vertical
|
||||
|
||||
# Filter by year
|
||||
if year:
|
||||
start = date(year, 1, 1)
|
||||
end = date(year, 12, 31)
|
||||
query = query.where(Show.date >= start).where(Show.date <= end)
|
||||
filters["year"] = year
|
||||
|
||||
# Filter by month (requires year)
|
||||
if month and year:
|
||||
start = date(year, month, 1)
|
||||
if month == 12:
|
||||
end = date(year + 1, 1, 1) - timedelta(days=1)
|
||||
else:
|
||||
end = date(year, month + 1, 1) - timedelta(days=1)
|
||||
query = query.where(Show.date >= start).where(Show.date <= end)
|
||||
filters["month"] = month
|
||||
|
||||
# Filter by tour
|
||||
if tour:
|
||||
t = session.exec(select(Tour).where(Tour.slug == tour)).first()
|
||||
if t:
|
||||
query = query.where(Show.tour_id == t.id)
|
||||
filters["tour"] = tour
|
||||
|
||||
# Apply sorting
|
||||
if sort == "date_asc":
|
||||
query = query.order_by(Show.date)
|
||||
else:
|
||||
query = query.order_by(desc(Show.date))
|
||||
|
||||
# Get total count before pagination
|
||||
all_shows = session.exec(query).all()
|
||||
total = len(all_shows)
|
||||
|
||||
# Apply pagination
|
||||
paginated = all_shows[offset:offset + limit]
|
||||
|
||||
# Filter by venue/city/state in Python (more flexible)
|
||||
results = []
|
||||
for show in paginated:
|
||||
venue_obj = session.get(Venue, show.venue_id) if show.venue_id else None
|
||||
vert_obj = session.get(Vertical, show.vertical_id)
|
||||
tour_obj = session.get(Tour, show.tour_id) if show.tour_id else None
|
||||
|
||||
# City/state filters
|
||||
if city and venue_obj and city.lower() not in venue_obj.city.lower():
|
||||
continue
|
||||
if state and venue_obj and venue_obj.state and state.lower() not in venue_obj.state.lower():
|
||||
continue
|
||||
if venue and venue_obj and venue.lower() not in venue_obj.name.lower():
|
||||
continue
|
||||
|
||||
results.append(DiscoverShow(
|
||||
id=show.id,
|
||||
date=show.date.strftime("%Y-%m-%d") if show.date else "",
|
||||
slug=show.slug,
|
||||
venue_name=venue_obj.name if venue_obj else None,
|
||||
venue_city=venue_obj.city if venue_obj else None,
|
||||
vertical_name=vert_obj.name if vert_obj else "Unknown",
|
||||
vertical_slug=vert_obj.slug if vert_obj else "unknown",
|
||||
tour_name=tour_obj.name if tour_obj else None
|
||||
))
|
||||
|
||||
if city:
|
||||
filters["city"] = city
|
||||
if state:
|
||||
filters["state"] = state
|
||||
if venue:
|
||||
filters["venue"] = venue
|
||||
|
||||
return DiscoverResponse(
|
||||
shows=results,
|
||||
total=total,
|
||||
filters_applied=filters
|
||||
)
|
||||
|
||||
|
||||
@router.get("/years")
|
||||
def get_available_years(
|
||||
vertical: Optional[str] = None,
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""Get list of years with shows for filtering UI"""
|
||||
query = select(Show).where(Show.date.isnot(None))
|
||||
|
||||
if vertical:
|
||||
v = session.exec(select(Vertical).where(Vertical.slug == vertical)).first()
|
||||
if v:
|
||||
query = query.where(Show.vertical_id == v.id)
|
||||
|
||||
shows = session.exec(query).all()
|
||||
years = sorted(set(s.date.year for s in shows if s.date), reverse=True)
|
||||
|
||||
return {"years": years}
|
||||
|
||||
|
||||
@router.get("/recent", response_model=List[DiscoverShow])
|
||||
def get_recent_shows(
|
||||
limit: int = 10,
|
||||
vertical: Optional[str] = None,
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""Get most recent shows for quick discovery"""
|
||||
query = select(Show).where(Show.date.isnot(None))
|
||||
|
||||
if vertical:
|
||||
v = session.exec(select(Vertical).where(Vertical.slug == vertical)).first()
|
||||
if v:
|
||||
query = query.where(Show.vertical_id == v.id)
|
||||
|
||||
query = query.order_by(desc(Show.date)).limit(limit)
|
||||
shows = session.exec(query).all()
|
||||
|
||||
results = []
|
||||
for show in shows:
|
||||
venue = session.get(Venue, show.venue_id) if show.venue_id else None
|
||||
vert = session.get(Vertical, show.vertical_id)
|
||||
|
||||
results.append(DiscoverShow(
|
||||
id=show.id,
|
||||
date=show.date.strftime("%Y-%m-%d") if show.date else "",
|
||||
slug=show.slug,
|
||||
venue_name=venue.name if venue else None,
|
||||
venue_city=venue.city if venue else None,
|
||||
vertical_name=vert.name if vert else "Unknown",
|
||||
vertical_slug=vert.slug if vert else "unknown"
|
||||
))
|
||||
|
||||
return results
|
||||
|
|
@ -5,6 +5,7 @@ from database import get_session
|
|||
from models import Review, Attendance, GroupPost, User, Profile, Performance, Show, Song
|
||||
from schemas import ReviewRead, AttendanceRead, GroupPostRead
|
||||
from datetime import datetime
|
||||
from auth import get_current_user_optional
|
||||
|
||||
router = APIRouter(prefix="/feed", tags=["feed"])
|
||||
|
||||
|
|
@ -129,3 +130,92 @@ def get_global_feed(
|
|||
feed_items.sort(key=lambda x: x.timestamp, reverse=True)
|
||||
|
||||
return feed_items[:limit]
|
||||
|
||||
|
||||
@router.get("/me", response_model=List[FeedItem])
|
||||
def get_personalized_feed(
|
||||
limit: int = 20,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user_optional)
|
||||
):
|
||||
"""Get feed filtered by user's band preferences (or all bands if not logged in)"""
|
||||
from models import UserVerticalPreference, Vertical
|
||||
|
||||
# Get user's preferred vertical IDs
|
||||
preferred_vertical_ids = None
|
||||
if current_user:
|
||||
prefs = session.exec(
|
||||
select(UserVerticalPreference)
|
||||
.where(UserVerticalPreference.user_id == current_user.id)
|
||||
.where(UserVerticalPreference.display_mode != "hidden")
|
||||
).all()
|
||||
if prefs:
|
||||
preferred_vertical_ids = [p.vertical_id for p in prefs]
|
||||
|
||||
# Fetch reviews (filtered by vertical if user has preferences)
|
||||
review_query = select(Review).order_by(desc(Review.created_at)).limit(limit * 2)
|
||||
reviews = session.exec(review_query).all()
|
||||
|
||||
# Fetch attendance (filtered by show's vertical)
|
||||
attendance_query = select(Attendance).order_by(desc(Attendance.created_at)).limit(limit * 2)
|
||||
attendance = session.exec(attendance_query).all()
|
||||
|
||||
feed_items = []
|
||||
|
||||
for r in reviews:
|
||||
# Filter by vertical if user has preferences
|
||||
vertical_id = None
|
||||
if r.performance_id:
|
||||
perf = session.get(Performance, r.performance_id)
|
||||
if perf:
|
||||
show = session.get(Show, perf.show_id)
|
||||
if show:
|
||||
vertical_id = show.vertical_id
|
||||
elif r.show_id:
|
||||
show = session.get(Show, r.show_id)
|
||||
if show:
|
||||
vertical_id = show.vertical_id
|
||||
elif r.song_id:
|
||||
song = session.get(Song, r.song_id)
|
||||
if song:
|
||||
vertical_id = song.vertical_id
|
||||
|
||||
if preferred_vertical_ids and vertical_id and vertical_id not in preferred_vertical_ids:
|
||||
continue
|
||||
|
||||
feed_items.append(FeedItem(
|
||||
type="review",
|
||||
timestamp=r.created_at or datetime.utcnow(),
|
||||
data=r,
|
||||
user=get_user_display(session, r.user_id),
|
||||
entity=get_entity_info(session, r)
|
||||
))
|
||||
|
||||
for a in attendance:
|
||||
show = session.get(Show, a.show_id) if a.show_id else None
|
||||
|
||||
# Filter by vertical
|
||||
if preferred_vertical_ids and show and show.vertical_id not in preferred_vertical_ids:
|
||||
continue
|
||||
|
||||
entity_info = None
|
||||
if show:
|
||||
entity_info = {
|
||||
"type": "show",
|
||||
"slug": show.slug,
|
||||
"title": show.date.strftime("%Y-%m-%d") if show.date else "Unknown",
|
||||
}
|
||||
|
||||
feed_items.append(FeedItem(
|
||||
type="attendance",
|
||||
timestamp=a.created_at,
|
||||
data=a,
|
||||
user=get_user_display(session, a.user_id),
|
||||
entity=entity_info
|
||||
))
|
||||
|
||||
# Sort by timestamp desc
|
||||
feed_items.sort(key=lambda x: x.timestamp, reverse=True)
|
||||
|
||||
return feed_items[:limit]
|
||||
|
||||
|
|
|
|||
168
backend/routers/festivals.py
Normal file
168
backend/routers/festivals.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""
|
||||
Festival API endpoints for multi-band festival discovery.
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlmodel import Session, select
|
||||
from pydantic import BaseModel
|
||||
from database import get_session
|
||||
from models import Festival, ShowFestival, Show, Vertical, Venue
|
||||
|
||||
router = APIRouter(prefix="/festivals", tags=["festivals"])
|
||||
|
||||
|
||||
class FestivalRead(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
year: Optional[int]
|
||||
start_date: Optional[str]
|
||||
end_date: Optional[str]
|
||||
website_url: Optional[str]
|
||||
description: Optional[str]
|
||||
|
||||
|
||||
class FestivalShowRead(BaseModel):
|
||||
show_id: int
|
||||
show_slug: Optional[str]
|
||||
show_date: str
|
||||
vertical_name: str
|
||||
vertical_slug: str
|
||||
stage: Optional[str]
|
||||
set_time: Optional[str]
|
||||
|
||||
|
||||
class FestivalDetailRead(BaseModel):
|
||||
festival: FestivalRead
|
||||
shows: List[FestivalShowRead]
|
||||
bands_count: int
|
||||
|
||||
|
||||
@router.get("/", response_model=List[FestivalRead])
|
||||
def list_festivals(
|
||||
year: Optional[int] = None,
|
||||
limit: int = Query(default=50, le=100),
|
||||
offset: int = 0,
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""List all festivals, optionally filtered by year"""
|
||||
query = select(Festival)
|
||||
if year:
|
||||
query = query.where(Festival.year == year)
|
||||
query = query.order_by(Festival.year.desc(), Festival.name).offset(offset).limit(limit)
|
||||
|
||||
festivals = session.exec(query).all()
|
||||
return [
|
||||
FestivalRead(
|
||||
id=f.id,
|
||||
name=f.name,
|
||||
slug=f.slug,
|
||||
year=f.year,
|
||||
start_date=f.start_date.strftime("%Y-%m-%d") if f.start_date else None,
|
||||
end_date=f.end_date.strftime("%Y-%m-%d") if f.end_date else None,
|
||||
website_url=f.website_url,
|
||||
description=f.description
|
||||
)
|
||||
for f in festivals
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{slug}", response_model=FestivalDetailRead)
|
||||
def get_festival(slug: str, session: Session = Depends(get_session)):
|
||||
"""Get festival details with all shows across bands"""
|
||||
festival = session.exec(
|
||||
select(Festival).where(Festival.slug == slug)
|
||||
).first()
|
||||
|
||||
if not festival:
|
||||
raise HTTPException(status_code=404, detail="Festival not found")
|
||||
|
||||
# Get all shows at this festival
|
||||
show_festivals = session.exec(
|
||||
select(ShowFestival).where(ShowFestival.festival_id == festival.id)
|
||||
).all()
|
||||
|
||||
shows = []
|
||||
bands_seen = set()
|
||||
for sf in show_festivals:
|
||||
show = session.get(Show, sf.show_id)
|
||||
if show:
|
||||
vertical = session.get(Vertical, show.vertical_id)
|
||||
if vertical:
|
||||
bands_seen.add(vertical.id)
|
||||
shows.append(FestivalShowRead(
|
||||
show_id=show.id,
|
||||
show_slug=show.slug,
|
||||
show_date=show.date.strftime("%Y-%m-%d") if show.date else "Unknown",
|
||||
vertical_name=vertical.name if vertical else "Unknown",
|
||||
vertical_slug=vertical.slug if vertical else "unknown",
|
||||
stage=sf.stage,
|
||||
set_time=sf.set_time
|
||||
))
|
||||
|
||||
# Sort by date
|
||||
shows.sort(key=lambda x: x.show_date)
|
||||
|
||||
return FestivalDetailRead(
|
||||
festival=FestivalRead(
|
||||
id=festival.id,
|
||||
name=festival.name,
|
||||
slug=festival.slug,
|
||||
year=festival.year,
|
||||
start_date=festival.start_date.strftime("%Y-%m-%d") if festival.start_date else None,
|
||||
end_date=festival.end_date.strftime("%Y-%m-%d") if festival.end_date else None,
|
||||
website_url=festival.website_url,
|
||||
description=festival.description
|
||||
),
|
||||
shows=shows,
|
||||
bands_count=len(bands_seen)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/by-band/{vertical_slug}")
|
||||
def get_festivals_by_band(vertical_slug: str, session: Session = Depends(get_session)):
|
||||
"""Get all festivals a band has played"""
|
||||
vertical = session.exec(
|
||||
select(Vertical).where(Vertical.slug == vertical_slug)
|
||||
).first()
|
||||
|
||||
if not vertical:
|
||||
raise HTTPException(status_code=404, detail="Band not found")
|
||||
|
||||
# Get shows for this vertical that are linked to festivals
|
||||
shows = session.exec(
|
||||
select(Show).where(Show.vertical_id == vertical.id)
|
||||
).all()
|
||||
|
||||
show_ids = [s.id for s in shows]
|
||||
|
||||
if not show_ids:
|
||||
return []
|
||||
|
||||
# Get festival links
|
||||
show_festivals = session.exec(
|
||||
select(ShowFestival).where(ShowFestival.show_id.in_(show_ids))
|
||||
).all()
|
||||
|
||||
festival_ids = list(set(sf.festival_id for sf in show_festivals))
|
||||
|
||||
if not festival_ids:
|
||||
return []
|
||||
|
||||
festivals = session.exec(
|
||||
select(Festival).where(Festival.id.in_(festival_ids))
|
||||
).all()
|
||||
|
||||
return [
|
||||
FestivalRead(
|
||||
id=f.id,
|
||||
name=f.name,
|
||||
slug=f.slug,
|
||||
year=f.year,
|
||||
start_date=f.start_date.strftime("%Y-%m-%d") if f.start_date else None,
|
||||
end_date=f.end_date.strftime("%Y-%m-%d") if f.end_date else None,
|
||||
website_url=f.website_url,
|
||||
description=f.description
|
||||
)
|
||||
for f in sorted(festivals, key=lambda x: x.year or 0, reverse=True)
|
||||
]
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
from typing import List
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlmodel import Session, select, func
|
||||
from database import get_session
|
||||
|
|
@ -32,12 +32,33 @@ def create_group(
|
|||
@router.get("/", response_model=List[GroupRead])
|
||||
def read_groups(
|
||||
offset: int = 0,
|
||||
limit: int = Query(default=100, le=100),
|
||||
limit: int = Query(default=100, le=100),
|
||||
vertical: Optional[str] = None,
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
# TODO: Add member count to response
|
||||
groups = session.exec(select(Group).offset(offset).limit(limit)).all()
|
||||
return groups
|
||||
from models import Vertical
|
||||
|
||||
query = select(Group)
|
||||
|
||||
# Filter by vertical if specified
|
||||
if vertical:
|
||||
v = session.exec(select(Vertical).where(Vertical.slug == vertical)).first()
|
||||
if v:
|
||||
query = query.where(Group.vertical_id == v.id)
|
||||
|
||||
groups = session.exec(query.offset(offset).limit(limit)).all()
|
||||
|
||||
# Add member count to each group
|
||||
result = []
|
||||
for g in groups:
|
||||
member_count = session.exec(
|
||||
select(func.count(GroupMember.id)).where(GroupMember.group_id == g.id)
|
||||
).one()
|
||||
result.append({
|
||||
**g.model_dump(),
|
||||
"member_count": member_count or 0
|
||||
})
|
||||
return result
|
||||
|
||||
@router.get("/{group_id}", response_model=GroupRead)
|
||||
def read_group(group_id: int, session: Session = Depends(get_session)):
|
||||
|
|
@ -83,6 +104,42 @@ def join_group(
|
|||
|
||||
return {"status": "joined"}
|
||||
|
||||
|
||||
@router.delete("/{group_id}/leave")
|
||||
def leave_group(
|
||||
group_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Leave a group (non-admins only, admins must transfer ownership first)"""
|
||||
member = session.exec(
|
||||
select(GroupMember)
|
||||
.where(GroupMember.group_id == group_id)
|
||||
.where(GroupMember.user_id == current_user.id)
|
||||
).first()
|
||||
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Not a member")
|
||||
|
||||
if member.role == "admin":
|
||||
# Check if only admin
|
||||
other_admins = session.exec(
|
||||
select(GroupMember)
|
||||
.where(GroupMember.group_id == group_id)
|
||||
.where(GroupMember.role == "admin")
|
||||
.where(GroupMember.user_id != current_user.id)
|
||||
).first()
|
||||
|
||||
if not other_admins:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot leave: you are the only admin. Transfer ownership first."
|
||||
)
|
||||
|
||||
session.delete(member)
|
||||
session.commit()
|
||||
return {"status": "left"}
|
||||
|
||||
# --- Posts ---
|
||||
|
||||
@router.post("/{group_id}/posts", response_model=GroupPostRead)
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ def list_musicians(
|
|||
@router.get("/{slug}")
|
||||
def get_musician(slug: str, session: Session = Depends(get_session)):
|
||||
"""Get musician details with band memberships and guest appearances"""
|
||||
from models import Show, Song, Vertical
|
||||
|
||||
musician = session.exec(select(Musician).where(Musician.slug == slug)).first()
|
||||
if not musician:
|
||||
raise HTTPException(status_code=404, detail="Musician not found")
|
||||
|
|
@ -70,8 +72,10 @@ def get_musician(slug: str, session: Session = Depends(get_session)):
|
|||
).all()
|
||||
|
||||
bands = []
|
||||
primary_band_ids = set()
|
||||
for m in memberships:
|
||||
artist = session.get(Artist, m.artist_id)
|
||||
primary_band_ids.add(m.artist_id)
|
||||
bands.append({
|
||||
"id": m.id,
|
||||
"artist_id": m.artist_id,
|
||||
|
|
@ -80,20 +84,44 @@ def get_musician(slug: str, session: Session = Depends(get_session)):
|
|||
"role": m.role,
|
||||
"start_date": str(m.start_date) if m.start_date else None,
|
||||
"end_date": str(m.end_date) if m.end_date else None,
|
||||
"is_current": m.end_date is None,
|
||||
})
|
||||
|
||||
# Get guest appearances
|
||||
# Get guest appearances with full context
|
||||
appearances = session.exec(
|
||||
select(PerformanceGuest).where(PerformanceGuest.musician_id == musician.id)
|
||||
).all()
|
||||
|
||||
guests = []
|
||||
sit_in_verticals = {} # Track which bands they've sat in with
|
||||
|
||||
for g in appearances:
|
||||
perf = session.get(Performance, g.performance_id)
|
||||
if not perf:
|
||||
continue
|
||||
|
||||
show = session.get(Show, perf.show_id) if perf else None
|
||||
song = session.get(Song, perf.song_id) if perf else None
|
||||
vertical = session.get(Vertical, show.vertical_id) if show else None
|
||||
|
||||
if vertical:
|
||||
if vertical.id not in sit_in_verticals:
|
||||
sit_in_verticals[vertical.id] = {
|
||||
"vertical_id": vertical.id,
|
||||
"vertical_name": vertical.name,
|
||||
"vertical_slug": vertical.slug,
|
||||
"count": 0
|
||||
}
|
||||
sit_in_verticals[vertical.id]["count"] += 1
|
||||
|
||||
guests.append({
|
||||
"id": g.id,
|
||||
"performance_id": g.performance_id,
|
||||
"performance_slug": perf.slug if perf else None,
|
||||
"song_title": song.title if song else None,
|
||||
"show_date": str(show.date.date()) if show and show.date else None,
|
||||
"vertical_name": vertical.name if vertical else None,
|
||||
"vertical_slug": vertical.slug if vertical else None,
|
||||
"instrument": g.instrument,
|
||||
})
|
||||
|
||||
|
|
@ -108,6 +136,13 @@ def get_musician(slug: str, session: Session = Depends(get_session)):
|
|||
},
|
||||
"bands": bands,
|
||||
"guest_appearances": guests,
|
||||
"sit_in_summary": list(sit_in_verticals.values()),
|
||||
"stats": {
|
||||
"total_bands": len(bands),
|
||||
"current_bands": len([b for b in bands if b.get("is_current")]),
|
||||
"total_sit_ins": len(guests),
|
||||
"bands_sat_in_with": len(sit_in_verticals),
|
||||
}
|
||||
}
|
||||
|
||||
# --- Admin Endpoints (for now, no auth check - can be added later) ---
|
||||
|
|
|
|||
125
backend/routers/on_this_day.py
Normal file
125
backend/routers/on_this_day.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""
|
||||
On This Day API endpoint - shows what happened on this date in history.
|
||||
"""
|
||||
from datetime import date
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlmodel import Session, select
|
||||
from pydantic import BaseModel
|
||||
from database import get_session
|
||||
from models import Show, Venue, Vertical, Performance, Song
|
||||
|
||||
router = APIRouter(prefix="/on-this-day", tags=["on-this-day"])
|
||||
|
||||
|
||||
class ShowOnThisDay(BaseModel):
|
||||
id: int
|
||||
date: str
|
||||
slug: str | None
|
||||
venue_name: str | None
|
||||
venue_city: str | None
|
||||
vertical_name: str
|
||||
vertical_slug: str
|
||||
years_ago: int
|
||||
|
||||
|
||||
class OnThisDayResponse(BaseModel):
|
||||
month: int
|
||||
day: int
|
||||
shows: List[ShowOnThisDay]
|
||||
total_shows: int
|
||||
|
||||
|
||||
@router.get("/", response_model=OnThisDayResponse)
|
||||
def get_on_this_day(
|
||||
month: Optional[int] = None,
|
||||
day: Optional[int] = None,
|
||||
vertical: Optional[str] = None,
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""
|
||||
Get all shows that happened on this day in history.
|
||||
Defaults to today's date if month/day not specified.
|
||||
"""
|
||||
today = date.today()
|
||||
target_month = month or today.month
|
||||
target_day = day or today.day
|
||||
|
||||
# Build query
|
||||
query = select(Show).where(
|
||||
Show.date.isnot(None)
|
||||
)
|
||||
|
||||
# Filter by vertical if specified
|
||||
if vertical:
|
||||
vertical_obj = session.exec(
|
||||
select(Vertical).where(Vertical.slug == vertical)
|
||||
).first()
|
||||
if vertical_obj:
|
||||
query = query.where(Show.vertical_id == vertical_obj.id)
|
||||
|
||||
# Execute and filter by month/day in Python (SQL date functions vary)
|
||||
all_shows = session.exec(query.order_by(Show.date.desc())).all()
|
||||
|
||||
matching_shows = []
|
||||
for show in all_shows:
|
||||
if show.date and show.date.month == target_month and show.date.day == target_day:
|
||||
venue = session.get(Venue, show.venue_id) if show.venue_id else None
|
||||
vert = session.get(Vertical, show.vertical_id)
|
||||
|
||||
years_ago = today.year - show.date.year
|
||||
|
||||
matching_shows.append(ShowOnThisDay(
|
||||
id=show.id,
|
||||
date=show.date.strftime("%Y-%m-%d"),
|
||||
slug=show.slug,
|
||||
venue_name=venue.name if venue else None,
|
||||
venue_city=venue.city if venue else None,
|
||||
vertical_name=vert.name if vert else "Unknown",
|
||||
vertical_slug=vert.slug if vert else "unknown",
|
||||
years_ago=years_ago
|
||||
))
|
||||
|
||||
# Sort by years ago (most recent anniversary first)
|
||||
matching_shows.sort(key=lambda x: x.years_ago)
|
||||
|
||||
return OnThisDayResponse(
|
||||
month=target_month,
|
||||
day=target_day,
|
||||
shows=matching_shows,
|
||||
total_shows=len(matching_shows)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/highlights", response_model=List[ShowOnThisDay])
|
||||
def get_on_this_day_highlights(
|
||||
limit: int = 5,
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""Get highlighted shows for today across all bands (limited list for homepage)"""
|
||||
today = date.today()
|
||||
|
||||
all_shows = session.exec(
|
||||
select(Show).where(Show.date.isnot(None))
|
||||
).all()
|
||||
|
||||
matching = []
|
||||
for show in all_shows:
|
||||
if show.date and show.date.month == today.month and show.date.day == today.day:
|
||||
venue = session.get(Venue, show.venue_id) if show.venue_id else None
|
||||
vert = session.get(Vertical, show.vertical_id)
|
||||
|
||||
matching.append(ShowOnThisDay(
|
||||
id=show.id,
|
||||
date=show.date.strftime("%Y-%m-%d"),
|
||||
slug=show.slug,
|
||||
venue_name=venue.name if venue else None,
|
||||
venue_city=venue.city if venue else None,
|
||||
vertical_name=vert.name if vert else "Unknown",
|
||||
vertical_slug=vert.slug if vert else "unknown",
|
||||
years_ago=today.year - show.date.year
|
||||
))
|
||||
|
||||
# Sort by anniversary and return limited
|
||||
matching.sort(key=lambda x: x.years_ago)
|
||||
return matching[:limit]
|
||||
317
backend/routers/playlists.py
Normal file
317
backend/routers/playlists.py
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
"""
|
||||
User Playlist API - curated collections of performances.
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlmodel import Session, select
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from database import get_session
|
||||
from models import UserPlaylist, PlaylistPerformance, Performance, Show, Song, User
|
||||
from auth import get_current_user
|
||||
from slugify import generate_slug
|
||||
|
||||
router = APIRouter(prefix="/playlists", tags=["playlists"])
|
||||
|
||||
|
||||
class PlaylistCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
is_public: bool = True
|
||||
|
||||
|
||||
class PlaylistUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_public: Optional[bool] = None
|
||||
|
||||
|
||||
class PerformanceInPlaylist(BaseModel):
|
||||
performance_id: int
|
||||
position: int
|
||||
notes: Optional[str] = None
|
||||
song_title: str
|
||||
show_date: str
|
||||
show_slug: Optional[str]
|
||||
|
||||
|
||||
class PlaylistRead(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
description: Optional[str]
|
||||
is_public: bool
|
||||
user_id: int
|
||||
username: Optional[str]
|
||||
created_at: str
|
||||
performance_count: int
|
||||
|
||||
|
||||
class PlaylistDetailRead(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
description: Optional[str]
|
||||
is_public: bool
|
||||
user_id: int
|
||||
username: Optional[str]
|
||||
created_at: str
|
||||
performances: List[PerformanceInPlaylist]
|
||||
|
||||
|
||||
@router.post("/", response_model=PlaylistRead)
|
||||
def create_playlist(
|
||||
playlist: PlaylistCreate,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new playlist"""
|
||||
slug = generate_slug(playlist.name)
|
||||
|
||||
# Make slug unique per user
|
||||
existing = session.exec(
|
||||
select(UserPlaylist)
|
||||
.where(UserPlaylist.user_id == current_user.id)
|
||||
.where(UserPlaylist.slug == slug)
|
||||
).first()
|
||||
if existing:
|
||||
slug = f"{slug}-{int(datetime.utcnow().timestamp())}"
|
||||
|
||||
db_playlist = UserPlaylist(
|
||||
user_id=current_user.id,
|
||||
name=playlist.name,
|
||||
slug=slug,
|
||||
description=playlist.description,
|
||||
is_public=playlist.is_public
|
||||
)
|
||||
session.add(db_playlist)
|
||||
session.commit()
|
||||
session.refresh(db_playlist)
|
||||
|
||||
return PlaylistRead(
|
||||
id=db_playlist.id,
|
||||
name=db_playlist.name,
|
||||
slug=db_playlist.slug,
|
||||
description=db_playlist.description,
|
||||
is_public=db_playlist.is_public,
|
||||
user_id=db_playlist.user_id,
|
||||
username=current_user.username,
|
||||
created_at=db_playlist.created_at.isoformat(),
|
||||
performance_count=0
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[PlaylistRead])
|
||||
def list_playlists(
|
||||
user_id: Optional[int] = None,
|
||||
limit: int = Query(default=20, le=100),
|
||||
offset: int = 0,
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""List public playlists, optionally filtered by user"""
|
||||
query = select(UserPlaylist).where(UserPlaylist.is_public == True)
|
||||
|
||||
if user_id:
|
||||
query = query.where(UserPlaylist.user_id == user_id)
|
||||
|
||||
query = query.order_by(UserPlaylist.created_at.desc()).offset(offset).limit(limit)
|
||||
playlists = session.exec(query).all()
|
||||
|
||||
result = []
|
||||
for p in playlists:
|
||||
user = session.get(User, p.user_id)
|
||||
perf_count = len(session.exec(
|
||||
select(PlaylistPerformance).where(PlaylistPerformance.playlist_id == p.id)
|
||||
).all())
|
||||
result.append(PlaylistRead(
|
||||
id=p.id,
|
||||
name=p.name,
|
||||
slug=p.slug,
|
||||
description=p.description,
|
||||
is_public=p.is_public,
|
||||
user_id=p.user_id,
|
||||
username=user.username if user else None,
|
||||
created_at=p.created_at.isoformat(),
|
||||
performance_count=perf_count
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/mine", response_model=List[PlaylistRead])
|
||||
def list_my_playlists(
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""List current user's playlists (public and private)"""
|
||||
playlists = session.exec(
|
||||
select(UserPlaylist)
|
||||
.where(UserPlaylist.user_id == current_user.id)
|
||||
.order_by(UserPlaylist.created_at.desc())
|
||||
).all()
|
||||
|
||||
result = []
|
||||
for p in playlists:
|
||||
perf_count = len(session.exec(
|
||||
select(PlaylistPerformance).where(PlaylistPerformance.playlist_id == p.id)
|
||||
).all())
|
||||
result.append(PlaylistRead(
|
||||
id=p.id,
|
||||
name=p.name,
|
||||
slug=p.slug,
|
||||
description=p.description,
|
||||
is_public=p.is_public,
|
||||
user_id=p.user_id,
|
||||
username=current_user.username,
|
||||
created_at=p.created_at.isoformat(),
|
||||
performance_count=perf_count
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{playlist_id}", response_model=PlaylistDetailRead)
|
||||
def get_playlist(
|
||||
playlist_id: int,
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""Get playlist details with performances"""
|
||||
playlist = session.get(UserPlaylist, playlist_id)
|
||||
if not playlist:
|
||||
raise HTTPException(status_code=404, detail="Playlist not found")
|
||||
|
||||
if not playlist.is_public:
|
||||
raise HTTPException(status_code=403, detail="This playlist is private")
|
||||
|
||||
user = session.get(User, playlist.user_id)
|
||||
|
||||
# Get performances
|
||||
playlist_perfs = session.exec(
|
||||
select(PlaylistPerformance)
|
||||
.where(PlaylistPerformance.playlist_id == playlist_id)
|
||||
.order_by(PlaylistPerformance.position)
|
||||
).all()
|
||||
|
||||
performances = []
|
||||
for pp in playlist_perfs:
|
||||
perf = session.get(Performance, pp.performance_id)
|
||||
if perf:
|
||||
song = session.get(Song, perf.song_id) if perf.song_id else None
|
||||
show = session.get(Show, perf.show_id) if perf.show_id else None
|
||||
performances.append(PerformanceInPlaylist(
|
||||
performance_id=pp.performance_id,
|
||||
position=pp.position,
|
||||
notes=pp.notes,
|
||||
song_title=song.title if song else "Unknown",
|
||||
show_date=show.date.strftime("%Y-%m-%d") if show and show.date else "Unknown",
|
||||
show_slug=show.slug if show else None
|
||||
))
|
||||
|
||||
return PlaylistDetailRead(
|
||||
id=playlist.id,
|
||||
name=playlist.name,
|
||||
slug=playlist.slug,
|
||||
description=playlist.description,
|
||||
is_public=playlist.is_public,
|
||||
user_id=playlist.user_id,
|
||||
username=user.username if user else None,
|
||||
created_at=playlist.created_at.isoformat(),
|
||||
performances=performances
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{playlist_id}/performances/{performance_id}")
|
||||
def add_to_playlist(
|
||||
playlist_id: int,
|
||||
performance_id: int,
|
||||
notes: Optional[str] = None,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Add a performance to a playlist"""
|
||||
playlist = session.get(UserPlaylist, playlist_id)
|
||||
if not playlist:
|
||||
raise HTTPException(status_code=404, detail="Playlist not found")
|
||||
if playlist.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not your playlist")
|
||||
|
||||
# Check if already in playlist
|
||||
existing = session.exec(
|
||||
select(PlaylistPerformance)
|
||||
.where(PlaylistPerformance.playlist_id == playlist_id)
|
||||
.where(PlaylistPerformance.performance_id == performance_id)
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Already in playlist")
|
||||
|
||||
# Get next position
|
||||
all_perfs = session.exec(
|
||||
select(PlaylistPerformance).where(PlaylistPerformance.playlist_id == playlist_id)
|
||||
).all()
|
||||
next_position = len(all_perfs) + 1
|
||||
|
||||
pp = PlaylistPerformance(
|
||||
playlist_id=playlist_id,
|
||||
performance_id=performance_id,
|
||||
position=next_position,
|
||||
notes=notes
|
||||
)
|
||||
session.add(pp)
|
||||
session.commit()
|
||||
|
||||
return {"message": "Added to playlist", "position": next_position}
|
||||
|
||||
|
||||
@router.delete("/{playlist_id}/performances/{performance_id}")
|
||||
def remove_from_playlist(
|
||||
playlist_id: int,
|
||||
performance_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Remove a performance from a playlist"""
|
||||
playlist = session.get(UserPlaylist, playlist_id)
|
||||
if not playlist:
|
||||
raise HTTPException(status_code=404, detail="Playlist not found")
|
||||
if playlist.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not your playlist")
|
||||
|
||||
pp = session.exec(
|
||||
select(PlaylistPerformance)
|
||||
.where(PlaylistPerformance.playlist_id == playlist_id)
|
||||
.where(PlaylistPerformance.performance_id == performance_id)
|
||||
).first()
|
||||
|
||||
if not pp:
|
||||
raise HTTPException(status_code=404, detail="Not in playlist")
|
||||
|
||||
session.delete(pp)
|
||||
session.commit()
|
||||
|
||||
return {"message": "Removed from playlist"}
|
||||
|
||||
|
||||
@router.delete("/{playlist_id}")
|
||||
def delete_playlist(
|
||||
playlist_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a playlist"""
|
||||
playlist = session.get(UserPlaylist, playlist_id)
|
||||
if not playlist:
|
||||
raise HTTPException(status_code=404, detail="Playlist not found")
|
||||
if playlist.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not your playlist")
|
||||
|
||||
# Delete all playlist performances first
|
||||
perfs = session.exec(
|
||||
select(PlaylistPerformance).where(PlaylistPerformance.playlist_id == playlist_id)
|
||||
).all()
|
||||
for p in perfs:
|
||||
session.delete(p)
|
||||
|
||||
session.delete(playlist)
|
||||
session.commit()
|
||||
|
||||
return {"message": "Playlist deleted"}
|
||||
156
backend/routers/recommendations.py
Normal file
156
backend/routers/recommendations.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
"""
|
||||
Recommendation API - Personalized suggestions for users.
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlmodel import Session, select, desc, func
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime, timedelta
|
||||
from database import get_session
|
||||
from models import Show, Vertical, UserVerticalPreference, Attendance, Rating, Performance, Song, Venue, BandMembership
|
||||
from auth import get_current_user
|
||||
from models import User
|
||||
|
||||
router = APIRouter(prefix="/recommendations", tags=["recommendations"])
|
||||
|
||||
|
||||
class RecommendedShow(BaseModel):
|
||||
id: int
|
||||
date: str
|
||||
venue_name: str | None
|
||||
vertical_name: str
|
||||
vertical_slug: str
|
||||
reason: str # "Recent Show", "Highly Rated", "Trending"
|
||||
|
||||
|
||||
class RecommendedPerformance(BaseModel):
|
||||
id: int
|
||||
song_title: str
|
||||
show_date: str
|
||||
vertical_name: str
|
||||
avg_rating: float
|
||||
notes: str | None
|
||||
|
||||
|
||||
@router.get("/shows/recent", response_model=List[RecommendedShow])
|
||||
def get_recent_subscriptions(
|
||||
limit: int = 10,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get recent shows from bands the user follows, excluding attended shows.
|
||||
"""
|
||||
# 1. Get user preferences
|
||||
prefs = session.exec(
|
||||
select(UserVerticalPreference).where(UserVerticalPreference.user_id == current_user.id)
|
||||
).all()
|
||||
|
||||
if not prefs:
|
||||
# Fallback: Just return recent shows from featured verticals
|
||||
# For now, return empty or generic
|
||||
return []
|
||||
|
||||
subscribed_vertical_ids = [p.vertical_id for p in prefs]
|
||||
|
||||
# 2. Get Attended Show IDs
|
||||
attended = session.exec(
|
||||
select(Attendance.show_id).where(Attendance.user_id == current_user.id)
|
||||
).all()
|
||||
attended_ids = set(attended)
|
||||
|
||||
# 3. Query Recent Shows
|
||||
query = (
|
||||
select(Show)
|
||||
.where(Show.vertical_id.in_(subscribed_vertical_ids))
|
||||
.where(Show.date <= datetime.now()) # Past shows only
|
||||
.where(Show.date >= datetime.now() - timedelta(days=90)) # Last 90 days
|
||||
.order_by(desc(Show.date))
|
||||
.limit(limit * 2) # Fetch extra to filter
|
||||
)
|
||||
|
||||
shows = session.exec(query).all()
|
||||
|
||||
results = []
|
||||
for show in shows:
|
||||
if show.id in attended_ids:
|
||||
continue
|
||||
|
||||
vertical = session.get(Vertical, show.vertical_id)
|
||||
venue = session.get(Venue, show.venue_id) if show.venue_id else None
|
||||
|
||||
results.append(RecommendedShow(
|
||||
id=show.id,
|
||||
date=show.date.strftime("%Y-%m-%d"),
|
||||
venue_name=venue.name if venue else "Unknown Venue",
|
||||
vertical_name=vertical.name,
|
||||
vertical_slug=vertical.slug,
|
||||
reason="Recent from your bands"
|
||||
))
|
||||
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/performances/top", response_model=List[RecommendedPerformance])
|
||||
def get_top_rated_tracks(
|
||||
limit: int = 10,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get top rated performances from bands the user follows.
|
||||
"""
|
||||
prefs = session.exec(
|
||||
select(UserVerticalPreference).where(UserVerticalPreference.user_id == current_user.id)
|
||||
).all()
|
||||
|
||||
if not prefs:
|
||||
return []
|
||||
|
||||
subscribed_vertical_ids = [p.vertical_id for p in prefs]
|
||||
|
||||
# Complex query: Join Performance -> Show -> Vertical, Join Rating
|
||||
# Getting avg rating per performance
|
||||
|
||||
# This might be slow on large datasets without materialized view.
|
||||
# Optimized approach: Query Rating table, group by performance_id, filter by subscribed verticals
|
||||
|
||||
results = session.exec(
|
||||
select(
|
||||
Rating.performance_id,
|
||||
func.avg(Rating.score).label("average"),
|
||||
func.count(Rating.id).label("count")
|
||||
)
|
||||
.join(Performance, Rating.performance_id == Performance.id)
|
||||
.join(Show, Performance.show_id == Show.id)
|
||||
.where(Show.vertical_id.in_(subscribed_vertical_ids))
|
||||
.where(Rating.performance_id.isnot(None))
|
||||
.group_by(Rating.performance_id)
|
||||
.having(func.count(Rating.id) >= 1) # At least 1 rating
|
||||
.order_by(desc("average"))
|
||||
.limit(limit)
|
||||
).all()
|
||||
|
||||
recommendations = []
|
||||
for row in results:
|
||||
perf_id, avg, count = row
|
||||
perf = session.get(Performance, perf_id)
|
||||
if not perf: continue
|
||||
|
||||
show = session.get(Show, perf.show_id)
|
||||
song = session.get(Song, perf.song_id)
|
||||
vertical = session.get(Vertical, show.vertical_id)
|
||||
|
||||
recommendations.append(RecommendedPerformance(
|
||||
id=perf.id,
|
||||
song_title=song.title,
|
||||
show_date=show.date.strftime("%Y-%m-%d"),
|
||||
vertical_name=vertical.name,
|
||||
avg_rating=round(avg, 1),
|
||||
notes=f"Rated {round(avg, 1)}/10 by {count} fans"
|
||||
))
|
||||
|
||||
return recommendations
|
||||
|
|
@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
|||
from sqlmodel import Session, select, col
|
||||
from sqlalchemy.orm import selectinload
|
||||
from database import get_session
|
||||
from models import Show, Song, Venue, Tour, Group, Performance, PerformanceNickname, Comment, Review
|
||||
from models import Show, Song, Venue, Tour, Group, Performance, PerformanceNickname, Comment, Review, Vertical, SongCanon
|
||||
|
||||
router = APIRouter(prefix="/search", tags=["search"])
|
||||
|
||||
|
|
@ -18,13 +18,32 @@ def global_search(
|
|||
|
||||
q_str = f"%{q}%"
|
||||
|
||||
# Search Songs
|
||||
songs = session.exec(
|
||||
# Search Canonical Songs (The Hub)
|
||||
canonical_songs = session.exec(
|
||||
select(SongCanon)
|
||||
.where(col(SongCanon.title).ilike(q_str))
|
||||
.limit(limit)
|
||||
).all()
|
||||
|
||||
# Search Songs (Artist Versions)
|
||||
songs_raw = session.exec(
|
||||
select(Song)
|
||||
.options(selectinload(Song.vertical))
|
||||
.where(col(Song.title).ilike(q_str))
|
||||
.limit(limit)
|
||||
).all()
|
||||
|
||||
# Serialize songs with vertical info
|
||||
songs = []
|
||||
for s in songs_raw:
|
||||
songs.append({
|
||||
"id": s.id,
|
||||
"title": s.title,
|
||||
"slug": s.slug,
|
||||
"original_artist": s.original_artist,
|
||||
"vertical": {"name": s.vertical.name, "slug": s.vertical.slug} if s.vertical else None
|
||||
})
|
||||
|
||||
# Search Venues
|
||||
venues = session.exec(
|
||||
select(Venue)
|
||||
|
|
@ -45,6 +64,13 @@ def global_search(
|
|||
.where(col(Group.name).ilike(q_str))
|
||||
.limit(limit)
|
||||
).all()
|
||||
|
||||
# Search Verticals (Bands)
|
||||
verticals = session.exec(
|
||||
select(Vertical)
|
||||
.where(col(Vertical.name).ilike(q_str))
|
||||
.limit(limit)
|
||||
).all()
|
||||
|
||||
# Search Nicknames
|
||||
nicknames_raw = session.exec(
|
||||
|
|
@ -111,10 +137,12 @@ def global_search(
|
|||
).all()
|
||||
|
||||
return {
|
||||
"canonical_songs": canonical_songs,
|
||||
"songs": songs,
|
||||
"venues": venues,
|
||||
"tours": tours,
|
||||
"groups": groups,
|
||||
"verticals": verticals,
|
||||
"nicknames": nicknames,
|
||||
"performances": performances,
|
||||
"reviews": reviews,
|
||||
|
|
|
|||
|
|
@ -1,32 +1,160 @@
|
|||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlmodel import Session, select
|
||||
from sqlalchemy import func
|
||||
from database import get_session
|
||||
from models import Show, Tag, EntityTag
|
||||
from schemas import ShowCreate, ShowRead, ShowUpdate, TagRead
|
||||
from auth import get_current_user
|
||||
from models import Show, Tag, EntityTag, Vertical, UserVerticalPreference
|
||||
from schemas import ShowCreate, ShowRead, ShowUpdate, TagRead, PaginatedResponse, PaginationMeta, VerticalSimple, VenueRead, TourRead
|
||||
from auth import get_current_user, get_current_user_optional
|
||||
|
||||
router = APIRouter(prefix="/shows", tags=["shows"])
|
||||
|
||||
from services.notification_service import NotificationService
|
||||
|
||||
def get_notification_service(session: Session = Depends(get_session)) -> NotificationService:
|
||||
return NotificationService(session)
|
||||
|
||||
@router.post("/", response_model=ShowRead)
|
||||
def create_show(show: ShowCreate, session: Session = Depends(get_session), current_user = Depends(get_current_user)):
|
||||
def create_show(
|
||||
show: ShowCreate,
|
||||
session: Session = Depends(get_session),
|
||||
current_user = Depends(get_current_user),
|
||||
notification_service: NotificationService = Depends(get_notification_service)
|
||||
):
|
||||
db_show = Show.model_validate(show)
|
||||
session.add(db_show)
|
||||
session.commit()
|
||||
session.refresh(db_show)
|
||||
|
||||
# Trigger notifications
|
||||
try:
|
||||
notification_service.check_show_alert(db_show)
|
||||
except Exception as e:
|
||||
print(f"Error sending notifications: {e}")
|
||||
|
||||
return db_show
|
||||
|
||||
@router.get("/", response_model=List[ShowRead])
|
||||
def serialize_show(show: Show) -> dict:
|
||||
"""
|
||||
Robustly serialize a Show object to a dictionary.
|
||||
Returning a dict breaks the link to SQLAlchemy ORM objects completely,
|
||||
preventing Pydantic from triggering lazy loads or infinite recursion during validation.
|
||||
"""
|
||||
try:
|
||||
# Base fields
|
||||
data = {
|
||||
"id": show.id,
|
||||
"date": show.date,
|
||||
"slug": show.slug,
|
||||
"vertical_id": show.vertical_id,
|
||||
"venue_id": show.venue_id,
|
||||
"tour_id": show.tour_id,
|
||||
"notes": show.notes,
|
||||
"bandcamp_link": show.bandcamp_link,
|
||||
"nugs_link": show.nugs_link,
|
||||
"youtube_link": show.youtube_link,
|
||||
"vertical": None,
|
||||
"venue": None,
|
||||
"tour": None,
|
||||
"tags": [],
|
||||
"performances": []
|
||||
}
|
||||
|
||||
# Manually map relationships if present
|
||||
if show.vertical:
|
||||
try:
|
||||
data["vertical"] = {
|
||||
"id": show.vertical.id,
|
||||
"name": show.vertical.name,
|
||||
"slug": show.vertical.slug,
|
||||
"description": show.vertical.description,
|
||||
"logo_url": show.vertical.logo_url,
|
||||
"accent_color": show.vertical.accent_color
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error serializing vertical for show {show.id}: {e}")
|
||||
|
||||
if show.venue:
|
||||
try:
|
||||
data["venue"] = {
|
||||
"id": show.venue.id,
|
||||
"name": show.venue.name,
|
||||
"slug": show.venue.slug,
|
||||
"city": show.venue.city,
|
||||
"state": show.venue.state,
|
||||
"country": show.venue.country,
|
||||
"capacity": show.venue.capacity,
|
||||
"notes": show.venue.notes
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error serializing venue for show {show.id}: {e}")
|
||||
|
||||
if show.tour:
|
||||
try:
|
||||
data["tour"] = {
|
||||
"id": show.tour.id,
|
||||
"name": show.tour.name,
|
||||
"slug": show.tour.slug,
|
||||
"start_date": show.tour.start_date,
|
||||
"end_date": show.tour.end_date,
|
||||
"notes": show.tour.notes
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error serializing tour for show {show.id}: {e}")
|
||||
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
print(f"CRITICAL Error serializing show {show.id}: {e}")
|
||||
# Return minimal valid dict
|
||||
return {
|
||||
"id": show.id,
|
||||
"date": show.date,
|
||||
"vertical_id": show.vertical_id,
|
||||
"slug": show.slug or "",
|
||||
"tags": [],
|
||||
"performances": []
|
||||
}
|
||||
|
||||
@router.get("/", response_model=PaginatedResponse[ShowRead])
|
||||
def read_shows(
|
||||
offset: int = 0,
|
||||
limit: int = Query(default=2000, le=5000),
|
||||
venue_id: int = None,
|
||||
tour_id: int = None,
|
||||
year: int = None,
|
||||
vertical: str = None, # Single vertical slug filter
|
||||
vertical_id: int = None, # Vertical ID filter
|
||||
vertical_slugs: List[str] = Query(None),
|
||||
status: str = Query(default=None, regex="^(past|upcoming)$"),
|
||||
tiers: List[str] = Query(None),
|
||||
current_user = Depends(get_current_user_optional),
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
query = select(Show)
|
||||
from sqlalchemy.orm import joinedload
|
||||
from datetime import datetime
|
||||
|
||||
query = select(Show).options(
|
||||
joinedload(Show.vertical),
|
||||
joinedload(Show.venue),
|
||||
joinedload(Show.tour)
|
||||
)
|
||||
|
||||
if tiers and current_user:
|
||||
prefs = session.exec(
|
||||
select(UserVerticalPreference)
|
||||
.where(UserVerticalPreference.user_id == current_user.id)
|
||||
.where(UserVerticalPreference.tier.in_(tiers))
|
||||
).all()
|
||||
allowed_ids = [p.vertical_id for p in prefs]
|
||||
# If user selected tiers but has no bands in them, return empty
|
||||
if not allowed_ids:
|
||||
return PaginatedResponse(data=[], meta=PaginationMeta(total=0, limit=limit, offset=offset))
|
||||
query = query.where(Show.vertical_id.in_(allowed_ids))
|
||||
elif tiers and not current_user:
|
||||
# Anonymous users can't filter by personal tiers
|
||||
return PaginatedResponse(data=[], meta=PaginationMeta(total=0, limit=limit, offset=offset))
|
||||
|
||||
if venue_id:
|
||||
query = query.where(Show.venue_id == venue_id)
|
||||
if tour_id:
|
||||
|
|
@ -35,41 +163,117 @@ def read_shows(
|
|||
from sqlalchemy import extract
|
||||
query = query.where(extract('year', Show.date) == year)
|
||||
|
||||
if status:
|
||||
from datetime import datetime
|
||||
if status == "past":
|
||||
query = query.where(Show.date <= datetime.now())
|
||||
elif status == "upcoming":
|
||||
query = query.where(Show.date > datetime.now())
|
||||
if vertical_slugs:
|
||||
query = query.join(Vertical).where(Vertical.slug.in_(vertical_slugs))
|
||||
elif vertical:
|
||||
# Single vertical slug filter
|
||||
query = query.join(Vertical).where(Vertical.slug == vertical)
|
||||
|
||||
if vertical_id:
|
||||
query = query.where(Show.vertical_id == vertical_id)
|
||||
|
||||
if status:
|
||||
today = datetime.now()
|
||||
if status == "past":
|
||||
query = query.where(Show.date <= today)
|
||||
elif status == "upcoming":
|
||||
query = query.where(Show.date > today)
|
||||
|
||||
# Calculate total count before pagination
|
||||
total = session.exec(select(func.count()).select_from(query.subquery())).one()
|
||||
|
||||
# Apply sorting and pagination
|
||||
if status == "upcoming":
|
||||
query = query.order_by(Show.date.asc())
|
||||
else:
|
||||
# Default sort by date descending so we get recent shows first
|
||||
query = query.order_by(Show.date.desc())
|
||||
|
||||
shows = session.exec(query.offset(offset).limit(limit)).all()
|
||||
return shows
|
||||
|
||||
# Serialize robustly
|
||||
serialized_shows = [serialize_show(s) for s in shows]
|
||||
|
||||
return PaginatedResponse(
|
||||
data=serialized_shows,
|
||||
meta=PaginationMeta(total=total, limit=limit, offset=offset)
|
||||
)
|
||||
|
||||
@router.get("/recent", response_model=List[ShowRead])
|
||||
def read_recent_shows(
|
||||
limit: int = Query(default=10, le=50),
|
||||
tiers: List[str] = Query(None),
|
||||
current_user = Depends(get_current_user_optional),
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""Get the most recent shows ordered by date descending"""
|
||||
from datetime import datetime
|
||||
query = select(Show).where(Show.date <= datetime.now()).order_by(Show.date.desc()).limit(limit)
|
||||
from sqlalchemy.orm import joinedload
|
||||
query = select(Show).options(
|
||||
joinedload(Show.vertical),
|
||||
joinedload(Show.venue),
|
||||
joinedload(Show.tour)
|
||||
).where(Show.date <= datetime.now())
|
||||
|
||||
if tiers and current_user:
|
||||
prefs = session.exec(
|
||||
select(UserVerticalPreference)
|
||||
.where(UserVerticalPreference.user_id == current_user.id)
|
||||
.where(UserVerticalPreference.tier.in_(tiers))
|
||||
).all()
|
||||
allowed_ids = [p.vertical_id for p in prefs]
|
||||
if not allowed_ids:
|
||||
return []
|
||||
query = query.where(Show.vertical_id.in_(allowed_ids))
|
||||
|
||||
query = query.order_by(Show.date.desc()).limit(limit)
|
||||
shows = session.exec(query).all()
|
||||
return shows
|
||||
|
||||
@router.get("/upcoming", response_model=List[ShowRead])
|
||||
def read_upcoming_shows(
|
||||
limit: int = Query(default=50, le=100),
|
||||
tiers: List[str] = Query(None),
|
||||
current_user = Depends(get_current_user_optional),
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""Get upcoming shows ordered by date ascending"""
|
||||
from datetime import datetime
|
||||
query = select(Show).where(Show.date > datetime.now()).order_by(Show.date.asc()).limit(limit)
|
||||
from sqlalchemy.orm import joinedload
|
||||
query = select(Show).options(
|
||||
joinedload(Show.vertical),
|
||||
joinedload(Show.venue),
|
||||
joinedload(Show.tour)
|
||||
).where(Show.date > datetime.now())
|
||||
|
||||
if tiers and current_user:
|
||||
prefs = session.exec(
|
||||
select(UserVerticalPreference)
|
||||
.where(UserVerticalPreference.user_id == current_user.id)
|
||||
.where(UserVerticalPreference.tier.in_(tiers))
|
||||
).all()
|
||||
allowed_ids = [p.vertical_id for p in prefs]
|
||||
if not allowed_ids:
|
||||
return []
|
||||
query = query.where(Show.vertical_id.in_(allowed_ids))
|
||||
|
||||
query = query.order_by(Show.date.asc()).limit(limit)
|
||||
shows = session.exec(query).all()
|
||||
return shows
|
||||
|
||||
@router.get("/{slug}", response_model=ShowRead)
|
||||
def read_show(slug: str, session: Session = Depends(get_session)):
|
||||
show = session.exec(select(Show).where(Show.slug == slug)).first()
|
||||
from sqlalchemy.orm import selectinload, joinedload
|
||||
from models import Performance, VideoPerformance, Video, VideoPlatform
|
||||
|
||||
# Eager load relationships clearly
|
||||
show = session.exec(
|
||||
select(Show)
|
||||
.options(
|
||||
selectinload(Show.performances).selectinload(Performance.video_links).joinedload(VideoPerformance.video)
|
||||
)
|
||||
.where(Show.slug == slug)
|
||||
).first()
|
||||
|
||||
if not show:
|
||||
raise HTTPException(status_code=404, detail="Show not found")
|
||||
|
|
@ -81,25 +285,27 @@ def read_show(slug: str, session: Session = Depends(get_session)):
|
|||
.where(EntityTag.entity_id == show.id)
|
||||
).all()
|
||||
|
||||
# Manually populate performances to ensure nicknames are filtered if needed
|
||||
# (Though for now we just return all, or filter approved in schema if we had a custom getter)
|
||||
# The relationship `show.performances` is already loaded if we access it, but we might want to sort.
|
||||
|
||||
# Re-fetch show with relationships if needed, or just rely on lazy loading + validation
|
||||
# But for nicknames, we only want "approved" ones usually.
|
||||
# Let's let the frontend filter or do it here.
|
||||
# Doing it here is safer.
|
||||
|
||||
show_data = ShowRead.model_validate(show)
|
||||
show_data.tags = tags
|
||||
|
||||
# Get vertical for band name
|
||||
vertical = session.get(Vertical, show.vertical_id)
|
||||
show_data.vertical = vertical
|
||||
|
||||
# Sort performances by position
|
||||
sorted_perfs = sorted(show.performances, key=lambda p: p.position)
|
||||
|
||||
# Filter nicknames for each performance
|
||||
# Process performances: Filter nicknames and populate video links
|
||||
for perf in sorted_perfs:
|
||||
perf.nicknames = [n for n in perf.nicknames if n.status == "approved"]
|
||||
|
||||
# Backfill youtube_link from Video entity if not present
|
||||
if not perf.youtube_link and perf.video_links:
|
||||
for link in perf.video_links:
|
||||
if link.video and link.video.platform == VideoPlatform.YOUTUBE:
|
||||
perf.youtube_link = link.video.url
|
||||
break
|
||||
|
||||
show_data.performances = sorted_perfs
|
||||
|
||||
return show_data
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
|||
from sqlmodel import Session, select, func
|
||||
from database import get_session
|
||||
from models import Song, User, Tag, EntityTag, Show, Performance, Rating
|
||||
from schemas import SongCreate, SongRead, SongReadWithStats, SongUpdate, TagRead, PerformanceReadWithShow
|
||||
from schemas import SongCreate, SongRead, SongReadWithStats, SongUpdate, TagRead, PerformanceReadWithShow, PaginatedResponse, PaginationMeta
|
||||
from auth import get_current_user
|
||||
from services.stats import get_song_stats
|
||||
|
||||
|
|
@ -18,14 +18,69 @@ def create_song(song: SongCreate, session: Session = Depends(get_session), curre
|
|||
session.refresh(db_song)
|
||||
return db_song
|
||||
|
||||
@router.get("/", response_model=List[SongRead])
|
||||
def read_songs(offset: int = 0, limit: int = Query(default=100, le=1000), session: Session = Depends(get_session)):
|
||||
songs = session.exec(select(Song).offset(offset).limit(limit)).all()
|
||||
return songs
|
||||
@router.get("/", response_model=PaginatedResponse[SongRead])
|
||||
def read_songs(
|
||||
offset: int = 0,
|
||||
limit: int = Query(default=100, le=1000),
|
||||
vertical: str = Query(default=None, description="Filter by vertical slug"),
|
||||
sort: str = Query(default=None, regex="^(times_played)$"),
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
query = select(Song)
|
||||
|
||||
if vertical:
|
||||
from models import Vertical
|
||||
vertical_entity = session.exec(select(Vertical).where(Vertical.slug == vertical)).first()
|
||||
if vertical_entity:
|
||||
query = query.where(Song.vertical_id == vertical_entity.id)
|
||||
else:
|
||||
return PaginatedResponse(data=[], meta=PaginationMeta(total=0, limit=limit, offset=offset))
|
||||
|
||||
if sort == "times_played":
|
||||
# Select both Song and count
|
||||
query = select(Song, func.count(Performance.id).label("times_played"))
|
||||
query = query.outerjoin(Performance).group_by(Song.id)
|
||||
|
||||
if vertical:
|
||||
query = query.where(Song.vertical_id == vertical_entity.id)
|
||||
|
||||
# Calculate total
|
||||
total_query = select(func.count()).select_from(select(Song.id).where(Song.vertical_id == vertical_entity.id) if vertical else select(Song.id))
|
||||
total = session.exec(total_query).one()
|
||||
|
||||
query = query.order_by(func.count(Performance.id).desc())
|
||||
|
||||
results = session.exec(query.offset(offset).limit(limit)).all()
|
||||
|
||||
# Map (Song, count) tuples to SongRead with times_played
|
||||
songs = []
|
||||
for song, count in results:
|
||||
song_read = SongRead.model_validate(song)
|
||||
song_read.times_played = count
|
||||
songs.append(song_read)
|
||||
|
||||
else:
|
||||
# Standard query
|
||||
# Calculate total count before pagination
|
||||
total = session.exec(select(func.count()).select_from(query.subquery())).one()
|
||||
songs = session.exec(query.offset(offset).limit(limit)).all()
|
||||
|
||||
return PaginatedResponse(
|
||||
data=songs,
|
||||
meta=PaginationMeta(total=total, limit=limit, offset=offset)
|
||||
)
|
||||
|
||||
@router.get("/{slug}", response_model=SongReadWithStats)
|
||||
def read_song(slug: str, session: Session = Depends(get_session)):
|
||||
song = session.exec(select(Song).where(Song.slug == slug)).first()
|
||||
from sqlalchemy.orm import joinedload
|
||||
song = session.exec(
|
||||
select(Song)
|
||||
.where(Song.slug == slug)
|
||||
.options(
|
||||
joinedload(Song.artist),
|
||||
joinedload(Song.vertical)
|
||||
)
|
||||
).first()
|
||||
|
||||
if not song:
|
||||
raise HTTPException(status_code=404, detail="Song not found")
|
||||
|
|
@ -40,10 +95,17 @@ def read_song(slug: str, session: Session = Depends(get_session)):
|
|||
.where(EntityTag.entity_id == song_id)
|
||||
).all()
|
||||
|
||||
# Fetch performances
|
||||
# Fetch performances with video links
|
||||
from sqlalchemy.orm import selectinload, joinedload
|
||||
from models import VideoPerformance
|
||||
from models import Video, VideoPlatform
|
||||
|
||||
perfs = session.exec(
|
||||
select(Performance)
|
||||
.join(Show)
|
||||
.options(
|
||||
selectinload(Performance.video_links).joinedload(VideoPerformance.video)
|
||||
)
|
||||
.where(Performance.song_id == song_id)
|
||||
.order_by(Show.date.desc())
|
||||
).all()
|
||||
|
|
@ -66,6 +128,8 @@ def read_song(slug: str, session: Session = Depends(get_session)):
|
|||
venue_name = "Unknown"
|
||||
venue_city = ""
|
||||
venue_state = ""
|
||||
artist_name = None
|
||||
artist_slug = None
|
||||
show_date = datetime.now()
|
||||
show_slug = None
|
||||
|
||||
|
|
@ -76,25 +140,44 @@ def read_song(slug: str, session: Session = Depends(get_session)):
|
|||
venue_name = p.show.venue.name
|
||||
venue_city = p.show.venue.city
|
||||
venue_state = p.show.venue.state
|
||||
if p.show.vertical:
|
||||
artist_name = p.show.vertical.name
|
||||
artist_slug = p.show.vertical.slug
|
||||
|
||||
r_stats = rating_stats.get(p.id, {"avg": 0.0, "count": 0})
|
||||
|
||||
# Backfill youtube_link
|
||||
youtube_link = p.youtube_link
|
||||
if not youtube_link and p.video_links:
|
||||
for link in p.video_links:
|
||||
if link.video and link.video.platform == VideoPlatform.YOUTUBE:
|
||||
youtube_link = link.video.url
|
||||
break
|
||||
|
||||
perf_dtos.append(PerformanceReadWithShow(
|
||||
**p.model_dump(),
|
||||
**p.model_dump(exclude={"youtube_link"}),
|
||||
youtube_link=youtube_link,
|
||||
show_date=show_date,
|
||||
show_slug=show_slug,
|
||||
venue_name=venue_name,
|
||||
venue_city=venue_city,
|
||||
venue_state=venue_state,
|
||||
artist_name=artist_name,
|
||||
artist_slug=artist_slug,
|
||||
avg_rating=r_stats["avg"],
|
||||
total_reviews=r_stats["count"]
|
||||
))
|
||||
|
||||
# Calculate artist distribution
|
||||
from collections import Counter
|
||||
artist_dist = Counter(p.artist_name for p in perf_dtos if p.artist_name)
|
||||
|
||||
# Merge song data with stats
|
||||
song_with_stats = SongReadWithStats(
|
||||
**song.model_dump(),
|
||||
**stats
|
||||
)
|
||||
song_with_stats.artist_distribution = artist_dist
|
||||
song_with_stats.tags = tags
|
||||
song_with_stats.performances = perf_dtos
|
||||
return song_with_stats
|
||||
|
|
@ -110,3 +193,72 @@ def update_song(song_id: int, song: SongUpdate, session: Session = Depends(get_s
|
|||
session.commit()
|
||||
session.refresh(db_song)
|
||||
return db_song
|
||||
|
||||
|
||||
@router.get("/{slug}/versions")
|
||||
def get_song_versions(slug: str, session: Session = Depends(get_session)):
|
||||
"""Get all versions of a song across different bands (via SongCanon)"""
|
||||
from models import SongCanon, Vertical
|
||||
|
||||
# Find the song by slug
|
||||
song = session.exec(select(Song).where(Song.slug == slug)).first()
|
||||
if not song:
|
||||
raise HTTPException(status_code=404, detail="Song not found")
|
||||
|
||||
# If no canon link, return empty
|
||||
if not song.canon_id:
|
||||
return {
|
||||
"song": {
|
||||
"id": song.id,
|
||||
"title": song.title,
|
||||
"slug": song.slug,
|
||||
"vertical_id": song.vertical_id,
|
||||
},
|
||||
"canon": None,
|
||||
"other_versions": []
|
||||
}
|
||||
|
||||
# Get the canon entry
|
||||
canon = session.get(SongCanon, song.canon_id)
|
||||
|
||||
# Get all other versions (same canon, different song)
|
||||
other_songs = session.exec(
|
||||
select(Song)
|
||||
.where(Song.canon_id == song.canon_id)
|
||||
.where(Song.id != song.id)
|
||||
).all()
|
||||
|
||||
other_versions = []
|
||||
for s in other_songs:
|
||||
vertical = session.get(Vertical, s.vertical_id)
|
||||
# Get play count for this version
|
||||
play_count = session.exec(
|
||||
select(func.count(Performance.id))
|
||||
.where(Performance.song_id == s.id)
|
||||
).one()
|
||||
|
||||
other_versions.append({
|
||||
"id": s.id,
|
||||
"title": s.title,
|
||||
"slug": s.slug,
|
||||
"vertical_id": s.vertical_id,
|
||||
"vertical_name": vertical.name if vertical else "Unknown",
|
||||
"vertical_slug": vertical.slug if vertical else "unknown",
|
||||
"play_count": play_count,
|
||||
})
|
||||
|
||||
return {
|
||||
"song": {
|
||||
"id": song.id,
|
||||
"title": song.title,
|
||||
"slug": song.slug,
|
||||
"vertical_id": song.vertical_id,
|
||||
},
|
||||
"canon": {
|
||||
"id": canon.id,
|
||||
"title": canon.title,
|
||||
"slug": canon.slug,
|
||||
"original_artist": canon.original_artist,
|
||||
} if canon else None,
|
||||
"other_versions": other_versions
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from sqlmodel import Session, select, func
|
|||
from pydantic import BaseModel
|
||||
from database import get_session
|
||||
from models import User, Review, Attendance, Group, GroupMember, Show, UserPreferences, Profile
|
||||
from schemas import UserRead, ReviewRead, ShowRead, GroupRead, UserPreferencesUpdate
|
||||
from schemas import UserRead, ReviewRead, ShowRead, GroupRead, UserPreferencesUpdate, PublicProfileRead, SocialHandles, HeadlinerBand
|
||||
from auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
|
@ -16,6 +16,11 @@ class UserProfileUpdate(BaseModel):
|
|||
display_name: Optional[str] = None
|
||||
avatar_bg_color: Optional[str] = None
|
||||
avatar_text: Optional[str] = None
|
||||
# Social handles
|
||||
bluesky_handle: Optional[str] = None
|
||||
mastodon_handle: Optional[str] = None
|
||||
instagram_handle: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
|
||||
# Preset avatar colors - Jewel Tones (Primary Set)
|
||||
AVATAR_COLORS = [
|
||||
|
|
@ -46,7 +51,7 @@ def update_my_profile(
|
|||
current_user: User = Depends(get_current_user),
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""Update current user's bio, avatar, and primary profile"""
|
||||
"""Update current user's bio, avatar, social handles, and primary profile"""
|
||||
if update.bio is not None:
|
||||
current_user.bio = update.bio
|
||||
if update.avatar is not None:
|
||||
|
|
@ -61,6 +66,18 @@ def update_my_profile(
|
|||
if len(update.avatar_text) <= 3 and re.match(r'^[A-Za-z0-9]*$', update.avatar_text):
|
||||
current_user.avatar_text = update.avatar_text if update.avatar_text else None
|
||||
|
||||
# Social handles (simple sanitization)
|
||||
if update.bluesky_handle is not None:
|
||||
current_user.bluesky_handle = update.bluesky_handle.strip() or None
|
||||
if update.mastodon_handle is not None:
|
||||
current_user.mastodon_handle = update.mastodon_handle.strip() or None
|
||||
if update.instagram_handle is not None:
|
||||
# Remove @ prefix if user includes it
|
||||
handle = update.instagram_handle.strip().lstrip('@')
|
||||
current_user.instagram_handle = handle or None
|
||||
if update.location is not None:
|
||||
current_user.location = update.location.strip() or None
|
||||
|
||||
if update.username or update.display_name:
|
||||
# Find or create primary profile
|
||||
query = select(Profile).where(Profile.user_id == current_user.id)
|
||||
|
|
@ -402,6 +419,74 @@ def delete_my_account(
|
|||
|
||||
# --- Dynamic ID Routes (must be last to avoid conflicts with /me, /avatar) ---
|
||||
|
||||
@router.get("/profile/{username}", response_model=PublicProfileRead)
|
||||
def get_public_profile(username: str, session: Session = Depends(get_session)):
|
||||
"""Get rich public profile for poster view"""
|
||||
# 1. Find profile by username
|
||||
profile = session.exec(select(Profile).where(Profile.username == username)).first()
|
||||
|
||||
# Fallback: check if username matches a user email prefix (legacy/fallback)
|
||||
# or just 404. Let's start with strict Profile lookup.
|
||||
if not profile:
|
||||
# Try to find by User ID if it looks like an int? No, username is string.
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
user = session.get(User, profile.user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# 2. Get Stats
|
||||
attendance_count = session.exec(select(func.count(Attendance.id)).where(Attendance.user_id == user.id)).one()
|
||||
review_count = session.exec(select(func.count(Review.id)).where(Review.user_id == user.id)).one()
|
||||
unique_venues = session.exec(select(func.count(func.distinct(Show.venue_id))).join(Attendance).where(Attendance.user_id == user.id)).one()
|
||||
|
||||
# 3. Get Headliners (Preferences)
|
||||
headliners = []
|
||||
supporting = []
|
||||
|
||||
# Sort prefs by priority/tier
|
||||
# We need to eager load vertical
|
||||
if user.vertical_preferences:
|
||||
for pref in user.vertical_preferences:
|
||||
band_data = HeadlinerBand(
|
||||
name=pref.vertical.name,
|
||||
slug=pref.vertical.slug,
|
||||
tier=pref.tier,
|
||||
logo_url=pref.vertical.logo_url
|
||||
)
|
||||
if pref.tier == "headliner":
|
||||
headliners.append(band_data)
|
||||
else:
|
||||
supporting.append(band_data)
|
||||
|
||||
# Social Handles
|
||||
socials = SocialHandles(
|
||||
bluesky=user.bluesky_handle,
|
||||
mastodon=user.mastodon_handle,
|
||||
instagram=user.instagram_handle
|
||||
)
|
||||
|
||||
return PublicProfileRead(
|
||||
id=user.id,
|
||||
username=profile.username,
|
||||
display_name=profile.display_name or profile.username,
|
||||
bio=user.bio,
|
||||
avatar=user.avatar,
|
||||
avatar_bg_color=user.avatar_bg_color,
|
||||
avatar_text=user.avatar_text,
|
||||
location=user.location,
|
||||
social_handles=socials,
|
||||
headliners=headliners,
|
||||
supporting_acts=supporting,
|
||||
stats={
|
||||
"shows_attended": attendance_count,
|
||||
"reviews_written": review_count,
|
||||
"venues_visited": unique_venues
|
||||
},
|
||||
joined_at=user.joined_at
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserRead)
|
||||
def get_user_public(user_id: int, session: Session = Depends(get_session)):
|
||||
"""Get public user profile"""
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlmodel import Session, select
|
||||
from sqlmodel import Session, select, func
|
||||
from database import get_session
|
||||
from models import Venue
|
||||
from schemas import VenueCreate, VenueRead, VenueUpdate
|
||||
from schemas import VenueCreate, VenueRead, VenueUpdate, PaginatedResponse, PaginationMeta
|
||||
from auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/venues", tags=["venues"])
|
||||
|
|
@ -16,10 +16,14 @@ def create_venue(venue: VenueCreate, session: Session = Depends(get_session), cu
|
|||
session.refresh(db_venue)
|
||||
return db_venue
|
||||
|
||||
@router.get("/", response_model=List[VenueRead])
|
||||
@router.get("/", response_model=PaginatedResponse[VenueRead])
|
||||
def read_venues(offset: int = 0, limit: int = Query(default=1000, le=1000), session: Session = Depends(get_session)):
|
||||
total = session.exec(select(func.count()).select_from(Venue)).one()
|
||||
venues = session.exec(select(Venue).offset(offset).limit(limit)).all()
|
||||
return venues
|
||||
return PaginatedResponse(
|
||||
data=venues,
|
||||
meta=PaginationMeta(total=total, limit=limit, offset=offset)
|
||||
)
|
||||
|
||||
@router.get("/{slug}", response_model=VenueRead)
|
||||
def read_venue(slug: str, session: Session = Depends(get_session)):
|
||||
|
|
@ -40,3 +44,142 @@ def update_venue(venue_id: int, venue: VenueUpdate, session: Session = Depends(g
|
|||
session.commit()
|
||||
session.refresh(db_venue)
|
||||
return db_venue
|
||||
|
||||
|
||||
@router.get("/{slug}/across-bands")
|
||||
def get_venue_across_bands(slug: str, session: Session = Depends(get_session)):
|
||||
"""Get aggregated stats for a venue across all bands that have played there (via VenueCanon)"""
|
||||
from models import VenueCanon, Show, Vertical
|
||||
from sqlmodel import func
|
||||
|
||||
# Find the venue by slug
|
||||
venue = session.exec(select(Venue).where(Venue.slug == slug)).first()
|
||||
if not venue:
|
||||
raise HTTPException(status_code=404, detail="Venue not found")
|
||||
|
||||
# If this venue has a canon_id, get all linked venues
|
||||
linked_venues = [venue]
|
||||
if venue.canon_id:
|
||||
linked_venues = session.exec(
|
||||
select(Venue).where(Venue.canon_id == venue.canon_id)
|
||||
).all()
|
||||
|
||||
venue_ids = [v.id for v in linked_venues]
|
||||
|
||||
# Get all shows at these venues
|
||||
shows = session.exec(
|
||||
select(Show)
|
||||
.where(Show.venue_id.in_(venue_ids))
|
||||
.order_by(Show.date.desc())
|
||||
).all()
|
||||
|
||||
# Group by vertical/band
|
||||
bands_stats = {}
|
||||
for show in shows:
|
||||
vertical = session.get(Vertical, show.vertical_id)
|
||||
if vertical:
|
||||
if vertical.id not in bands_stats:
|
||||
bands_stats[vertical.id] = {
|
||||
"vertical_id": vertical.id,
|
||||
"vertical_name": vertical.name,
|
||||
"vertical_slug": vertical.slug,
|
||||
"show_count": 0,
|
||||
"first_show": show.date,
|
||||
"last_show": show.date,
|
||||
"recent_shows": []
|
||||
}
|
||||
bands_stats[vertical.id]["show_count"] += 1
|
||||
if show.date < bands_stats[vertical.id]["first_show"]:
|
||||
bands_stats[vertical.id]["first_show"] = show.date
|
||||
if show.date > bands_stats[vertical.id]["last_show"]:
|
||||
bands_stats[vertical.id]["last_show"] = show.date
|
||||
if len(bands_stats[vertical.id]["recent_shows"]) < 3:
|
||||
bands_stats[vertical.id]["recent_shows"].append({
|
||||
"date": show.date.strftime("%Y-%m-%d") if show.date else None,
|
||||
"slug": show.slug
|
||||
})
|
||||
|
||||
# Format response
|
||||
bands_list = sorted(bands_stats.values(), key=lambda x: x["show_count"], reverse=True)
|
||||
for band in bands_list:
|
||||
band["first_show"] = band["first_show"].strftime("%Y-%m-%d") if band["first_show"] else None
|
||||
band["last_show"] = band["last_show"].strftime("%Y-%m-%d") if band["last_show"] else None
|
||||
|
||||
return {
|
||||
"venue": {
|
||||
"id": venue.id,
|
||||
"name": venue.name,
|
||||
"slug": venue.slug,
|
||||
"city": venue.city,
|
||||
"state": venue.state,
|
||||
"country": venue.country,
|
||||
"capacity": venue.capacity,
|
||||
},
|
||||
"total_shows": len(shows),
|
||||
"bands_count": len(bands_list),
|
||||
"bands": bands_list
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{slug}/timeline")
|
||||
def get_venue_timeline(
|
||||
slug: str,
|
||||
limit: int = Query(default=50, le=200),
|
||||
offset: int = 0,
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""Get chronological timeline of all shows at this venue across all bands"""
|
||||
from models import VenueCanon, Show, Vertical
|
||||
|
||||
venue = session.exec(select(Venue).where(Venue.slug == slug)).first()
|
||||
if not venue:
|
||||
raise HTTPException(status_code=404, detail="Venue not found")
|
||||
|
||||
# Get all linked venues via canon
|
||||
venue_ids = [venue.id]
|
||||
if venue.canon_id:
|
||||
linked = session.exec(
|
||||
select(Venue).where(Venue.canon_id == venue.canon_id)
|
||||
).all()
|
||||
venue_ids = [v.id for v in linked]
|
||||
|
||||
# Get all shows at these venues, ordered by date
|
||||
shows = session.exec(
|
||||
select(Show)
|
||||
.where(Show.venue_id.in_(venue_ids))
|
||||
.order_by(Show.date.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
).all()
|
||||
|
||||
timeline = []
|
||||
for show in shows:
|
||||
vertical = session.get(Vertical, show.vertical_id)
|
||||
timeline.append({
|
||||
"show_id": show.id,
|
||||
"show_slug": show.slug,
|
||||
"date": show.date.strftime("%Y-%m-%d") if show.date else None,
|
||||
"vertical_name": vertical.name if vertical else "Unknown",
|
||||
"vertical_slug": vertical.slug if vertical else "unknown",
|
||||
"vertical_color": vertical.primary_color if vertical else None,
|
||||
"notes": show.notes
|
||||
})
|
||||
|
||||
# Get total count
|
||||
total = len(session.exec(
|
||||
select(Show).where(Show.venue_id.in_(venue_ids))
|
||||
).all())
|
||||
|
||||
return {
|
||||
"venue": {
|
||||
"id": venue.id,
|
||||
"name": venue.name,
|
||||
"slug": venue.slug,
|
||||
"city": venue.city,
|
||||
"state": venue.state
|
||||
},
|
||||
"total_shows": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"timeline": timeline
|
||||
}
|
||||
|
|
|
|||
287
backend/routers/verticals.py
Normal file
287
backend/routers/verticals.py
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
from typing import List
|
||||
from database import get_session
|
||||
from models import User, Vertical, UserVerticalPreference
|
||||
from auth import get_current_user
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(prefix="/verticals", tags=["verticals"])
|
||||
|
||||
|
||||
class VerticalRead(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
description: str | None = None
|
||||
logo_url: str | None = None
|
||||
show_count: int = 0
|
||||
|
||||
|
||||
class UserVerticalPreferenceRead(BaseModel):
|
||||
vertical_id: int
|
||||
vertical: VerticalRead
|
||||
display_mode: str
|
||||
priority: int
|
||||
tier: str = "main_stage"
|
||||
notify_on_show: bool
|
||||
|
||||
|
||||
class UserVerticalPreferenceCreate(BaseModel):
|
||||
vertical_id: int
|
||||
display_mode: str = "primary" # primary, secondary, attribution_only, hidden
|
||||
priority: int = 0
|
||||
tier: str = "main_stage"
|
||||
notify_on_show: bool = True
|
||||
|
||||
|
||||
class UserVerticalPreferenceUpdate(BaseModel):
|
||||
display_mode: str | None = None
|
||||
priority: int | None = None
|
||||
tier: str | None = None
|
||||
notify_on_show: bool | None = None
|
||||
|
||||
|
||||
class BulkVerticalPreferencesCreate(BaseModel):
|
||||
"""For onboarding - set multiple band preferences at once"""
|
||||
vertical_ids: List[int]
|
||||
display_mode: str = "primary"
|
||||
|
||||
|
||||
# --- Public endpoints ---
|
||||
|
||||
@router.get("/", response_model=List[VerticalRead])
|
||||
def list_verticals(
|
||||
scene: str | None = None,
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""List all available verticals (bands) with show counts"""
|
||||
from models import Show, VerticalScene, Scene
|
||||
from sqlalchemy import func
|
||||
|
||||
# Base query: Active verticals with show count
|
||||
query = select(Vertical, func.count(Show.id).label("show_count")) \
|
||||
.outerjoin(Show, Vertical.id == Show.vertical_id) \
|
||||
.where(Vertical.is_active == True)
|
||||
|
||||
if scene:
|
||||
query = query.join(VerticalScene).join(Scene).where(Scene.slug == scene)
|
||||
|
||||
query = query.group_by(Vertical.id).order_by(Vertical.name)
|
||||
|
||||
results = session.exec(query).all()
|
||||
|
||||
return [
|
||||
VerticalRead(
|
||||
**v.model_dump(),
|
||||
logo_url=v.logo_url,
|
||||
show_count=count
|
||||
)
|
||||
for v, count in results
|
||||
]
|
||||
|
||||
|
||||
class SceneRead(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@router.get("/scenes", response_model=List[SceneRead])
|
||||
def list_scenes(session: Session = Depends(get_session)):
|
||||
"""List all scenes (genres)"""
|
||||
from models import Scene
|
||||
scenes = session.exec(select(Scene)).all()
|
||||
return scenes
|
||||
|
||||
|
||||
@router.get("/{slug}", response_model=VerticalRead)
|
||||
def get_vertical(slug: str, session: Session = Depends(get_session)):
|
||||
"""Get a specific vertical by slug"""
|
||||
vertical = session.exec(select(Vertical).where(Vertical.slug == slug)).first()
|
||||
if not vertical:
|
||||
raise HTTPException(status_code=404, detail="Vertical not found")
|
||||
return vertical
|
||||
|
||||
|
||||
# --- User preference endpoints ---
|
||||
|
||||
@router.get("/preferences/me", response_model=List[UserVerticalPreferenceRead])
|
||||
def get_my_vertical_preferences(
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get current user's band preferences"""
|
||||
prefs = session.exec(
|
||||
select(UserVerticalPreference)
|
||||
.where(UserVerticalPreference.user_id == current_user.id)
|
||||
.order_by(UserVerticalPreference.priority)
|
||||
).all()
|
||||
|
||||
# Enrich with vertical data
|
||||
result = []
|
||||
for pref in prefs:
|
||||
vertical = session.get(Vertical, pref.vertical_id)
|
||||
if vertical:
|
||||
result.append({
|
||||
"vertical_id": pref.vertical_id,
|
||||
"vertical": vertical,
|
||||
"display_mode": pref.display_mode,
|
||||
"priority": pref.priority,
|
||||
"tier": pref.tier,
|
||||
"notify_on_show": pref.notify_on_show
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/preferences", response_model=UserVerticalPreferenceRead)
|
||||
def add_vertical_preference(
|
||||
pref: UserVerticalPreferenceCreate,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Add a band to user's preferences"""
|
||||
# Check vertical exists
|
||||
vertical = session.get(Vertical, pref.vertical_id)
|
||||
if not vertical:
|
||||
raise HTTPException(status_code=404, detail="Vertical not found")
|
||||
|
||||
# Check if already exists
|
||||
existing = session.exec(
|
||||
select(UserVerticalPreference)
|
||||
.where(UserVerticalPreference.user_id == current_user.id)
|
||||
.where(UserVerticalPreference.vertical_id == pref.vertical_id)
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Preference already exists")
|
||||
|
||||
db_pref = UserVerticalPreference(
|
||||
user_id=current_user.id,
|
||||
vertical_id=pref.vertical_id,
|
||||
display_mode=pref.display_mode,
|
||||
priority=pref.priority,
|
||||
tier=pref.tier,
|
||||
notify_on_show=pref.notify_on_show
|
||||
)
|
||||
session.add(db_pref)
|
||||
session.commit()
|
||||
session.refresh(db_pref)
|
||||
|
||||
return {
|
||||
"vertical_id": db_pref.vertical_id,
|
||||
"vertical": vertical,
|
||||
"display_mode": db_pref.display_mode,
|
||||
"priority": db_pref.priority,
|
||||
"tier": db_pref.tier,
|
||||
"notify_on_show": db_pref.notify_on_show
|
||||
}
|
||||
|
||||
|
||||
@router.post("/preferences/bulk", response_model=List[UserVerticalPreferenceRead])
|
||||
def set_vertical_preferences_bulk(
|
||||
data: BulkVerticalPreferencesCreate,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Set multiple band preferences at once (for onboarding)"""
|
||||
result = []
|
||||
|
||||
for idx, vid in enumerate(data.vertical_ids):
|
||||
vertical = session.get(Vertical, vid)
|
||||
if not vertical:
|
||||
continue
|
||||
|
||||
# Upsert
|
||||
existing = session.exec(
|
||||
select(UserVerticalPreference)
|
||||
.where(UserVerticalPreference.user_id == current_user.id)
|
||||
.where(UserVerticalPreference.vertical_id == vid)
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.display_mode = data.display_mode
|
||||
existing.priority = idx
|
||||
session.add(existing)
|
||||
pref = existing
|
||||
else:
|
||||
pref = UserVerticalPreference(
|
||||
user_id=current_user.id,
|
||||
vertical_id=vid,
|
||||
display_mode=data.display_mode,
|
||||
priority=idx,
|
||||
notify_on_show=True
|
||||
)
|
||||
session.add(pref)
|
||||
|
||||
result.append({
|
||||
"vertical_id": vid,
|
||||
"vertical": vertical,
|
||||
"display_mode": data.display_mode,
|
||||
"priority": idx,
|
||||
"notify_on_show": True
|
||||
})
|
||||
|
||||
session.commit()
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/preferences/{vertical_id}", response_model=UserVerticalPreferenceRead)
|
||||
def update_vertical_preference(
|
||||
vertical_id: int,
|
||||
data: UserVerticalPreferenceUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update a specific band preference"""
|
||||
pref = session.exec(
|
||||
select(UserVerticalPreference)
|
||||
.where(UserVerticalPreference.user_id == current_user.id)
|
||||
.where(UserVerticalPreference.vertical_id == vertical_id)
|
||||
).first()
|
||||
|
||||
if not pref:
|
||||
raise HTTPException(status_code=404, detail="Preference not found")
|
||||
|
||||
vertical = session.get(Vertical, vertical_id)
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(pref, key, value)
|
||||
|
||||
session.add(pref)
|
||||
session.commit()
|
||||
session.refresh(pref)
|
||||
|
||||
return {
|
||||
"vertical_id": pref.vertical_id,
|
||||
"vertical": vertical,
|
||||
"display_mode": pref.display_mode,
|
||||
"priority": pref.priority,
|
||||
"tier": pref.tier,
|
||||
"notify_on_show": pref.notify_on_show
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/preferences/{vertical_id}")
|
||||
def delete_vertical_preference(
|
||||
vertical_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Remove a band from user's preferences"""
|
||||
pref = session.exec(
|
||||
select(UserVerticalPreference)
|
||||
.where(UserVerticalPreference.user_id == current_user.id)
|
||||
.where(UserVerticalPreference.vertical_id == vertical_id)
|
||||
).first()
|
||||
|
||||
if not pref:
|
||||
raise HTTPException(status_code=404, detail="Preference not found")
|
||||
|
||||
session.delete(pref)
|
||||
session.commit()
|
||||
|
||||
return {"ok": True}
|
||||
|
|
@ -1,24 +1,484 @@
|
|||
"""
|
||||
Videos endpoint - list all performances and shows with YouTube links
|
||||
Video API Router - Modular Video Entity System
|
||||
Videos can be linked to multiple entities: shows, performances, songs, musicians
|
||||
Building for scale - no shortcuts.
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlmodel import Session, select
|
||||
from database import get_session
|
||||
from models import Show, Performance, Song, Venue
|
||||
from models import (
|
||||
Video, VideoShow, VideoPerformance, VideoSong, VideoMusician,
|
||||
Show, Performance, Song, Musician, Vertical,
|
||||
VideoType, VideoPlatform
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
router = APIRouter(prefix="/videos", tags=["videos"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def get_all_videos(
|
||||
# --- Schemas ---
|
||||
|
||||
class VideoCreate(BaseModel):
|
||||
url: str
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
platform: Optional[str] = "youtube"
|
||||
video_type: Optional[str] = "single_song"
|
||||
duration_seconds: Optional[int] = None
|
||||
thumbnail_url: Optional[str] = None
|
||||
vertical_id: Optional[int] = None
|
||||
# Link IDs (optional on create)
|
||||
show_ids: Optional[List[int]] = None
|
||||
performance_ids: Optional[List[int]] = None
|
||||
song_ids: Optional[List[int]] = None
|
||||
musician_ids: Optional[List[int]] = None
|
||||
|
||||
|
||||
class VideoRead(BaseModel):
|
||||
id: int
|
||||
url: str
|
||||
title: Optional[str]
|
||||
description: Optional[str]
|
||||
platform: str
|
||||
video_type: str
|
||||
duration_seconds: Optional[int]
|
||||
thumbnail_url: Optional[str]
|
||||
external_id: Optional[str]
|
||||
recorded_date: Optional[datetime]
|
||||
published_date: Optional[datetime]
|
||||
created_at: datetime
|
||||
vertical_id: Optional[int]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class VideoWithRelations(VideoRead):
|
||||
"""Video with linked entities"""
|
||||
shows: List[dict] = []
|
||||
performances: List[dict] = []
|
||||
songs: List[dict] = []
|
||||
musicians: List[dict] = []
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
def extract_youtube_id(url: str) -> Optional[str]:
|
||||
"""Extract YouTube video ID from URL"""
|
||||
patterns = [
|
||||
r'(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/embed/)([a-zA-Z0-9_-]{11})',
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def detect_platform(url: str) -> str:
|
||||
"""Detect video platform from URL"""
|
||||
url_lower = url.lower()
|
||||
if 'youtube.com' in url_lower or 'youtu.be' in url_lower:
|
||||
return "youtube"
|
||||
elif 'vimeo.com' in url_lower:
|
||||
return "vimeo"
|
||||
elif 'nugs.net' in url_lower:
|
||||
return "nugs"
|
||||
elif 'bandcamp.com' in url_lower:
|
||||
return "bandcamp"
|
||||
elif 'archive.org' in url_lower:
|
||||
return "archive"
|
||||
return "other"
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.get("/", response_model=List[VideoRead])
|
||||
def list_videos(
|
||||
limit: int = Query(default=50, le=200),
|
||||
offset: int = 0,
|
||||
platform: Optional[str] = None,
|
||||
video_type: Optional[str] = None,
|
||||
vertical_id: Optional[int] = None,
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""List all videos with optional filters"""
|
||||
query = select(Video).order_by(Video.created_at.desc())
|
||||
|
||||
if platform:
|
||||
query = query.where(Video.platform == platform)
|
||||
if video_type:
|
||||
query = query.where(Video.video_type == video_type)
|
||||
if vertical_id:
|
||||
query = query.where(Video.vertical_id == vertical_id)
|
||||
|
||||
query = query.offset(offset).limit(limit)
|
||||
videos = session.exec(query).all()
|
||||
|
||||
# Convert to response format
|
||||
return [
|
||||
VideoRead(
|
||||
id=v.id,
|
||||
url=v.url,
|
||||
title=v.title,
|
||||
description=v.description,
|
||||
platform=v.platform.value if hasattr(v.platform, 'value') else str(v.platform),
|
||||
video_type=v.video_type.value if hasattr(v.video_type, 'value') else str(v.video_type),
|
||||
duration_seconds=v.duration_seconds,
|
||||
thumbnail_url=v.thumbnail_url,
|
||||
external_id=v.external_id,
|
||||
recorded_date=v.recorded_date,
|
||||
published_date=v.published_date,
|
||||
created_at=v.created_at,
|
||||
vertical_id=v.vertical_id,
|
||||
)
|
||||
for v in videos
|
||||
]
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def get_video_stats(session: Session = Depends(get_session)):
|
||||
"""Get video statistics"""
|
||||
from sqlmodel import func
|
||||
|
||||
total_videos = session.exec(select(func.count(Video.id))).one()
|
||||
|
||||
# Count by platform
|
||||
youtube_count = session.exec(
|
||||
select(func.count(Video.id)).where(Video.platform == VideoPlatform.YOUTUBE)
|
||||
).one()
|
||||
|
||||
# Count by type
|
||||
full_show_count = session.exec(
|
||||
select(func.count(Video.id)).where(Video.video_type == VideoType.FULL_SHOW)
|
||||
).one()
|
||||
|
||||
return {
|
||||
"total_videos": total_videos,
|
||||
"youtube_videos": youtube_count,
|
||||
"full_show_videos": full_show_count,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{video_id}", response_model=VideoWithRelations)
|
||||
def get_video(video_id: int, session: Session = Depends(get_session)):
|
||||
"""Get a single video with all relationships"""
|
||||
video = session.get(Video, video_id)
|
||||
if not video:
|
||||
raise HTTPException(status_code=404, detail="Video not found")
|
||||
|
||||
# Build response with relations
|
||||
return VideoWithRelations(
|
||||
id=video.id,
|
||||
url=video.url,
|
||||
title=video.title,
|
||||
description=video.description,
|
||||
platform=video.platform.value if hasattr(video.platform, 'value') else str(video.platform),
|
||||
video_type=video.video_type.value if hasattr(video.video_type, 'value') else str(video.video_type),
|
||||
duration_seconds=video.duration_seconds,
|
||||
thumbnail_url=video.thumbnail_url,
|
||||
external_id=video.external_id,
|
||||
recorded_date=video.recorded_date,
|
||||
published_date=video.published_date,
|
||||
created_at=video.created_at,
|
||||
vertical_id=video.vertical_id,
|
||||
shows=[{"id": vs.show.id, "date": vs.show.date.isoformat(), "slug": vs.show.slug} for vs in video.shows if vs.show],
|
||||
performances=[{"id": vp.performance.id, "slug": vp.performance.slug} for vp in video.performances if vp.performance],
|
||||
songs=[{"id": vs.song.id, "title": vs.song.title, "slug": vs.song.slug} for vs in video.songs if vs.song],
|
||||
musicians=[{"id": vm.musician.id, "name": vm.musician.name, "slug": vm.musician.slug} for vm in video.musicians if vm.musician],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=VideoRead)
|
||||
def create_video(video_data: VideoCreate, session: Session = Depends(get_session)):
|
||||
"""Create a new video with optional entity links"""
|
||||
|
||||
# Auto-detect platform
|
||||
platform = detect_platform(video_data.url)
|
||||
|
||||
# Extract external ID for YouTube
|
||||
external_id = None
|
||||
if platform == "youtube":
|
||||
external_id = extract_youtube_id(video_data.url)
|
||||
|
||||
# Map string to enum
|
||||
try:
|
||||
platform_enum = VideoPlatform(platform)
|
||||
except ValueError:
|
||||
platform_enum = VideoPlatform.OTHER
|
||||
|
||||
try:
|
||||
video_type_enum = VideoType(video_data.video_type or "single_song")
|
||||
except ValueError:
|
||||
video_type_enum = VideoType.OTHER
|
||||
|
||||
# Create video
|
||||
video = Video(
|
||||
url=video_data.url,
|
||||
title=video_data.title,
|
||||
description=video_data.description,
|
||||
platform=platform_enum,
|
||||
video_type=video_type_enum,
|
||||
duration_seconds=video_data.duration_seconds,
|
||||
thumbnail_url=video_data.thumbnail_url,
|
||||
external_id=external_id,
|
||||
vertical_id=video_data.vertical_id,
|
||||
)
|
||||
session.add(video)
|
||||
session.commit()
|
||||
session.refresh(video)
|
||||
|
||||
# Create relationships
|
||||
if video_data.show_ids:
|
||||
for show_id in video_data.show_ids:
|
||||
link = VideoShow(video_id=video.id, show_id=show_id)
|
||||
session.add(link)
|
||||
|
||||
if video_data.performance_ids:
|
||||
for perf_id in video_data.performance_ids:
|
||||
link = VideoPerformance(video_id=video.id, performance_id=perf_id)
|
||||
session.add(link)
|
||||
|
||||
if video_data.song_ids:
|
||||
for song_id in video_data.song_ids:
|
||||
link = VideoSong(video_id=video.id, song_id=song_id)
|
||||
session.add(link)
|
||||
|
||||
if video_data.musician_ids:
|
||||
for musician_id in video_data.musician_ids:
|
||||
link = VideoMusician(video_id=video.id, musician_id=musician_id)
|
||||
session.add(link)
|
||||
|
||||
session.commit()
|
||||
|
||||
return VideoRead(
|
||||
id=video.id,
|
||||
url=video.url,
|
||||
title=video.title,
|
||||
description=video.description,
|
||||
platform=video.platform.value if hasattr(video.platform, 'value') else str(video.platform),
|
||||
video_type=video.video_type.value if hasattr(video.video_type, 'value') else str(video.video_type),
|
||||
duration_seconds=video.duration_seconds,
|
||||
thumbnail_url=video.thumbnail_url,
|
||||
external_id=video.external_id,
|
||||
recorded_date=video.recorded_date,
|
||||
published_date=video.published_date,
|
||||
created_at=video.created_at,
|
||||
vertical_id=video.vertical_id,
|
||||
)
|
||||
|
||||
|
||||
# --- Entity-specific video endpoints ---
|
||||
|
||||
@router.get("/by-show/{show_id}", response_model=List[VideoRead])
|
||||
def get_videos_for_show(show_id: int, session: Session = Depends(get_session)):
|
||||
"""Get all videos linked to a specific show"""
|
||||
query = select(Video).join(VideoShow).where(VideoShow.show_id == show_id)
|
||||
videos = session.exec(query).all()
|
||||
return [
|
||||
VideoRead(
|
||||
id=v.id, url=v.url, title=v.title, description=v.description,
|
||||
platform=v.platform.value if hasattr(v.platform, 'value') else str(v.platform),
|
||||
video_type=v.video_type.value if hasattr(v.video_type, 'value') else str(v.video_type),
|
||||
duration_seconds=v.duration_seconds, thumbnail_url=v.thumbnail_url,
|
||||
external_id=v.external_id, recorded_date=v.recorded_date,
|
||||
published_date=v.published_date, created_at=v.created_at, vertical_id=v.vertical_id
|
||||
) for v in videos
|
||||
]
|
||||
|
||||
|
||||
@router.get("/by-performance/{performance_id}", response_model=List[VideoRead])
|
||||
def get_videos_for_performance(performance_id: int, session: Session = Depends(get_session)):
|
||||
"""Get all videos linked to a specific performance"""
|
||||
query = select(Video).join(VideoPerformance).where(VideoPerformance.performance_id == performance_id)
|
||||
videos = session.exec(query).all()
|
||||
return [
|
||||
VideoRead(
|
||||
id=v.id, url=v.url, title=v.title, description=v.description,
|
||||
platform=v.platform.value if hasattr(v.platform, 'value') else str(v.platform),
|
||||
video_type=v.video_type.value if hasattr(v.video_type, 'value') else str(v.video_type),
|
||||
duration_seconds=v.duration_seconds, thumbnail_url=v.thumbnail_url,
|
||||
external_id=v.external_id, recorded_date=v.recorded_date,
|
||||
published_date=v.published_date, created_at=v.created_at, vertical_id=v.vertical_id
|
||||
) for v in videos
|
||||
]
|
||||
|
||||
|
||||
@router.get("/by-song/{song_id}", response_model=List[VideoRead])
|
||||
def get_videos_for_song(song_id: int, session: Session = Depends(get_session)):
|
||||
"""Get all videos linked to a specific song"""
|
||||
query = select(Video).join(VideoSong).where(VideoSong.song_id == song_id)
|
||||
videos = session.exec(query).all()
|
||||
return [
|
||||
VideoRead(
|
||||
id=v.id, url=v.url, title=v.title, description=v.description,
|
||||
platform=v.platform.value if hasattr(v.platform, 'value') else str(v.platform),
|
||||
video_type=v.video_type.value if hasattr(v.video_type, 'value') else str(v.video_type),
|
||||
duration_seconds=v.duration_seconds, thumbnail_url=v.thumbnail_url,
|
||||
external_id=v.external_id, recorded_date=v.recorded_date,
|
||||
published_date=v.published_date, created_at=v.created_at, vertical_id=v.vertical_id
|
||||
) for v in videos
|
||||
]
|
||||
|
||||
|
||||
@router.get("/by-musician/{musician_id}", response_model=List[VideoRead])
|
||||
def get_videos_for_musician(musician_id: int, session: Session = Depends(get_session)):
|
||||
"""Get all videos linked to a specific musician"""
|
||||
query = select(Video).join(VideoMusician).where(VideoMusician.musician_id == musician_id)
|
||||
videos = session.exec(query).all()
|
||||
return [
|
||||
VideoRead(
|
||||
id=v.id, url=v.url, title=v.title, description=v.description,
|
||||
platform=v.platform.value if hasattr(v.platform, 'value') else str(v.platform),
|
||||
video_type=v.video_type.value if hasattr(v.video_type, 'value') else str(v.video_type),
|
||||
duration_seconds=v.duration_seconds, thumbnail_url=v.thumbnail_url,
|
||||
external_id=v.external_id, recorded_date=v.recorded_date,
|
||||
published_date=v.published_date, created_at=v.created_at, vertical_id=v.vertical_id
|
||||
) for v in videos
|
||||
]
|
||||
|
||||
|
||||
@router.get("/by-band/{vertical_slug}", response_model=List[VideoRead])
|
||||
def get_videos_for_band(
|
||||
vertical_slug: str,
|
||||
limit: int = Query(default=50, le=200),
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""Get all videos for a band/vertical"""
|
||||
vertical = session.exec(select(Vertical).where(Vertical.slug == vertical_slug)).first()
|
||||
if not vertical:
|
||||
raise HTTPException(status_code=404, detail="Band not found")
|
||||
|
||||
query = select(Video).where(Video.vertical_id == vertical.id).order_by(Video.created_at.desc()).limit(limit)
|
||||
videos = session.exec(query).all()
|
||||
return [
|
||||
VideoRead(
|
||||
id=v.id, url=v.url, title=v.title, description=v.description,
|
||||
platform=v.platform.value if hasattr(v.platform, 'value') else str(v.platform),
|
||||
video_type=v.video_type.value if hasattr(v.video_type, 'value') else str(v.video_type),
|
||||
duration_seconds=v.duration_seconds, thumbnail_url=v.thumbnail_url,
|
||||
external_id=v.external_id, recorded_date=v.recorded_date,
|
||||
published_date=v.published_date, created_at=v.created_at, vertical_id=v.vertical_id
|
||||
) for v in videos
|
||||
]
|
||||
|
||||
|
||||
# --- Link management endpoints ---
|
||||
|
||||
@router.post("/{video_id}/link-show/{show_id}")
|
||||
def link_video_to_show(video_id: int, show_id: int, session: Session = Depends(get_session)):
|
||||
"""Link a video to a show"""
|
||||
video = session.get(Video, video_id)
|
||||
show = session.get(Show, show_id)
|
||||
if not video or not show:
|
||||
raise HTTPException(status_code=404, detail="Video or show not found")
|
||||
|
||||
existing = session.exec(
|
||||
select(VideoShow).where(VideoShow.video_id == video_id, VideoShow.show_id == show_id)
|
||||
).first()
|
||||
if existing:
|
||||
return {"message": "Already linked"}
|
||||
|
||||
link = VideoShow(video_id=video_id, show_id=show_id)
|
||||
session.add(link)
|
||||
session.commit()
|
||||
return {"message": "Linked successfully"}
|
||||
|
||||
|
||||
@router.post("/{video_id}/link-performance/{performance_id}")
|
||||
def link_video_to_performance(
|
||||
video_id: int,
|
||||
performance_id: int,
|
||||
timestamp_start: Optional[int] = Query(default=None),
|
||||
timestamp_end: Optional[int] = Query(default=None),
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""Link a video to a performance with optional timestamps"""
|
||||
video = session.get(Video, video_id)
|
||||
performance = session.get(Performance, performance_id)
|
||||
if not video or not performance:
|
||||
raise HTTPException(status_code=404, detail="Video or performance not found")
|
||||
|
||||
existing = session.exec(
|
||||
select(VideoPerformance).where(
|
||||
VideoPerformance.video_id == video_id,
|
||||
VideoPerformance.performance_id == performance_id
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
return {"message": "Already linked"}
|
||||
|
||||
link = VideoPerformance(
|
||||
video_id=video_id,
|
||||
performance_id=performance_id,
|
||||
timestamp_start=timestamp_start,
|
||||
timestamp_end=timestamp_end
|
||||
)
|
||||
session.add(link)
|
||||
session.commit()
|
||||
return {"message": "Linked successfully"}
|
||||
|
||||
|
||||
@router.post("/{video_id}/link-song/{song_id}")
|
||||
def link_video_to_song(video_id: int, song_id: int, session: Session = Depends(get_session)):
|
||||
"""Link a video to a song"""
|
||||
video = session.get(Video, video_id)
|
||||
song = session.get(Song, song_id)
|
||||
if not video or not song:
|
||||
raise HTTPException(status_code=404, detail="Video or song not found")
|
||||
|
||||
existing = session.exec(
|
||||
select(VideoSong).where(VideoSong.video_id == video_id, VideoSong.song_id == song_id)
|
||||
).first()
|
||||
if existing:
|
||||
return {"message": "Already linked"}
|
||||
|
||||
link = VideoSong(video_id=video_id, song_id=song_id)
|
||||
session.add(link)
|
||||
session.commit()
|
||||
return {"message": "Linked successfully"}
|
||||
|
||||
|
||||
@router.post("/{video_id}/link-musician/{musician_id}")
|
||||
def link_video_to_musician(
|
||||
video_id: int,
|
||||
musician_id: int,
|
||||
role: Optional[str] = Query(default=None),
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""Link a video to a musician"""
|
||||
video = session.get(Video, video_id)
|
||||
musician = session.get(Musician, musician_id)
|
||||
if not video or not musician:
|
||||
raise HTTPException(status_code=404, detail="Video or musician not found")
|
||||
|
||||
existing = session.exec(
|
||||
select(VideoMusician).where(VideoMusician.video_id == video_id, VideoMusician.musician_id == musician_id)
|
||||
).first()
|
||||
if existing:
|
||||
return {"message": "Already linked"}
|
||||
|
||||
link = VideoMusician(video_id=video_id, musician_id=musician_id, role=role)
|
||||
session.add(link)
|
||||
session.commit()
|
||||
return {"message": "Linked successfully"}
|
||||
|
||||
|
||||
# --- Legacy compatibility: Also return youtube_link from existing Show/Performance fields ---
|
||||
|
||||
@router.get("/legacy/all")
|
||||
def get_legacy_videos(
|
||||
limit: int = Query(default=100, le=500),
|
||||
offset: int = Query(default=0),
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
"""Get all performances and shows with YouTube links."""
|
||||
"""Legacy endpoint: Get shows and performances with youtube_link fields (for backwards compatibility)"""
|
||||
from models import Venue
|
||||
|
||||
# Get performances with videos
|
||||
# Get performances with youtube_link
|
||||
perf_query = (
|
||||
select(
|
||||
Performance.id,
|
||||
|
|
@ -32,12 +492,10 @@ def get_all_videos(
|
|||
Venue.name.label("venue_name"),
|
||||
Venue.city.label("venue_city"),
|
||||
Venue.state.label("venue_state"),
|
||||
Performance.slug.label("performance_slug"),
|
||||
Venue.slug.label("venue_slug")
|
||||
)
|
||||
.join(Song, Performance.song_id == Song.id)
|
||||
.join(Show, Performance.show_id == Show.id)
|
||||
.join(Venue, Show.venue_id == Venue.id)
|
||||
.outerjoin(Venue, Show.venue_id == Venue.id)
|
||||
.where(Performance.youtube_link != None)
|
||||
.order_by(Show.date.desc())
|
||||
.limit(limit)
|
||||
|
|
@ -54,19 +512,16 @@ def get_all_videos(
|
|||
"show_id": r[2],
|
||||
"song_id": r[3],
|
||||
"song_title": r[4],
|
||||
"song_slug": r[5],
|
||||
"date": r[6].isoformat() if r[6] else None,
|
||||
"show_slug": r[7],
|
||||
"venue_name": r[8],
|
||||
"venue_city": r[9],
|
||||
"venue_state": r[10],
|
||||
"performance_slug": r[11],
|
||||
"venue_slug": r[12]
|
||||
}
|
||||
for r in perf_results
|
||||
]
|
||||
|
||||
# Get shows with videos
|
||||
# Get shows with youtube_link
|
||||
show_query = (
|
||||
select(
|
||||
Show.id,
|
||||
|
|
@ -76,9 +531,8 @@ def get_all_videos(
|
|||
Venue.name.label("venue_name"),
|
||||
Venue.city.label("venue_city"),
|
||||
Venue.state.label("venue_state"),
|
||||
Venue.slug.label("venue_slug")
|
||||
)
|
||||
.join(Venue, Show.venue_id == Venue.id)
|
||||
.outerjoin(Venue, Show.venue_id == Venue.id)
|
||||
.where(Show.youtube_link != None)
|
||||
.order_by(Show.date.desc())
|
||||
.limit(limit)
|
||||
|
|
@ -97,7 +551,6 @@ def get_all_videos(
|
|||
"venue_name": r[4],
|
||||
"venue_city": r[5],
|
||||
"venue_state": r[6],
|
||||
"venue_slug": r[7]
|
||||
}
|
||||
for r in show_results
|
||||
]
|
||||
|
|
@ -108,23 +561,3 @@ def get_all_videos(
|
|||
"total_performances": len(performances),
|
||||
"total_shows": len(shows)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def get_video_stats(session: Session = Depends(get_session)):
|
||||
"""Get counts of videos in the database."""
|
||||
from sqlmodel import func
|
||||
|
||||
perf_count = session.exec(
|
||||
select(func.count(Performance.id)).where(Performance.youtube_link != None)
|
||||
).one()
|
||||
|
||||
show_count = session.exec(
|
||||
select(func.count(Show.id)).where(Show.youtube_link != None)
|
||||
).one()
|
||||
|
||||
return {
|
||||
"performance_videos": perf_count,
|
||||
"full_show_videos": show_count,
|
||||
"total": perf_count + show_count
|
||||
}
|
||||
|
|
|
|||
123
backend/run_import.py
Normal file
123
backend/run_import.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""
|
||||
Universal Setlist.fm importer for any band
|
||||
Usage: python run_import.py <band_slug>
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
from sqlmodel import Session, select
|
||||
from database import engine
|
||||
from models import Vertical
|
||||
from importers.setlistfm import SetlistFmImporter
|
||||
|
||||
# MusicBrainz IDs for bands (add more as needed)
|
||||
MBID_MAP = {
|
||||
"phish": "e01646f2-2a04-450d-8bf2-0571be6c3e3a",
|
||||
"goose": "b557b7f1-7c9f-431e-ac19-218967987251",
|
||||
"billy-strings": "640db492-34c4-47df-be14-96e2cd4b9fe4",
|
||||
"dmb": "07e748f1-075e-428d-85dc-ce3be434e906",
|
||||
"widespread-panic": "3797a6d0-7700-44bf-96fb-f44f8f3f4c10",
|
||||
"umphreys-mcgee": "47beb3b4-c7a5-4d8c-a186-e1d55f3bf5c6",
|
||||
"dead-and-company": "94f8947c-2d9c-4519-bcf9-6d11a24ad006",
|
||||
"sci": "589e8b3f-bae6-4f3b-b17a-7eb9b8f1b4c8", # String Cheese
|
||||
"moe": "f4c91c1e-3c51-4f7a-b8b7-5b9dd4c8e8c0",
|
||||
"disco-biscuits": "91e16aa5-76e1-4e36-bcce-6a3d2d1c9e6c",
|
||||
"tedeschi-trucks": "e99323c4-ce8d-4d2f-9c1f-0b8f3c8e2e1a",
|
||||
"ween": "4e58d516-e8e5-4d45-a7d2-1c16e0ad0e7c",
|
||||
"mmj": "ea5883b7-68ce-48b3-b115-61746ea53b8c", # My Morning Jacket
|
||||
"jrad": "7e8e5f7e-3b3a-4e5a-9f9b-8c9d7e6f5a4b", # Joe Russo's Almost Dead
|
||||
"grateful-dead": "837db7e8-9776-4700-9833-289524021287",
|
||||
"greensky-bluegrass": "8a9b0c1d-2e3f-4a5b-6c7d-8e9f0a1b2c3d",
|
||||
"lotus": "4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a",
|
||||
"pigeons": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", # Pigeons Playing Ping Pong
|
||||
"twiddle": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
|
||||
"spafford": "3c4d5e6f-7a8b-9c0d-1e2f-3a4b5c6d7e8f",
|
||||
"king-gizzard": "f58384a4-2ad2-4f24-89c5-51f2bc5df8d6",
|
||||
"khruangbin": "60b8f2ed-9d71-46ac-b8c2-15e3e6c1e9f3",
|
||||
}
|
||||
|
||||
|
||||
class DynamicImporter(SetlistFmImporter):
|
||||
"""Importer that can be configured for any band"""
|
||||
|
||||
def __init__(self, session: Session, vertical: Vertical, mbid: str):
|
||||
self.VERTICAL_NAME = vertical.name
|
||||
self.VERTICAL_SLUG = vertical.slug
|
||||
self.VERTICAL_DESCRIPTION = vertical.description or ""
|
||||
self.ARTIST_MBID = mbid
|
||||
self._vertical_obj = vertical
|
||||
super().__init__(session)
|
||||
|
||||
def get_or_create_vertical(self):
|
||||
"""Override to use existing vertical"""
|
||||
self.vertical = self._vertical_obj
|
||||
self.vertical_id = self._vertical_obj.id
|
||||
return self._vertical_obj
|
||||
|
||||
|
||||
def run_import(slug: str):
|
||||
"""Run import for a specific band by slug"""
|
||||
with Session(engine) as session:
|
||||
# Find the vertical
|
||||
vertical = session.exec(
|
||||
select(Vertical).where(Vertical.slug == slug)
|
||||
).first()
|
||||
|
||||
if not vertical:
|
||||
print(f"❌ Band not found: {slug}")
|
||||
print("Available bands:")
|
||||
all_v = session.exec(select(Vertical)).all()
|
||||
for v in all_v:
|
||||
print(f" - {v.slug}")
|
||||
return
|
||||
|
||||
# Get MBID
|
||||
mbid = MBID_MAP.get(slug)
|
||||
if not mbid:
|
||||
print(f"❌ No MusicBrainz ID found for: {slug}")
|
||||
print("Add MBID to MBID_MAP in run_import.py")
|
||||
return
|
||||
|
||||
print(f"🎸 Starting import for: {vertical.name}")
|
||||
print(f" MBID: {mbid}")
|
||||
|
||||
importer = DynamicImporter(session, vertical, mbid)
|
||||
importer.import_all()
|
||||
|
||||
|
||||
def run_all():
|
||||
"""Import all bands that have MBIDs"""
|
||||
with Session(engine) as session:
|
||||
for slug, mbid in MBID_MAP.items():
|
||||
vertical = session.exec(
|
||||
select(Vertical).where(Vertical.slug == slug)
|
||||
).first()
|
||||
|
||||
if not vertical:
|
||||
print(f"⚠️ Skipping {slug} - not in database")
|
||||
continue
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"🎸 Importing: {vertical.name}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
try:
|
||||
importer = DynamicImporter(session, vertical, mbid)
|
||||
importer.import_all()
|
||||
except Exception as e:
|
||||
print(f"❌ Error importing {slug}: {e}")
|
||||
continue
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1:
|
||||
if sys.argv[1] == "--all":
|
||||
run_all()
|
||||
else:
|
||||
run_import(sys.argv[1])
|
||||
else:
|
||||
print("Usage:")
|
||||
print(" python run_import.py <band-slug> # Import single band")
|
||||
print(" python run_import.py --all # Import all bands with MBIDs")
|
||||
print("\nAvailable slugs with MBIDs:")
|
||||
for slug in sorted(MBID_MAP.keys()):
|
||||
print(f" - {slug}")
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
from typing import Optional, List, Dict
|
||||
from typing import Optional, List, Dict, Generic, TypeVar
|
||||
from sqlmodel import SQLModel
|
||||
from datetime import datetime
|
||||
from pydantic import ConfigDict
|
||||
|
||||
class UserCreate(SQLModel):
|
||||
email: str
|
||||
|
|
@ -18,6 +19,14 @@ class UserRead(SQLModel):
|
|||
profile_public: bool = True
|
||||
show_attendance_public: bool = True
|
||||
appear_in_leaderboards: bool = True
|
||||
bio: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
joined_at: Optional[datetime] = None
|
||||
# Social handles
|
||||
bluesky_handle: Optional[str] = None
|
||||
mastodon_handle: Optional[str] = None
|
||||
instagram_handle: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
|
||||
class Token(SQLModel):
|
||||
access_token: str
|
||||
|
|
@ -26,6 +35,17 @@ class Token(SQLModel):
|
|||
class TokenData(SQLModel):
|
||||
email: Optional[str] = None
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
class PaginationMeta(SQLModel):
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
class PaginatedResponse(SQLModel, Generic[T]):
|
||||
data: List[T]
|
||||
meta: PaginationMeta
|
||||
|
||||
# --- Venue Schemas ---
|
||||
class VenueBase(SQLModel):
|
||||
name: str
|
||||
|
|
@ -64,6 +84,9 @@ class SongRead(SongBase):
|
|||
id: int
|
||||
slug: Optional[str] = None
|
||||
tags: List["TagRead"] = []
|
||||
artist: Optional["ArtistRead"] = None
|
||||
vertical: Optional["VerticalSimple"] = None
|
||||
times_played: Optional[int] = 0
|
||||
|
||||
|
||||
|
||||
|
|
@ -72,6 +95,15 @@ class SongUpdate(SQLModel):
|
|||
original_artist: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
# --- Vertical Schema (simple for embedding) ---
|
||||
class VerticalSimple(SQLModel):
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
description: Optional[str] = None
|
||||
logo_url: Optional[str] = None
|
||||
accent_color: Optional[str] = None
|
||||
|
||||
# --- Show Schemas ---
|
||||
class ShowBase(SQLModel):
|
||||
date: datetime
|
||||
|
|
@ -105,6 +137,8 @@ class PerformanceReadWithShow(PerformanceRead):
|
|||
venue_name: str
|
||||
venue_city: str
|
||||
venue_state: Optional[str] = None
|
||||
artist_name: Optional[str] = None
|
||||
artist_slug: Optional[str] = None
|
||||
avg_rating: Optional[float] = 0.0
|
||||
total_reviews: Optional[int] = 0
|
||||
|
||||
|
|
@ -113,6 +147,7 @@ class SongReadWithStats(SongRead):
|
|||
gap: int
|
||||
last_played: Optional[datetime] = None
|
||||
set_breakdown: Dict[str, int] = {}
|
||||
artist_distribution: Dict[str, int] = {}
|
||||
performances: List[PerformanceReadWithShow] = []
|
||||
|
||||
class PerformanceDetailRead(PerformanceRead):
|
||||
|
|
@ -161,9 +196,11 @@ class GroupPostRead(GroupPostBase):
|
|||
nicknames: List["PerformanceNicknameRead"] = []
|
||||
|
||||
class ShowRead(ShowBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
slug: Optional[str] = None
|
||||
venue: Optional["VenueRead"] = None
|
||||
vertical: Optional[VerticalSimple] = None
|
||||
venue: Optional[VenueRead] = None
|
||||
tour: Optional["TourRead"] = None
|
||||
tags: List["TagRead"] = []
|
||||
performances: List["PerformanceRead"] = []
|
||||
|
|
@ -399,3 +436,52 @@ class ReactionRead(ReactionBase):
|
|||
id: int
|
||||
user_id: int
|
||||
created_at: datetime
|
||||
|
||||
# --- Profile Schemas ---
|
||||
|
||||
class SocialHandles(SQLModel):
|
||||
bluesky: Optional[str] = None
|
||||
mastodon: Optional[str] = None
|
||||
instagram: Optional[str] = None
|
||||
|
||||
class HeadlinerBand(SQLModel):
|
||||
name: str
|
||||
slug: str
|
||||
tier: str # headliner, main_stage, supporting
|
||||
logo_url: Optional[str] = None
|
||||
|
||||
class PublicProfileRead(SQLModel):
|
||||
id: int
|
||||
username: str
|
||||
display_name: str
|
||||
bio: Optional[str] = None
|
||||
avatar: Optional[str] = None
|
||||
avatar_bg_color: Optional[str] = None
|
||||
avatar_text: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
|
||||
# Socials
|
||||
social_handles: SocialHandles
|
||||
|
||||
# The Lineup
|
||||
headliners: List[HeadlinerBand]
|
||||
supporting_acts: List[HeadlinerBand]
|
||||
|
||||
# Stats
|
||||
stats: Dict[str, int]
|
||||
|
||||
joined_at: datetime
|
||||
|
||||
|
||||
# --- Pagination ---
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
class PaginationMeta(SQLModel):
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
class PaginatedResponse(SQLModel, Generic[T]):
|
||||
data: List[T]
|
||||
meta: PaginationMeta
|
||||
|
|
|
|||
9
backend/scripts/check_metadata.py
Normal file
9
backend/scripts/check_metadata.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import sys
|
||||
import os
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), ".."))
|
||||
from sqlmodel import SQLModel
|
||||
import models
|
||||
|
||||
print("Tables in metadata:")
|
||||
for table in SQLModel.metadata.tables:
|
||||
print(f"- {table}")
|
||||
48
backend/scripts/find_artist.py
Normal file
48
backend/scripts/find_artist.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
API_KEY = os.getenv("SETLISTFM_API_KEY")
|
||||
BASE_URL = "https://api.setlist.fm/rest/1.0"
|
||||
|
||||
def search_artist(name):
|
||||
print(f"Searching for '{name}'...")
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"x-api-key": API_KEY
|
||||
}
|
||||
|
||||
url = f"{BASE_URL}/search/artists"
|
||||
params = {"artistName": name, "sort": "relevance"}
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
artists = data.get("artist", [])
|
||||
if not artists:
|
||||
print("No artists found.")
|
||||
return
|
||||
|
||||
print(f"Found {len(artists)} results:")
|
||||
for artist in artists[:3]:
|
||||
print(f" - Name: {artist.get('name')}")
|
||||
print(f" MBID: {artist.get('mbid')}")
|
||||
print(f" URL: {artist.get('url')}")
|
||||
print(f" Disambiguation: {artist.get('disambiguation', 'N/A')}")
|
||||
print("-" * 20)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python find_artist.py <artist_name>")
|
||||
sys.exit(1)
|
||||
|
||||
search_artist(sys.argv[1])
|
||||
79
backend/scripts/import_videos.py
Normal file
79
backend/scripts/import_videos.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""
|
||||
Import YouTube links from elmeg as Video entities
|
||||
"""
|
||||
import csv
|
||||
import sys
|
||||
sys.path.insert(0, '/Users/ten/DEV/fediversion/backend')
|
||||
|
||||
import psycopg2
|
||||
from datetime import datetime
|
||||
|
||||
# Connect to fediversion database via SSH tunnel
|
||||
# Run: ssh -L 5433:localhost:5432 nexus-vector
|
||||
conn = psycopg2.connect(
|
||||
host="localhost",
|
||||
port=5433,
|
||||
database="fediversion",
|
||||
user="fediversion",
|
||||
password="fediversion_password"
|
||||
)
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
|
||||
# Get Goose vertical_id
|
||||
cur.execute("SELECT id FROM vertical WHERE slug = 'goose'")
|
||||
goose_vertical_id = cur.fetchone()[0]
|
||||
print(f"Goose vertical_id: {goose_vertical_id}")
|
||||
|
||||
# Read CSV
|
||||
videos_created = 0
|
||||
links_created = 0
|
||||
skipped = 0
|
||||
|
||||
with open('/tmp/perf_youtube_full.csv', 'r') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
youtube_url = row['youtube_link']
|
||||
show_slug = row['show_slug']
|
||||
song_title = row['song_title']
|
||||
show_date = row['show_date']
|
||||
|
||||
# Check if video already exists
|
||||
cur.execute("SELECT id FROM video WHERE url = %s", (youtube_url,))
|
||||
existing = cur.fetchone()
|
||||
|
||||
if existing:
|
||||
video_id = existing[0]
|
||||
else:
|
||||
# Create video
|
||||
cur.execute("""
|
||||
INSERT INTO video (url, title, platform, video_type, vertical_id, created_at)
|
||||
VALUES (%s, %s, 'youtube', 'single_song', %s, NOW())
|
||||
RETURNING id
|
||||
""", (youtube_url, f"{song_title} - {show_date}", goose_vertical_id))
|
||||
video_id = cur.fetchone()[0]
|
||||
videos_created += 1
|
||||
|
||||
# Find the show in fediversion
|
||||
cur.execute("SELECT id FROM show WHERE slug = %s AND vertical_id = %s", (show_slug, goose_vertical_id))
|
||||
show_result = cur.fetchone()
|
||||
|
||||
if show_result:
|
||||
show_id = show_result[0]
|
||||
# Link video to show
|
||||
cur.execute("""
|
||||
INSERT INTO videoshow (video_id, show_id)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
""", (video_id, show_id))
|
||||
links_created += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
print(f"\nResults:")
|
||||
print(f" Videos created: {videos_created}")
|
||||
print(f" Show links created: {links_created}")
|
||||
print(f" Shows not found: {skipped}")
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
417
backend/scripts/seed_musicians.py
Normal file
417
backend/scripts/seed_musicians.py
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Seed script for multi-band musicians.
|
||||
Creates Musician records and their BandMembership relationships.
|
||||
|
||||
Key musicians who span multiple bands:
|
||||
- Dead Family: Bob Weir, Phil Lesh, Mickey Hart, Bill Kreutzmann, Jeff Chimenti, Oteil Burbridge, John Mayer
|
||||
- JRAD: Joe Russo, Scott Metzger, Marco Benevento, Dave Dreiwitz, Tom Hamilton
|
||||
- Billy Strings: Billy Strings himself, plus touring members
|
||||
- Goose: Rick Mitarotonda, Peter Anspach, Trevor Weekz, Ben Atkind, Jeff Arevalo
|
||||
- SCI: Bill Nershi, Kyle Hollingsworth, Michael Travis, Keith Moseley, Michael Kang, Jason Hann
|
||||
- Disco Biscuits: Jon Gutwillig, Marc Brownstein, Aron Magner, Allen Aucoin
|
||||
"""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, '.')
|
||||
|
||||
from sqlmodel import Session, select
|
||||
from database import engine
|
||||
from models import Musician, Artist, Vertical, BandMembership
|
||||
from slugify import generate_slug as slugify
|
||||
from datetime import datetime
|
||||
|
||||
def create_musician(session: Session, name: str, primary_instrument: str = None,
|
||||
bio: str = None, image_url: str = None, wikipedia_url: str = None,
|
||||
instagram_url: str = None, birth_year: int = None,
|
||||
origin_city: str = None, origin_state: str = None) -> Musician:
|
||||
"""Create or get a musician by name"""
|
||||
slug = slugify(name)
|
||||
existing = session.exec(select(Musician).where(Musician.slug == slug)).first()
|
||||
if existing:
|
||||
print(f" Musician already exists: {name}")
|
||||
return existing
|
||||
|
||||
musician = Musician(
|
||||
name=name,
|
||||
slug=slug,
|
||||
primary_instrument=primary_instrument,
|
||||
bio=bio,
|
||||
image_url=image_url,
|
||||
wikipedia_url=wikipedia_url,
|
||||
instagram_url=instagram_url,
|
||||
birth_year=birth_year,
|
||||
origin_city=origin_city,
|
||||
origin_state=origin_state,
|
||||
origin_country="USA"
|
||||
)
|
||||
session.add(musician)
|
||||
session.commit()
|
||||
session.refresh(musician)
|
||||
print(f" Created musician: {name}")
|
||||
return musician
|
||||
|
||||
|
||||
def get_or_create_artist(session: Session, name: str) -> Artist:
|
||||
"""Get or create an artist (band) by name"""
|
||||
slug = slugify(name)
|
||||
existing = session.exec(select(Artist).where(Artist.slug == slug)).first()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
artist = Artist(name=name, slug=slug)
|
||||
session.add(artist)
|
||||
session.commit()
|
||||
session.refresh(artist)
|
||||
print(f" Created artist: {name}")
|
||||
return artist
|
||||
|
||||
|
||||
def create_membership(session: Session, musician: Musician, artist: Artist,
|
||||
role: str, start_year: int = None, end_year: int = None,
|
||||
notes: str = None) -> BandMembership:
|
||||
"""Create a band membership record"""
|
||||
# Check if already exists
|
||||
existing = session.exec(
|
||||
select(BandMembership)
|
||||
.where(BandMembership.musician_id == musician.id)
|
||||
.where(BandMembership.artist_id == artist.id)
|
||||
).first()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
membership = BandMembership(
|
||||
musician_id=musician.id,
|
||||
artist_id=artist.id,
|
||||
role=role,
|
||||
start_date=datetime(start_year, 1, 1) if start_year else None,
|
||||
end_date=datetime(end_year, 12, 31) if end_year else None,
|
||||
notes=notes
|
||||
)
|
||||
session.add(membership)
|
||||
session.commit()
|
||||
print(f" -> {musician.name} in {artist.name} as {role}")
|
||||
return membership
|
||||
|
||||
|
||||
def seed_dead_family(session: Session):
|
||||
"""Seed Grateful Dead family musicians"""
|
||||
print("\n🎸 Seeding Dead Family musicians...")
|
||||
|
||||
# Create band artists
|
||||
grateful_dead = get_or_create_artist(session, "Grateful Dead")
|
||||
dead_and_co = get_or_create_artist(session, "Dead & Company")
|
||||
ratdog = get_or_create_artist(session, "Ratdog")
|
||||
phil_and_friends = get_or_create_artist(session, "Phil Lesh & Friends")
|
||||
furthur = get_or_create_artist(session, "Furthur")
|
||||
the_dead = get_or_create_artist(session, "The Dead")
|
||||
bob_weir_wolf_bros = get_or_create_artist(session, "Bob Weir & Wolf Bros")
|
||||
|
||||
# Bob Weir
|
||||
bob = create_musician(
|
||||
session, "Bob Weir", "Guitar",
|
||||
bio="Founding member of the Grateful Dead. Rhythm guitarist and vocalist.",
|
||||
birth_year=1947, origin_city="San Francisco", origin_state="CA",
|
||||
wikipedia_url="https://en.wikipedia.org/wiki/Bob_Weir"
|
||||
)
|
||||
create_membership(session, bob, grateful_dead, "Rhythm Guitar, Vocals", 1965, 1995)
|
||||
create_membership(session, bob, ratdog, "Guitar, Vocals", 1995, 2014)
|
||||
create_membership(session, bob, the_dead, "Guitar, Vocals", 2003, 2009)
|
||||
create_membership(session, bob, furthur, "Guitar, Vocals", 2009, 2014)
|
||||
create_membership(session, bob, dead_and_co, "Guitar, Vocals", 2015, 2023)
|
||||
create_membership(session, bob, bob_weir_wolf_bros, "Guitar, Vocals", 2018)
|
||||
|
||||
# Phil Lesh
|
||||
phil = create_musician(
|
||||
session, "Phil Lesh", "Bass",
|
||||
bio="Founding member of the Grateful Dead. Bassist.",
|
||||
birth_year=1940, origin_city="Berkeley", origin_state="CA",
|
||||
wikipedia_url="https://en.wikipedia.org/wiki/Phil_Lesh"
|
||||
)
|
||||
create_membership(session, phil, grateful_dead, "Bass", 1965, 1995)
|
||||
create_membership(session, phil, phil_and_friends, "Bass", 1998)
|
||||
create_membership(session, phil, the_dead, "Bass", 2003, 2009)
|
||||
create_membership(session, phil, furthur, "Bass", 2009, 2014)
|
||||
|
||||
# Mickey Hart
|
||||
mickey = create_musician(
|
||||
session, "Mickey Hart", "Drums",
|
||||
bio="Grateful Dead drummer and percussionist.",
|
||||
birth_year=1943, origin_city="Brooklyn", origin_state="NY",
|
||||
wikipedia_url="https://en.wikipedia.org/wiki/Mickey_Hart"
|
||||
)
|
||||
create_membership(session, mickey, grateful_dead, "Drums, Percussion", 1967, 1995)
|
||||
create_membership(session, mickey, the_dead, "Drums", 2003, 2009)
|
||||
create_membership(session, mickey, furthur, "Drums", 2009, 2014)
|
||||
create_membership(session, mickey, dead_and_co, "Drums", 2015, 2023)
|
||||
|
||||
# Bill Kreutzmann
|
||||
bill_k = create_musician(
|
||||
session, "Bill Kreutzmann", "Drums",
|
||||
bio="Founding Grateful Dead drummer.",
|
||||
birth_year=1946, origin_city="Palo Alto", origin_state="CA",
|
||||
wikipedia_url="https://en.wikipedia.org/wiki/Bill_Kreutzmann"
|
||||
)
|
||||
create_membership(session, bill_k, grateful_dead, "Drums", 1965, 1995)
|
||||
create_membership(session, bill_k, the_dead, "Drums", 2003, 2009)
|
||||
create_membership(session, bill_k, furthur, "Drums", 2009, 2014)
|
||||
create_membership(session, bill_k, dead_and_co, "Drums", 2015, 2023)
|
||||
|
||||
# Jeff Chimenti - key multi-band musician
|
||||
jeff_c = create_musician(
|
||||
session, "Jeff Chimenti", "Keys",
|
||||
bio="Keyboardist with multiple Dead projects. Known for versatile playing style.",
|
||||
birth_year=1968, origin_city="San Jose", origin_state="CA",
|
||||
wikipedia_url="https://en.wikipedia.org/wiki/Jeff_Chimenti"
|
||||
)
|
||||
create_membership(session, jeff_c, ratdog, "Keyboards", 1997, 2014)
|
||||
create_membership(session, jeff_c, the_dead, "Keyboards", 2003, 2009)
|
||||
create_membership(session, jeff_c, furthur, "Keyboards", 2009, 2014)
|
||||
create_membership(session, jeff_c, dead_and_co, "Keyboards", 2015, 2023)
|
||||
|
||||
# Oteil Burbridge - key multi-band musician
|
||||
oteil = create_musician(
|
||||
session, "Oteil Burbridge", "Bass",
|
||||
bio="Bassist for Dead & Company and Allman Brothers Band.",
|
||||
birth_year=1964, origin_city="Washington", origin_state="DC",
|
||||
wikipedia_url="https://en.wikipedia.org/wiki/Oteil_Burbridge"
|
||||
)
|
||||
allman_brothers = get_or_create_artist(session, "Allman Brothers Band")
|
||||
create_membership(session, oteil, allman_brothers, "Bass", 1997, 2014)
|
||||
create_membership(session, oteil, dead_and_co, "Bass", 2015, 2023)
|
||||
|
||||
# John Mayer
|
||||
john_m = create_musician(
|
||||
session, "John Mayer", "Guitar",
|
||||
bio="Solo artist and Dead & Company lead guitarist.",
|
||||
birth_year=1977, origin_city="Bridgeport", origin_state="CT",
|
||||
wikipedia_url="https://en.wikipedia.org/wiki/John_Mayer",
|
||||
instagram_url="https://instagram.com/johnmayer"
|
||||
)
|
||||
create_membership(session, john_m, dead_and_co, "Lead Guitar, Vocals", 2015, 2023)
|
||||
|
||||
|
||||
def seed_jrad(session: Session):
|
||||
"""Seed Joe Russo's Almost Dead musicians"""
|
||||
print("\n🎸 Seeding JRAD musicians...")
|
||||
|
||||
jrad = get_or_create_artist(session, "Joe Russo's Almost Dead")
|
||||
|
||||
# Joe Russo
|
||||
joe = create_musician(
|
||||
session, "Joe Russo", "Drums",
|
||||
bio="Drummer and bandleader of JRAD. Also plays with Furthur and other projects.",
|
||||
wikipedia_url="https://en.wikipedia.org/wiki/Joe_Russo_(drummer)"
|
||||
)
|
||||
furthur = get_or_create_artist(session, "Furthur")
|
||||
create_membership(session, joe, jrad, "Drums", 2013)
|
||||
create_membership(session, joe, furthur, "Drums", 2009, 2014)
|
||||
|
||||
# Scott Metzger
|
||||
scott = create_musician(
|
||||
session, "Scott Metzger", "Guitar",
|
||||
bio="Guitarist for JRAD and many other projects.",
|
||||
)
|
||||
create_membership(session, scott, jrad, "Guitar", 2013)
|
||||
|
||||
# Marco Benevento
|
||||
marco = create_musician(
|
||||
session, "Marco Benevento", "Keys",
|
||||
bio="Keyboardist for JRAD and solo artist.",
|
||||
wikipedia_url="https://en.wikipedia.org/wiki/Marco_Benevento"
|
||||
)
|
||||
create_membership(session, marco, jrad, "Keyboards", 2013)
|
||||
|
||||
# Dave Dreiwitz
|
||||
dave_d = create_musician(
|
||||
session, "Dave Dreiwitz", "Bass",
|
||||
bio="Bassist for JRAD and Ween.",
|
||||
)
|
||||
ween = get_or_create_artist(session, "Ween")
|
||||
create_membership(session, dave_d, jrad, "Bass", 2013)
|
||||
create_membership(session, dave_d, ween, "Bass", 2000)
|
||||
|
||||
# Tom Hamilton
|
||||
tom_h = create_musician(
|
||||
session, "Tom Hamilton", "Guitar",
|
||||
bio="Guitarist for JRAD, Ghost Light, and American Babies.",
|
||||
)
|
||||
ghost_light = get_or_create_artist(session, "Ghost Light")
|
||||
create_membership(session, tom_h, jrad, "Guitar", 2013)
|
||||
create_membership(session, tom_h, ghost_light, "Guitar", 2017)
|
||||
|
||||
|
||||
def seed_goose(session: Session):
|
||||
"""Seed Goose musicians"""
|
||||
print("\n🎸 Seeding Goose musicians...")
|
||||
|
||||
goose = get_or_create_artist(session, "Goose")
|
||||
|
||||
rick = create_musician(
|
||||
session, "Rick Mitarotonda", "Guitar",
|
||||
bio="Frontman and lead guitarist of Goose.",
|
||||
origin_city="Norwalk", origin_state="CT"
|
||||
)
|
||||
create_membership(session, rick, goose, "Lead Guitar, Vocals", 2014)
|
||||
|
||||
peter = create_musician(
|
||||
session, "Peter Anspach", "Keys",
|
||||
bio="Keyboardist and guitarist for Goose.",
|
||||
)
|
||||
create_membership(session, peter, goose, "Keys, Guitar, Vocals", 2016)
|
||||
|
||||
trevor = create_musician(
|
||||
session, "Trevor Weekz", "Bass",
|
||||
bio="Bassist for Goose.",
|
||||
)
|
||||
create_membership(session, trevor, goose, "Bass", 2016)
|
||||
|
||||
ben = create_musician(
|
||||
session, "Ben Atkind", "Drums",
|
||||
bio="Drummer for Goose.",
|
||||
)
|
||||
create_membership(session, ben, goose, "Drums", 2014)
|
||||
|
||||
jeff_a = create_musician(
|
||||
session, "Jeff Arevalo", "Percussion",
|
||||
bio="Percussionist for Goose.",
|
||||
)
|
||||
create_membership(session, jeff_a, goose, "Percussion", 2018)
|
||||
|
||||
|
||||
def seed_sci(session: Session):
|
||||
"""Seed String Cheese Incident musicians"""
|
||||
print("\n🎸 Seeding SCI musicians...")
|
||||
|
||||
sci = get_or_create_artist(session, "The String Cheese Incident")
|
||||
|
||||
bill_n = create_musician(
|
||||
session, "Bill Nershi", "Guitar",
|
||||
bio="Guitarist and founding member of String Cheese Incident.",
|
||||
wikipedia_url="https://en.wikipedia.org/wiki/Bill_Nershi"
|
||||
)
|
||||
create_membership(session, bill_n, sci, "Guitar, Vocals", 1993)
|
||||
|
||||
kyle = create_musician(
|
||||
session, "Kyle Hollingsworth", "Keys",
|
||||
bio="Keyboardist for String Cheese Incident.",
|
||||
wikipedia_url="https://en.wikipedia.org/wiki/Kyle_Hollingsworth"
|
||||
)
|
||||
create_membership(session, kyle, sci, "Keyboards", 1993)
|
||||
|
||||
travis = create_musician(
|
||||
session, "Michael Travis", "Drums",
|
||||
bio="Drummer for String Cheese Incident.",
|
||||
)
|
||||
create_membership(session, travis, sci, "Drums", 1993)
|
||||
|
||||
kang = create_musician(
|
||||
session, "Michael Kang", "Mandolin",
|
||||
bio="Mandolin and violin player for String Cheese Incident.",
|
||||
wikipedia_url="https://en.wikipedia.org/wiki/Michael_Kang"
|
||||
)
|
||||
create_membership(session, kang, sci, "Mandolin, Violin, Guitar", 1993)
|
||||
|
||||
keith = create_musician(
|
||||
session, "Keith Moseley", "Bass",
|
||||
bio="Bassist for String Cheese Incident.",
|
||||
)
|
||||
create_membership(session, keith, sci, "Bass", 1993)
|
||||
|
||||
jason_h = create_musician(
|
||||
session, "Jason Hann", "Percussion",
|
||||
bio="Percussionist for String Cheese Incident.",
|
||||
)
|
||||
create_membership(session, jason_h, sci, "Percussion", 2004)
|
||||
|
||||
|
||||
def seed_biscuits(session: Session):
|
||||
"""Seed Disco Biscuits musicians"""
|
||||
print("\n🎸 Seeding Disco Biscuits musicians...")
|
||||
|
||||
biscuits = get_or_create_artist(session, "The Disco Biscuits")
|
||||
|
||||
barber = create_musician(
|
||||
session, "Jon Gutwillig", "Guitar",
|
||||
bio="Guitarist for Disco Biscuits. Known as 'Barber'.",
|
||||
)
|
||||
create_membership(session, barber, biscuits, "Guitar", 1995)
|
||||
|
||||
brownie = create_musician(
|
||||
session, "Marc Brownstein", "Bass",
|
||||
bio="Bassist for Disco Biscuits. Known as 'Brownie'.",
|
||||
)
|
||||
create_membership(session, brownie, biscuits, "Bass", 1995)
|
||||
|
||||
aron = create_musician(
|
||||
session, "Aron Magner", "Keys",
|
||||
bio="Keyboardist for Disco Biscuits.",
|
||||
)
|
||||
create_membership(session, aron, biscuits, "Keyboards", 1995)
|
||||
|
||||
allen = create_musician(
|
||||
session, "Allen Aucoin", "Drums",
|
||||
bio="Drummer for Disco Biscuits.",
|
||||
)
|
||||
create_membership(session, allen, biscuits, "Drums", 1995)
|
||||
|
||||
|
||||
def seed_billy_strings(session: Session):
|
||||
"""Seed Billy Strings musicians"""
|
||||
print("\n🎸 Seeding Billy Strings band...")
|
||||
|
||||
billy_band = get_or_create_artist(session, "Billy Strings")
|
||||
|
||||
billy = create_musician(
|
||||
session, "Billy Strings", "Guitar",
|
||||
bio="Grammy-winning bluegrass guitarist and singer.",
|
||||
birth_year=1992, origin_city="Lansing", origin_state="MI",
|
||||
wikipedia_url="https://en.wikipedia.org/wiki/Billy_Strings",
|
||||
instagram_url="https://instagram.com/billystrings"
|
||||
)
|
||||
create_membership(session, billy, billy_band, "Guitar, Vocals", 2016)
|
||||
|
||||
jarrod = create_musician(
|
||||
session, "Jarrod Walker", "Mandolin",
|
||||
bio="Mandolinist for Billy Strings.",
|
||||
)
|
||||
create_membership(session, jarrod, billy_band, "Mandolin", 2021)
|
||||
|
||||
royal = create_musician(
|
||||
session, "Royal Masat", "Bass",
|
||||
bio="Bassist for Billy Strings.",
|
||||
)
|
||||
create_membership(session, royal, billy_band, "Bass", 2019)
|
||||
|
||||
alex = create_musician(
|
||||
session, "Alex Hargreaves", "Fiddle",
|
||||
bio="Fiddler for Billy Strings.",
|
||||
)
|
||||
create_membership(session, alex, billy_band, "Fiddle", 2021)
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Seeding Multi-Band Musicians")
|
||||
print("=" * 60)
|
||||
|
||||
with Session(engine) as session:
|
||||
seed_dead_family(session)
|
||||
seed_jrad(session)
|
||||
seed_goose(session)
|
||||
seed_sci(session)
|
||||
seed_biscuits(session)
|
||||
seed_billy_strings(session)
|
||||
|
||||
# Summary
|
||||
total_musicians = session.exec(select(Musician)).all()
|
||||
total_memberships = session.exec(select(BandMembership)).all()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"✅ Created {len(total_musicians)} musicians")
|
||||
print(f"✅ Created {len(total_memberships)} band memberships")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
98
backend/scripts/verify_notifications.py
Normal file
98
backend/scripts/verify_notifications.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import sys
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
# Add backend to path
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from sqlmodel import Session, select
|
||||
from database import engine
|
||||
from models import User, Vertical, UserVerticalPreference, Show, Venue, Notification, NotificationType, PreferenceTier
|
||||
from services.notification_service import NotificationService
|
||||
|
||||
def verify_notifications():
|
||||
print(f"DEBUG: Using database URL: {engine.url}")
|
||||
with Session(engine) as session:
|
||||
print("Setting up test data...")
|
||||
|
||||
# 1. Get or create a user
|
||||
user = session.exec(select(User).where(User.email == "test_notify@example.com")).first()
|
||||
if not user:
|
||||
user = User(email="test_notify@example.com", hashed_password="hashed_password", is_active=True)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
print(f"User ID: {user.id}")
|
||||
|
||||
# 2. Get a vertical (Phish or Goose)
|
||||
vertical = session.exec(select(Vertical).where(Vertical.slug == "phish")).first()
|
||||
if not vertical:
|
||||
print("Phish vertical not found, creating dummy...")
|
||||
vertical = Vertical(slug="phish", name="Phish")
|
||||
session.add(vertical)
|
||||
session.commit()
|
||||
session.refresh(vertical)
|
||||
print(f"Vertical ID: {vertical.id}")
|
||||
|
||||
# 3. Create/Update preference
|
||||
pref = session.exec(
|
||||
select(UserVerticalPreference)
|
||||
.where(UserVerticalPreference.user_id == user.id)
|
||||
.where(UserVerticalPreference.vertical_id == vertical.id)
|
||||
).first()
|
||||
|
||||
if not pref:
|
||||
pref = UserVerticalPreference(
|
||||
user_id=user.id,
|
||||
vertical_id=vertical.id,
|
||||
tier=PreferenceTier.HEADLINER,
|
||||
notify_on_show=True
|
||||
)
|
||||
session.add(pref)
|
||||
else:
|
||||
pref.notify_on_show = True
|
||||
session.add(pref)
|
||||
session.commit()
|
||||
print("User preference set to notify_on_show=True")
|
||||
|
||||
# 4. Create a Venue
|
||||
venue = session.exec(select(Venue).where(Venue.name == "Test Venue")).first()
|
||||
if not venue:
|
||||
venue = Venue(name="Test Venue", city="Test City", country="USA")
|
||||
session.add(venue)
|
||||
session.commit()
|
||||
session.refresh(venue)
|
||||
|
||||
# 5. Create a Show using Service logic (simulate API call)
|
||||
# We invoke NotificationService manually on a new show object
|
||||
print("Creating new show...")
|
||||
new_show = Show(
|
||||
date=datetime.now(),
|
||||
slug=f"phish-test-{int(datetime.now().timestamp())}",
|
||||
vertical_id=vertical.id,
|
||||
venue_id=venue.id
|
||||
)
|
||||
session.add(new_show)
|
||||
session.commit()
|
||||
session.refresh(new_show)
|
||||
|
||||
service = NotificationService(session)
|
||||
service.check_show_alert(new_show)
|
||||
|
||||
# 6. Verify Notification
|
||||
print("Checking for notification...")
|
||||
notes = service.get_user_notifications(user.id)
|
||||
found = False
|
||||
for n in notes:
|
||||
if n.type == NotificationType.SHOW_ALERT and n.link == f"/{vertical.slug}/shows/{new_show.slug}":
|
||||
print(f"✅ Notification found: {n.title} - {n.message}")
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
print("❌ Notification NOT found!")
|
||||
|
||||
# Clean up test data if needed, but keeping for debug might be fine
|
||||
|
||||
if __name__ == "__main__":
|
||||
verify_notifications()
|
||||
88
backend/seed_all_bands.py
Normal file
88
backend/seed_all_bands.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""
|
||||
Seed all major jam bands for Fediversion
|
||||
Comprehensive list based on Nugs.net catalog
|
||||
"""
|
||||
from sqlmodel import Session, select
|
||||
from database import engine
|
||||
from models import Vertical
|
||||
|
||||
# Major jam bands - based on Nugs.net catalog
|
||||
BANDS = [
|
||||
# Tier 1 - The big names
|
||||
{"name": "Phish", "slug": "phish", "description": "Vermont-based jam band formed in 1983. One of the most influential live acts in music history.", "is_featured": True},
|
||||
{"name": "Goose", "slug": "goose", "description": "Connecticut jam band formed in 2014. Known for improvisational rock and explosive live shows.", "is_featured": True},
|
||||
{"name": "Billy Strings", "slug": "billy-strings", "description": "Grammy-winning bluegrass musician from Michigan. Blends traditional bluegrass with progressive elements.", "is_featured": True},
|
||||
{"name": "Dave Matthews Band", "slug": "dmb", "description": "Iconic jam band from Charlottesville, VA. Known for saxophone-driven rock and massive touring.", "is_featured": True},
|
||||
{"name": "Widespread Panic", "slug": "widespread-panic", "description": "Southern rock jam band from Athens, GA. Touring since 1986 with devoted fanbase.", "is_featured": True},
|
||||
{"name": "Umphrey's McGee", "slug": "umphreys-mcgee", "description": "Progressive jam band from South Bend, IN. Known for technical proficiency and genre-blending.", "is_featured": True},
|
||||
{"name": "Dead & Company", "slug": "dead-and-company", "description": "Grateful Dead members with John Mayer. Carrying on the Dead legacy since 2015.", "is_featured": True},
|
||||
{"name": "The String Cheese Incident", "slug": "sci", "description": "Colorado jam band formed in 1993. Known for bluegrass-infused improvisational rock.", "is_featured": True},
|
||||
|
||||
# Tier 2 - Popular touring acts
|
||||
{"name": "My Morning Jacket", "slug": "mmj", "description": "Louisville rock band led by Jim James. Known for epic live performances.", "is_featured": True},
|
||||
{"name": "Greensky Bluegrass", "slug": "greensky-bluegrass", "description": "Michigan bluegrass band. Progressive approach to traditional bluegrass.", "is_featured": False},
|
||||
{"name": "Pigeons Playing Ping Pong", "slug": "pigeons", "description": "Baltimore funk-rock jam band. High energy shows with funky grooves.", "is_featured": False},
|
||||
{"name": "Lotus", "slug": "lotus", "description": "Electronic jam band from Indiana/Colorado. Livetronica pioneers.", "is_featured": False},
|
||||
{"name": "Joe Russo's Almost Dead", "slug": "jrad", "description": "Grateful Dead tribute supergroup. Known for creative Dead reinterpretations.", "is_featured": False},
|
||||
{"name": "Twiddle", "slug": "twiddle", "description": "Vermont jam band formed at Castleton State. Progressive jam rock.", "is_featured": False},
|
||||
{"name": "Spafford", "slug": "spafford", "description": "Arizona jam band. Known for extended improvisations and dedicated fanbase.", "is_featured": False},
|
||||
{"name": "Dopapod", "slug": "dopapod", "description": "Keytar-driven jam band. Blend of electronica, prog, and funk.", "is_featured": False},
|
||||
{"name": "Aqueous", "slug": "aqueous", "description": "Buffalo-based jam rock band. Progressive rock with heavy improvisation.", "is_featured": False},
|
||||
{"name": "Eggy", "slug": "eggy", "description": "New England jam band. Rising stars in the scene.", "is_featured": False},
|
||||
{"name": "Dogs in a Pile", "slug": "dogs-in-a-pile", "description": "New Jersey jam band. Young and energetic.", "is_featured": False},
|
||||
{"name": "Kitchen Dwellers", "slug": "kitchen-dwellers", "description": "Montana bluegrass-jam band. Galaxy Grass pioneers.", "is_featured": False},
|
||||
|
||||
# Classic/Legacy acts
|
||||
{"name": "Grateful Dead", "slug": "grateful-dead", "description": "The legendary San Francisco band (1965-1995). Foundation of the jam band scene.", "is_featured": True},
|
||||
{"name": "The Allman Brothers Band", "slug": "allman-brothers", "description": "Southern rock pioneers from Macon, GA. Legendary improvisational rock.", "is_featured": False},
|
||||
|
||||
# Additional popular acts
|
||||
{"name": "Khruangbin", "slug": "khruangbin", "description": "Houston trio. Global funk and psychedelic grooves.", "is_featured": False},
|
||||
{"name": "Vampire Weekend", "slug": "vampire-weekend", "description": "Indie rock band from NYC. Art rock with world music influences.", "is_featured": False},
|
||||
{"name": "King Gizzard & The Lizard Wizard", "slug": "king-gizzard", "description": "Australian psychedelic rock band. Prolific and genre-defying.", "is_featured": False},
|
||||
]
|
||||
|
||||
|
||||
def seed_all_bands():
|
||||
with Session(engine) as session:
|
||||
added = 0
|
||||
skipped = 0
|
||||
|
||||
for band_data in BANDS:
|
||||
existing = session.exec(
|
||||
select(Vertical).where(Vertical.slug == band_data["slug"])
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
print(f"✓ {band_data['name']} already exists")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
vertical = Vertical(
|
||||
name=band_data["name"],
|
||||
slug=band_data["slug"],
|
||||
description=band_data["description"],
|
||||
is_active=True,
|
||||
is_featured=band_data.get("is_featured", False)
|
||||
)
|
||||
session.add(vertical)
|
||||
print(f"✅ Added: {band_data['name']}")
|
||||
added += 1
|
||||
|
||||
session.commit()
|
||||
|
||||
# Show totals
|
||||
all_verts = session.exec(select(Vertical).where(Vertical.is_active == True)).all()
|
||||
print(f"\n{'='*50}")
|
||||
print(f"📊 Added: {added} | Skipped: {skipped}")
|
||||
print(f"📊 Total active bands: {len(all_verts)}")
|
||||
print(f"{'='*50}")
|
||||
|
||||
featured = [v for v in all_verts if v.is_featured]
|
||||
print(f"\n⭐ Featured bands ({len(featured)}):")
|
||||
for v in featured:
|
||||
print(f" - {v.name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed_all_bands()
|
||||
57
backend/seed_dso.py
Normal file
57
backend/seed_dso.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from sqlmodel import Session, select, create_engine
|
||||
from database import engine
|
||||
from models import Vertical, Scene, VerticalScene, Artist
|
||||
from importers.dso import DsoImporter
|
||||
|
||||
def seed_dso():
|
||||
with Session(engine) as session:
|
||||
# 1. Ensure "Grateful Dead Family" scene exists
|
||||
scene = session.exec(select(Scene).where(Scene.slug == "grateful-dead-family")).first()
|
||||
if not scene:
|
||||
scene = Scene(name="Grateful Dead Family", slug="grateful-dead-family")
|
||||
session.add(scene)
|
||||
session.commit()
|
||||
session.refresh(scene)
|
||||
|
||||
# 2. Add Artist
|
||||
print("Creating Artist...")
|
||||
artist = session.exec(select(Artist).where(Artist.slug == "dark-star-orchestra")).first()
|
||||
if not artist:
|
||||
artist = Artist(name="Dark Star Orchestra", slug="dark-star-orchestra")
|
||||
session.add(artist)
|
||||
session.commit()
|
||||
session.refresh(artist)
|
||||
|
||||
# 3. Add Vertical (Band)
|
||||
print("Creating Vertical...")
|
||||
vertical = session.exec(select(Vertical).where(Vertical.slug == "dark-star-orchestra")).first()
|
||||
if not vertical:
|
||||
vertical = Vertical(
|
||||
name="Dark Star Orchestra",
|
||||
slug="dark-star-orchestra",
|
||||
description="Recreating the Grateful Dead concert experience.",
|
||||
setlistfm_mbid="e477d9c0-1f35-40f7-ad1a-b915d2523b84",
|
||||
primary_artist_id=artist.id
|
||||
)
|
||||
session.add(vertical)
|
||||
session.commit()
|
||||
session.refresh(vertical)
|
||||
|
||||
# 4. Link to Scene
|
||||
link = session.exec(select(VerticalScene).where(
|
||||
VerticalScene.vertical_id == vertical.id,
|
||||
VerticalScene.scene_id == scene.id
|
||||
)).first()
|
||||
if not link:
|
||||
session.add(VerticalScene(vertical_id=vertical.id, scene_id=scene.id))
|
||||
session.commit()
|
||||
|
||||
print("✅ Vertical seeded successfully.")
|
||||
|
||||
# 5. Run Import (Optional - can be run separately)
|
||||
print("Starting import...")
|
||||
importer = DsoImporter(session)
|
||||
importer.import_all()
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed_dso()
|
||||
116
backend/seed_musicians.py
Normal file
116
backend/seed_musicians.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""
|
||||
Seed script to create common cross-band musicians.
|
||||
|
||||
These are musicians known for sitting in with multiple bands.
|
||||
"""
|
||||
|
||||
from sqlmodel import Session, select
|
||||
from database import engine
|
||||
from models import Musician, Artist, BandMembership
|
||||
import re
|
||||
|
||||
|
||||
def generate_slug(name: str) -> str:
|
||||
"""Generate URL-safe slug"""
|
||||
slug = name.lower()
|
||||
slug = re.sub(r'[^\w\s-]', '', slug)
|
||||
slug = re.sub(r'[\s_]+', '-', slug)
|
||||
return slug.strip('-')
|
||||
|
||||
|
||||
# Notable jam scene musicians who appear across bands
|
||||
CROSS_BAND_MUSICIANS = [
|
||||
# Grateful Dead / Dead Family
|
||||
{"name": "Bob Weir", "primary_instrument": "Guitar", "bands": ["grateful-dead", "dead-and-company"]},
|
||||
{"name": "Mickey Hart", "primary_instrument": "Drums", "bands": ["grateful-dead", "dead-and-company"]},
|
||||
{"name": "Bill Kreutzmann", "primary_instrument": "Drums", "bands": ["grateful-dead", "dead-and-company"]},
|
||||
{"name": "John Mayer", "primary_instrument": "Guitar", "bands": ["dead-and-company"]},
|
||||
{"name": "Oteil Burbridge", "primary_instrument": "Bass", "bands": ["dead-and-company"]},
|
||||
{"name": "Jeff Chimenti", "primary_instrument": "Keyboards", "bands": ["dead-and-company"]},
|
||||
|
||||
# Goose
|
||||
{"name": "Rick Mitarotonda", "primary_instrument": "Guitar, Vocals", "bands": ["goose"]},
|
||||
{"name": "Peter Anspach", "primary_instrument": "Keyboards, Guitar", "bands": ["goose"]},
|
||||
{"name": "Trevor Weekz", "primary_instrument": "Bass", "bands": ["goose"]},
|
||||
{"name": "Ben Atkind", "primary_instrument": "Drums", "bands": ["goose"]},
|
||||
{"name": "Jeff Arevalo", "primary_instrument": "Percussion", "bands": ["goose"]},
|
||||
|
||||
# Phish
|
||||
{"name": "Trey Anastasio", "primary_instrument": "Guitar", "bands": ["phish"]},
|
||||
{"name": "Page McConnell", "primary_instrument": "Keyboards", "bands": ["phish"]},
|
||||
{"name": "Mike Gordon", "primary_instrument": "Bass", "bands": ["phish"]},
|
||||
{"name": "Jon Fishman", "primary_instrument": "Drums", "bands": ["phish"]},
|
||||
|
||||
# Billy Strings
|
||||
{"name": "Billy Strings", "primary_instrument": "Guitar, Vocals", "bands": ["billy-strings"]},
|
||||
{"name": "Billy Failing", "primary_instrument": "Banjo", "bands": ["billy-strings"]},
|
||||
{"name": "Royal Masat", "primary_instrument": "Bass", "bands": ["billy-strings"]},
|
||||
{"name": "Jarrod Walker", "primary_instrument": "Mandolin", "bands": ["billy-strings"]},
|
||||
|
||||
# Cross-band sit-in regulars
|
||||
{"name": "Marcus King", "primary_instrument": "Guitar, Vocals", "bands": []},
|
||||
{"name": "Pigeons Playing Ping Pong", "primary_instrument": "Funk", "bands": []},
|
||||
{"name": "Karina Rykman", "primary_instrument": "Bass, Vocals", "bands": []},
|
||||
]
|
||||
|
||||
|
||||
def seed_musicians():
|
||||
"""Create musicians if they don't exist"""
|
||||
print("Seeding musicians...\n")
|
||||
|
||||
with Session(engine) as session:
|
||||
created = 0
|
||||
|
||||
for m in CROSS_BAND_MUSICIANS:
|
||||
slug = generate_slug(m["name"])
|
||||
|
||||
existing = session.exec(
|
||||
select(Musician).where(Musician.slug == slug)
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
print(f" Exists: {m['name']}")
|
||||
musician = existing
|
||||
else:
|
||||
musician = Musician(
|
||||
name=m["name"],
|
||||
slug=slug,
|
||||
primary_instrument=m["primary_instrument"]
|
||||
)
|
||||
session.add(musician)
|
||||
session.commit()
|
||||
session.refresh(musician)
|
||||
created += 1
|
||||
print(f" Created: {m['name']}")
|
||||
|
||||
# Create band memberships
|
||||
for band_slug in m.get("bands", []):
|
||||
# Find vertical by slug to get artist
|
||||
from models import Vertical
|
||||
vertical = session.exec(
|
||||
select(Vertical).where(Vertical.slug == band_slug)
|
||||
).first()
|
||||
|
||||
if vertical and vertical.primary_artist_id:
|
||||
# Check if membership exists
|
||||
existing_membership = session.exec(
|
||||
select(BandMembership)
|
||||
.where(BandMembership.musician_id == musician.id)
|
||||
.where(BandMembership.artist_id == vertical.primary_artist_id)
|
||||
).first()
|
||||
|
||||
if not existing_membership:
|
||||
membership = BandMembership(
|
||||
musician_id=musician.id,
|
||||
artist_id=vertical.primary_artist_id,
|
||||
role=m["primary_instrument"]
|
||||
)
|
||||
session.add(membership)
|
||||
print(f" -> Added to {band_slug}")
|
||||
|
||||
session.commit()
|
||||
print(f"\nCreated {created} new musicians")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed_musicians()
|
||||
103
backend/seed_new_bands.py
Normal file
103
backend/seed_new_bands.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""
|
||||
Seed additional band verticals for Fediversion
|
||||
"""
|
||||
from sqlmodel import Session, select
|
||||
from database import engine
|
||||
from models import Vertical, Scene, VerticalScene
|
||||
|
||||
# New bands to add
|
||||
NEW_BANDS = [
|
||||
{
|
||||
"name": "Tedeschi Trucks Band",
|
||||
"slug": "tedeschi-trucks",
|
||||
"description": "Blues-rock band led by Derek Trucks and Susan Tedeschi. Known for soulful Southern rock and improvisational live shows.",
|
||||
"is_active": True,
|
||||
"is_featured": True,
|
||||
"scenes": ["jam", "blues-rock"]
|
||||
},
|
||||
{
|
||||
"name": "Ween",
|
||||
"slug": "ween",
|
||||
"description": "Eclectic rock duo from New Hope, PA. Known for genre-hopping and devoted fan base (Brownies).",
|
||||
"is_active": True,
|
||||
"is_featured": True,
|
||||
"scenes": ["jam", "alternative"]
|
||||
},
|
||||
{
|
||||
"name": "moe.",
|
||||
"slug": "moe",
|
||||
"description": "Jam band from Buffalo, NY. Founded 1989, known for annual moe.down festival.",
|
||||
"is_active": True,
|
||||
"is_featured": True,
|
||||
"scenes": ["jam"]
|
||||
},
|
||||
{
|
||||
"name": "The Disco Biscuits",
|
||||
"slug": "disco-biscuits",
|
||||
"description": "Electronic jam band from Philadelphia. Pioneers of 'trance fusion' and Camp Bisco festival.",
|
||||
"is_active": True,
|
||||
"is_featured": True,
|
||||
"scenes": ["jam", "electronic"]
|
||||
},
|
||||
]
|
||||
|
||||
# Ensure scenes exist
|
||||
SCENES_TO_ADD = [
|
||||
{"name": "Blues Rock", "slug": "blues-rock", "description": "Blues-influenced rock music"},
|
||||
{"name": "Alternative", "slug": "alternative", "description": "Alternative and indie rock"},
|
||||
{"name": "Electronic", "slug": "electronic", "description": "Electronic and dance music"},
|
||||
]
|
||||
|
||||
|
||||
def seed_bands():
|
||||
with Session(engine) as session:
|
||||
# First ensure scenes exist
|
||||
for scene_data in SCENES_TO_ADD:
|
||||
existing = session.exec(
|
||||
select(Scene).where(Scene.slug == scene_data["slug"])
|
||||
).first()
|
||||
if not existing:
|
||||
scene = Scene(**scene_data)
|
||||
session.add(scene)
|
||||
print(f"Added scene: {scene_data['name']}")
|
||||
session.commit()
|
||||
|
||||
# Now add bands
|
||||
for band_data in NEW_BANDS:
|
||||
existing = session.exec(
|
||||
select(Vertical).where(Vertical.slug == band_data["slug"])
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
print(f"✓ {band_data['name']} already exists")
|
||||
continue
|
||||
|
||||
# Extract scenes before creating vertical
|
||||
scene_slugs = band_data.pop("scenes", [])
|
||||
|
||||
vertical = Vertical(**band_data)
|
||||
session.add(vertical)
|
||||
session.flush() # Get the ID
|
||||
|
||||
# Link to scenes
|
||||
for slug in scene_slugs:
|
||||
scene = session.exec(
|
||||
select(Scene).where(Scene.slug == slug)
|
||||
).first()
|
||||
if scene:
|
||||
link = VerticalScene(vertical_id=vertical.id, scene_id=scene.id)
|
||||
session.add(link)
|
||||
|
||||
print(f"✅ Added: {band_data['name']}")
|
||||
|
||||
session.commit()
|
||||
|
||||
# Show all verticals
|
||||
all_verts = session.exec(select(Vertical).where(Vertical.is_active == True)).all()
|
||||
print(f"\n📊 Total active verticals: {len(all_verts)}")
|
||||
for v in all_verts:
|
||||
print(f" - {v.name} ({v.slug})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed_bands()
|
||||
98
backend/seed_scenes.py
Normal file
98
backend/seed_scenes.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"""Seed script to create initial scenes and assign bands to them"""
|
||||
|
||||
from sqlmodel import Session, select
|
||||
from database import engine
|
||||
from models import Scene, Vertical, VerticalScene
|
||||
|
||||
|
||||
# Scene definitions
|
||||
SCENES = [
|
||||
{"name": "Jam", "slug": "jam", "description": "Improvisational rock bands with extended jams"},
|
||||
{"name": "Bluegrass", "slug": "bluegrass", "description": "Progressive and traditional bluegrass"},
|
||||
{"name": "Dead Family", "slug": "dead-family", "description": "Grateful Dead and related projects"},
|
||||
{"name": "Funk", "slug": "funk", "description": "Funk, soul, and groove-oriented bands"},
|
||||
]
|
||||
|
||||
# Band -> Scene assignments
|
||||
BAND_SCENES = {
|
||||
"goose": ["jam"],
|
||||
"phish": ["jam"],
|
||||
"grateful-dead": ["jam", "dead-family"],
|
||||
"dead-and-company": ["jam", "dead-family"],
|
||||
"billy-strings": ["bluegrass", "jam"],
|
||||
# Expansion Wave 1
|
||||
"pigeons-playing-ping-pong": ["jam", "funk"],
|
||||
"eggy": ["jam"],
|
||||
"dogs-in-a-pile": ["jam"],
|
||||
"greensky-bluegrass": ["bluegrass", "jam"],
|
||||
"daniel-donato": ["jam"],
|
||||
# Expansion Wave 2
|
||||
"umphreys-mcgee": ["jam"],
|
||||
"moe": ["jam"],
|
||||
"widespread-panic": ["jam"],
|
||||
"sturgill-simpson": ["bluegrass"],
|
||||
"slightly-stoopid": ["jam", "funk"],
|
||||
}
|
||||
|
||||
|
||||
def seed_scenes():
|
||||
"""Create scenes if they don't exist"""
|
||||
print("Seeding scenes...")
|
||||
|
||||
with Session(engine) as session:
|
||||
for scene_data in SCENES:
|
||||
existing = session.exec(
|
||||
select(Scene).where(Scene.slug == scene_data["slug"])
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
scene = Scene(**scene_data)
|
||||
session.add(scene)
|
||||
print(f" Created scene: {scene_data['name']}")
|
||||
else:
|
||||
print(f" Scene exists: {scene_data['name']}")
|
||||
|
||||
session.commit()
|
||||
print("Scenes seeded.")
|
||||
|
||||
|
||||
def assign_bands_to_scenes():
|
||||
"""Assign existing bands to their scenes"""
|
||||
print("\nAssigning bands to scenes...")
|
||||
|
||||
with Session(engine) as session:
|
||||
for band_slug, scene_slugs in BAND_SCENES.items():
|
||||
vertical = session.exec(
|
||||
select(Vertical).where(Vertical.slug == band_slug)
|
||||
).first()
|
||||
|
||||
if not vertical:
|
||||
continue
|
||||
|
||||
for scene_slug in scene_slugs:
|
||||
scene = session.exec(
|
||||
select(Scene).where(Scene.slug == scene_slug)
|
||||
).first()
|
||||
|
||||
if not scene:
|
||||
continue
|
||||
|
||||
# Check if assignment exists
|
||||
existing = session.exec(
|
||||
select(VerticalScene)
|
||||
.where(VerticalScene.vertical_id == vertical.id)
|
||||
.where(VerticalScene.scene_id == scene.id)
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
vs = VerticalScene(vertical_id=vertical.id, scene_id=scene.id)
|
||||
session.add(vs)
|
||||
print(f" Assigned {band_slug} -> {scene_slug}")
|
||||
|
||||
session.commit()
|
||||
print("Band scene assignments complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed_scenes()
|
||||
assign_bands_to_scenes()
|
||||
|
|
@ -151,6 +151,12 @@ BADGE_DEFINITIONS = [
|
|||
{"name": "Debut Hunter", "slug": "debut-witness", "description": "Was in attendance for a song debut", "icon": "sparkles", "tier": "gold", "category": "milestones", "xp_reward": 200},
|
||||
{"name": "Heady Spotter", "slug": "heady-witness", "description": "Attended a top-rated performance", "icon": "trophy", "tier": "silver", "category": "milestones", "xp_reward": 150},
|
||||
{"name": "Song Chaser", "slug": "chase-caught-5", "description": "Caught 5 chase songs", "icon": "target", "tier": "silver", "category": "milestones", "xp_reward": 200},
|
||||
|
||||
# Cross-band badges (Fediversion-specific)
|
||||
{"name": "Scene Explorer", "slug": "multi-band-2", "description": "Attended shows from 2 different bands", "icon": "compass", "tier": "bronze", "category": "cross-band", "xp_reward": 75},
|
||||
{"name": "Multi-Scene Fan", "slug": "multi-band-5", "description": "Attended shows from 5 different bands", "icon": "map", "tier": "silver", "category": "cross-band", "xp_reward": 200},
|
||||
{"name": "Scene Master", "slug": "multi-band-10", "description": "Attended shows from 10 different bands", "icon": "globe", "tier": "gold", "category": "cross-band", "xp_reward": 500},
|
||||
{"name": "Jam Ambassador", "slug": "cross-band-reviewer", "description": "Reviewed performances from 3+ different bands", "icon": "message-square", "tier": "silver", "category": "cross-band", "xp_reward": 150},
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
89
backend/services/notification_service.py
Normal file
89
backend/services/notification_service.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
from typing import List, Optional
|
||||
from sqlmodel import Session, select
|
||||
from models import Notification, NotificationType, User, UserVerticalPreference, Show, PreferenceTier
|
||||
|
||||
class NotificationService:
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def create_notification(
|
||||
self,
|
||||
user_id: int,
|
||||
type: NotificationType,
|
||||
title: str,
|
||||
message: str,
|
||||
link: Optional[str] = None
|
||||
) -> Notification:
|
||||
notification = Notification(
|
||||
user_id=user_id,
|
||||
type=type,
|
||||
title=title,
|
||||
message=message,
|
||||
link=link
|
||||
)
|
||||
self.session.add(notification)
|
||||
self.session.commit()
|
||||
self.session.refresh(notification)
|
||||
return notification
|
||||
|
||||
def get_user_notifications(
|
||||
self,
|
||||
user_id: int,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[Notification]:
|
||||
query = select(Notification).where(Notification.user_id == user_id).order_by(Notification.created_at.desc()).offset(offset).limit(limit)
|
||||
return self.session.exec(query).all()
|
||||
|
||||
def mark_as_read(self, notification_id: int, user_id: int) -> bool:
|
||||
notification = self.session.get(Notification, notification_id)
|
||||
if notification and notification.user_id == user_id:
|
||||
notification.is_read = True
|
||||
self.session.add(notification)
|
||||
self.session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
def mark_all_as_read(self, user_id: int):
|
||||
statement = select(Notification).where(Notification.user_id == user_id).where(Notification.is_read == False)
|
||||
unread = self.session.exec(statement).all()
|
||||
for note in unread:
|
||||
note.is_read = True
|
||||
self.session.add(note)
|
||||
self.session.commit()
|
||||
|
||||
def check_show_alert(self, show: Show):
|
||||
"""
|
||||
Check if any users want to be notified about this new show.
|
||||
This roughly matches users who:
|
||||
1. Follow the vertical (UserVerticalPreference)
|
||||
2. Have notify_on_show = True
|
||||
3. Valid Tier (Usually Headliner/MainStage)
|
||||
"""
|
||||
# Find users who subscribe to this band
|
||||
# Filtering logic:
|
||||
# - Matches vertical_id
|
||||
# - notify_on_show is True
|
||||
# - Tier is HEADLINER (for high priority) or specific preference
|
||||
# For now, let's alert all who have notify_on_show=True for this vertical
|
||||
|
||||
subscriptions = self.session.exec(
|
||||
select(UserVerticalPreference)
|
||||
.where(UserVerticalPreference.vertical_id == show.vertical_id)
|
||||
.where(UserVerticalPreference.notify_on_show == True)
|
||||
).all()
|
||||
|
||||
for sub in subscriptions:
|
||||
# We can customize message based on tier if needed
|
||||
title = f"New Show Added: {show.vertical.name}"
|
||||
date_str = show.date.strftime("%b %d, %Y")
|
||||
message = f"{show.vertical.name} at {show.venue.name} on {date_str}"
|
||||
link = f"/{show.vertical.slug}/shows/{show.slug}"
|
||||
|
||||
self.create_notification(
|
||||
user_id=sub.user_id,
|
||||
type=NotificationType.SHOW_ALERT,
|
||||
title=title,
|
||||
message=message,
|
||||
link=link
|
||||
)
|
||||
31
backend/services/scheduler.py
Normal file
31
backend/services/scheduler.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
import import_elgoose
|
||||
from sqlmodel import Session
|
||||
from database import engine
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
scheduler = BackgroundScheduler()
|
||||
|
||||
def daily_import_job():
|
||||
logger.info("Starting daily Goose data import...")
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
stats = import_elgoose.run_import(session, with_users=False)
|
||||
logger.info(f"Daily import complete. Stats: {stats}")
|
||||
except Exception as e:
|
||||
logger.error(f"Daily import failed: {e}")
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
def start_scheduler():
|
||||
# Regular interval
|
||||
scheduler.add_job(daily_import_job, 'interval', hours=12, id='goose_import')
|
||||
|
||||
# Run once on startup (with 10s delay to let server settle)
|
||||
run_date = datetime.now() + timedelta(seconds=10)
|
||||
scheduler.add_job(daily_import_job, 'date', run_date=run_date, id='goose_import_startup')
|
||||
|
||||
scheduler.start()
|
||||
logger.info("Scheduler started with daily import job.")
|
||||
97
backend/tests/test_bands.py
Normal file
97
backend/tests/test_bands.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
from datetime import datetime
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import Session, select
|
||||
from models import Vertical, Musician, BandMembership, UserVerticalPreference, PreferenceTier, Notification, NotificationType, Artist
|
||||
|
||||
def test_create_vertical(client: TestClient, session: Session):
|
||||
vertical = Vertical(name="Test Band", slug="test-band")
|
||||
session.add(vertical)
|
||||
session.commit()
|
||||
|
||||
response = client.get("/bands/test-band")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
# The API returns structured data: { "band": {...}, "stats": {...} }
|
||||
assert data["band"]["name"] == "Test Band"
|
||||
assert data["band"]["slug"] == "test-band"
|
||||
|
||||
def test_create_musician_and_membership(client: TestClient, session: Session):
|
||||
# 1. Setup Artist & Vertical
|
||||
artist = Artist(name="The Testers", slug="the-testers-artist")
|
||||
session.add(artist)
|
||||
session.commit()
|
||||
|
||||
band = Vertical(name="The Testers", slug="the-testers", primary_artist_id=artist.id)
|
||||
session.add(band)
|
||||
session.commit()
|
||||
|
||||
# 2. Setup Musician
|
||||
musician = Musician(name="John Doe", slug="john-doe")
|
||||
session.add(musician)
|
||||
session.commit()
|
||||
|
||||
# 3. Link them via Artist
|
||||
membership = BandMembership(
|
||||
musician_id=musician.id,
|
||||
artist_id=artist.id,
|
||||
role="Guitar",
|
||||
start_date=datetime(2020, 1, 1)
|
||||
)
|
||||
session.add(membership)
|
||||
session.commit()
|
||||
|
||||
# 4. Test API
|
||||
response = client.get("/musicians/john-doe")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["musician"]["name"] == "John Doe"
|
||||
assert len(data["bands"]) == 1
|
||||
# The 'bands' list in response contains artist_name/slug
|
||||
assert data["bands"][0]["artist_name"] == "The Testers"
|
||||
|
||||
def test_notification_integration(client: TestClient, session: Session, test_user_token: str):
|
||||
# 1. Setup Band
|
||||
band = Vertical(name="Notify Band", slug="notify-band")
|
||||
session.add(band)
|
||||
session.commit()
|
||||
|
||||
# 2. Setup User & Preference
|
||||
# We need the user ID. The 'test_user_token' fixture creates a user with email "test@example.com".
|
||||
from models import User
|
||||
user = session.exec(select(User).where(User.email == "test@example.com")).first()
|
||||
assert user is not None
|
||||
|
||||
pref = UserVerticalPreference(
|
||||
user_id=user.id,
|
||||
vertical_id=band.id,
|
||||
tier=PreferenceTier.HEADLINER,
|
||||
notify_on_show=True
|
||||
)
|
||||
session.add(pref)
|
||||
session.commit()
|
||||
|
||||
# 3. Create Show via API (triggering notification)
|
||||
# Ensure venue exists for potential creation
|
||||
from models import Venue
|
||||
venue = Venue(name="Notify Venue", city="City", country="Country", slug="notify-venue")
|
||||
session.add(venue)
|
||||
session.commit()
|
||||
|
||||
response = client.post(
|
||||
"/shows/",
|
||||
json={
|
||||
"date": "2025-01-01T00:00:00",
|
||||
"vertical_id": band.id,
|
||||
"venue_id": venue.id,
|
||||
"slug": "notify-show-1"
|
||||
},
|
||||
headers={"Authorization": f"Bearer {test_user_token}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200, f"Response: {response.text}"
|
||||
|
||||
# 4. Verify Notification
|
||||
notes = session.exec(select(Notification).where(Notification.user_id == user.id)).all()
|
||||
assert len(notes) > 0, "No notifications found for user"
|
||||
assert notes[0].type == NotificationType.SHOW_ALERT
|
||||
assert "Notify Band" in notes[0].title
|
||||
BIN
backend/update_package.tar.gz
Normal file
BIN
backend/update_package.tar.gz
Normal file
Binary file not shown.
22
debug_dmb_deadco.py
Normal file
22
debug_dmb_deadco.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from sqlmodel import Session, select
|
||||
from database import engine
|
||||
from models import Vertical, Song, Performance
|
||||
|
||||
with Session(engine) as session:
|
||||
for s in ['dmb', 'dead-and-company']:
|
||||
v = session.exec(select(Vertical).where(Vertical.slug == s)).first()
|
||||
print(f"--- {s} ---")
|
||||
if not v:
|
||||
print("Vertical missing")
|
||||
continue
|
||||
print(f"Vertical ID: {v.id}")
|
||||
|
||||
perfs = session.exec(select(Performance).join(Song).where(Song.vertical_id == v.id)).all()
|
||||
print(f"Total Perfs: {len(perfs)}")
|
||||
|
||||
song = session.exec(select(Song).where(Song.title == 'All Along the Watchtower', Song.vertical_id == v.id)).first()
|
||||
if song:
|
||||
watchtower_perfs = session.exec(select(Performance).where(Performance.song_id == song.id)).all()
|
||||
print(f"Watchtower: ID={song.id}, Canon={song.canon_id}, Perfs={len(watchtower_perfs)}")
|
||||
else:
|
||||
print("Watchtower: Missing")
|
||||
51
frontend/app/[vertical]/archive/page.tsx
Normal file
51
frontend/app/[vertical]/archive/page.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import Link from "next/link"
|
||||
import { Calendar } from "lucide-react"
|
||||
import { VERTICALS } from "@/config/verticals"
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ vertical: string }>
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return VERTICALS.map((v) => ({
|
||||
vertical: v.slug,
|
||||
}))
|
||||
}
|
||||
|
||||
// TODO: Make this dynamic based on the band's history
|
||||
const currentYear = new Date().getFullYear()
|
||||
const years = Array.from({ length: 50 }, (_, i) => currentYear - i)
|
||||
|
||||
export default async function VerticalArchivePage({ params }: Props) {
|
||||
const { vertical: verticalSlug } = await params
|
||||
const vertical = VERTICALS.find((v) => v.slug === verticalSlug)
|
||||
|
||||
if (!vertical) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{vertical.name} Archive</h1>
|
||||
<p className="text-muted-foreground">Browse shows by year.</p>
|
||||
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4">
|
||||
{years.map((year) => (
|
||||
<Link key={year} href={`/${verticalSlug}/shows?year=${year}`}>
|
||||
<Card className="hover:bg-accent/50 transition-colors cursor-pointer text-center py-6">
|
||||
<CardContent>
|
||||
<div className="text-4xl font-bold text-primary">{year}</div>
|
||||
<div className="flex items-center justify-center gap-2 mt-2 text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>Browse Shows</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,8 +1,23 @@
|
|||
import { notFound } from "next/navigation"
|
||||
import { VERTICALS } from "@/contexts/vertical-context"
|
||||
import { VERTICALS } from "@/config/verticals"
|
||||
import { getApiUrl } from "@/lib/api-config"
|
||||
import Link from "next/link"
|
||||
import { Calendar, MapPin, Music, Trophy, Video, Ticket, Building, ChevronRight } from "lucide-react"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Show, Song, PaginatedResponse } from "@/types/models"
|
||||
|
||||
interface Props {
|
||||
params: { vertical: string }
|
||||
params: Promise<{ vertical: string }>
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
|
|
@ -11,49 +26,226 @@ export function generateStaticParams() {
|
|||
}))
|
||||
}
|
||||
|
||||
export default function VerticalPage({ params }: Props) {
|
||||
const vertical = VERTICALS.find((v) => v.slug === params.vertical)
|
||||
async function getRecentShows(verticalSlug: string): Promise<Show[]> {
|
||||
try {
|
||||
// Fetch 10 recent shows
|
||||
const res = await fetch(`${getApiUrl()}/shows/?vertical_slugs=${verticalSlug}&limit=10&status=past`, {
|
||||
next: { revalidate: 60 }
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const data: PaginatedResponse<Show> = await res.json()
|
||||
return data.data || []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function getTopSongs(verticalSlug: string): Promise<Song[]> {
|
||||
try {
|
||||
// Fetch top 10 songs, assuming backend now populates times_played
|
||||
const res = await fetch(`${getApiUrl()}/songs/?vertical=${verticalSlug}&limit=10&sort=times_played`, {
|
||||
next: { revalidate: 60 }
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const data: PaginatedResponse<Song> = await res.json()
|
||||
return data.data || []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function getVerticalStats(verticalSlug: string) {
|
||||
try {
|
||||
const res = await fetch(`${getApiUrl()}/bands/${verticalSlug}`, {
|
||||
next: { revalidate: 300 }
|
||||
})
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default async function VerticalPage({ params }: Props) {
|
||||
const { vertical: verticalSlug } = await params
|
||||
const vertical = VERTICALS.find((v) => v.slug === verticalSlug)
|
||||
|
||||
if (!vertical) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const [recentShows, topSongs, bandProfile] = await Promise.all([
|
||||
getRecentShows(verticalSlug),
|
||||
getTopSongs(verticalSlug),
|
||||
getVerticalStats(verticalSlug)
|
||||
])
|
||||
|
||||
const stats = bandProfile?.stats || {}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-4xl font-bold flex items-center justify-center gap-4">
|
||||
<span className="text-5xl">{vertical.emoji}</span>
|
||||
<span style={{ color: vertical.color }}>{vertical.name}</span>
|
||||
</h1>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Explore setlists, rate performances, and connect with the {vertical.name} community.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-8 pb-12">
|
||||
{/* Hero Section - Compact & Utilitarian */}
|
||||
<section className="bg-muted/30 border-b">
|
||||
<div className="container py-8 px-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-4xl font-bold tracking-tight">{vertical.name}</h1>
|
||||
<p className="text-muted-foreground max-w-2xl text-lg">
|
||||
{vertical.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<a
|
||||
href={`/${vertical.slug}/shows`}
|
||||
className="block p-6 rounded-lg border bg-card hover:bg-accent transition-colors"
|
||||
>
|
||||
<h2 className="text-xl font-semibold mb-2">Shows</h2>
|
||||
<p className="text-muted-foreground">Browse all concerts and setlists</p>
|
||||
</a>
|
||||
{/* High-Level Stats */}
|
||||
<div className="grid grid-cols-3 gap-8 text-center md:text-right">
|
||||
<div>
|
||||
<div className="text-3xl font-bold">{stats.total_shows || 0}</div>
|
||||
<div className="text-sm text-muted-foreground font-medium uppercase tracking-wider">Shows</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold">{stats.total_songs || 0}</div>
|
||||
<div className="text-sm text-muted-foreground font-medium uppercase tracking-wider">Songs</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold">{stats.total_venues || 0}</div>
|
||||
<div className="text-sm text-muted-foreground font-medium uppercase tracking-wider">Venues</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={`/${vertical.slug}/songs`}
|
||||
className="block p-6 rounded-lg border bg-card hover:bg-accent transition-colors"
|
||||
>
|
||||
<h2 className="text-xl font-semibold mb-2">Songs</h2>
|
||||
<p className="text-muted-foreground">Explore the catalog and stats</p>
|
||||
</a>
|
||||
{/* Quick Actions Bar */}
|
||||
<div className="flex flex-wrap gap-2 mt-8">
|
||||
<Link href={`/${verticalSlug}/shows`}>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Calendar className="h-4 w-4" /> Shows
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/${verticalSlug}/songs`}>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Music className="h-4 w-4" /> Songs
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/${verticalSlug}/venues`}>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<MapPin className="h-4 w-4" /> Venues
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/${verticalSlug}/performances`}>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Trophy className="h-4 w-4" /> Top Rated
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/videos?band=${verticalSlug}`}>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Video className="h-4 w-4" /> Videos
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<a
|
||||
href={`/${vertical.slug}/venues`}
|
||||
className="block p-6 rounded-lg border bg-card hover:bg-accent transition-colors"
|
||||
>
|
||||
<h2 className="text-xl font-semibold mb-2">Venues</h2>
|
||||
<p className="text-muted-foreground">See where they've played</p>
|
||||
</a>
|
||||
<div className="container px-4 space-y-12">
|
||||
<div className="grid gap-8 lg:grid-cols-2">
|
||||
{/* Recent Shows Table */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Recent Shows</h2>
|
||||
<Link href={`/${verticalSlug}/shows`}>
|
||||
<Button variant="ghost" size="sm" className="gap-1">
|
||||
View All <ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Venue</TableHead>
|
||||
<TableHead className="text-right">Location</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recentShows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center h-24 text-muted-foreground">
|
||||
No shows found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
recentShows.map((show) => (
|
||||
<TableRow key={show.id}>
|
||||
<TableCell className="font-medium whitespace-nowrap">
|
||||
<Link href={`/${verticalSlug}/shows/${show.slug}`} className="hover:underline text-primary">
|
||||
{new Date(show.date).toLocaleDateString('en-US', {
|
||||
month: 'short', day: 'numeric', year: 'numeric'
|
||||
})}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="line-clamp-1">{show.venue?.name || "Unknown"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
{show.venue?.city}, {show.venue?.state}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Most Played Songs Table */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Most Played Songs</h2>
|
||||
<Link href={`/${verticalSlug}/songs`}>
|
||||
<Button variant="ghost" size="sm" className="gap-1">
|
||||
View Catalog <ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead className="text-right">Plays</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{topSongs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center h-24 text-muted-foreground">
|
||||
No songs found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
topSongs.map((song) => (
|
||||
<TableRow key={song.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link href={`/${verticalSlug}/songs/${song.slug}`} className="hover:underline text-primary">
|
||||
{song.title}
|
||||
</Link>
|
||||
{song.original_artist && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
by {song.original_artist}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Badge variant="secondary">{song.times_played || 0}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
400
frontend/app/[vertical]/shows/[slug]/page.tsx
Normal file
400
frontend/app/[vertical]/shows/[slug]/page.tsx
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { ArrowLeft, MapPin, Music2, Disc, PlayCircle, Youtube } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { CommentSection } from "@/components/social/comment-section"
|
||||
import { EntityRating } from "@/components/social/entity-rating"
|
||||
import { ShowAttendance } from "@/components/shows/show-attendance"
|
||||
import { SocialWrapper } from "@/components/social/social-wrapper"
|
||||
import { notFound } from "next/navigation"
|
||||
import { SuggestNicknameDialog } from "@/components/shows/suggest-nickname-dialog"
|
||||
import { EntityReviews } from "@/components/reviews/entity-reviews"
|
||||
import { getApiUrl } from "@/lib/api-config"
|
||||
import { YouTubeEmbed } from "@/components/ui/youtube-embed"
|
||||
import { MarkCaughtButton } from "@/components/chase/mark-caught-button"
|
||||
import { VERTICALS } from "@/config/verticals"
|
||||
|
||||
// Helper to validate valid verticals for SSG
|
||||
export function generateStaticParams() {
|
||||
return VERTICALS.map((v) => ({
|
||||
vertical: v.slug,
|
||||
}))
|
||||
}
|
||||
|
||||
async function getShow(id: string) {
|
||||
try {
|
||||
const res = await fetch(`${getApiUrl()}/shows/${id}`, { cache: 'no-store' })
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default async function VerticalShowDetailPage({ params }: { params: Promise<{ vertical: string, slug: string }> }) {
|
||||
const { vertical, slug } = await params
|
||||
|
||||
// Verify vertical exists
|
||||
const validVertical = VERTICALS.find(v => v.slug === vertical)
|
||||
if (!validVertical) notFound()
|
||||
|
||||
const show = await getShow(slug)
|
||||
|
||||
if (!show) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
// Group by set
|
||||
const sets: Record<string, any[]> = {};
|
||||
if (show.performances) {
|
||||
show.performances.forEach((perf: any) => {
|
||||
const setName = perf.set_name || "Set 1"; // Default to Set 1 if missing
|
||||
if (!sets[setName]) sets[setName] = [];
|
||||
sets[setName].push(perf);
|
||||
});
|
||||
}
|
||||
|
||||
// Sort keys: Set 1, Set 2, Set 3, Encore, Encore 2...
|
||||
const sortedKeys = Object.keys(sets).sort((a, b) => {
|
||||
const aLower = a.toLowerCase();
|
||||
const bLower = b.toLowerCase();
|
||||
|
||||
// Encore always last
|
||||
if (aLower.includes("encore") && !bLower.includes("encore")) return 1;
|
||||
if (!aLower.includes("encore") && bLower.includes("encore")) return -1;
|
||||
|
||||
// If both have Set, compare numbers
|
||||
if (aLower.includes("set") && bLower.includes("set")) {
|
||||
const aNum = parseInt(a.replace(/\D/g, "") || "0");
|
||||
const bNum = parseInt(b.replace(/\D/g, "") || "0");
|
||||
return aNum - bNum;
|
||||
}
|
||||
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/${vertical}/shows`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
{/* Band Name - Most Important */}
|
||||
{show.vertical && (
|
||||
<Link
|
||||
href={`/${show.vertical.slug}`}
|
||||
className="inline-flex items-center gap-2 text-sm font-semibold text-primary hover:underline mb-1"
|
||||
>
|
||||
<Music2 className="h-4 w-4" />
|
||||
{show.vertical.name}
|
||||
</Link>
|
||||
)}
|
||||
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight">
|
||||
{new Date(show.date).toLocaleDateString()}
|
||||
</h1>
|
||||
{show.venue && (
|
||||
<p className="text-base sm:text-lg text-muted-foreground mt-1">
|
||||
{show.venue.name}, {show.venue.city}, {show.venue.state}
|
||||
</p>
|
||||
)}
|
||||
{show.tags && show.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{show.tags.map((tag: any) => (
|
||||
<span key={tag.id} className="bg-secondary text-secondary-foreground px-2 py-0.5 rounded-full text-xs font-medium">
|
||||
#{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center flex-wrap gap-4 mt-2">
|
||||
{show.tour && (
|
||||
<p className="text-muted-foreground flex items-center gap-2">
|
||||
<Music2 className="h-4 w-4" />
|
||||
<Link href={`/tours/${show.tour.slug || show.tour.id}`} className="hover:underline">
|
||||
{show.tour.name}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{show.notes && (
|
||||
<div className="bg-muted/50 p-4 rounded-lg border text-sm italic">
|
||||
Note: {show.notes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-[2fr_1fr]">
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Full Show Video */}
|
||||
{show.youtube_link && (
|
||||
<Card className="border-2 border-red-500/20 bg-gradient-to-br from-red-50/5 to-transparent">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<Youtube className="h-4 w-4 text-red-500" />
|
||||
Full Show Video
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<YouTubeEmbed url={show.youtube_link} title={`${show.date?.split('T')[0]} - ${show.venue?.name}`} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Setlist</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{show.performances && show.performances.length > 0 ? (
|
||||
<div>
|
||||
{sortedKeys.map((setName) => (
|
||||
<div key={setName} className="mb-6 last:mb-0">
|
||||
<h3 className="font-semibold text-sm uppercase tracking-wider text-muted-foreground mb-3 pl-2 border-b pb-1">
|
||||
{setName}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{sets[setName].map((perf: any) => (
|
||||
<div key={perf.id} className="flex flex-col group py-1.5 hover:bg-muted/50 rounded px-2 -mx-2 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-muted-foreground/60 w-6 text-right text-xs font-mono">{perf.position}.</span>
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
<Link
|
||||
href={`/${vertical}/songs/${perf.slug}`}
|
||||
className="hover:text-primary hover:underline transition-colors"
|
||||
>
|
||||
{perf.song?.title || "Unknown Song"}
|
||||
</Link>
|
||||
{perf.track_url && (
|
||||
<a
|
||||
href={perf.track_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-primary"
|
||||
title="Listen"
|
||||
>
|
||||
<PlayCircle className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
{perf.youtube_link && (
|
||||
<span
|
||||
className="text-red-500"
|
||||
title="Video available"
|
||||
>
|
||||
<Youtube className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
{perf.bandcamp_link && (
|
||||
<a
|
||||
href={perf.bandcamp_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#629aa9] hover:text-[#4a7a89]"
|
||||
title="Listen on Bandcamp"
|
||||
>
|
||||
<Disc className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
{perf.nugs_link && (
|
||||
<a
|
||||
href={perf.nugs_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#ff6b00] hover:text-[#cc5500]"
|
||||
title="Listen on Nugs.net"
|
||||
>
|
||||
<PlayCircle className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
{perf.segue && <span className="ml-1 text-muted-foreground">></span>}
|
||||
</div>
|
||||
|
||||
{/* Nicknames */}
|
||||
{perf.nicknames && perf.nicknames.length > 0 && (
|
||||
<div className="flex gap-1 ml-2">
|
||||
{perf.nicknames.map((nick: any) => (
|
||||
<span key={nick.id} className="text-[10px] bg-yellow-100/80 text-yellow-800 px-1.5 py-0.5 rounded-full border border-yellow-200" title={nick.description}>
|
||||
"{nick.nickname}"
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggest Nickname Button */}
|
||||
<div className="opacity-50 md:opacity-30 md:group-hover:opacity-100 transition-opacity">
|
||||
<SuggestNicknameDialog
|
||||
performanceId={perf.id}
|
||||
songTitle={perf.song?.title || "Song"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rating Column */}
|
||||
<SocialWrapper type="ratings">
|
||||
<EntityRating
|
||||
entityType="performance"
|
||||
entityId={perf.id}
|
||||
compact={true}
|
||||
/>
|
||||
</SocialWrapper>
|
||||
|
||||
{/* Mark Caught (for chase songs) */}
|
||||
<MarkCaughtButton
|
||||
songId={perf.song?.id}
|
||||
songTitle={perf.song?.title || "Song"}
|
||||
showId={show.id}
|
||||
/>
|
||||
</div>
|
||||
{perf.notes && (
|
||||
<div className="text-xs text-muted-foreground ml-9 italic mt-0.5">
|
||||
{perf.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Music2 className="h-12 w-12 text-muted-foreground/30 mb-4" />
|
||||
<p className="text-muted-foreground font-medium">No Setlist Documented</p>
|
||||
<p className="text-sm text-muted-foreground/70 mt-1 max-w-sm">
|
||||
This show's setlist hasn't been added yet. Early shows often weren't documented.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<SocialWrapper type="comments">
|
||||
<CommentSection entityType="show" entityId={show.id} />
|
||||
</SocialWrapper>
|
||||
|
||||
<SocialWrapper type="reviews">
|
||||
<EntityReviews entityType="show" entityId={show.id} />
|
||||
</SocialWrapper>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Venue Info Card */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Venue</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
{show.venue ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||
<Link href={`/venues/${show.venue.slug}`} className="font-medium hover:underline hover:text-primary">
|
||||
{show.venue.name}
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground pl-6">
|
||||
{show.venue.city}, {show.venue.state} {show.venue.country}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Unknown Venue</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Listen On Card */}
|
||||
{(show.nugs_link || show.bandcamp_link) && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Listen On</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{show.nugs_link && (
|
||||
<a
|
||||
href={show.nugs_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-2 rounded-lg bg-orange-500/10 hover:bg-orange-500/20 border border-orange-500/20 transition-colors"
|
||||
>
|
||||
<PlayCircle className="h-5 w-5 text-orange-500" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Nugs.net</p>
|
||||
<p className="text-xs text-muted-foreground">Stream or download</p>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
{show.bandcamp_link && (
|
||||
<a
|
||||
href={show.bandcamp_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-2 rounded-lg bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/20 transition-colors"
|
||||
>
|
||||
<Disc className="h-5 w-5 text-blue-500" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Bandcamp</p>
|
||||
<p className="text-xs text-muted-foreground">Official release</p>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tour Info */}
|
||||
{show.tour && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Tour</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<Music2 className="h-4 w-4 text-muted-foreground" />
|
||||
<Link href={`/tours/${show.tour.slug || show.tour.id}`} className="font-medium hover:underline hover:text-primary">
|
||||
{show.tour.name}
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Attendance */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">I Was There</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ShowAttendance showId={show.id} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Rate This Show */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Rate This Show</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SocialWrapper type="ratings">
|
||||
<EntityRating
|
||||
entityType="show"
|
||||
entityId={show.id}
|
||||
compact={false}
|
||||
/>
|
||||
</SocialWrapper>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
import { VERTICALS } from "@/contexts/vertical-context"
|
||||
import { VERTICALS } from "@/config/verticals"
|
||||
import { notFound } from "next/navigation"
|
||||
import { getApiUrl } from "@/lib/api-config"
|
||||
import { Show, PaginatedResponse } from "@/types/models"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Calendar, MapPin } from "lucide-react"
|
||||
|
||||
interface Props {
|
||||
params: { vertical: string }
|
||||
params: Promise<{ vertical: string }>
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
|
|
@ -12,34 +15,33 @@ export function generateStaticParams() {
|
|||
}))
|
||||
}
|
||||
|
||||
async function getShows(verticalSlug: string) {
|
||||
async function getShows(verticalSlug: string): Promise<PaginatedResponse<Show> | null> {
|
||||
try {
|
||||
const res = await fetch(`${getApiUrl()}/shows?vertical=${verticalSlug}`, {
|
||||
const res = await fetch(`${getApiUrl()}/shows/?vertical=${verticalSlug}`, {
|
||||
next: { revalidate: 60 }
|
||||
})
|
||||
if (!res.ok) return []
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
} catch {
|
||||
return []
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ShowsPage({ params }: Props) {
|
||||
const vertical = VERTICALS.find((v) => v.slug === params.vertical)
|
||||
const { vertical: verticalSlug } = await params
|
||||
const vertical = VERTICALS.find((v) => v.slug === verticalSlug)
|
||||
|
||||
if (!vertical) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const shows = await getShows(vertical.slug)
|
||||
const data = await getShows(vertical.slug)
|
||||
const shows = data?.data || []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold">
|
||||
<span className="mr-2">{vertical.emoji}</span>
|
||||
{vertical.name} Shows
|
||||
</h1>
|
||||
<h1 className="text-3xl font-bold">{vertical.name} Shows</h1>
|
||||
</div>
|
||||
|
||||
{shows.length === 0 ? (
|
||||
|
|
@ -48,25 +50,37 @@ export default async function ShowsPage({ params }: Props) {
|
|||
<p className="text-sm mt-2">Run the data importer to populate shows.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{shows.map((show: any) => (
|
||||
<div className="grid gap-4">
|
||||
{shows.map((show) => (
|
||||
<a
|
||||
key={show.id}
|
||||
href={`/${vertical.slug}/shows/${show.slug}`}
|
||||
className="block p-4 rounded-lg border bg-card hover:bg-accent transition-colors"
|
||||
className="block group"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="font-semibold">{show.venue?.name || "Unknown Venue"}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{show.venue?.city}, {show.venue?.state || show.venue?.country}
|
||||
<Card className="p-4 hover:bg-accent transition-colors">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="font-semibold flex items-center gap-2">
|
||||
{show.venue?.name || "Unknown Venue"}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-1 mt-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{show.venue?.city}, {show.venue?.state || show.venue?.country}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-mono text-sm font-bold flex items-center gap-1 justify-end">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(show.date).toLocaleDateString()}
|
||||
</div>
|
||||
{show.performances && show.performances.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{show.performances.length} songs
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-mono">{new Date(show.date).toLocaleDateString()}</div>
|
||||
<div className="text-sm text-muted-foreground">{show.tour?.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
311
frontend/app/[vertical]/songs/[slug]/page.tsx
Normal file
311
frontend/app/[vertical]/songs/[slug]/page.tsx
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { ArrowLeft, PlayCircle, History, Calendar, Trophy, Youtube, Star } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { getApiUrl } from "@/lib/api-config"
|
||||
import { CommentSection } from "@/components/social/comment-section"
|
||||
import { EntityRating } from "@/components/social/entity-rating"
|
||||
import { EntityReviews } from "@/components/reviews/entity-reviews"
|
||||
import { SocialWrapper } from "@/components/social/social-wrapper"
|
||||
import { PerformanceList } from "@/components/songs/performance-list"
|
||||
import { SongEvolutionChart } from "@/components/songs/song-evolution-chart"
|
||||
import { YouTubeEmbed } from "@/components/ui/youtube-embed"
|
||||
import { VERTICALS } from "@/config/verticals"
|
||||
|
||||
// Helper to validate valid verticals for SSG
|
||||
export function generateStaticParams() {
|
||||
return VERTICALS.map((v) => ({
|
||||
vertical: v.slug,
|
||||
}))
|
||||
}
|
||||
|
||||
async function getSong(id: string) {
|
||||
try {
|
||||
const res = await fetch(`${getApiUrl()}/songs/${id}`, { cache: 'no-store' })
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch cross-band versions of this song via SongCanon
|
||||
async function getRelatedVersions(songId: number) {
|
||||
try {
|
||||
const res = await fetch(`${getApiUrl()}/canon/song/${songId}/related`, {
|
||||
next: { revalidate: 60 }
|
||||
})
|
||||
if (!res.ok) return []
|
||||
return res.json()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Get top rated performances for "Heady Version" leaderboard
|
||||
function getHeadyVersions(performances: any[]) {
|
||||
if (!performances || performances.length === 0) return []
|
||||
return [...performances]
|
||||
.filter(p => p.avg_rating && p.rating_count > 0)
|
||||
.sort((a, b) => b.avg_rating - a.avg_rating)
|
||||
.slice(0, 5)
|
||||
}
|
||||
|
||||
export default async function VerticalSongDetailPage({ params }: { params: Promise<{ vertical: string, slug: string }> }) {
|
||||
const { vertical, slug } = await params
|
||||
|
||||
// Verify vertical exists (optional, could just let 404 handle it if song logic doesn't care)
|
||||
const validVertical = VERTICALS.find(v => v.slug === vertical)
|
||||
if (!validVertical) notFound()
|
||||
|
||||
const song = await getSong(slug)
|
||||
|
||||
if (!song) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const headyVersions = getHeadyVersions(song.performances || [])
|
||||
const topPerformance = headyVersions[0]
|
||||
|
||||
// Fetch cross-band versions
|
||||
const relatedVersions = await getRelatedVersions(song.id)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/${vertical}/songs`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{song.title}</h1>
|
||||
{song.artist ? (
|
||||
<Link href={`/artists/${song.artist.slug}`} className="text-lg text-muted-foreground font-medium hover:text-primary transition-colors">
|
||||
({song.artist.name})
|
||||
</Link>
|
||||
) : song.original_artist ? (
|
||||
<span className="text-lg text-muted-foreground font-medium">({song.original_artist})</span>
|
||||
) : null}
|
||||
</div>
|
||||
{song.tags && song.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{song.tags.map((tag: any) => (
|
||||
<span key={tag.id} className="bg-secondary text-secondary-foreground px-2 py-0.5 rounded-full text-xs font-medium">
|
||||
#{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SocialWrapper type="ratings">
|
||||
<EntityRating entityType="song" entityId={song.id} />
|
||||
</SocialWrapper>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Times Played</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold flex items-center gap-2">
|
||||
<PlayCircle className="h-5 w-5 text-primary" />
|
||||
{song.times_played}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Gap (Shows)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold flex items-center gap-2">
|
||||
<History className="h-5 w-5 text-primary" />
|
||||
{song.gap}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Last Played</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5 text-primary" />
|
||||
{song.last_played ? new Date(song.last_played).toLocaleDateString() : "Never"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Set Breakdown */}
|
||||
{song.set_breakdown && Object.keys(song.set_breakdown).length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Set Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-6">
|
||||
{Object.entries(song.set_breakdown).sort((a, b) => (b[1] as number) - (a[1] as number)).map(([set, count]) => (
|
||||
<div key={set} className="flex flex-col items-center">
|
||||
<span className="text-2xl font-bold">{count as number}</span>
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide">{set}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Heady Version Section */}
|
||||
{headyVersions.length > 0 && (
|
||||
<Card className="border-2 border-yellow-500/20 bg-gradient-to-br from-yellow-50/50 to-orange-50/50 dark:from-yellow-900/10 dark:to-orange-900/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-yellow-700 dark:text-yellow-400">
|
||||
<Trophy className="h-6 w-6" />
|
||||
Heady Version Leaderboard
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Top Performance with YouTube */}
|
||||
{topPerformance && (
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{topPerformance.youtube_link ? (
|
||||
<YouTubeEmbed url={topPerformance.youtube_link} />
|
||||
) : song.youtube_link ? (
|
||||
<YouTubeEmbed url={song.youtube_link} />
|
||||
) : (
|
||||
<div className="aspect-video bg-muted rounded-lg flex items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Youtube className="h-12 w-12 mx-auto mb-2 opacity-30" />
|
||||
<p className="text-sm">No video available</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-yellow-500 text-yellow-900">🏆 #1 Heady</Badge>
|
||||
</div>
|
||||
<p className="font-bold text-lg">
|
||||
{topPerformance.show?.date ? new Date(topPerformance.show.date).toLocaleDateString() : "Unknown Date"}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{topPerformance.show?.venue?.name || "Unknown Venue"}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 text-yellow-600">
|
||||
<Star className="h-5 w-5 fill-current" />
|
||||
<span className="font-bold text-xl">{topPerformance.avg_rating?.toFixed(1)}</span>
|
||||
<span className="text-sm text-muted-foreground">({topPerformance.rating_count} ratings)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Leaderboard List */}
|
||||
<div className="space-y-2">
|
||||
{headyVersions.map((perf: any, index: number) => (
|
||||
<div
|
||||
key={perf.id}
|
||||
className={`flex items-center justify-between p-3 rounded-lg ${index === 0 ? 'bg-yellow-100/50 dark:bg-yellow-900/20' : 'bg-background/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 text-center font-bold">
|
||||
{index === 0 ? "🥇" : index === 1 ? "🥈" : index === 2 ? "🥉" : `${index + 1}.`}
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{perf.show?.date ? new Date(perf.show.date).toLocaleDateString() : "Unknown"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{perf.show?.venue?.name || "Unknown Venue"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{perf.youtube_link && (
|
||||
<a href={perf.youtube_link} target="_blank" rel="noopener noreferrer">
|
||||
<Youtube className="h-4 w-4 text-red-500" />
|
||||
</a>
|
||||
)}
|
||||
<div className="text-right">
|
||||
<span className="font-bold">{perf.avg_rating?.toFixed(1)}★</span>
|
||||
<span className="text-xs text-muted-foreground ml-1">({perf.rating_count})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Cross-Band Versions */}
|
||||
{relatedVersions && relatedVersions.length > 0 && (
|
||||
<Card className="border-2 border-indigo-500/20 bg-gradient-to-br from-indigo-50/50 to-purple-50/50 dark:from-indigo-900/10 dark:to-purple-900/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-indigo-700 dark:text-indigo-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
<path d="M2 12h20" />
|
||||
</svg>
|
||||
Also Played By
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This song is performed by {relatedVersions.length + 1} different bands
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{relatedVersions.map((version: any) => (
|
||||
<Link
|
||||
key={version.id}
|
||||
href={`/${version.vertical_slug}/songs/${version.slug}`}
|
||||
className="block group"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-background/50 hover:bg-background/80 transition-colors border border-transparent hover:border-indigo-200 dark:hover:border-indigo-800">
|
||||
<div>
|
||||
<p className="font-medium group-hover:text-primary transition-colors">
|
||||
{version.vertical_name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{version.title}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
View →
|
||||
</Badge>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<SongEvolutionChart performances={song.performances || []} />
|
||||
|
||||
{/* Performance List Component (Handles Client Sorting) */}
|
||||
<PerformanceList performances={song.performances || []} songTitle={song.title} />
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<SocialWrapper type="comments">
|
||||
<CommentSection entityType="song" entityId={song.id} />
|
||||
</SocialWrapper>
|
||||
|
||||
<SocialWrapper type="reviews">
|
||||
<EntityReviews entityType="song" entityId={song.id} />
|
||||
</SocialWrapper>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import { VERTICALS } from "@/contexts/vertical-context"
|
||||
import { VERTICALS } from "@/config/verticals"
|
||||
import { notFound } from "next/navigation"
|
||||
import { getApiUrl } from "@/lib/api-config"
|
||||
|
||||
interface Props {
|
||||
params: { vertical: string }
|
||||
params: Promise<{ vertical: string }>
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
|
|
@ -18,14 +18,16 @@ async function getSongs(verticalSlug: string) {
|
|||
next: { revalidate: 60 }
|
||||
})
|
||||
if (!res.ok) return []
|
||||
return res.json()
|
||||
const data = await res.json()
|
||||
return data.data || []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export default async function SongsPage({ params }: Props) {
|
||||
const vertical = VERTICALS.find((v) => v.slug === params.vertical)
|
||||
const { vertical: verticalSlug } = await params
|
||||
const vertical = VERTICALS.find((v) => v.slug === verticalSlug)
|
||||
|
||||
if (!vertical) {
|
||||
notFound()
|
||||
|
|
@ -36,10 +38,7 @@ export default async function SongsPage({ params }: Props) {
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold">
|
||||
<span className="mr-2">{vertical.emoji}</span>
|
||||
{vertical.name} Songs
|
||||
</h1>
|
||||
<h1 className="text-3xl font-bold">{vertical.name} Songs</h1>
|
||||
</div>
|
||||
|
||||
{songs.length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { VERTICALS } from "@/contexts/vertical-context"
|
||||
import { VERTICALS } from "@/config/verticals"
|
||||
import { notFound } from "next/navigation"
|
||||
import { getApiUrl } from "@/lib/api-config"
|
||||
|
||||
interface Props {
|
||||
params: { vertical: string }
|
||||
params: Promise<{ vertical: string }>
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
|
|
@ -18,14 +18,16 @@ async function getVenues(verticalSlug: string) {
|
|||
next: { revalidate: 60 }
|
||||
})
|
||||
if (!res.ok) return []
|
||||
return res.json()
|
||||
const data = await res.json()
|
||||
return data.data || []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export default async function VenuesPage({ params }: Props) {
|
||||
const vertical = VERTICALS.find((v) => v.slug === params.vertical)
|
||||
const { vertical: verticalSlug } = await params
|
||||
const vertical = VERTICALS.find((v) => v.slug === verticalSlug)
|
||||
|
||||
if (!vertical) {
|
||||
notFound()
|
||||
|
|
@ -36,10 +38,7 @@ export default async function VenuesPage({ params }: Props) {
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold">
|
||||
<span className="mr-2">{vertical.emoji}</span>
|
||||
{vertical.name} Venues
|
||||
</h1>
|
||||
<h1 className="text-3xl font-bold">{vertical.name} Venues</h1>
|
||||
</div>
|
||||
|
||||
{venues.length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ export default function AboutPage() {
|
|||
<div className="flex flex-col gap-12 max-w-4xl mx-auto py-8">
|
||||
{/* Header */}
|
||||
<section className="text-center space-y-4">
|
||||
<h1 className="text-4xl font-extrabold tracking-tight sm:text-5xl">About Elmeg</h1>
|
||||
<h1 className="text-4xl font-extrabold tracking-tight sm:text-5xl">About Fediversion</h1>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
A comprehensive community-driven archive dedicated to preserving the history and evolution of the band <span className="text-foreground font-semibold">Goose</span>.
|
||||
The unified, community-driven platform for the entire <span className="text-foreground font-semibold">Jam Scene</span>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
|
@ -22,9 +22,10 @@ export default function AboutPage() {
|
|||
<h2 className="text-2xl font-bold">Our Mission</h2>
|
||||
</div>
|
||||
<p className="text-lg leading-relaxed text-muted-foreground">
|
||||
Elmeg is a collaborative effort, growing organically through the contributions of the flock.
|
||||
We believe that every performance is a shared experience. Our goal is to build a mycelium-like network
|
||||
of information, where every setlist, note, and rating helps others discover the magic of the music.
|
||||
Fediversion is a collaborative effort to bring the entire scene together under one roof.
|
||||
We believe that the magic of live music transcends any single band. Our goal is to create a seamless,
|
||||
interconnected archive where fans can track their stats, rate performances, and discover new music
|
||||
across the entire spectrum of the jam universe.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -37,33 +38,33 @@ export default function AboutPage() {
|
|||
<h2 className="text-xl font-bold">Heady Inspiration</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
The soul of Elmeg's performance rating system is directly inspired by <strong>Heady Version</strong>.
|
||||
The soul of our rating system is directly inspired by <strong>Heady Version</strong>.
|
||||
We've adopted the concept of "Heady Versions" to help fans identify and celebrate the definitive
|
||||
takes on their favorite Goose songs.
|
||||
takes on songs, now expanded across every band in the scene.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Music className="h-6 w-6 text-blue-500" />
|
||||
<h2 className="text-xl font-bold">The Dead Heritage</h2>
|
||||
<h2 className="text-xl font-bold">The Taper Heritage</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
We stand on the shoulders of giants. The culture of meticulous documentation pioneered by the
|
||||
legendary fans of the <strong>Grateful Dead</strong>. Elmeg continues this tradition,
|
||||
bringing the spirit of the Tapestry into the modern era.
|
||||
legendary fans of the <strong>Grateful Dead</strong>. Fediversion continues this tradition,
|
||||
bringing the spirit of the Tapestry into the modern era for a new generation of fans.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Music className="h-6 w-6 text-green-500" />
|
||||
<h2 className="text-xl font-bold">Data & API</h2>
|
||||
<h2 className="text-xl font-bold">Community Data</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
We are incredibly grateful to <strong>elgoose.net</strong> for providing their comprehensive
|
||||
API. Their dedication to documenting the band's history makes the archival work of Elmeg possible
|
||||
for all fans to enjoy.
|
||||
We are incredibly grateful to the open data communities that make this possible, including
|
||||
<strong> Setlist.fm</strong>, <strong>Phish.net</strong>, <strong>Elgoose.net</strong>, and others.
|
||||
Their dedication to documenting history allows us to build this unified home for all fans.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
|
@ -75,9 +76,9 @@ export default function AboutPage() {
|
|||
<div className="space-y-2">
|
||||
<h3 className="font-bold text-yellow-600 dark:text-yellow-400 uppercase tracking-wider text-sm">Official Disclaimer</h3>
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-100/80 leading-relaxed">
|
||||
Elmeg is an independent, non-commercial community archive.
|
||||
This site is <strong>NOT</strong> endorsed by, affiliated with, or sponsored by <strong>Goose</strong> or
|
||||
their management. All trade names, trademarks, and band imagery are the property of their respective owners.
|
||||
Fediversion is an independent, non-commercial community archive.
|
||||
This site is <strong>NOT</strong> endorsed by, affiliated with, or sponsored by any of the bands featured herein.
|
||||
All trade names, trademarks, and band imagery are the property of their respective owners.
|
||||
We are simply fans celebrating the music.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -88,7 +89,7 @@ export default function AboutPage() {
|
|||
{/* Footer Note */}
|
||||
<section className="text-center pt-8 border-t">
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
"Built by the flock, for the flock."
|
||||
"Built by fans, for the scene."
|
||||
</p>
|
||||
</section>
|
||||
</div >
|
||||
|
|
|
|||
|
|
@ -198,7 +198,7 @@ export default function AdminSequencesPage() {
|
|||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setAllSongs(data.songs || data)
|
||||
setAllSongs(data.data || [])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch songs", e)
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export default function AdminShowsPage() {
|
|||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setShows(data.shows || data)
|
||||
setShows(data.data || [])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch shows", e)
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export default function AdminSongsPage() {
|
|||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSongs(data.songs || data)
|
||||
setSongs(data.data || [])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch songs", e)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useState, useCallback } from "react"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
|
|
@ -38,6 +38,24 @@ export default function AdminVenuesPage() {
|
|||
const [editingVenue, setEditingVenue] = useState<Venue | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const fetchVenues = useCallback(async () => {
|
||||
if (!token) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${getApiUrl()}/venues?limit=200`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setVenues(data.data || [])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch venues", e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [token])
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return
|
||||
if (!user) {
|
||||
|
|
@ -49,25 +67,7 @@ export default function AdminVenuesPage() {
|
|||
return
|
||||
}
|
||||
fetchVenues()
|
||||
}, [user, router, authLoading])
|
||||
|
||||
const fetchVenues = async () => {
|
||||
if (!token) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${getApiUrl()}/venues?limit=200`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setVenues(data.venues || data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch venues", e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [user, router, authLoading, fetchVenues])
|
||||
|
||||
const updateVenue = async () => {
|
||||
if (!token || !editingVenue) return
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ import { Separator } from "@/components/ui/separator"
|
|||
import Link from "next/link"
|
||||
|
||||
interface ArtistPageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
slug: string
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
async function getArtist(slug: string) {
|
||||
|
|
@ -26,17 +26,19 @@ async function getArtist(slug: string) {
|
|||
}
|
||||
|
||||
export async function generateMetadata({ params }: ArtistPageProps): Promise<Metadata> {
|
||||
const data = await getArtist(params.slug)
|
||||
const { slug } = await params
|
||||
const data = await getArtist(slug)
|
||||
if (!data) return { title: "Artist Not Found" }
|
||||
|
||||
return {
|
||||
title: `${data.artist.name} | Elmeg`,
|
||||
description: data.artist.bio || `Artist profile for ${data.artist.name} on Elmeg.`,
|
||||
title: `${data.artist.name} | Fediversion`,
|
||||
description: data.artist.bio || `Artist profile for ${data.artist.name} on Fediversion.`,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ArtistPage({ params }: ArtistPageProps) {
|
||||
const data = await getArtist(params.slug)
|
||||
const { slug } = await params
|
||||
const data = await getArtist(slug)
|
||||
if (!data) return notFound()
|
||||
|
||||
const { artist, covers, guest_appearances } = data
|
||||
|
|
|
|||
308
frontend/app/bands/[slug]/page.tsx
Normal file
308
frontend/app/bands/[slug]/page.tsx
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
import { Metadata } from "next"
|
||||
import { notFound } from "next/navigation"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import Link from "next/link"
|
||||
import { Music, Calendar, MapPin, Users, ExternalLink, Globe } from "lucide-react"
|
||||
|
||||
interface BandPageProps {
|
||||
params: Promise<{
|
||||
slug: string
|
||||
}>
|
||||
}
|
||||
|
||||
async function getBand(slug: string) {
|
||||
const res = await fetch(`${process.env.INTERNAL_API_URL}/bands/${slug}`, {
|
||||
next: { revalidate: 60 },
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) return null
|
||||
throw new Error("Failed to fetch band")
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: BandPageProps): Promise<Metadata> {
|
||||
const { slug } = await params
|
||||
const data = await getBand(slug)
|
||||
if (!data) return { title: "Band Not Found" }
|
||||
|
||||
return {
|
||||
title: `${data.band.name} | Fediversion`,
|
||||
description: data.band.description || `Band profile for ${data.band.name} on Fediversion.`,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function BandPage({ params }: BandPageProps) {
|
||||
const { slug } = await params
|
||||
const data = await getBand(slug)
|
||||
if (!data) return notFound()
|
||||
|
||||
const { band, current_members, past_members, stats } = data
|
||||
|
||||
// Format origin location
|
||||
const originParts = [band.origin_city, band.origin_state, band.origin_country].filter(Boolean)
|
||||
const originLocation = originParts.join(", ")
|
||||
|
||||
return (
|
||||
<div className="container py-8 space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{band.logo_url ? (
|
||||
<img
|
||||
src={band.logo_url}
|
||||
alt={band.name}
|
||||
className="w-24 h-24 rounded-lg object-cover border-2 border-primary/20"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-24 h-24 rounded-lg flex items-center justify-center text-3xl font-bold text-white"
|
||||
style={{ backgroundColor: band.accent_color || '#6366f1' }}
|
||||
>
|
||||
{band.name[0]}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tight">{band.name}</h1>
|
||||
<div className="flex flex-wrap gap-3 mt-2 text-sm text-muted-foreground">
|
||||
{originLocation && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-4 w-4" />
|
||||
{originLocation}
|
||||
</span>
|
||||
)}
|
||||
{band.formed_year && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Formed {band.formed_year}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{band.description && (
|
||||
<p className="max-w-3xl text-lg text-muted-foreground leading-relaxed">
|
||||
{band.long_description || band.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* External Links */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{band.website_url && (
|
||||
<Link href={band.website_url} target="_blank" className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-accent hover:bg-accent/80 text-sm">
|
||||
<Globe className="h-3 w-3" /> Website
|
||||
</Link>
|
||||
)}
|
||||
{band.nugs_url && (
|
||||
<Link href={band.nugs_url} target="_blank" className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-orange-500/20 hover:bg-orange-500/30 text-sm text-orange-600 dark:text-orange-400">
|
||||
<Music className="h-3 w-3" /> Nugs.net
|
||||
</Link>
|
||||
)}
|
||||
{band.relisten_url && (
|
||||
<Link href={band.relisten_url} target="_blank" className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-blue-500/20 hover:bg-blue-500/30 text-sm text-blue-600 dark:text-blue-400">
|
||||
<Music className="h-3 w-3" /> Relisten
|
||||
</Link>
|
||||
)}
|
||||
{band.spotify_url && (
|
||||
<Link href={band.spotify_url} target="_blank" className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-green-500/20 hover:bg-green-500/30 text-sm text-green-600 dark:text-green-400">
|
||||
<Music className="h-3 w-3" /> Spotify
|
||||
</Link>
|
||||
)}
|
||||
{band.wikipedia_url && (
|
||||
<Link href={band.wikipedia_url} target="_blank" className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-gray-500/20 hover:bg-gray-500/30 text-sm">
|
||||
<ExternalLink className="h-3 w-3" /> Wikipedia
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-3xl font-bold">{stats.total_shows.toLocaleString()}</div>
|
||||
<div className="text-sm text-muted-foreground">Shows</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-3xl font-bold">{stats.total_songs.toLocaleString()}</div>
|
||||
<div className="text-sm text-muted-foreground">Songs</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-3xl font-bold">{stats.total_venues.toLocaleString()}</div>
|
||||
<div className="text-sm text-muted-foreground">Venues</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-3xl font-bold">
|
||||
{stats.first_show && stats.last_show
|
||||
? new Date(stats.last_show).getFullYear() - new Date(stats.first_show).getFullYear() + 1
|
||||
: '—'
|
||||
}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Active Years</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Members Section */}
|
||||
{(current_members.length > 0 || past_members.length > 0) && (
|
||||
<>
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Users className="h-6 w-6" /> Members
|
||||
</h2>
|
||||
<Tabs defaultValue="current" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="current">
|
||||
Current
|
||||
<Badge variant="secondary" className="ml-2">{current_members.length}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="past">
|
||||
Past
|
||||
<Badge variant="secondary" className="ml-2">{past_members.length}</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="current" className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{current_members.map((member: any) => (
|
||||
<Link
|
||||
key={member.id}
|
||||
href={`/musicians/${member.slug}`}
|
||||
className="block group"
|
||||
>
|
||||
<Card className="h-full transition-colors group-hover:bg-accent/50">
|
||||
<CardContent className="pt-6 flex items-center gap-4">
|
||||
{member.image_url ? (
|
||||
<img
|
||||
src={member.image_url}
|
||||
alt={member.name}
|
||||
className="w-16 h-16 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-full bg-accent flex items-center justify-center text-xl font-bold">
|
||||
{member.name[0]}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-semibold group-hover:text-primary transition-colors">
|
||||
{member.name}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{member.role || member.primary_instrument}
|
||||
</div>
|
||||
{member.start_date && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Since {new Date(member.start_date).getFullYear()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
{current_members.length === 0 && (
|
||||
<div className="col-span-full py-12 text-center text-muted-foreground">
|
||||
No current members listed.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="past" className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{past_members.map((member: any) => (
|
||||
<Link
|
||||
key={member.id}
|
||||
href={`/musicians/${member.slug}`}
|
||||
className="block group"
|
||||
>
|
||||
<Card className="h-full transition-colors group-hover:bg-accent/50">
|
||||
<CardContent className="pt-6 flex items-center gap-4">
|
||||
{member.image_url ? (
|
||||
<img
|
||||
src={member.image_url}
|
||||
alt={member.name}
|
||||
className="w-16 h-16 rounded-full object-cover opacity-75"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-full bg-accent flex items-center justify-center text-xl font-bold opacity-75">
|
||||
{member.name[0]}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-semibold group-hover:text-primary transition-colors">
|
||||
{member.name}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{member.role || member.primary_instrument}
|
||||
</div>
|
||||
{(member.start_date || member.end_date) && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{member.start_date ? new Date(member.start_date).getFullYear() : '?'}
|
||||
{' - '}
|
||||
{member.end_date ? new Date(member.end_date).getFullYear() : '?'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
{past_members.length === 0 && (
|
||||
<div className="col-span-full py-12 text-center text-muted-foreground">
|
||||
No past members listed.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Link href={`/${band.slug}/shows`} className="block">
|
||||
<Card className="hover:bg-accent/50 transition-colors">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<span className="font-semibold">All Shows</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href={`/${band.slug}/songs`} className="block">
|
||||
<Card className="hover:bg-accent/50 transition-colors">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<span className="font-semibold">All Songs</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href={`/${band.slug}/venues`} className="block">
|
||||
<Card className="hover:bg-accent/50 transition-colors">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<span className="font-semibold">All Venues</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href={`/${band.slug}/performances`} className="block">
|
||||
<Card className="hover:bg-accent/50 transition-colors">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<span className="font-semibold">Top Performances</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
frontend/app/bands/page.tsx
Normal file
21
frontend/app/bands/page.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { Metadata } from "next"
|
||||
import { BandsGrid } from "@/components/bands/bands-grid"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Bands | Fediversion",
|
||||
description: "Browse all bands in the Fediversion archive"
|
||||
}
|
||||
|
||||
export default function BandsPage() {
|
||||
return (
|
||||
<div className="container max-w-6xl py-8 space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Bands</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Select a band to explore their archive of shows, songs, and performances.
|
||||
</p>
|
||||
</div>
|
||||
<BandsGrid />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { AuthProvider } from "@/contexts/auth-context";
|
|||
import { VerticalProvider } from "@/contexts/vertical-context";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Footer } from "@/components/layout/footer";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import Script from "next/script";
|
||||
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
|
|
@ -21,8 +22,11 @@ const jetbrainsMono = JetBrains_Mono({
|
|||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Fediversion",
|
||||
description: "The ultimate HeadyVersion platform for all jam bands",
|
||||
title: {
|
||||
default: "Fediversion",
|
||||
template: "%s | Fediversion",
|
||||
},
|
||||
description: "The definitive archive for the modern jam era. Track setlists, find sit-ins, and build your profile.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
|
@ -37,11 +41,11 @@ export default function RootLayout({
|
|||
jetbrainsMono.variable,
|
||||
"min-h-screen bg-background font-sans antialiased flex flex-col"
|
||||
)}>
|
||||
<Script
|
||||
{/* <Script
|
||||
defer
|
||||
src="https://stats.elmeg.xyz/stats"
|
||||
data-website-id="4338bbf0-fe74-4256-8973-8cdc0cffe08c"
|
||||
/>
|
||||
/> */}
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
|
|
@ -56,6 +60,7 @@ export default function RootLayout({
|
|||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
<Toaster />
|
||||
</PreferencesProvider>
|
||||
</VerticalProvider>
|
||||
</AuthProvider>
|
||||
|
|
|
|||
|
|
@ -339,7 +339,7 @@ export default function ModDashboardPage() {
|
|||
) : (
|
||||
<div className="space-y-4">
|
||||
{pendingReports.map(report => (
|
||||
<div key={report.id} className="flex flex-col md:flex-row gap-4 justify-between border p-4 rounded-lg bg-red-50/10 border-red-100 dark:border-red-900/20">
|
||||
<Card key={report.id} className="flex flex-col md:flex-row gap-4 justify-between p-4 bg-red-50/10 border-red-100 dark:border-red-900/20 shadow-none">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={selectedReports.includes(report.id)}
|
||||
|
|
@ -383,7 +383,7 @@ export default function ModDashboardPage() {
|
|||
<X className="h-4 w-4 mr-1" /> Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -412,7 +412,7 @@ export default function ModDashboardPage() {
|
|||
) : (
|
||||
<div className="space-y-4">
|
||||
{pendingNicknames.map((item) => (
|
||||
<div key={item.id} className="flex items-center justify-between border p-4 rounded-lg">
|
||||
<Card key={item.id} className="flex items-center justify-between p-4 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={selectedNicknames.includes(item.id)}
|
||||
|
|
@ -449,7 +449,7 @@ export default function ModDashboardPage() {
|
|||
<X className="h-4 w-4 mr-1" /> Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -480,7 +480,7 @@ export default function ModDashboardPage() {
|
|||
</div>
|
||||
|
||||
{lookupUser && (
|
||||
<div className="border rounded-lg p-4 space-y-4">
|
||||
<Card className="p-4 space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="font-bold text-lg">{lookupUser.username || "No username"}</p>
|
||||
|
|
@ -538,7 +538,7 @@ export default function ModDashboardPage() {
|
|||
<p className="text-xs text-muted-foreground">Reports</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
241
frontend/app/musicians/[slug]/page.tsx
Normal file
241
frontend/app/musicians/[slug]/page.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import { Metadata } from "next"
|
||||
import { notFound } from "next/navigation"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import Link from "next/link"
|
||||
import { Music, Calendar, MapPin, Users, ExternalLink, Globe, Instagram } from "lucide-react"
|
||||
|
||||
interface MusicianPageProps {
|
||||
params: Promise<{
|
||||
slug: string
|
||||
}>
|
||||
}
|
||||
|
||||
async function getMusician(slug: string) {
|
||||
const res = await fetch(`${process.env.INTERNAL_API_URL}/musicians/${slug}`, {
|
||||
next: { revalidate: 60 },
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) return null
|
||||
throw new Error("Failed to fetch musician")
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: MusicianPageProps): Promise<Metadata> {
|
||||
const { slug } = await params
|
||||
const data = await getMusician(slug)
|
||||
if (!data) return { title: "Musician Not Found" }
|
||||
|
||||
return {
|
||||
title: `${data.musician.name} | Fediversion`,
|
||||
description: data.musician.bio || `Musician profile for ${data.musician.name} on Fediversion.`,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function MusicianPage({ params }: MusicianPageProps) {
|
||||
const { slug } = await params
|
||||
const data = await getMusician(slug)
|
||||
if (!data) return notFound()
|
||||
|
||||
const { musician, bands, guest_appearances, sit_in_summary, stats } = data
|
||||
|
||||
return (
|
||||
<div className="container py-8 space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{musician.image_url ? (
|
||||
<img
|
||||
src={musician.image_url}
|
||||
alt={musician.name}
|
||||
className="w-24 h-24 rounded-full object-cover border-2 border-primary/20"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full bg-accent flex items-center justify-center text-3xl font-bold text-muted-foreground">
|
||||
{musician.name[0]}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tight">{musician.name}</h1>
|
||||
{musician.primary_instrument && (
|
||||
<p className="text-muted-foreground flex items-center gap-1">
|
||||
<Music className="h-4 w-4" />
|
||||
{musician.primary_instrument}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{musician.bio && (
|
||||
<p className="max-w-3xl text-lg text-muted-foreground leading-relaxed">
|
||||
{musician.bio}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* External Links */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{musician.website_url && (
|
||||
<Link href={musician.website_url} target="_blank" className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-accent hover:bg-accent/80 text-sm">
|
||||
<Globe className="h-3 w-3" /> Website
|
||||
</Link>
|
||||
)}
|
||||
{musician.instagram_url && (
|
||||
<Link href={musician.instagram_url} target="_blank" className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-pink-500/20 hover:bg-pink-500/30 text-sm text-pink-600 dark:text-pink-400">
|
||||
<Instagram className="h-3 w-3" /> Instagram
|
||||
</Link>
|
||||
)}
|
||||
{musician.wikipedia_url && (
|
||||
<Link href={musician.wikipedia_url} target="_blank" className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-gray-500/20 hover:bg-gray-500/30 text-sm">
|
||||
<ExternalLink className="h-3 w-3" /> Wikipedia
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-3xl font-bold">{stats?.total_bands || 0}</div>
|
||||
<div className="text-sm text-muted-foreground">Bands</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-3xl font-bold">{stats?.current_bands || 0}</div>
|
||||
<div className="text-sm text-muted-foreground">Current</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-3xl font-bold">{stats?.total_sit_ins || 0}</div>
|
||||
<div className="text-sm text-muted-foreground">Sit-Ins</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-3xl font-bold">{stats?.bands_sat_in_with || 0}</div>
|
||||
<div className="text-sm text-muted-foreground">Bands Sat In With</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Band History */}
|
||||
{bands && bands.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Users className="h-6 w-6" /> Band History
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{bands.map((band: any, i: number) => (
|
||||
<Link
|
||||
key={i}
|
||||
href={`/bands/${band.artist_slug || band.band_slug}`}
|
||||
className="block group"
|
||||
>
|
||||
<Card className={`h-full transition-colors group-hover:bg-accent/50 ${band.is_current ? 'border-primary/50' : ''}`}>
|
||||
<CardContent className="pt-6 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-semibold group-hover:text-primary transition-colors flex items-center gap-2">
|
||||
{band.artist_name}
|
||||
{band.is_current && (
|
||||
<Badge variant="default" className="text-xs">Current</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{band.role}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground text-right">
|
||||
{band.start_date?.split('-')[0] || '?'}
|
||||
{' - '}
|
||||
{band.is_current ? 'Present' : (band.end_date?.split('-')[0] || '?')}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Sit-In Summary */}
|
||||
{sit_in_summary && sit_in_summary.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Music className="h-6 w-6" /> Sit-In Summary
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4">
|
||||
{sit_in_summary.map((band: any, i: number) => (
|
||||
<Link
|
||||
key={i}
|
||||
href={`/${band.vertical_slug}`}
|
||||
className="block group"
|
||||
>
|
||||
<Card className="h-full transition-colors group-hover:bg-accent/50">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="font-semibold group-hover:text-primary transition-colors">
|
||||
{band.vertical_name}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-primary">
|
||||
{band.count}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
sit-in{band.count !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Recent Guest Appearances */}
|
||||
{guest_appearances && guest_appearances.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-2xl font-bold">Recent Guest Appearances</h2>
|
||||
<div className="border rounded-lg">
|
||||
<div className="divide-y">
|
||||
{guest_appearances.slice(0, 20).map((appearance: any, i: number) => (
|
||||
<div key={i} className="p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:bg-accent/50 transition-colors">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{appearance.vertical_name}</Badge>
|
||||
<Link
|
||||
href={`/shows/${appearance.performance_slug?.split('-').slice(0, 4).join('-') || '#'}`}
|
||||
className="font-semibold hover:underline"
|
||||
>
|
||||
{appearance.show_date}
|
||||
</Link>
|
||||
</div>
|
||||
{appearance.instrument && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Playing: {appearance.instrument}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Sat in on: </span>
|
||||
<span className="font-medium">{appearance.song_title}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{guest_appearances.length > 20 && (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Showing 20 of {guest_appearances.length} appearances
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
340
frontend/app/my-bands/page.tsx
Normal file
340
frontend/app/my-bands/page.tsx
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { getApiUrl } from "@/lib/api-config"
|
||||
import { Star, Check, Ban, Loader2, Music2 } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
interface Vertical {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
description: string | null
|
||||
color: string | null
|
||||
emoji: string | null
|
||||
}
|
||||
|
||||
interface UserPreference {
|
||||
vertical_id: number
|
||||
tier: "headliner" | "main_stage" | "supporting" | "ignored"
|
||||
priority: number
|
||||
}
|
||||
|
||||
type TierType = "headliner" | "main_stage" | "supporting" | "ignored" | null
|
||||
|
||||
export default function MyBandsPage() {
|
||||
const [verticals, setVerticals] = useState<Vertical[]>([])
|
||||
const [preferences, setPreferences] = useState<Map<number, UserPreference>>(new Map())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [updating, setUpdating] = useState<number | null>(null)
|
||||
const { user, token } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
// Fetch all verticals
|
||||
const verticalsRes = await fetch(`${getApiUrl()}/verticals`)
|
||||
if (verticalsRes.ok) {
|
||||
setVerticals(await verticalsRes.json())
|
||||
}
|
||||
|
||||
// Fetch user preferences if logged in
|
||||
if (token) {
|
||||
const prefsRes = await fetch(`${getApiUrl()}/verticals/preferences`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
if (prefsRes.ok) {
|
||||
const prefsData = await prefsRes.json()
|
||||
const prefsMap = new Map<number, UserPreference>()
|
||||
prefsData.forEach((p: UserPreference) => {
|
||||
prefsMap.set(p.vertical_id, p)
|
||||
})
|
||||
setPreferences(prefsMap)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch data", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [token])
|
||||
|
||||
const getTier = (verticalId: number): TierType => {
|
||||
const pref = preferences.get(verticalId)
|
||||
return pref?.tier || null
|
||||
}
|
||||
|
||||
const setTier = async (verticalId: number, tier: TierType) => {
|
||||
if (!token) {
|
||||
router.push("/login")
|
||||
return
|
||||
}
|
||||
|
||||
setUpdating(verticalId)
|
||||
try {
|
||||
if (tier === null) {
|
||||
// Remove preference
|
||||
await fetch(`${getApiUrl()}/verticals/preferences/${verticalId}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
setPreferences(prev => {
|
||||
const next = new Map(prev)
|
||||
next.delete(verticalId)
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
// Set or update preference
|
||||
const res = await fetch(`${getApiUrl()}/verticals/preferences/${verticalId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tier,
|
||||
priority: tier === "headliner" ? 100 : tier === "main_stage" ? 50 : 0
|
||||
})
|
||||
})
|
||||
if (res.ok) {
|
||||
setPreferences(prev => {
|
||||
const next = new Map(prev)
|
||||
next.set(verticalId, { vertical_id: verticalId, tier, priority: 0 })
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to update preference", error)
|
||||
} finally {
|
||||
setUpdating(null)
|
||||
}
|
||||
}
|
||||
|
||||
const headliners = verticals.filter(v => getTier(v.id) === "headliner")
|
||||
const following = verticals.filter(v => ["main_stage", "supporting"].includes(getTier(v.id) || ""))
|
||||
const ignored = verticals.filter(v => getTier(v.id) === "ignored")
|
||||
const unfollowed = verticals.filter(v => getTier(v.id) === null)
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="container max-w-4xl py-20 text-center space-y-4">
|
||||
<Music2 className="h-16 w-16 mx-auto text-muted-foreground" />
|
||||
<h1 className="text-2xl font-bold">Sign in to manage your bands</h1>
|
||||
<p className="text-muted-foreground">Track your favorite artists and customize your feed.</p>
|
||||
<Link href="/login">
|
||||
<Button size="lg">Sign In</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container max-w-5xl py-8 space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">My Bands</h1>
|
||||
<p className="text-muted-foreground">Manage which bands appear in your feed</p>
|
||||
</div>
|
||||
<Link href="/onboarding">
|
||||
<Button variant="outline">Quick Setup</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="all" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4 mb-6">
|
||||
<TabsTrigger value="headliners" className="flex items-center gap-2">
|
||||
<Star className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Headliners</span>
|
||||
<span className="text-xs bg-primary/20 px-1.5 rounded">{headliners.length}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="following" className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Following</span>
|
||||
<span className="text-xs bg-primary/20 px-1.5 rounded">{following.length}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="all">
|
||||
All Bands
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="ignored" className="flex items-center gap-2">
|
||||
<Ban className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Ignored</span>
|
||||
<span className="text-xs bg-muted px-1.5 rounded">{ignored.length}</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="headliners" className="space-y-4">
|
||||
{headliners.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No headliners yet"
|
||||
description="Star your favorite bands to feature them prominently"
|
||||
/>
|
||||
) : (
|
||||
<BandGrid
|
||||
bands={headliners}
|
||||
getTier={getTier}
|
||||
setTier={setTier}
|
||||
updating={updating}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="following" className="space-y-4">
|
||||
{following.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Not following any bands"
|
||||
description="Click on bands below to start following"
|
||||
/>
|
||||
) : (
|
||||
<BandGrid
|
||||
bands={following}
|
||||
getTier={getTier}
|
||||
setTier={setTier}
|
||||
updating={updating}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="all" className="space-y-4">
|
||||
<BandGrid
|
||||
bands={verticals}
|
||||
getTier={getTier}
|
||||
setTier={setTier}
|
||||
updating={updating}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ignored" className="space-y-4">
|
||||
{ignored.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No ignored bands"
|
||||
description="Ignored bands won't appear in your feed but will still show in attribution"
|
||||
/>
|
||||
) : (
|
||||
<BandGrid
|
||||
bands={ignored}
|
||||
getTier={getTier}
|
||||
setTier={setTier}
|
||||
updating={updating}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Music2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="font-semibold text-lg">{title}</h3>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function BandGrid({
|
||||
bands,
|
||||
getTier,
|
||||
setTier,
|
||||
updating
|
||||
}: {
|
||||
bands: Vertical[]
|
||||
getTier: (id: number) => TierType
|
||||
setTier: (id: number, tier: TierType) => Promise<void>
|
||||
updating: number | null
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{bands.map((band) => {
|
||||
const tier = getTier(band.id)
|
||||
const isUpdating = updating === band.id
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={band.id}
|
||||
className={`relative overflow-hidden transition-all duration-200 hover:scale-[1.02] hover:shadow-lg ${tier === "headliner" ? "ring-2 ring-yellow-500 bg-yellow-500/5" :
|
||||
tier === "ignored" ? "opacity-60 grayscale" :
|
||||
tier ? "ring-1 ring-primary/30" : ""
|
||||
}`}
|
||||
>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center text-primary font-bold text-sm transition-colors">
|
||||
{band.name.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<CardTitle className="text-base">{band.name}</CardTitle>
|
||||
</div>
|
||||
{isUpdating && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="flex gap-1 mt-2">
|
||||
{/* Headliner */}
|
||||
<button
|
||||
onClick={() => setTier(band.id, tier === "headliner" ? "main_stage" : "headliner")}
|
||||
disabled={isUpdating}
|
||||
className={`flex-1 p-2 rounded-lg text-xs font-medium transition-all duration-150 active:scale-95 ${tier === "headliner"
|
||||
? "bg-yellow-500 text-black"
|
||||
: "bg-muted hover:bg-yellow-500/20"
|
||||
}`}
|
||||
title="Headliner"
|
||||
>
|
||||
<Star className="h-4 w-4 mx-auto" />
|
||||
</button>
|
||||
|
||||
{/* Follow */}
|
||||
<button
|
||||
onClick={() => setTier(band.id, tier === "main_stage" ? null : "main_stage")}
|
||||
disabled={isUpdating}
|
||||
className={`flex-1 p-2 rounded-lg text-xs font-medium transition-all duration-150 active:scale-95 ${tier === "main_stage" || tier === "supporting"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted hover:bg-primary/20"
|
||||
}`}
|
||||
title="Follow"
|
||||
>
|
||||
<Check className="h-4 w-4 mx-auto" />
|
||||
</button>
|
||||
|
||||
{/* Ignore */}
|
||||
<button
|
||||
onClick={() => setTier(band.id, tier === "ignored" ? null : "ignored")}
|
||||
disabled={isUpdating}
|
||||
className={`flex-1 p-2 rounded-lg text-xs font-medium transition-all duration-150 active:scale-95 ${tier === "ignored"
|
||||
? "bg-destructive text-destructive-foreground"
|
||||
: "bg-muted hover:bg-destructive/20"
|
||||
}`}
|
||||
title="Ignore"
|
||||
>
|
||||
<Ban className="h-4 w-4 mx-auto" />
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
frontend/app/onboarding/page.tsx
Normal file
14
frontend/app/onboarding/page.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { BandOnboarding } from "@/components/onboarding/band-onboarding"
|
||||
|
||||
export const metadata = {
|
||||
title: "Pick Your Bands | Fediversion",
|
||||
description: "Select the bands you want to follow on Fediversion"
|
||||
}
|
||||
|
||||
export default function OnboardingPage() {
|
||||
return (
|
||||
<div className="py-8">
|
||||
<BandOnboarding />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,253 +1,95 @@
|
|||
import { ActivityFeed } from "@/components/feed/activity-feed"
|
||||
import { XPLeaderboard } from "@/components/gamification/xp-leaderboard"
|
||||
import { TieredBandList } from "@/components/home/tiered-band-list"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import Link from "next/link"
|
||||
import { Trophy, Music, MapPin, Calendar, ChevronRight, Star, Youtube, Route } from "lucide-react"
|
||||
import { getApiUrl } from "@/lib/api-config"
|
||||
|
||||
interface Show {
|
||||
interface Vertical {
|
||||
id: number
|
||||
slug?: string
|
||||
date: string
|
||||
venue?: {
|
||||
id: number
|
||||
name: string
|
||||
slug?: string
|
||||
city?: string
|
||||
state?: string
|
||||
}
|
||||
tour?: {
|
||||
id: number
|
||||
name: string
|
||||
slug?: string
|
||||
}
|
||||
name: string
|
||||
slug: string
|
||||
description: string | null
|
||||
}
|
||||
|
||||
interface Song {
|
||||
id: number
|
||||
title: string
|
||||
slug?: string
|
||||
performance_count?: number
|
||||
avg_rating?: number
|
||||
}
|
||||
|
||||
async function getRecentShows(): Promise<Show[]> {
|
||||
async function getVerticals(): Promise<Vertical[]> {
|
||||
try {
|
||||
const res = await fetch(`${getApiUrl()}/shows/recent?limit=8`, {
|
||||
cache: 'no-store',
|
||||
next: { revalidate: 60 }
|
||||
})
|
||||
const res = await fetch(`${getApiUrl()}/verticals/`, { next: { revalidate: 60 } })
|
||||
if (!res.ok) return []
|
||||
return res.json()
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch recent shows:', e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function getTopSongs(): Promise<Song[]> {
|
||||
try {
|
||||
const res = await fetch(`${getApiUrl()}/stats/top-songs?limit=5`, {
|
||||
cache: 'no-store',
|
||||
next: { revalidate: 300 }
|
||||
})
|
||||
if (!res.ok) return []
|
||||
return res.json()
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch top songs:', e)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default async function Home() {
|
||||
const [recentShows, topSongs] = await Promise.all([
|
||||
getRecentShows(),
|
||||
getTopSongs()
|
||||
])
|
||||
export default async function HomePage() {
|
||||
const verticals = await getVerticals()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="space-y-20 pb-16">
|
||||
{/* Hero Section */}
|
||||
<section className="flex flex-col items-center gap-4 py-12 text-center md:py-20 bg-gradient-to-b from-background to-accent/20 rounded-lg border">
|
||||
<h1 className="text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl">
|
||||
Elmeg
|
||||
</h1>
|
||||
<p className="max-w-[600px] text-lg text-muted-foreground">
|
||||
A comprehensive community-driven archive for Goose history.
|
||||
<br />
|
||||
Discover shows, share ratings, and explore the music together.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
|
||||
<Link href="/performances">
|
||||
<Button size="lg" className="gap-2 w-full sm:w-auto">
|
||||
<Trophy className="h-4 w-4" />
|
||||
Top Performances
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/shows">
|
||||
<Button variant="outline" size="lg" className="w-full sm:w-auto">
|
||||
Browse Shows
|
||||
</Button>
|
||||
</Link>
|
||||
<section className="text-center pt-20 pb-10 space-y-8 animate-in fade-in zoom-in duration-700">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-6xl font-extrabold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-primary to-primary/60">
|
||||
Fediversion
|
||||
</h1>
|
||||
<p className="text-2xl text-muted-foreground max-w-2xl mx-auto font-light leading-relaxed">
|
||||
The definitive archive for the modern jam era.
|
||||
<br />
|
||||
<span className="text-foreground font-medium">Every setlist. Every sit-in. One profile.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-4 pt-4">
|
||||
<Button asChild size="xl" className="h-14 px-8 text-lg rounded-full">
|
||||
<Link href="/register">Get on the Bus</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="xl" className="h-14 px-8 text-lg rounded-full">
|
||||
<Link href="/shows">Explore the Archive</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Recent Shows */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Calendar className="h-6 w-6 text-blue-500" />
|
||||
Recent Shows
|
||||
</h2>
|
||||
<Link href="/shows" className="text-sm text-muted-foreground hover:underline flex items-center gap-1">
|
||||
View all shows <ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
{/* Tiered Band List - The "Meat" */}
|
||||
<TieredBandList initialVerticals={verticals} />
|
||||
|
||||
{/* Community / Stats - Reimagined */}
|
||||
<section className="max-w-4xl mx-auto px-4">
|
||||
<div className="bg-muted/50 rounded-3xl p-12 text-center space-y-8 border backdrop-blur-sm">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Community Powered</h2>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Built by heads, for heads. Track your stats, rate the heat, and find your crew.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 pt-4">
|
||||
<StatItem
|
||||
value={verticals.length > 0 ? verticals.length.toString() : "50+"}
|
||||
label="Bands Indexed"
|
||||
/>
|
||||
<StatItem
|
||||
value="10k+"
|
||||
label="Shows Tracked"
|
||||
/>
|
||||
<StatItem
|
||||
value="1"
|
||||
label="Unified Profile"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{recentShows.length > 0 ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{recentShows.map((show) => (
|
||||
<Link key={show.id} href={`/shows/${show.slug}`}>
|
||||
<Card className="h-full hover:bg-accent/50 transition-colors cursor-pointer">
|
||||
<CardContent className="p-4">
|
||||
<div className="font-semibold">
|
||||
{new Date(show.date).toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
{show.venue && (
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
{show.venue.name}
|
||||
</div>
|
||||
)}
|
||||
{show.venue?.city && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{show.venue.city}{show.venue.state ? `, ${show.venue.state}` : ''}
|
||||
</div>
|
||||
)}
|
||||
{show.tour && (
|
||||
<div className="text-xs text-primary mt-2">
|
||||
{show.tour.name}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
<p>No shows yet. Check back soon!</p>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-3">
|
||||
{/* Top Songs */}
|
||||
<section className="space-y-4 lg:col-span-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-yellow-500" />
|
||||
Top Songs
|
||||
</h2>
|
||||
<Link href="/songs" className="text-sm text-muted-foreground hover:underline flex items-center gap-1">
|
||||
All songs <ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{topSongs.length > 0 ? (
|
||||
<ul className="divide-y">
|
||||
{topSongs.map((song, idx) => (
|
||||
<li key={song.id}>
|
||||
<Link
|
||||
href={`/songs/${song.slug}`}
|
||||
className="flex items-center gap-3 p-3 hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<span className="text-lg font-bold text-muted-foreground w-6 text-center">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{song.title}</div>
|
||||
{song.performance_count && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{song.performance_count} performances
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="p-4 text-center text-muted-foreground text-sm">
|
||||
No songs yet
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Activity Feed */}
|
||||
<section className="space-y-4 lg:col-span-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold">Recent Activity</h2>
|
||||
<Link href="/leaderboards" className="text-sm text-muted-foreground hover:underline flex items-center gap-1">
|
||||
View all <ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
<ActivityFeed />
|
||||
</section>
|
||||
|
||||
{/* XP Leaderboard */}
|
||||
<section className="space-y-4 lg:col-span-1">
|
||||
<XPLeaderboard />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Link href="/shows" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
|
||||
<Calendar className="h-8 w-8 mb-2 text-blue-500 group-hover:scale-110 transition-transform" />
|
||||
<h3 className="font-bold">Shows</h3>
|
||||
<p className="text-sm text-muted-foreground">Browse the complete archive</p>
|
||||
</Link>
|
||||
<Link href="/venues" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
|
||||
<MapPin className="h-8 w-8 mb-2 text-green-500 group-hover:scale-110 transition-transform" />
|
||||
<h3 className="font-bold">Venues</h3>
|
||||
<p className="text-sm text-muted-foreground">Find your favorite spots</p>
|
||||
</Link>
|
||||
<Link href="/songs" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
|
||||
<Music className="h-8 w-8 mb-2 text-purple-500 group-hover:scale-110 transition-transform" />
|
||||
<h3 className="font-bold">Songs</h3>
|
||||
<p className="text-sm text-muted-foreground">Explore the catalog</p>
|
||||
</Link>
|
||||
<Link href="/performances" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
|
||||
<Trophy className="h-8 w-8 mb-2 text-yellow-500 group-hover:scale-110 transition-transform" />
|
||||
<h3 className="font-bold">Top Performances</h3>
|
||||
<p className="text-sm text-muted-foreground">Highest rated jams</p>
|
||||
</Link>
|
||||
<Link href="/leaderboards" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
|
||||
<Star className="h-8 w-8 mb-2 text-yellow-500 group-hover:scale-110 transition-transform" />
|
||||
<h3 className="font-bold">Leaderboards</h3>
|
||||
<p className="text-sm text-muted-foreground">Top rated everything</p>
|
||||
</Link>
|
||||
<Link href="/tours" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
|
||||
<Route className="h-8 w-8 mb-2 text-orange-500 group-hover:scale-110 transition-transform" />
|
||||
<h3 className="font-bold">Tours</h3>
|
||||
<p className="text-sm text-muted-foreground">Browse by tour</p>
|
||||
</Link>
|
||||
<Link href="/videos" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
|
||||
<Youtube className="h-8 w-8 mb-2 text-red-500 group-hover:scale-110 transition-transform" />
|
||||
<h3 className="font-bold">Videos</h3>
|
||||
<p className="text-sm text-muted-foreground">Watch full shows and songs</p>
|
||||
</Link>
|
||||
</section>
|
||||
</div >
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatItem({ value, label }: { value: string, label: string }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="text-4xl font-black text-primary tracking-tight">{value}</div>
|
||||
<div className="text-sm font-medium text-muted-foreground uppercase tracking-wider">{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
204
frontend/app/playlists/[id]/page.tsx
Normal file
204
frontend/app/playlists/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { ArrowLeft, Trash2, Calendar, Music, User as UserIcon, PlayCircle, MoreHorizontal } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { getApiUrl } from "@/lib/api-config"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
export default function PlaylistDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [playlist, setPlaylist] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [currentUser, setCurrentUser] = useState<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("token")
|
||||
|
||||
// Fetch current user
|
||||
if (token) {
|
||||
fetch(`${getApiUrl()}/auth/users/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
.then(res => res.ok ? res.json() : null)
|
||||
.then(data => setCurrentUser(data))
|
||||
}
|
||||
|
||||
// Fetch playlist
|
||||
fetch(`${getApiUrl()}/playlists/${params.id}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
})
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error("Failed to fetch playlist")
|
||||
return res.json()
|
||||
})
|
||||
.then(data => setPlaylist(data))
|
||||
.catch(err => {
|
||||
console.error(err)
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Could not load playlist",
|
||||
variant: "destructive"
|
||||
})
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [params.id])
|
||||
|
||||
const handleDeletePlaylist = async () => {
|
||||
if (!confirm("Are you sure you want to delete this playlist?")) return
|
||||
|
||||
const token = localStorage.getItem("token")
|
||||
try {
|
||||
const res = await fetch(`${getApiUrl()}/playlists/${params.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
toast({ title: "Playlist deleted" })
|
||||
router.push("/profile")
|
||||
} else {
|
||||
throw new Error("Failed to delete")
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: "Error", description: "Could not delete playlist", variant: "destructive" })
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveTrack = async (performanceId: number) => {
|
||||
const token = localStorage.getItem("token")
|
||||
try {
|
||||
const res = await fetch(`${getApiUrl()}/playlists/${params.id}/performances/${performanceId}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
// Optimistic update
|
||||
setPlaylist((prev: any) => ({
|
||||
...prev,
|
||||
performances: prev.performances.filter((p: any) => p.performance_id !== performanceId)
|
||||
}))
|
||||
toast({ title: "Track removed" })
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: "Error", description: "Could not remove track", variant: "destructive" })
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="container py-20 text-center">Loading playlist...</div>
|
||||
if (!playlist) return <div className="container py-20 text-center">Playlist not found</div>
|
||||
|
||||
const isOwner = currentUser && currentUser.id === playlist.user_id
|
||||
|
||||
return (
|
||||
<div className="container py-10 max-w-4xl space-y-8">
|
||||
<Link href="/profile" className="flex items-center text-muted-foreground hover:text-foreground mb-4">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Profile
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between items-start gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{playlist.name}</h1>
|
||||
{!playlist.is_public && (
|
||||
<span className="text-xs uppercase font-bold tracking-wider bg-muted text-muted-foreground px-2 py-1 rounded">Private</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-lg mb-4">{playlist.description}</p>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<UserIcon className="h-4 w-4" />
|
||||
<span>{playlist.username}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>{new Date(playlist.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Music className="h-4 w-4" />
|
||||
<span>{playlist.performances.length} tracks</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOwner && (
|
||||
<Button variant="destructive" size="sm" onClick={handleDeletePlaylist} className="gap-2">
|
||||
<Trash2 className="h-4 w-4" /> Delete Playlist
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tracks</CardTitle>
|
||||
<CardDescription>
|
||||
{playlist.performances.length === 0 ? "No tracks added yet." : "Performances in this collection."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{playlist.performances.length > 0 && (
|
||||
<div className="divide-y">
|
||||
{playlist.performances.map((perf: any, index: number) => (
|
||||
<div key={perf.performance_id} className="p-4 flex items-center justify-between hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-muted-foreground font-mono w-6 text-center">{index + 1}</span>
|
||||
<div>
|
||||
<p className="font-medium">{perf.song_title}</p>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{new Date(perf.show_date).toLocaleDateString()}</span>
|
||||
{perf.notes && (
|
||||
<span className="italic text-muted-foreground/70">- {perf.notes}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{perf.show_slug && (
|
||||
<Link href={`/shows/${perf.show_slug}`}>
|
||||
<Button size="icon" variant="ghost" title="Go to Show">
|
||||
<PlayCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{isOwner && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="ghost">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => handleRemoveTrack(perf.performance_id)}
|
||||
>
|
||||
Remove from Playlist
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,21 +1,21 @@
|
|||
import { Metadata } from "next"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Privacy Policy - Elmeg",
|
||||
description: "Privacy Policy for Elmeg, a community archive platform for live music fans.",
|
||||
title: "Privacy Policy - Fediversion",
|
||||
description: "Privacy Policy for Fediversion, a community archive platform for live music fans.",
|
||||
}
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto py-8">
|
||||
<h1 className="text-4xl font-bold mb-2">Privacy Policy</h1>
|
||||
<p className="text-muted-foreground mb-8">Last updated: December 21, 2024</p>
|
||||
<p className="text-muted-foreground mb-8">Last updated: December 28, 2025</p>
|
||||
|
||||
<div className="prose prose-neutral dark:prose-invert max-w-none space-y-8">
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-4">1. Introduction</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Elmeg ("we," "our," or "us") respects your privacy and is committed to protecting your
|
||||
Fediversion ("we," "our," or "us") respects your privacy and is committed to protecting your
|
||||
personal data. This Privacy Policy explains how we collect, use, disclose, and safeguard
|
||||
your information when you use our Service.
|
||||
</p>
|
||||
|
|
@ -119,8 +119,8 @@ export default function PrivacyPage() {
|
|||
</ul>
|
||||
<p className="mt-3">
|
||||
To exercise these rights, contact us at{" "}
|
||||
<a href="mailto:privacy@elmeg.xyz" className="text-primary hover:underline">
|
||||
privacy@elmeg.xyz
|
||||
<a href="mailto:privacy@fediversion.runfoo.run" className="text-primary hover:underline">
|
||||
privacy@fediversion.runfoo.run
|
||||
</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -174,13 +174,13 @@ export default function PrivacyPage() {
|
|||
<p>If you have questions about this Privacy Policy or our data practices, contact us at:</p>
|
||||
<div className="mt-4 p-4 bg-muted/50 rounded-lg">
|
||||
<p><strong className="text-foreground">Email:</strong>{" "}
|
||||
<a href="mailto:privacy@elmeg.xyz" className="text-primary hover:underline">
|
||||
privacy@elmeg.xyz
|
||||
<a href="mailto:privacy@fediversion.runfoo.run" className="text-primary hover:underline">
|
||||
privacy@fediversion.runfoo.run
|
||||
</a>
|
||||
</p>
|
||||
<p className="mt-2"><strong className="text-foreground">General Support:</strong>{" "}
|
||||
<a href="mailto:support@elmeg.xyz" className="text-primary hover:underline">
|
||||
support@elmeg.xyz
|
||||
<a href="mailto:support@fediversion.runfoo.run" className="text-primary hover:underline">
|
||||
support@fediversion.runfoo.run
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ interface UserProfile {
|
|||
email: string
|
||||
username: string | null
|
||||
bio: string | null
|
||||
created_at: string
|
||||
joined_at: string | null
|
||||
}
|
||||
|
||||
interface UserBadge {
|
||||
|
|
@ -97,16 +97,18 @@ export default function PublicProfilePage({ params }: { params: Promise<{ slug:
|
|||
|
||||
<Card className="border-0 shadow-none bg-transparent">
|
||||
<div className="flex flex-col md:flex-row gap-8 items-start">
|
||||
<Avatar className="h-32 w-32 border-4 border-background shadow-lg">
|
||||
<AvatarImage src={`https://api.dicebear.com/7.x/notionists/svg?seed=${user.id}`} />
|
||||
<AvatarFallback><User className="h-12 w-12" /></AvatarFallback>
|
||||
</Avatar>
|
||||
<div
|
||||
className="h-32 w-32 rounded-full border-4 border-background shadow-lg flex items-center justify-center text-white text-4xl font-bold"
|
||||
style={{ backgroundColor: (user as any).avatar_bg_color || '#3B82F6' }}
|
||||
>
|
||||
{(user as any).avatar_text || displayName.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="space-y-4 flex-1">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tight">{displayName}</h1>
|
||||
<p className="text-muted-foreground flex items-center gap-2 mt-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Member since {new Date(user.created_at).toLocaleDateString()}
|
||||
Member since {user.joined_at ? new Date(user.joined_at).toLocaleDateString() : 'Unknown'}
|
||||
</p>
|
||||
</div>
|
||||
{user.bio && (
|
||||
|
|
|
|||
|
|
@ -13,9 +13,12 @@ import { UserReviewsList } from "@/components/profile/user-reviews-list"
|
|||
import { UserGroupsList } from "@/components/profile/user-groups-list"
|
||||
import { ChaseSongsList } from "@/components/profile/chase-songs-list"
|
||||
import { AttendanceSummary } from "@/components/profile/attendance-summary"
|
||||
import { UserPlaylistsList } from "@/components/profile/user-playlists-list"
|
||||
import { LevelProgressCard } from "@/components/gamification/level-progress"
|
||||
import { UserAvatar } from "@/components/ui/user-avatar"
|
||||
import { motion } from "framer-motion"
|
||||
import { RecommendedShows } from "@/components/recommendations/recommended-shows"
|
||||
import { RecommendedTracks } from "@/components/recommendations/recommended-tracks"
|
||||
|
||||
// Types
|
||||
interface UserProfile {
|
||||
|
|
@ -180,6 +183,7 @@ export default function ProfilePage() {
|
|||
<TabsTrigger value="overview" className="text-base font-medium">Overview</TabsTrigger>
|
||||
<TabsTrigger value="attendance" className="text-base font-medium">My Shows</TabsTrigger>
|
||||
<TabsTrigger value="reviews" className="text-base font-medium">Reviews</TabsTrigger>
|
||||
<TabsTrigger value="playlists" className="text-base font-medium">Playlists</TabsTrigger>
|
||||
<TabsTrigger value="groups" className="text-base font-medium">Communities</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
|
@ -193,6 +197,17 @@ export default function ProfilePage() {
|
|||
<LevelProgressCard />
|
||||
</motion.div>
|
||||
|
||||
{/* Recommendations */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: 0.05 }}
|
||||
className="grid md:grid-cols-2 gap-6"
|
||||
>
|
||||
<RecommendedShows />
|
||||
<RecommendedTracks />
|
||||
</motion.div>
|
||||
|
||||
{/* Attendance Summary */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
|
|
@ -253,6 +268,16 @@ export default function ProfilePage() {
|
|||
</motion.div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="playlists">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<UserPlaylistsList userId={user.id} isOwner={true} />
|
||||
</motion.div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="groups">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
|
||||
import { MetadataRoute } from 'next'
|
||||
import { VERTICALS } from '@/config/verticals'
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const baseUrl = 'https://fediversion.runfoo.run'
|
||||
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: ['/admin/', '/api/'],
|
||||
disallow: ['/api/', '/admin/'],
|
||||
},
|
||||
sitemap: 'https://elmeg.xyz/sitemap.xml',
|
||||
sitemap: `${baseUrl}/sitemap.xml`,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ export default function SettingsPage() {
|
|||
// Profile state
|
||||
const [bio, setBio] = useState("")
|
||||
const [username, setUsername] = useState("")
|
||||
const [location, setLocation] = useState("")
|
||||
const [blueskyHandle, setBlueskyHandle] = useState("")
|
||||
const [mastodonHandle, setMastodonHandle] = useState("")
|
||||
const [instagramHandle, setInstagramHandle] = useState("")
|
||||
const [profileSaving, setProfileSaving] = useState(false)
|
||||
const [profileSaved, setProfileSaved] = useState(false)
|
||||
|
||||
|
|
@ -75,6 +79,10 @@ export default function SettingsPage() {
|
|||
const extUser = user as any
|
||||
setBio(extUser.bio || "")
|
||||
setUsername(extUser.email?.split('@')[0] || "")
|
||||
setLocation(extUser.location || "")
|
||||
setBlueskyHandle(extUser.bluesky_handle || "")
|
||||
setMastodonHandle(extUser.mastodon_handle || "")
|
||||
setInstagramHandle(extUser.instagram_handle || "")
|
||||
setAvatarBgColor(extUser.avatar_bg_color || "#0F4C81")
|
||||
setAvatarText(extUser.avatar_text || "")
|
||||
setPrivacySettings({
|
||||
|
|
@ -96,7 +104,14 @@ export default function SettingsPage() {
|
|||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ bio, username })
|
||||
body: JSON.stringify({
|
||||
bio,
|
||||
username,
|
||||
location,
|
||||
bluesky_handle: blueskyHandle,
|
||||
mastodon_handle: mastodonHandle,
|
||||
instagram_handle: instagramHandle
|
||||
})
|
||||
})
|
||||
setProfileSaved(true)
|
||||
setTimeout(() => setProfileSaved(false), 2000)
|
||||
|
|
@ -221,6 +236,14 @@ export default function SettingsPage() {
|
|||
setBio={setBio}
|
||||
username={username}
|
||||
setUsername={setUsername}
|
||||
location={location}
|
||||
setLocation={setLocation}
|
||||
blueskyHandle={blueskyHandle}
|
||||
setBlueskyHandle={setBlueskyHandle}
|
||||
mastodonHandle={mastodonHandle}
|
||||
setMastodonHandle={setMastodonHandle}
|
||||
instagramHandle={instagramHandle}
|
||||
setInstagramHandle={setInstagramHandle}
|
||||
saving={profileSaving}
|
||||
saved={profileSaved}
|
||||
onSave={handleSaveProfile}
|
||||
|
|
@ -285,6 +308,14 @@ export default function SettingsPage() {
|
|||
setBio={setBio}
|
||||
username={username}
|
||||
setUsername={setUsername}
|
||||
location={location}
|
||||
setLocation={setLocation}
|
||||
blueskyHandle={blueskyHandle}
|
||||
setBlueskyHandle={setBlueskyHandle}
|
||||
mastodonHandle={mastodonHandle}
|
||||
setMastodonHandle={setMastodonHandle}
|
||||
instagramHandle={instagramHandle}
|
||||
setInstagramHandle={setInstagramHandle}
|
||||
saving={profileSaving}
|
||||
saved={profileSaved}
|
||||
onSave={handleSaveProfile}
|
||||
|
|
@ -348,7 +379,14 @@ function SidebarLink({ icon: Icon, label, href, active }: {
|
|||
}
|
||||
|
||||
// Profile Section
|
||||
function ProfileSection({ bio, setBio, username, setUsername, saving, saved, onSave }: any) {
|
||||
function ProfileSection({
|
||||
bio, setBio, username, setUsername,
|
||||
location, setLocation,
|
||||
blueskyHandle, setBlueskyHandle,
|
||||
mastodonHandle, setMastodonHandle,
|
||||
instagramHandle, setInstagramHandle,
|
||||
saving, saved, onSave
|
||||
}: any) {
|
||||
return (
|
||||
<Card id="profile">
|
||||
<CardHeader>
|
||||
|
|
@ -375,13 +413,16 @@ function ProfileSection({ bio, setBio, username, setUsername, saving, saved, onS
|
|||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Label htmlFor="location">Location / Local Scene</Label>
|
||||
<Input
|
||||
id="email"
|
||||
disabled
|
||||
value="(cannot be changed)"
|
||||
className="bg-muted"
|
||||
id="location"
|
||||
placeholder="e.g. Portland, OR"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your hometown or local music scene
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -399,6 +440,51 @@ function ProfileSection({ bio, setBio, username, setUsername, saving, saved, onS
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Social Handles Section */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-sm mb-1">Social Links</h4>
|
||||
<p className="text-xs text-muted-foreground">Connect your accounts (displayed on your public profile)</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bluesky" className="flex items-center gap-2">
|
||||
BlueSky
|
||||
</Label>
|
||||
<Input
|
||||
id="bluesky"
|
||||
placeholder="handle.bsky.social"
|
||||
value={blueskyHandle}
|
||||
onChange={(e) => setBlueskyHandle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mastodon" className="flex items-center gap-2">
|
||||
Mastodon
|
||||
</Label>
|
||||
<Input
|
||||
id="mastodon"
|
||||
placeholder="@user@instance.social"
|
||||
value={mastodonHandle}
|
||||
onChange={(e) => setMastodonHandle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="instagram" className="flex items-center gap-2">
|
||||
Instagram
|
||||
</Label>
|
||||
<Input
|
||||
id="instagram"
|
||||
placeholder="@username"
|
||||
value={instagramHandle}
|
||||
onChange={(e) => setInstagramHandle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={onSave} disabled={saving}>
|
||||
{saving ? "Saving..." : saved ? "Saved ✓" : "Save Changes"}
|
||||
|
|
@ -678,7 +764,7 @@ function PrivacySection({ settings, onChange }: {
|
|||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'my-elmeg-data.json'
|
||||
a.download = 'my-fediversion-data.json'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { EntityReviews } from "@/components/reviews/entity-reviews"
|
|||
import { getApiUrl } from "@/lib/api-config"
|
||||
import { YouTubeEmbed } from "@/components/ui/youtube-embed"
|
||||
import { MarkCaughtButton } from "@/components/chase/mark-caught-button"
|
||||
import { AddToPlaylistDialog } from "@/components/playlists/add-to-playlist-dialog"
|
||||
|
||||
async function getShow(id: string) {
|
||||
try {
|
||||
|
|
@ -66,12 +67,22 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ slu
|
|||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/archive">
|
||||
<Link href="/shows">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
{/* Band Name - Most Important */}
|
||||
{show.vertical && (
|
||||
<Link
|
||||
href={`/bands/${show.vertical.slug}`}
|
||||
className="inline-flex items-center gap-2 text-sm font-semibold text-primary hover:underline mb-1"
|
||||
>
|
||||
<Music2 className="h-4 w-4" />
|
||||
{show.vertical.name}
|
||||
</Link>
|
||||
)}
|
||||
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight">
|
||||
{new Date(show.date).toLocaleDateString()}
|
||||
</h1>
|
||||
|
|
@ -230,6 +241,14 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ slu
|
|||
songTitle={perf.song?.title || "Song"}
|
||||
showId={show.id}
|
||||
/>
|
||||
|
||||
{/* Add to Playlist */}
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<AddToPlaylistDialog
|
||||
performanceId={perf.id}
|
||||
songTitle={perf.song?.title || "Song"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{perf.notes && (
|
||||
<div className="text-xs text-muted-foreground ml-9 italic mt-0.5">
|
||||
|
|
@ -247,7 +266,7 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ slu
|
|||
<Music2 className="h-12 w-12 text-muted-foreground/30 mb-4" />
|
||||
<p className="text-muted-foreground font-medium">No Setlist Documented</p>
|
||||
<p className="text-sm text-muted-foreground/70 mt-1 max-w-sm">
|
||||
This show's setlist hasn't been added yet. Early Goose shows (2014-2016) often weren't documented.
|
||||
This show's setlist hasn't been added yet. Early shows often weren't documented.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,144 +2,249 @@
|
|||
|
||||
import { useEffect, useState, Suspense } from "react"
|
||||
import { getApiUrl } from "@/lib/api-config"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import Link from "next/link"
|
||||
import { Calendar, MapPin, Loader2, Youtube } from "lucide-react"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { Loader2, Music2 } from "lucide-react"
|
||||
import { useSearchParams, useRouter, usePathname } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface Show {
|
||||
id: number
|
||||
slug?: string
|
||||
date: string
|
||||
youtube_link?: string
|
||||
venue: {
|
||||
id: number
|
||||
name: string
|
||||
city: string
|
||||
state: string
|
||||
}
|
||||
}
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||
import { DateGroupedList } from "@/components/shows/date-grouped-list"
|
||||
import { FilterPills } from "@/components/shows/filter-pills"
|
||||
import { BandGrid } from "@/components/shows/band-grid"
|
||||
import { Show, Vertical, PaginatedResponse, Venue } from "@/types/models"
|
||||
|
||||
function ShowsContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const year = searchParams.get("year")
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
// --- State ---
|
||||
const activeView = searchParams.get("view") || "recent"
|
||||
const bandsParam = searchParams.get("bands")
|
||||
const yearParam = searchParams.get("year")
|
||||
const selectedBands = bandsParam ? bandsParam.split(",") : []
|
||||
|
||||
const [shows, setShows] = useState<Show[]>([])
|
||||
const [verticals, setVerticals] = useState<Vertical[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingVerticals, setLoadingVerticals] = useState(true)
|
||||
|
||||
// --- Data Fetching: Verticals (Always) ---
|
||||
useEffect(() => {
|
||||
const url = `${getApiUrl()}/shows/?limit=2000&status=past${year ? `&year=${year}` : ''}`
|
||||
fetch(url)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
// Sort by date descending
|
||||
const sorted = data.sort((a: Show, b: Show) =>
|
||||
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
)
|
||||
setShows(sorted)
|
||||
setLoadingVerticals(true)
|
||||
fetch(`${getApiUrl()}/verticals/`)
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error("Failed to fetch verticals")
|
||||
return res.json()
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
}, [year])
|
||||
.then(data => {
|
||||
setVerticals(data)
|
||||
setLoadingVerticals(false)
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err)
|
||||
setLoadingVerticals(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container py-10 space-y-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-10 w-48" />
|
||||
<Skeleton className="h-5 w-96" />
|
||||
</div>
|
||||
// --- Data Fetching: Shows (Dependent on View) ---
|
||||
useEffect(() => {
|
||||
if (activeView === "bands") {
|
||||
// Don't fetch shows if we are just browsing bands
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<Card key={i} className="h-full border-muted/40">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-5 rounded-full" />
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams()
|
||||
|
||||
// Add band filters
|
||||
if (selectedBands.length > 0) {
|
||||
selectedBands.forEach(b => params.append("vertical_slugs", b))
|
||||
}
|
||||
|
||||
// Add year filter
|
||||
if (yearParam) {
|
||||
params.set("year", yearParam)
|
||||
}
|
||||
|
||||
// Add view-specific params
|
||||
if (activeView === "upcoming") {
|
||||
params.set("status", "upcoming")
|
||||
} else if (activeView === "my-feed") {
|
||||
// My Feed implies specific tiers
|
||||
// We use the same tiers as FeedFilter used: HEADLINER, MAIN_STAGE, SUPPORTING
|
||||
["HEADLINER", "MAIN_STAGE", "SUPPORTING"].forEach(t => params.append("tiers", t))
|
||||
// Also we might want to default to "past" shows for feed? Or all?
|
||||
// "My Feed" usually means recent updates.
|
||||
// Let's explicitly ask for "past" shows (recent history) unless user wants upcoming feed?
|
||||
// For now, let's show PAST shows in My Feed (History), maybe add Upcoming toggle later?
|
||||
// Or just show all? `read_shows` sorts by date desc.
|
||||
// Let's default to Recent (Past) for Feed.
|
||||
// params.set("status", "past")
|
||||
// Actually, if we leave status blank, it returns all (sorted by date desc if modified, but check `read_shows`)
|
||||
// `read_shows` default sorts DESC.
|
||||
// Let's assume user wants recent feed.
|
||||
} else {
|
||||
// Recent (Default)
|
||||
params.set("status", "past")
|
||||
}
|
||||
|
||||
fetch(`${getApiUrl()}/shows/?${params.toString()}`)
|
||||
.then(res => {
|
||||
// If 401 (Unauthorized) for My Feed, we might get empty list or error
|
||||
if (res.status === 401 && activeView === "my-feed") {
|
||||
return { data: [] as Show[], meta: { total: 0, limit: 0, offset: 0 } }
|
||||
}
|
||||
if (!res.ok) throw new Error("Failed to fetch shows")
|
||||
return res.json()
|
||||
})
|
||||
.then((data: PaginatedResponse<Show>) => {
|
||||
setShows(data.data || [])
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err)
|
||||
setShows([]) // Clear on error
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
}, [activeView, bandsParam]) // bandsParam is the dependency
|
||||
|
||||
// --- Handlers ---
|
||||
const updateUrl = (view: string, bands: string[]) => {
|
||||
const params = new URLSearchParams()
|
||||
if (view !== "recent") params.set("view", view)
|
||||
if (bands.length > 0) params.set("bands", bands.join(","))
|
||||
|
||||
// Push state
|
||||
router.push(`${pathname}${params.toString() ? `?${params.toString()}` : ''}`)
|
||||
}
|
||||
|
||||
const handleTabChange = (val: string) => {
|
||||
updateUrl(val, selectedBands)
|
||||
}
|
||||
|
||||
const handleToggleBand = (slug: string) => {
|
||||
let newBands = [...selectedBands]
|
||||
if (newBands.includes(slug)) {
|
||||
newBands = newBands.filter(b => b !== slug)
|
||||
} else {
|
||||
newBands.push(slug)
|
||||
}
|
||||
|
||||
// If we are on "bands" tab and select a band, switch to "recent" to show results?
|
||||
// User plan says: "Clicking a band adds it to the active filter and switches to 'Recent' view."
|
||||
if (activeView === "bands" && !selectedBands.includes(slug)) {
|
||||
updateUrl("recent", newBands)
|
||||
} else {
|
||||
// Otherwise just update filters (e.g. if unchecking, stay on grid? or if adding from elsewhere?)
|
||||
// If checking from grid -> go to recent.
|
||||
// If unchecking -> stay?
|
||||
// Let's just follow the rule: Select -> Go to Recent. Unselect -> Stay.
|
||||
if (!selectedBands.includes(slug)) {
|
||||
updateUrl("recent", newBands)
|
||||
} else {
|
||||
updateUrl(activeView, newBands)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveBand = (slug: string) => {
|
||||
const newBands = selectedBands.filter(b => b !== slug)
|
||||
updateUrl(activeView, newBands)
|
||||
}
|
||||
|
||||
const handleClearBands = () => {
|
||||
updateUrl(activeView, [])
|
||||
}
|
||||
|
||||
// --- Render Helpers ---
|
||||
const renderShowList = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (shows.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-20 text-muted-foreground">
|
||||
<Music2 className="h-12 w-12 mx-auto mb-4 opacity-20" />
|
||||
<p>No shows found matching your criteria.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <DateGroupedList shows={shows} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container py-10 space-y-8 animate-in fade-in duration-700">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Shows</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Browse the complete archive of performances.
|
||||
</p>
|
||||
<div className="container py-6 max-w-5xl">
|
||||
<div className="sticky top-0 z-10 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-4 -mx-4 px-4 md:mx-0 md:px-0">
|
||||
<Tabs value={activeView} onValueChange={handleTabChange} className="w-full">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-4">
|
||||
<TabsList className="grid w-full md:w-auto grid-cols-4 h-11">
|
||||
<TabsTrigger value="recent" className="flex items-center gap-2">
|
||||
Recent
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="my-feed" className="flex items-center gap-2">
|
||||
My Feed
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="upcoming" className="flex items-center gap-2">
|
||||
Upcoming
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="bands" className="flex items-center gap-2">
|
||||
By Band
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<Link href="/shows/upcoming">
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Upcoming Shows
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{shows.map((show) => (
|
||||
<Link key={show.id} href={`/shows/${show.slug}`} className="block group">
|
||||
<Card className="h-full transition-all duration-300 hover:scale-[1.02] hover:shadow-lg group-hover:border-primary/50 relative">
|
||||
{show.youtube_link && (
|
||||
<div className="absolute top-2 right-2 bg-red-500/10 text-red-500 p-1.5 rounded-full" title="Full show video available">
|
||||
<Youtube className="h-4 w-4" />
|
||||
<FilterPills
|
||||
selectedBands={selectedBands}
|
||||
verticals={verticals}
|
||||
onRemove={handleRemoveBand}
|
||||
onClear={handleClearBands}
|
||||
/>
|
||||
|
||||
<div className="mt-2 min-h-[500px]">
|
||||
<TabsContent value="recent" className="m-0">
|
||||
{renderShowList()}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="my-feed" className="m-0">
|
||||
{/* Maybe add auth check banner here if shows is empty and user not logged in? */}
|
||||
{/* For now, just render list */}
|
||||
{renderShowList()}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="upcoming" className="m-0">
|
||||
{renderShowList()}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="bands" className="m-0">
|
||||
{loadingVerticals ? (
|
||||
<div className="flex justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<BandGrid
|
||||
verticals={verticals}
|
||||
selectedBands={selectedBands}
|
||||
onToggle={handleToggleBand} // Note: logic inside handleToggle moves to Recent
|
||||
/>
|
||||
)}
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 group-hover:text-primary transition-colors">
|
||||
<Calendar className="h-5 w-5 text-muted-foreground group-hover:text-primary/70 transition-colors" />
|
||||
{new Date(show.date).toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2 text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
<MapPin className="h-4 w-4" />
|
||||
<span>
|
||||
{show.venue?.name}, {show.venue?.city}, {show.venue?.state}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingFallback() {
|
||||
return (
|
||||
<div className="container py-10 flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ShowsPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<Suspense fallback={<div className="container py-6 flex justify-center"><Loader2 className="h-8 w-8 animate-spin" /></div>}>
|
||||
<ShowsContent />
|
||||
</Suspense>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,22 +1,53 @@
|
|||
|
||||
import { MetadataRoute } from 'next'
|
||||
import { VERTICALS } from '@/config/verticals'
|
||||
import { getApiUrl } from '@/lib/api-config'
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const baseUrl = 'https://elmeg.xyz'
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = 'https://fediversion.runfoo.run'
|
||||
|
||||
// Static routes
|
||||
const routes = [
|
||||
'',
|
||||
'/shows',
|
||||
'/songs',
|
||||
'/venues',
|
||||
'/videos',
|
||||
'/stats',
|
||||
'/shows/upcoming',
|
||||
'/login',
|
||||
'/register',
|
||||
'/about',
|
||||
'/terms',
|
||||
'/privacy',
|
||||
].map((route) => ({
|
||||
url: `${baseUrl}${route}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'daily' as const,
|
||||
priority: route === '' ? 1 : 0.8,
|
||||
priority: 1,
|
||||
}))
|
||||
|
||||
return routes
|
||||
// Generate routes for each vertical
|
||||
const verticalRoutes = VERTICALS.flatMap((vertical) => [
|
||||
{
|
||||
url: `${baseUrl}/${vertical.slug}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'daily' as const,
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/${vertical.slug}/songs`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly' as const,
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/${vertical.slug}/shows`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly' as const,
|
||||
priority: 0.8,
|
||||
},
|
||||
])
|
||||
|
||||
// TODO: Fetch dynamic routes (shows, songs) from API once we have a performant way to get all slugs
|
||||
// For now, we rely on the main list pages being indexed and crawlers following links
|
||||
|
||||
return [...routes, ...verticalRoutes]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
|
||||
import { MostPlayedByCard } from "@/components/songs/most-played-by-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { ArrowLeft, PlayCircle, History, Calendar, Trophy, Youtube, Star } from "lucide-react"
|
||||
|
|
@ -25,6 +26,19 @@ async function getSong(id: string) {
|
|||
}
|
||||
}
|
||||
|
||||
// Fetch cross-band versions of this song via SongCanon
|
||||
async function getRelatedVersions(songId: number) {
|
||||
try {
|
||||
const res = await fetch(`${getApiUrl()}/canon/song/${songId}/related`, {
|
||||
next: { revalidate: 60 }
|
||||
})
|
||||
if (!res.ok) return []
|
||||
return res.json()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Get top rated performances for "Heady Version" leaderboard
|
||||
function getHeadyVersions(performances: any[]) {
|
||||
if (!performances || performances.length === 0) return []
|
||||
|
|
@ -45,6 +59,9 @@ export default async function SongDetailPage({ params }: { params: Promise<{ slu
|
|||
const headyVersions = getHeadyVersions(song.performances || [])
|
||||
const topPerformance = headyVersions[0]
|
||||
|
||||
// Fetch cross-band versions
|
||||
const relatedVersions = await getRelatedVersions(song.id)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
|
|
@ -57,14 +74,17 @@ export default async function SongDetailPage({ params }: { params: Promise<{ slu
|
|||
<div>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{song.title}</h1>
|
||||
{song.artist ? (
|
||||
{song.artist && (
|
||||
<Link href={`/artists/${song.artist.slug}`} className="text-lg text-muted-foreground font-medium hover:text-primary transition-colors">
|
||||
({song.artist.name})
|
||||
{song.artist.name}
|
||||
</Link>
|
||||
) : song.original_artist ? (
|
||||
<span className="text-lg text-muted-foreground font-medium">({song.original_artist})</span>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
{song.original_artist && (
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
Original Artist: <span className="font-medium text-foreground">{song.original_artist}</span>
|
||||
</div>
|
||||
)}
|
||||
{song.tags && song.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{song.tags.map((tag: any) => (
|
||||
|
|
@ -81,60 +101,95 @@ export default async function SongDetailPage({ params }: { params: Promise<{ slu
|
|||
</SocialWrapper>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Times Played</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold flex items-center gap-2">
|
||||
<PlayCircle className="h-5 w-5 text-primary" />
|
||||
{song.times_played}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Gap (Shows)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold flex items-center gap-2">
|
||||
<History className="h-5 w-5 text-primary" />
|
||||
{song.gap}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Last Played</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5 text-primary" />
|
||||
{song.last_played ? new Date(song.last_played).toLocaleDateString() : "Never"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Set Breakdown */}
|
||||
{song.set_breakdown && Object.keys(song.set_breakdown).length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Set Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-6">
|
||||
{Object.entries(song.set_breakdown).sort((a, b) => (b[1] as number) - (a[1] as number)).map(([set, count]) => (
|
||||
<div key={set} className="flex flex-col items-center">
|
||||
<span className="text-2xl font-bold">{count as number}</span>
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide">{set}</span>
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-6">
|
||||
{/* Left Sidebar: Stats & Charts */}
|
||||
<div className="md:col-span-4 space-y-6">
|
||||
{/* Basic Stats Grid - Compact */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2 p-4">
|
||||
<CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Times Played</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="text-2xl font-bold flex items-center gap-2">
|
||||
<PlayCircle className="h-5 w-5 text-primary" />
|
||||
{song.times_played}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2 p-4">
|
||||
<CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Gap</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="text-2xl font-bold flex items-center gap-2">
|
||||
<History className="h-5 w-5 text-primary" />
|
||||
{song.gap}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="col-span-2">
|
||||
<CardHeader className="pb-2 p-4">
|
||||
<CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Last Played</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="text-xl font-bold flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5 text-primary" />
|
||||
{song.last_played ? new Date(song.last_played).toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}) : "Never"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Most Played By */}
|
||||
{song.artist_distribution && (
|
||||
<MostPlayedByCard distribution={song.artist_distribution} />
|
||||
)}
|
||||
|
||||
{/* Set Breakdown */}
|
||||
{song.set_breakdown && Object.keys(song.set_breakdown).length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Set Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
{Object.entries(song.set_breakdown).sort((a, b) => (b[1] as number) - (a[1] as number)).map(([set, count]) => (
|
||||
<div key={set} className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{set}</span>
|
||||
<span className="font-bold">{count as number}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Content: Performance History */}
|
||||
<div className="md:col-span-8 space-y-6">
|
||||
<PerformanceList performances={song.performances} songTitle={song.title} />
|
||||
|
||||
{/* Song Evolution (moved to bottom) */}
|
||||
<SongEvolutionChart performances={song.performances} />
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<SocialWrapper type="comments">
|
||||
<CommentSection entityType="song" entityId={song.id} />
|
||||
</SocialWrapper>
|
||||
<EntityReviews
|
||||
entityType="song"
|
||||
entityId={song.id}
|
||||
entityName={song.title}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Heady Version Section */}
|
||||
{headyVersions.length > 0 && (
|
||||
|
|
@ -219,20 +274,51 @@ export default async function SongDetailPage({ params }: { params: Promise<{ slu
|
|||
</Card>
|
||||
)}
|
||||
|
||||
<SongEvolutionChart performances={song.performances || []} />
|
||||
{/* Cross-Band Versions */}
|
||||
{relatedVersions && relatedVersions.length > 0 && (
|
||||
<Card className="border-2 border-indigo-500/20 bg-gradient-to-br from-indigo-50/50 to-purple-50/50 dark:from-indigo-900/10 dark:to-purple-900/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-indigo-700 dark:text-indigo-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
<path d="M2 12h20" />
|
||||
</svg>
|
||||
Also Played By
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This song is performed by {relatedVersions.length + 1} different bands
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{relatedVersions.map((version: any) => (
|
||||
<Link
|
||||
key={version.id}
|
||||
href={`/${version.vertical_slug}/songs/${version.slug}`}
|
||||
className="block group"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-background/50 hover:bg-background/80 transition-colors border border-transparent hover:border-indigo-200 dark:hover:border-indigo-800">
|
||||
<div>
|
||||
<p className="font-medium group-hover:text-primary transition-colors">
|
||||
{version.vertical_name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{version.title}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
View →
|
||||
</Badge>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Performance List Component (Handles Client Sorting) */}
|
||||
<PerformanceList performances={song.performances || []} songTitle={song.title} />
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<SocialWrapper type="comments">
|
||||
<CommentSection entityType="song" entityId={song.id} />
|
||||
</SocialWrapper>
|
||||
|
||||
<SocialWrapper type="reviews">
|
||||
<EntityReviews entityType="song" entityId={song.id} />
|
||||
</SocialWrapper>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,7 @@ import { getApiUrl } from "@/lib/api-config"
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import Link from "next/link"
|
||||
import { Music } from "lucide-react"
|
||||
|
||||
interface Song {
|
||||
id: number
|
||||
title: string
|
||||
slug?: string
|
||||
original_artist?: string
|
||||
}
|
||||
import { Song, PaginatedResponse } from "@/types/models"
|
||||
|
||||
export default function SongsPage() {
|
||||
const [songs, setSongs] = useState<Song[]>([])
|
||||
|
|
@ -20,13 +14,11 @@ export default function SongsPage() {
|
|||
useEffect(() => {
|
||||
fetch(`${getApiUrl()}/songs/?limit=1000`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (!Array.isArray(data)) {
|
||||
console.error("API Error: Expected array but got:", data)
|
||||
return
|
||||
}
|
||||
.then((data: PaginatedResponse<Song>) => {
|
||||
// Handle envelope
|
||||
const songData = data.data || []
|
||||
// Sort alphabetically
|
||||
const sorted = data.sort((a: Song, b: Song) => a.title.localeCompare(b.title))
|
||||
const sorted = songData.sort((a, b) => a.title.localeCompare(b.title))
|
||||
setSongs(sorted)
|
||||
})
|
||||
.catch(console.error)
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
import { Metadata } from "next"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Terms of Service - Elmeg",
|
||||
description: "Terms of Service for Elmeg, a community archive platform for live music fans.",
|
||||
title: "Terms of Service - Fediversion",
|
||||
description: "Terms of Service for Fediversion, a community archive platform for live music fans.",
|
||||
}
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto py-8">
|
||||
<h1 className="text-4xl font-bold mb-2">Terms of Service</h1>
|
||||
<p className="text-muted-foreground mb-8">Last updated: December 21, 2024</p>
|
||||
<p className="text-muted-foreground mb-8">Last updated: December 28, 2025</p>
|
||||
|
||||
<div className="prose prose-neutral dark:prose-invert max-w-none space-y-8">
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-4">1. Acceptance of Terms</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
By accessing or using Elmeg ("the Service"), you agree to be bound by these Terms of Service.
|
||||
By accessing or using Fediversion ("the Service"), you agree to be bound by these Terms of Service.
|
||||
If you do not agree to these terms, please do not use the Service. We reserve the right to
|
||||
update these terms at any time, and your continued use of the Service constitutes acceptance
|
||||
of any changes.
|
||||
|
|
@ -25,7 +25,7 @@ export default function TermsPage() {
|
|||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-4">2. Description of Service</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Elmeg is a community-driven archive platform for live music enthusiasts. The Service allows
|
||||
Fediversion is a community-driven archive platform for live music enthusiasts. The Service allows
|
||||
users to browse setlists, rate performances, participate in discussions, and contribute to
|
||||
the archive. The Service is provided "as is" and we make no guarantees regarding availability,
|
||||
accuracy, or completeness of content.
|
||||
|
|
@ -140,8 +140,8 @@ export default function TermsPage() {
|
|||
<h2 className="text-2xl font-semibold mb-4">11. Contact</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
If you have questions about these Terms of Service, please contact us at{" "}
|
||||
<a href="mailto:support@elmeg.xyz" className="text-primary hover:underline">
|
||||
support@elmeg.xyz
|
||||
<a href="mailto:support@fediversion.runfoo.run" className="text-primary hover:underline">
|
||||
support@fediversion.runfoo.run
|
||||
</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -23,9 +23,49 @@ interface Show {
|
|||
slug?: string
|
||||
date: string
|
||||
tour?: { name: string }
|
||||
vertical?: { name: string; slug: string }
|
||||
performances?: any[]
|
||||
}
|
||||
|
||||
// ... (skipping to render loop)
|
||||
|
||||
{
|
||||
shows.map((show) => (
|
||||
<Link
|
||||
key={show.id}
|
||||
href={show.vertical ? `/${show.vertical.slug}/shows/${show.slug}` : `/shows/${show.slug}`}
|
||||
className="block group"
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border bg-card hover:bg-accent transition-colors">
|
||||
<div className="space-y-1">
|
||||
<div className="font-semibold flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{new Date(show.date).toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}</span>
|
||||
{show.vertical && (
|
||||
<span className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-primary text-primary-foreground hover:bg-primary/80 ml-2">
|
||||
{show.vertical.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{show.tour?.name && (
|
||||
<div className="text-sm text-muted-foreground ml-6">
|
||||
{show.tour.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right text-sm text-muted-foreground">
|
||||
{show.performances?.length || 0} songs
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
}
|
||||
|
||||
export default function VenueDetailPage() {
|
||||
const params = useParams()
|
||||
const slug = params.slug as string
|
||||
|
|
@ -54,7 +94,8 @@ export default function VenueDetailPage() {
|
|||
// Fetch shows at this venue using numeric ID
|
||||
const showsRes = await fetch(`${getApiUrl()}/shows/?venue_id=${venueData.id}&limit=100`)
|
||||
if (showsRes.ok) {
|
||||
const showsData = await showsRes.json()
|
||||
const showsEnvelope = await showsRes.json()
|
||||
const showsData = showsEnvelope.data || []
|
||||
// Sort by date descending
|
||||
showsData.sort((a: Show, b: Show) =>
|
||||
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
|
|
@ -137,7 +178,7 @@ export default function VenueDetailPage() {
|
|||
{shows.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{shows.map((show) => (
|
||||
<Link key={show.id} href={`/shows/${show.slug}`} className="block group">
|
||||
<Link key={show.id} href={show.vertical ? `/${show.vertical.slug}/shows/${show.slug}` : `/shows/${show.slug}`} className="block group">
|
||||
<div className="flex items-center justify-between p-3 rounded-md hover:bg-muted/50 transition-colors border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
|
|
@ -149,6 +190,11 @@ export default function VenueDetailPage() {
|
|||
day: "numeric"
|
||||
})}
|
||||
</span>
|
||||
{show.vertical && (
|
||||
<span className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold bg-primary text-primary-foreground">
|
||||
{show.vertical.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{show.tour && (
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue