Compare commits

...
Sign in to create a new pull request.

111 commits

Author SHA1 Message Date
fullsizemalt
8b32b71db5 chore: add debug script
Some checks failed
Deploy Fediversion / deploy (push) Failing after 4s
2026-01-07 22:28:27 -08:00
fullsizemalt
46cf45ad33 chore: add Claude Code configuration
Some checks failed
Deploy Fediversion / deploy (push) Failing after 6s
Add Boris-style workflow config for Fediversion:
- Project context (Python FastAPI + Next.js)
- Slash commands for migrate, deploy, import-data
- Verification agent for full-stack checks
- Permissions for Python/npm/SSH operations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-07 20:56:40 -08:00
fullsizemalt
10b15fd521 fix: add missing table.tsx component
Some checks failed
Deploy Fediversion / deploy (push) Failing after 0s
2026-01-01 03:26:16 -08:00
fullsizemalt
3d090082fb fix: use dicts instead of pydantic models in manual serialization to prevent crashes
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2026-01-01 02:37:58 -08:00
fullsizemalt
da72e89fd6 fix: use string forward ref for TourRead in ShowRead
Some checks failed
Deploy Fediversion / deploy (push) Failing after 0s
2026-01-01 02:36:20 -08:00
fullsizemalt
9c0abc12e3 refactor: robust manual serialization for shows to fix recursion crash
Some checks failed
Deploy Fediversion / deploy (push) Failing after 0s
2026-01-01 02:34:31 -08:00
fullsizemalt
59099b2b66 revert: rollback schema changes to restore stability
Some checks failed
Deploy Fediversion / deploy (push) Failing after 0s
2025-12-31 18:42:41 -08:00
fullsizemalt
696d317c6c revert: remove manual serialization to fix crash
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-31 18:41:50 -08:00
fullsizemalt
7edec61af1 fix: manual population of show relationships in response
Some checks failed
Deploy Fediversion / deploy (push) Failing after 0s
2025-12-31 18:40:46 -08:00
fullsizemalt
6b9d778b4d fix: explicit from_attributes config for nested schemas
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-31 18:39:13 -08:00
fullsizemalt
c9d4266b77 fix: restore string forward ref for TourRead
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-31 18:38:02 -08:00
fullsizemalt
bfcc94a67f fix: explicit serialization config for ShowRead
Some checks failed
Deploy Fediversion / deploy (push) Failing after 0s
2025-12-31 18:37:03 -08:00
fullsizemalt
9e28fc168a debug: serialize showread
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-31 18:35:42 -08:00
fullsizemalt
8d55b1303b debug: log show relationships
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-31 18:34:39 -08:00
fullsizemalt
0c80904661 fix: pass year param to shows api
Some checks failed
Deploy Fediversion / deploy (push) Failing after 0s
2025-12-31 16:41:59 -08:00
fullsizemalt
18b102558d feat: redesign band hub page and populate song stats
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-31 14:29:35 -08:00
fullsizemalt
f10f8ad465 fix: add missing import for MostPlayedByCard in song detail page
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-31 10:11:34 -08:00
fullsizemalt
29cc0289d6 feat: redesign song detail page with artist stats and grid layout
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-31 10:05:53 -08:00
fullsizemalt
1d8eb36034 fix: resolve duplicate youtube_link argument in read_song
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-31 09:41:51 -08:00
fullsizemalt
dfeeb2ae81 feat: show artist and original artist on song detail page
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-31 09:34:12 -08:00
fullsizemalt
4795d624cb feat: show artist (vertical) on venue detail page
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-31 09:29:51 -08:00
fullsizemalt
f1bb59afb0 fix: remove garbage syntax error at end of ShowsPage
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-31 09:21:13 -08:00
fullsizemalt
379e0eff85 fix: access .data instead of .items in ShowsPage
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-31 09:16:53 -08:00
fullsizemalt
de2dd0a69d fix: ShowsPage pagination, strict mode, and component standardization
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-31 02:07:44 -08:00
fullsizemalt
dd5d513534 fix(backend): Handle duplicates in songs and tours imports
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-30 23:45:05 -08:00
fullsizemalt
be5921b6ee fix(backend): Ensure import runs on startup via scheduler
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-30 22:42:41 -08:00
fullsizemalt
f026cb2423 fix(backend): Robust duplicate handling and public API fallback for importer 2025-12-30 22:40:53 -08:00
fullsizemalt
429858287f fix(backend): Add missing PaginatedResponse schema definition
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-30 22:31:54 -08:00
fullsizemalt
60456c4737 feat(frontend): Enforce strict mode and refactor pages
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-30 22:29:16 -08:00
fullsizemalt
2941fa482e feat(backend): Implement automation scheduler and pagination envelope 2025-12-30 22:29:04 -08:00
fullsizemalt
3aaf35d43b refactor(api): standardize venues endpoint
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
- Backend: /api/venues returns PaginatedResponse envelope
- Frontend: Updated VenuesPage, AdminVenuesPage, VerticalVenuesPage to consume envelope
2025-12-30 20:35:59 -08:00
fullsizemalt
c0e3e2a7e2 refactor(api): standardize songs endpoint
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
- Backend: /api/songs returns PaginatedResponse envelope
- Frontend: Updated SongsPage, AdminSongsPage, AdminSequencesPage, BandPage to consume envelope
2025-12-30 20:33:18 -08:00
fullsizemalt
c860075681 feat(shows): redesign global shows hub
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
- Frontend: Implemented Tabbed interface (Recent, My Feed, Upcoming, By Band)
- Frontend: Added BandGrid component with selection logic
- Frontend: Added FilterPills component for active filters
- Backend: Added show_count to Verticals API
- Backend: Updated read_shows to support correct sorting for Upcoming status
2025-12-30 20:18:10 -08:00
fullsizemalt
c090a395dc feat(videos): add video icons to setlists and song versions
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
- Backend: Added video_links relationship to Performance model
- Backend: Updated shows and songs routers to eager-load videos and populate youtube_link
- Frontend: Added YouTube icon to performance list items if video exists
2025-12-30 19:52:04 -08:00
fullsizemalt
8e7be96991 refactor: clarify Top Songs definition
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
- Updated backend  to support
- Renamed frontend section from 'Top Songs' to 'Most Played Songs'
- Ensuring terms are clearly defined throughout the site
2025-12-30 19:47:55 -08:00
fullsizemalt
8d7339b950 fix(shows): fix venue visibility and song filtering
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
- Added eager loading of venue and tour to shows API endpoints to fix 'Unknown Venue' display
- Fixed query param  ->  in getTopSongs to correctly filter suggested songs
2025-12-30 19:38:19 -08:00
fullsizemalt
6d3b30ed6f feat: add VideoGallery component to band pages
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
- VideoGallery component with modal playback
- YouTube thumbnail extraction
- Responsive grid layout
- Added to band home pages
- Import script for video entities
2025-12-30 19:32:35 -08:00
fullsizemalt
1cb08bc778 fix: add sqlmodel import to migration
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-30 19:27:46 -08:00
fullsizemalt
7d266208ae feat: add modular Video entity with many-to-many relationships
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
- Video model with VideoType/VideoPlatform enums
- Junction tables: VideoShow, VideoPerformance, VideoSong, VideoMusician
- Full API router with CRUD, entity-specific endpoints, link management
- Legacy compatibility endpoint for existing youtube_link fields
- Building for scale, no shortcuts
2025-12-30 19:26:51 -08:00
fullsizemalt
265200b6ad fix: change back button from /archive to /shows
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-30 18:53:09 -08:00
fullsizemalt
b1eed75b31 feat: redesign global shows page with tabs, visible filters, bands grid
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-30 18:28:36 -08:00
fullsizemalt
6cf9a100d4 fix: backend default sort by date desc, frontend enable tier filters
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-30 16:58:39 -08:00
fullsizemalt
1652dd230d fix: add missing getApiUrl import and trailing slash on homepage
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-30 13:48:23 -08:00
fullsizemalt
5eb8edf209 fix: add trailing slashes to API URLs for SSR compatibility
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-30 13:39:22 -08:00
fullsizemalt
7d10d195f3 fix: add vertical_id and vertical slug filter params to shows endpoint
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-30 13:20:38 -08:00
fullsizemalt
bac4d3cff6 fix(importers): implement abstract method import_venues in GratefulDeadImporter
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-29 22:43:20 -08:00
fullsizemalt
fb34db3ea3 fix: use correct API query parameters (vertical_slugs, vertical_slug)
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-29 22:36:02 -08:00
fullsizemalt
1d9e56a2da feat(bands): redesign band landing page with elmeg-style layout
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-29 22:28:31 -08:00
fullsizemalt
d4f6f60df6 fix: update dynamic routes for Next.js 16 async params API
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-29 22:16:11 -08:00
fullsizemalt
e68486ddd2 fix(nav): add /bands discovery page, fix Browse links to unified routes
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-29 22:03:40 -08:00
fullsizemalt
0f571864e0 fix: remove emoji from UI, fix JSX structure, add microanimations
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-29 21:56:31 -08:00
fullsizemalt
0c7df04b92 feat(bands): filter ignored bands from home feed
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-29 21:51:20 -08:00
fullsizemalt
212082050c feat(bands): add My Bands page with tier management and IGNORED tier
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-29 21:49:28 -08:00
fullsizemalt
e07c23aceb feat(social): add social handles to settings page
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-29 21:39:53 -08:00
fullsizemalt
a87c0cc8a3 fix: profile date, avatar system, UserRead schema
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-29 21:26:48 -08:00
fullsizemalt
d20cc75085 fix: remove conflicting profile route
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-29 21:10:35 -08:00
fullsizemalt
97417ee03c fix: use user.id for profile link, fix TS errors
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-29 21:09:12 -08:00
fullsizemalt
58f077268f feat(social): add profile poster, social handles, remove X
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-29 21:05:34 -08:00
fullsizemalt
bd4c5bf215 polish(frontend): update landing page copy and metadata
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-29 19:40:57 -08:00
fullsizemalt
0e67d7b53d fix(frontend): add missing imports for Button and Link
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-29 18:49:51 -08:00
fullsizemalt
ae3741c9ee fix: explicitly create preferencetier enum
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-29 18:36:51 -08:00
fullsizemalt
c4ba926a74 fix: rebase migrations onto staging head
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-29 18:34:14 -08:00
fullsizemalt
7b8ba4b54c feat: User Personalization, Playlists, Recommendations, and DSO Importer
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-29 16:28:43 -08:00
fullsizemalt
413430b700 fix(ui): adjust sticky header offset to match navbar height
Some checks failed
Deploy Fediversion / deploy (push) Failing after 0s
2025-12-29 10:07:33 -08:00
fullsizemalt
b6337f4c85 feat(seo): add initial robots.ts and sitemap.ts
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-29 10:06:53 -08:00
fullsizemalt
7c9bcd81a6 feat(frontend): implement date-grouped show list and band filter for All Bands view
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-29 01:18:28 -08:00
fullsizemalt
af9fcd4060 feat(frontend): add vertical-specific detail pages for songs and shows to fix 404
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-29 01:11:28 -08:00
fullsizemalt
c59c06915b feat(frontend): implement All Bands view as default when no vertical selected
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-29 01:05:09 -08:00
fullsizemalt
7886095342 fix: Default to Billy Strings (populated) instead of Goose
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-29 00:08:33 -08:00
fullsizemalt
5b8cfffcf9 fix: Songs API filtering and default vertical
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
- Added vertical query param to GET /songs for correct filtering
- Changed default vertical to Phish (was Goose) to avoid empty data for new users
2025-12-29 00:03:56 -08:00
fullsizemalt
f966ef7c2e feat: Add comprehensive analytics API - gaps, velocity, trends, bustouts, debuts
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
New endpoints:
- GET /analytics/gaps/{vertical} - Songs overdue for a play
- GET /analytics/velocity/{vertical} - Hot vs cooling songs
- GET /analytics/trends/{vertical} - Monthly/quarterly chart data
- GET /analytics/stats/{vertical} - Aggregate band statistics
- GET /analytics/bustouts/{vertical} - Songs returning after long gaps
- GET /analytics/debut-songs/{vertical} - Recently debuted songs
2025-12-28 23:45:10 -08:00
fullsizemalt
b38da24055 feat: Cross-band milestone - Festivals, Playlists, Musicians, Venue Timeline
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
Sprint 2: Added 54 musicians with 78 band memberships
- Phish, Widespread Panic, Umphreys McGee core members
- Notable sit-in artists (Karl Denson, Branford Marsalis, Derek/Susan Trucks)
- Toy Factory Project supergroup (Oteil, Marcus King, Charlie Starr)

