Psytrance as a galaxy: every genre a star, its artists orbiting it, and upcoming festivals and parties around the world as ships sailing between them. See who plays where.
One-click character swap for videos: an open-source, local Higgsfield Genjutsu alternative built on ComfyUI and MiniMax H3, with FLUX.3 Klein relighting.
Show it a video you love. Get a new video in the same style. An AI crew (Claude Code or Codex) plans, builds and reviews it with you.
8h +9★ 1,799Media
Site preview image
Do you operate this project?
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Organization repository · verify with a repository file
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
REST API for Map of Psy, a galaxy of psytrance genres where every upcoming party sails as a ship to the
stars of the genres it plays. It pulls events from the public Goabase API, reads
their line-ups to work out who plays where and which map genres each party has, reads timetables from
the line-ups, and lets users register, save events, say they're going, write reviews, follow artists and
get emails. Artists, organizers, labels and festivals can own their pages. Admins and editors correct the
data in an admin panel with a change history and undo, including the galaxy itself: each genre's texts,
key tracks (with the second of the drop, for the quiz) and what has been verified.
Hosted on free tiers (Vercel, Render, Neon). The backend sleeps when nobody uses it, so the first load can take 1–2 minutes.
An outside cron job pings GET /api/health every 14 minutes to keep it awake, so the background jobs keep running.
Tech stack
Java 17, Spring Boot 4 — Web MVC, Security, Data JPA, Validation, Mail
PostgreSQL with Hibernate / JPA and Flyway migrations
JWT authentication (JJWT) with BCrypt password hashing, and Sign in with Google (Google ID tokens
checked with Spring Security's JOSE support)
Spring RestClient for the Goabase, YouTube Data API and Brevo (email) integrations
flowchart LR
UI[React frontend] -->|HTTP + JWT| C
subgraph Spring Boot
C[Controllers] --> S[Services]
S --> R[JPA repositories]
S --> G[GoabaseClient]
S --> Y[YouTubeClient]
S --> B[BrevoMailSender]
F[JwtFilter] -.checks token.-> C
J[BackgroundJobs<br/>reminders + new-gig emails hourly,<br/>artist index every few minutes] --> S
end
R --> DB[(PostgreSQL<br/>users, favorites, reviews, going,<br/>artists, event genres, timetables,<br/>map placements, galaxy content,<br/>follows, pages and owners, images,<br/>feedback, admin changes)]
G -->|full list, at most every 10 min| GA[Goabase API]
Y -->|festival aftermovies, about once a month each| YT[YouTube Data API]
B -->|HTTPS| BR[Brevo email API]
Requests go through the usual layers: controller (HTTP, validation) → service (business logic) →
repository (database) or client (external API).
Design decisions
Server-side search and paging over a cached snapshot. Goabase has no paging, only a limit.
EventService downloads the full list at most once every 10 minutes, then filters, sorts and pages it
in memory. This keeps responses fast and avoids hammering Goabase. If a refresh fails, the last good list
keeps being served instead of an error.
One class talks to Goabase.GoabaseClient owns the URLs, timeouts and JSON shape, and maps responses
into typed records. The rest of the app never sees Goabase's raw format. Redirects are disabled on purpose:
Goabase answers an unknown event id with a 301 to an HTML page, which the client turns into "not found".
Aftermovies without burning the YouTube quota. A YouTube search costs 100 of the 10,000 free daily
units, so AftermovieService searches at most once a month per festival and stores the result, including
"nothing found", in PostgreSQL rather than in memory, because the free host restarts the app often. A video
only counts when its title has the festival's name and "aftermovie"; otherwise the page shows no video
rather than a wrong one. Without YOUTUBE_API_KEY the feature is simply off.
Artists picked out of free-text line-ups. Goabase line-ups are whatever the organizer typed: emoji,
flags, record labels, stage headers, URLs. LineUpParser keeps every line as written and adds the artist
name only when it's confident (tested against real line-ups), so the frontend can link artists to SoundCloud.
Genre titles ("Darkpsy/Forest"), credits ("Artwork by ...") and country tags ("[DE]") are left out.
An artist index built in the background. Goabase only has line-ups on its single-event endpoint, so
"where does this artist play?" would mean hundreds of Goabase calls per request. Instead ArtistIndexer
reads 25 line-ups every few minutes (APP_ARTISTS_INTERVAL; events never read first, then the oldest reads,
since organizers keep adding artists; on a tie the soonest event first) and stores who plays where in PostgreSQL. The same artist written differently ("ACE VENTURA",
"Ace Ventura") is matched by a slug (ace-ventura) that also serves as the page address, and the normal
spelling is preferred over all-caps.
Reminder emails that are safe to run any time.ReminderService runs every hour and emails users about
events they saved or are going to that start in the next 3 days, one email per user listing all of them.
Every sent reminder is recorded (unique per user and event), and only after a successful send, so nobody
gets one twice, a failed email is retried, and runs missed while the free server was asleep are caught up.
Users can turn reminders off on their profile.
API
Method
Endpoint
Auth
Description
POST
/api/auth/register
–
Create an account (username, email, password)
POST
/api/auth/login
–
Returns { "token": "..." }
GET
/api/auth/me
✔
Current user
POST
/api/auth/forgot-password
–
Email a one-time reset link (email); same answer whether or not the account exists
POST
/api/auth/reset-password
–
Set a new password with the link's token (token, newPassword)
POST
/api/auth/verify-email
–
Confirm an email with the token from the emailed link
GET
/api/auth/google
–
The Google client id for the frontend's button (empty while Google sign-in is off)
POST
/api/auth/google
–
Sign in with a Google ID token (credential); a new Google account first answers needsUsername, then sends a username
GET
/api/events
–
Paged, filtered event list (see below)
GET
/api/events/map
–
All events that have coordinates, for the map
GET
/api/events/{id}
–
One event
GET
/api/events/{id}/aftermovie
–
The festival's aftermovie on YouTube (videoId, title), or 204 if there is none
GET
/api/events/{id}/details
GET /api/events
Parameter
Default
Description
page
0
Page number, starting at 0
size
24
Page size, 1–100
search
–
Matches event name or town
country
–
Matches country name
genre
–
Matches event type or name, e.g. Forest
timeline
ALL
ALL or THIS_WEEKEND
GET /api/events?page=0&size=2&country=spain&timeline=THIS_WEEKEND
The fastest way to run everything (database, backend and frontend) is Docker Compose. Clone both repos next
to each other:
git clone https://github.com/Peconjii/psytrance-tracker-backend.git
git clone https://github.com/Peconjii/psytrance-tracker.git
cd psytrance-tracker-backend
docker compose up --build
Then open http://localhost:5173. The API is on http://localhost:8080, and the data lives in a Docker
volume, so it survives restarts (docker compose down -v wipes it).
The database password and JWT secret in docker-compose.yml are throwaway values for this local setup.
Running locally without Docker
You need: JDK 17+ and a PostgreSQL database, hosted (such as Neon) or local. A local one in Docker:
In IntelliJ, set the same variables under Run → Edit Configurations → Environment variables.
Email (optional). Without any of this, the emails (confirmation and reset links, reminders) are written to
the application log, which is enough for local development.
Variable
What it does
BREVO_API_KEY
Send every email through Brevo's HTTP API (works on hosts that block SMTP, like Render)
MAIL_FROM
The sender, e.g. Map of Psy <[email protected]>; with Brevo it must be a sender verified there
Or plain SMTP instead of Brevo (e.g. Gmail with an app password)
FRONTEND_URL
http://localhost:5173 (default), used to build the links in emails and allowed by CORS
EXTRA_ORIGINS
empty (default); other addresses of the same frontend allowed by CORS, comma separated (e.g. https://www.mapofpsy.com,https://psyatlas-one.vercel.app)
BACKEND_URL
This server's own address, for the links of uploaded pictures (default http://localhost:8080)
Other settings
Variable
What it does
ADMIN_USERNAMES
Comma-separated usernames that are always admins, compared exactly (admin,psyatlas)
GOOGLE_CLIENT_ID
The OAuth client id from Google Cloud Console; turns "Continue with Google" on (the frontend gets it from the API)
APP_ARTISTS_INTERVAL
How often the artist index reads a batch of 25 line-ups (default PT3M; PT30M saves free database hours)
YouTube aftermovies (optional). Set YOUTUBE_API_KEY to a YouTube Data API v3 key (Google Cloud Console →
APIs & Services → enable YouTube Data API v3 → Credentials → Create API key). Without it, festival
pages just have no aftermovie.
The API starts on http://localhost:8080. On first start, Flyway creates the tables from the SQL migrations.
CORS allows the frontend dev server at http://localhost:5173, the address in FRONTEND_URL and those in EXTRA_ORIGINS.
If the PORT variable is set (as on Render), the API listens on that port instead.
Tests
./mvnw test
Docker must be running: the integration tests start a throwaway PostgreSQL container with
Testcontainers. No other setup or environment variables are needed.
GitHub Actions runs the same tests on every push.
All integration test classes share one database, so they run in the same (alphabetical) order on every machine,
and before every test the events earlier tests approved are taken off the lists. A test that counts what a list
shows then sees only its own data, on Windows and on the CI's Linux alike.
Unit tests check one class on its own. Its dependencies are mocked, and time is controlled with a fake Clock.
EventServiceTest: paging, sorting, search, timeline filters and the cache (one fetch while fresh, refresh
after expiry, old list kept when Goabase is down)
PasswordResetServiceTest: unknown emails send nothing, only the token hash is stored, a link works once,
expired or made-up links are rejected
ReminderServiceTest: only events starting in the next 3 days, saved and going events in one email, nothing
sent twice, failed emails retried, reminders that were turned off stay off
ArtistIndexerTest: events never read come first, then the oldest reads, soonest event first on a tie; at
most one batch per run; stops when Goabase is down; map genres stored with the line-up
EventGenresTest, MapGenresTest: genres from map artists, stage headers and keywords; merged spellings
TimetableParserTest: set times in many styles, after midnight, stages and dates, HTML entities, "Live" acts
LineUpParserTest, ArtistNamesTest: artist names from real line-ups; spellings of one artist share a slug
GoogleIdTokensTest: only a Google token for this site, unexpired, with a confirmed email, counts
BrevoMailSenderTest, MailConfigTest: what goes to Brevo, and Brevo winning over the SMTP settings
Integration tests start the whole application with real security and a real PostgreSQL, and call the API
through MockMvc. Only Goabase is faked.
AuthIntegrationTest: register, log in, /me; passwords stored as BCrypt hashes and never returned;
duplicate usernames, validation errors, wrong passwords, missing or invalid tokens
FavoriteIntegrationTest: add, list, remove; duplicates are a 409; users can't read or change someone
else's favorites (403)
ReviewIntegrationTest: reading is public, writing needs login, a second review updates the first,
rating must be 1–5
GoingIntegrationTest: guests see counts but must log in to mark, marking twice counts once, a page of
counts in one request, unknown events are a 404
ReminderIntegrationTest: one reminder per user and event even across runs, users can turn reminders off
ArtistIntegrationTest: indexing line-ups, artist pages, busiest-first list, accent-free search, a re-read
line-up replaces the old one
MapIntegrationTest, TimetableIntegrationTest: map events with genres, placements, timetables
AdminAccessIntegrationTest: every admin endpoint, found automatically, is closed to guests (401) and users (403)
AdminArtistIntegrationTest, AdminEventIntegrationTest, AdminChangeIntegrationTest,
ArtistCleanupIntegrationTest: the admin panel, its history and undo
FeedbackIntegrationTest: guests can send, only admins read, rate limit, hidden bot field, links to this site only
FollowIntegrationTest: follow and unfollow, the gigs for the map, only new gigs emailed, each once
GalaxyContentIntegrationTest: texts and tracks start from the map file, editors fix them, undo (and never
over a newer fix), tracks without a video, wrong links refused, only admins and editors
GoogleSignInIntegrationTest: a new Google account picks a username, an account with the same confirmed email
is linked, an unconfirmed one is never taken over
"I'm going" counts without N+1 queries. The events page asks for a whole page of counts at once
(GET /api/events/going?ids=...), answered with one GROUP BY query plus one query for the user's own
marks. Marking twice is harmless: the insert uses PostgreSQL's ON CONFLICT DO NOTHING against a unique
constraint, so even two clicks at the same moment can't create a duplicate.
Map genres from the line-up.EventGenres gives each party its Map of Psy genres from the best evidence
it has: artists placed on the map count most, then genres named in stage headers ("DARKPSY / FOREST STAGE"),
and only when the line-up says nothing the genres the organizer ticked on Goabase (read from its JSON-LD,
ignored when more than 4 are ticked). Every artist counts once, split between their genres, so the shares say
where the party's ship sits between the stars. Genres an admin set by hand (MANUAL) are never overwritten.
Timetables from free text.TimetableParser reads set times written in line-ups ("22:00 - 00:00 | Naraka",
"7:00 PM – 8:00 PM", sets after midnight, stage headers, date lines), on the venue's clock with its UTC offset.
Each set gets a map genre (its artist, a genre written next to it, or its stage) for the live fleets.
Map placements in the database. Which artist orbits which genre started as genres.json and now lives in
map_artist_genres (seeded from the JSON on first start), so admins can change it and it applies at once:
to the map, and to the genres of that artist's events, whose line-ups are marked to be read again.
The galaxy's texts in the database. Each genre's description, how it sounds, tempo and key tracks also
started in genres.json; they now live in map_genre_content (seeded once), so a wrong fact is fixed in
Admin → Galaxy without a deploy. Every track can be marked verified and carry the second of its drop, which the
quiz starts just before. A save is logged with the whole content before and after, so it can be undone.
Two roles: admin and editor. The usernames in ADMIN_USERNAMES are always admins; admins give other
accounts the admin or editor role in the panel (an editor handles the content only). /api/admin/** is closed
in SecurityConfig and every endpoint checks the role again; a test finds every admin endpoint and checks
that guests get 401 and users 403. Every change is logged in admin_changes (who, before, after) and can be
undone as a new change, refused when the same thing was changed again since.
Page owners and trust. An artist, organizer, label or festival can own its page. A trusted owner's
changes go live at once; the others wait for an admin. Trust is given per page, not per account.
Email over HTTPS. The free host blocks outgoing SMTP ports, so with BREVO_API_KEY set every email goes
through Brevo's HTTP API instead (BrevoMailSender, the same JavaMailSender the mailers use). The password
reset email is sent in the background, so the form answers at once and its timing doesn't show whether an
account exists.
Sign in with Google. The browser's Google ID token is checked against Google's public keys, this site's
client id and a confirmed email. It logs into the account already linked to that Google account, or the
oldest account with the same confirmed email (never one whose email nobody confirmed, since anyone could
have typed it), or a new account with a username the person picks.
Following artists. Follows and the gigs a follower already knows about are stored, so the hourly job
emails only gigs that are new, each once, and the gigs known when following starts are not mailed.
Abuse limits. 10 wrong passwords for one username lock it for 15 minutes; one address gets at most 3
password reset emails an hour; the feedback form takes 5 messages an hour per address and quietly drops
bots that fill a hidden field. Feedback page links must be pages of this site.
Testable time. Date logic ("upcoming", "this weekend") uses an injected java.time.Clock, so tests can
pin "today" to a fixed date and move time forward to test cache expiry.
Stateless auth. Login returns a signed JWT; every protected request is authenticated from its
Authorization: Bearer header. Users can only read and change their own favorites.
Password reset without leaking accounts. Reset links carry a random 256-bit token; only its SHA-256
hash is stored, it expires after 30 minutes and works once. The endpoint answers identically for known and
unknown emails, so it can't be used to discover who has an account.
Schema changes as versioned SQL. Flyway migrations in src/main/resources/db/migration own the database
schema, and Hibernate only validates that the entities match it (ddl-auto=validate). Every database (local,
Docker, tests, production) goes through the same numbered steps, which are reviewed in git like any other code.
Moving off ddl-auto=update also surfaced a real gap: it had never added the unique constraint on usernames
to the existing table, which V2__unique_username.sql now does.
Consistent errors. A @RestControllerAdvice turns validation failures, conflicts, missing events and
Goabase outages into JSON responses with the right status code (400, 404, 409, 503).
–
One event with times, line-up (each artist with the artistSlug of their page), venue notes, entry fee, description and organizer (cached per event)
GET
/api/events/going?ids=1,2,3
–
{ "1": { "count": 4, "going": true }, ... } for up to 100 events; going is always false for guests
GET
/api/events/{id}/going
–
{ "count", "going" } for one event
PUT
/api/events/{id}/going
✔
Mark yourself as going (doing it twice changes nothing)
DELETE
/api/events/{id}/going
✔
Not going any more
GET
/api/me/going
✔
Events you're going to (eventId, eventName, startsOn), soonest first
PUT
/api/me/reminders
✔
Turn reminder emails on or off (enabled)
GET
/api/artists?search=
–
Artists playing upcoming events (slug, name, upcomingEvents), busiest first; search ignores case and accents
GET
/api/artists/{slug}
–
One artist and their upcoming events, soonest first
GET
/api/events/{id}/timetable
–
Set times read from the line-up or set by an admin (stage, artist, startsAt, endsAt, genreId)
GET
/api/map/events?period=
–
The events of the next WEEK or MONTH with their map genres (genreId, share, source)
GET
/api/map/genres?ids=1,2,3
–
Map genres of up to 100 events, for the event tickets
GET
/api/map/timetables?period=
–
The period's timetables, for the live fleets
GET
/api/map/placements
–
Which artists orbit which genre: { genreId: [names] }
GET
/api/map/content
–
Each genre's texts and key tracks (description, bass, kick, atmosphere, bpmMin, bpmMax, verified, tracks with drop)
POST / GET
/api/images[/{id}]
✔ / –
Upload a picture for a page (logo, photo); read it