Sprint 4: Festival entity for multi-band events
- Festival and ShowFestival models
- /festivals API with list, detail, by-band endpoints

Sprint 5: User Playlists for curated collections
- UserPlaylist and PlaylistPerformance models
- Full CRUD /playlists API

Sprint 6: Venue Timeline endpoint
- /venues/{slug}/timeline for chronological cross-band history

Blockers (need production data):
- Venue linking script (no venues in local DB)
- Canon song linking (no songs in local DB)
2025-12-28 23:34:05 -08:00
fullsizemalt
2c7ff6207a feat: Add multi-band musician seed script with 31 musicians and 52 memberships
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-28 23:20:51 -08:00
fullsizemalt
af6a4ae5d3 feat: Add VenueCanon for cross-band venue deduplication with across-bands endpoint
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-28 23:12:42 -08:00
fullsizemalt
60e2abfb65 feat: Add cross-band song discovery - versions endpoint and UI 2025-12-28 23:10:20 -08:00
fullsizemalt
cf7748a980 feat: Add band profile and musician profile pages with API endpoints and database support
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-28 23:00:30 -08:00
fullsizemalt
762d2b81ff feat: Add MSI, SCI, Disco Biscuits importers + refactor About page to be band-agnostic
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-28 22:36:52 -08:00
fullsizemalt
1a9c89e1f1 fix: Restore DropdownMenu imports in navbar
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-28 21:38:16 -08:00
fullsizemalt
1d1e1e84e9 fix: Resolve syntax error in search-dialog.tsx
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-28 21:37:07 -08:00
fullsizemalt
1dab125396 feat: Redesign navigation for scalability - replace dropdown with search
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-28 21:35:18 -08:00
fullsizemalt
9f57f4f3c2 feat: Add vertical-specific archive page
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-28 21:31:23 -08:00
fullsizemalt
9e927c114e fix: refactor VERTICALS constant to config file to fix server build
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-28 21:26:08 -08:00
fullsizemalt
b2c1ce6ef5 feat: Dynamic footer based on vertical
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-28 21:21:39 -08:00
fullsizemalt
9914fdb802 feat: Add band name to show pages and fix multi-band UX issues
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-28 18:46:21 -08:00
fullsizemalt
d3557fedbb fix: Add missing Sheet UI component
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-28 18:09:51 -08:00
fullsizemalt
c026af2720 fix: Update Goose with correct MBID
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-28 17:47:39 -08:00
fullsizemalt
619c91e2f5 fix: Correct Grateful Dead MBID
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-28 17:46:57 -08:00
fullsizemalt
f2ad02df81 fix: Add city to venue slugs for uniqueness
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-28 17:42:27 -08:00
fullsizemalt
afb55153e2 fix: Add vertical slug prefix to show slugs for cross-band uniqueness
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-28 17:38:56 -08:00
fullsizemalt
5ee6735a99 fix: Add vertical slug prefix to song slugs for uniqueness
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-28 17:37:52 -08:00
fullsizemalt
d11878fdcd fix: DynamicImporter vertical bug + increase rate limit to 2s
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-28 17:33:14 -08:00
fullsizemalt
73df24f28f feat: Add universal setlist.fm importer with MBID map
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-28 17:30:34 -08:00
fullsizemalt
cdaeec1280 feat: Add comprehensive band seed (26 bands from Nugs catalog)
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-28 17:21:23 -08:00
fullsizemalt
ee89fcef7e feat: Add seed script for TTB, Ween, moe., Disco Biscuits
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-28 17:14:31 -08:00
fullsizemalt
0bdb7ca8f6 fix: Add get_current_user_optional for public endpoints
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-28 17:06:44 -08:00
fullsizemalt
fae5349f9c fix: Remove escaped quotes in badge definitions
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-28 17:04:00 -08:00
fullsizemalt
c8e5a48d57 feat: Groups refinement and band theming
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
- Group model: Add vertical_id scoping, image_url
- Vertical model: Add logo_url, accent_color for branding
- Groups router: Add vertical filter, member count, leave endpoint
- Fix CI/CD deploy.yml git clone URL (runfoo-org)
2025-12-28 16:57:41 -08:00
fullsizemalt
a9eb35fa75 feat: Add cross-band badges for multi-band activity
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
- Scene Explorer: 2+ bands attended
- Multi-Scene Fan: 5+ bands attended
- Scene Master: 10+ bands attended
- Jam Ambassador: 3+ bands reviewed
2025-12-28 16:50:45 -08:00
fullsizemalt
99e5924588 feat: Sprint 2 - empty states, discovery, attendance stats
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
- Add EmptyState component with 6 variants
- Add discover.py router with smart filtering
  - GET /discover/shows (year, venue, city, tour filters)
  - GET /discover/years
  - GET /discover/recent
- Add GET /attendance/me/stats (by vertical breakdown)
2025-12-28 16:49:24 -08:00
fullsizemalt
fe81271ab3 feat: Mobile-optimized band selector with sheet drawer
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
- Desktop: Existing dropdown menu
- Mobile: Bottom sheet with large touch targets (h-14)
- Uses responsive classes (hidden md:block / md:hidden)
2025-12-28 16:41:25 -08:00
fullsizemalt
8718fc663a feat: Add relisten_link field to Show model
- Adds archive.org/Relisten deep link support
- Complements existing nugs_link, youtube_link, bandcamp_link
2025-12-28 16:40:54 -08:00
fullsizemalt
465017cda9 feat: Add On This Day endpoint (P2)
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
- GET /on-this-day - shows matching today's month/day in history
- GET /on-this-day/highlights - top 5 for homepage widget
- Supports ?vertical= filter and ?month=&day= override
2025-12-28 16:39:52 -08:00
fullsizemalt
35ce12bc84 feat: Sprint 1 frontend polish
- Add landing page with hero, scenes, featured bands (FR-004)
- Add cross-band versions fetch to song page (FR-008)
- Create sprint plan artifact

Aligns with Specify spec fediversion-multi-band.md
2025-12-28 16:38:52 -08:00
fullsizemalt
159cbc853c feat: Enhance musician API with cross-band sit-in tracking
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
- Add sit_in_summary to GET /musicians/{slug}
- Add stats: total_bands, current_bands, total_sit_ins
- Include song_title, show_date, vertical in guest appearances
- Add seed_musicians.py for initial musician data
2025-12-28 16:30:38 -08:00
fullsizemalt
5b236608f8 feat: Add SongCanon API for cross-band song linking
- Add routers/canon.py with endpoints:
  - GET /canon - list all canonical songs with versions
  - GET /canon/{slug} - get canon with all band versions
  - GET /canon/song/{id}/related - get related versions
- Add link_canon_songs.py auto-linker script
  - Finds songs with same title across bands
  - Creates SongCanon entries automatically
  - Run with --apply to execute
2025-12-28 16:28:58 -08:00
fullsizemalt
19c5e97e7f feat: Add scene filtering to verticals API
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
- GET /verticals now supports ?scene= parameter
- Add GET /verticals/scenes endpoint
- Filter verticals by is_active=true
2025-12-28 16:07:46 -08:00
fullsizemalt
704a8d9a0b feat: Add Scene model for band genre categorization
- Add Scene model (Jam, Bluegrass, Dead Family, Funk)
- Add VerticalScene join table (many-to-many)
- Update Vertical with setlistfm_mbid, is_active, is_featured
- Add scenes relationship to Vertical
- Add seed_scenes.py script to populate initial data
2025-12-28 16:07:20 -08:00
fullsizemalt
5ced96f4e6 feat: Add personalized feed endpoint with vertical filtering
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
- Add GET /feed/me endpoint
- Filters reviews and attendance by user's band preferences
- Excludes 'hidden' display_mode bands
- Falls back to all bands if no preferences set
2025-12-28 16:05:43 -08:00
fullsizemalt
c1c041bbe9 feat: Add user vertical preferences API and onboarding UI
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
Backend:
- Add routers/verticals.py with CRUD endpoints
- GET /verticals - list all bands
- POST /verticals/preferences/bulk - onboarding bulk set
- CRUD for individual preferences

Frontend:
- Add BandOnboarding component with checkbox grid
- Add /onboarding page route
- Calls bulk preferences API on submit
2025-12-28 16:04:18 -08:00
fullsizemalt
d8b949a965 refactor: Remove emojis and band colors from codebase
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
- Simplify Vertical model (remove color, emoji fields)
- Update vertical-context.tsx to just slug/name
- Update band-selector.tsx (no colors)
- Update all [vertical] page routes (no emojis in headings)

Themes will be added later as a separate feature.
2025-12-28 15:07:42 -08:00
fullsizemalt
5c1b05a169 docs: Add Tailscale SSH push instructions to README
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-28 14:56:06 -08:00
143 changed files with 25066 additions and 925 deletions

View 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)

View 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
View 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

View 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)

View 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

View 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)

View file

@ -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
View file

@ -7,3 +7,4 @@ node_modules/
.env
*.log
.DS_Store
backend/importers/.cache/

View file

@ -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
```

View 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 ###

View 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 ###

View file

@ -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 ###

View 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 ###

View file

@ -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')

View file

@ -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 ###

View file

@ -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 ###

View file

@ -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

Binary file not shown.

12313
backend/elmeg_dump.sql Normal file

File diff suppressed because it is too large Load diff

View file

@ -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")

View file

@ -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
View 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)

View file

@ -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...")

View file

@ -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
View 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")

View file

@ -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"}

View file

@ -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()

View file

@ -13,3 +13,5 @@ requests
beautifulsoup4
boto3
email-validator
apscheduler
python-slugify

View 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}

View file

@ -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
View 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
View 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
View 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

View file

@ -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]

View 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)
]

View file

@ -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
@ -33,11 +33,32 @@ def create_group(
def read_groups(
offset: int = 0,
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)

View file

@ -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) ---

View 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]

View 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"}

View 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

View file

@ -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)
@ -46,6 +65,13 @@ def global_search(
.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(
select(PerformanceNickname)
@ -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,

View file

@ -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 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:
from datetime import datetime
today = datetime.now()
if status == "past":
query = query.where(Show.date <= datetime.now())
query = query.where(Show.date <= today)
elif status == "upcoming":
query = query.where(Show.date > datetime.now())
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

View file

@ -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
}

View file

@ -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"""

View file

@ -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
}

View 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}

View file

@ -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
View 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}")

View file

@ -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

View 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}")

View 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])

View 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()

View 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()

View 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
View 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
View 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
View 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
View 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
View 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()

View file

@ -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},
]

View 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
)

View 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.")

View 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

Binary file not shown.

22
debug_dmb_deadco.py Normal file
View 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")

View 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>
)
}

View file

@ -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>
)

View 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">&gt;</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}>
&quot;{nick.nickname}&quot;
</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&apos;s setlist hasn&apos;t been added yet. Early shows often weren&apos;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 >
)
}

View file

@ -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>

View 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>
)
}

View file

@ -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 ? (

View file

@ -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 ? (

View file

@ -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&apos;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 >

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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

View 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>
)
}

View 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>
)
}

View file

@ -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>

View file

@ -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>

View 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>
)
}

View 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>
)
}

View 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>
)
}

View file

@ -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>
)
}

View 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>
)
}

View file

@ -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>

View file

@ -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 && (

View file

@ -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 }}

View file

@ -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`,
}
}

View file

@ -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)
}

View file

@ -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&apos;s setlist hasn&apos;t been added yet. Early Goose shows (2014-2016) often weren&apos;t documented.
This show&apos;s setlist hasn&apos;t been added yet. Early shows often weren&apos;t documented.
</p>
</div>
)}

View file

@ -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>
)

View file

@ -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]
}

View file

@ -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>
)
}

View file

@ -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)

View file

@ -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>

View file

@ -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