Un clon de Spotify construido con Astro, Svelte, React y Tailwind, conectado a un backend Go, enfocado en organizar y reproducir música de tus animes favoritos.
FreakFy nació como un clon personal de Spotify basado en el excelente tutorial de @midudev, y se convirtió en un espacio propio donde catalogar los openings, endings y OSTs de los animes favoritos del autor.
El frontend sigue Screaming Architecture por features (src/features/), consume un backend Go (hexagonal, CQRS, PostgreSQL 16, Redis 7) a través del proxy de nginx en la misma origin, y se despliega como sitio estático sobre un VPS propio con Dokploy.
Refactor completado (2024): El backend Go fue refactorizado para sigue una arquitectura hexagonal con dominio/persistencia desacoplado, manejo centralizado de errores, middleware de hardening (request-id, body-limit, timeouts), y tests de contrato para compatibilidad con el frontend.
FreakFy es un sitio estático Astro (sin SSR) que se conecta a un backend Go en la misma origin gracias a un proxy nginx. El frontend sigue el patrón de islas: HTML pre-renderizado en build, con partes interactivas hidratadas en cliente con React o Svelte.
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#229bc1",
"primaryTextColor": "#ffffff",
"primaryBorderColor": "#229bc1",
"lineColor": "#229bc1",
"secondaryColor": "#181818",
"tertiaryColor": "#282828"
}
}}%%
flowchart TB
subgraph dev["💻 Desarrollo Local"]
direction TB
PC[PC Desarrollador]
Vite[Vite Dev Server<br/>:4321]
GoAPI[Go API<br/>localhost:8080]
DB[(PostgreSQL<br/>localhost:5432)]
Cache[(Redis<br/>localhost:6379)]
PC -->|pnpm dev| Vite
Vite -->|proxy /api/*| GoAPI
GoAPI --> DB
GoAPI --> Cache
end
subgraph build["🏗️ Build Time"]
Vite -->|pnpm build| Astro[Astro SSG]
Script[fetch-playlists.mjs] -->|genera| PlaylistIDs[playlist-ids.json]
PlaylistIDs --> Astro
Astro -->|output| Dist[dist/ HTML]
end
subgraph prod["🚀 Producción VPS"]
direction TB
nginx[nginx:alpine<br/>freakfy-web]
GoProd[Go + Gin<br/>freakfy-api]
Traefik[Traefik + TLS<br/>dokploy]
User[👤 Usuario]
DBprod[(PostgreSQL)]
CacheProd[(Redis)]
Worker[Go + Asynq<br/>freakfy-worker]
Traefik -->|https| User
User -->|http| nginx
nginx -->|serve static| Dist2[dist/ HTML]
nginx -->|proxy /api/*| GoProd
GoProd --> DBprod
GoProd --> CacheProd
GoProd -->|async jobs| Worker
end
Script -.->|se genera en build| Dist2
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#00add8",
"primaryTextColor": "#ffffff",
"primaryBorderColor": "#00add8",
"lineColor": "#00add8",
"secondaryColor": "#1a1a2e",
"tertiaryColor": "#16213e"
}
}}%%
flowchart TB
subgraph client["🌐 Clientes"]
Browser[Browsers]
Mobile[Mobile Apps]
ThirdParty[3rd Party]
end
subgraph gateway["🚪 API Gateway Layer"]
Nginx[nginx:alpine<br/>Proxy]
Gin[Gin Router<br/>/api/v1/*]
Middleware[Middleware Stack<br/>- RequestID<br/>- Auth<br/>- CORS<br/>- RateLimit<br/>- Cache<br/>- Sanitize<br/>- Timing]
end
subgraph handlers["🎯 HTTP Handlers"]
AuthH[auth_handlers.go<br/>- Register<br/>- Login<br/>- Refresh]
PlaylistH[playlist_handlers.go<br/>- GetPlaylists<br/>- Create<br/>- Update<br/>- Delete<br/>- AddSong<br/>- RemoveSong]
SongH[song_handlers.go<br/>- GetSongs<br/>- Search]
UserH[user_handlers.go<br/>- GetMe<br/>- GetFavorites<br/>- ToggleFavorite<br/>- History]
end
subgraph cqrs["⚡ CQRS Layer"]
subgraph commands["✍️ Commands (Write)"]
CmdHandler[Command Handler<br/>cmd_handler.go]
Cmd1[RegisterUser]
Cmd2[CreatePlaylist]
Cmd3[AddSongToPlaylist]
Cmd4[ToggleFavorite]
Cmd5[RecordHistory]
end
subgraph queries["📖 Queries (Read)"]
QueryHandler[Query Handler<br/>query_handler.go]
Q1[GetUserPlaylists]
Q2[GetPlaylistByID]
Q3[SearchSongs]
Q4[GetUserFavorites]
Q5[GetHistory]
end
ReadFacade[Read Facade<br/>- Playlist + Songs join<br/>- Pagination]
end
subgraph domain["🏛️ Domain Layer"]
Ent[Entities<br/>- User<br/>- Playlist<br/>- Song<br/>- Session<br/>- Favorite<br/>- History]
Services[Domain Services<br/>- AuthService<br/>- PlaylistService<br/>- SongService]
Repos[Repository Interfaces<br/>- UserRepository<br/>- PlaylistRepository<br/>- SongRepository<br/>...]
VO[Value Objects<br/>- Pagination<br/>- Password<br/>- Email<br/>- Color]
end
subgraph infra["🏗️ Infrastructure Layer"]
GORM[GORM Implementation]
Models[SQL Models<br/>- UserModel<br/>- PlaylistModel<br/>- SongModel<br/>...]
Mappers[Entity ↔ Model<br/>Mappers]
DB[(PostgreSQL 16<br/>:5432)]
Redis[(Redis 7<br/>:6379)]
Asynq[Asynq Worker<br/>Background Jobs]
end
subgraph pkg["📦 Paquetes Compartidos"]
Config[config/<br/>- Load/env<br/>- Validate]
Error[apierror/<br/>- Error codes<br/>- FromContext]
Response[response/<br/>- OK/Error<br/>- Paginated]
Security[security/<br/>- JWT<br/>- Hash]
Logger[logger/<br/>- Structured JSON]
Metrics[metrics/<br/>- Histograms<br/>- Stats]
Cache[caching/<br/>- Query Cache<br/>- ETag]
Retry[retry/<br/>- Backoff<br/>- Circuit Breaker]
end
client -->|HTTP| Nginx
Nginx -->|route| Gin
Gin -->|middleware chain| Middleware
Middleware -->|dispatch| AuthH
Middleware -->|dispatch| PlaylistH
Middleware -->|dispatch| SongH
Middleware -->|dispatch| UserH
AuthH --> CmdHandler
PlaylistH -->|CQ| CmdHandler
PlaylistH -->|CQ| QueryHandler
SongH -->|CQ| QueryHandler
UserH -->|CQ| CmdHandler
UserH -->|CQ| QueryHandler
CmdHandler -->|execute| Cmd1
CmdHandler -->|execute| Cmd2
CmdHandler -->|execute| Cmd3
CmdHandler -->|execute| Cmd4
CmdHandler -->|execute| Cmd5
QueryHandler -->|execute| Q1
QueryHandler -->|execute| Q2
QueryHandler -->|execute| Q3
QueryHandler -->|execute| Q4
QueryHandler -->|execute| Q5
Q1 --> ReadFacade
Q2 --> ReadFacade
Cmd1 -->|persists| Services
Cmd2 -->|persists| Services
Cmd3 -->|persists| Services
Cmd4 -->|persists| Services
Cmd5 -->|persists| Services
Services -->|depends on| Repos
Q1 -->|depends on| Repos
Q2 -->|calls| Repos
Repos -->|implements| GORM
GORM -->|maps| Mappers
Mappers -->|converts| Models
Models -->|CRUD| DB
GORM -->|cache/session| Redis
GORM -->|async jobs| Asynq
Asynq -->|queue| Redis
Services -.->| validates| VO
Repos -.->| uses| Ent
flowchart TB
Astro[Astro] --> HTML[HTML]
HTML --> Browser[Browser]
Browser --> React[React Islands]
Browser --> Svelte[Svelte Islands]
Browser --> Static[Static .astro]
React --> Store[Zustand]
Svelte --> Store
Store --> LS[localStorage]
React --> Fetch[apiFetch]
Fetch --> Fallback[Static Fallback]
Fetch --> API[Go API]
React --> Audio[HTML5 Audio]
sequenceDiagram
User->>Browser: Click Play
Browser->>API: GET /playlists/:id
API->>DB: Query playlist+songs
DB->>API: PlaylistDTO
API->>Browser: 200+JSON
Browser->>Store: selectTrack
Browser->>Audio: play
User->>Browser: Search query
Browser->>API: GET /songs/search
API->>DB: ILIKE query
DB->>API: Songs
API->>Browser: 200
User->>Browser: Login submit
Browser->>API: POST /auth/login
API->>API: Validate+JWT
API->>Redis: Session cache
API->>Browser: 200+JWT
Browser->>Store: setTokens
flowchart LR
Dev --> Git
Git -->|webhook| Dok[Dokploy]
Dok --> Nginx
Dok --> GoAPI
Dok --> DB
Dok --> Cache
Dok --> Worker
User --> Traefik
Traefik --> Nginx
Nginx --> GoAPI
Diagramas detallados en
docs/architecture/overview.md.
| Capa | Herramienta | Versión | Por qué |
|---|---|---|---|
| Framework | Astro | 6.x | SSG rápido con islands architecture |
| UI interactiva | React | 19.x | Player, SearchView, CardPlayButton |
| UI declarativa | Svelte | 5.x | Greeting — componente sin estado global |
| Estado global | Zustand | 5.x | Store tipado por feature, sin boilerplate |
| Estilos | TailwindCSS | 4.x | CSS-first con @theme y @utility |
| Iconos | Lucide | 1.x | Stroke-based, consistentes |
| TypeScript | TypeScript | 5.9 | Tipado estricto en todo el frontend |
| Capa | Herramienta | Versión | Por qué |
|---|---|---|---|
| API | Go + Gin | 1.22 | Hexagonal architecture, CQRS |
| ORM | GORM | 2.x | Migraciones y queries tipadas |
| Base de datos | PostgreSQL | 16 | Persistencia principal |
| Cache / queues | Redis + Asynq | 7 | Cache de sesiones, jobs async |
| Auth | JWT | — | Access token + refresh token |
| Herramienta | Uso |
|---|---|
| pnpm 10.x | Package manager, rápido y eficiente |
| Vitest 4 + jsdom | Tests unitarios y de componente |
| ESLint + Prettier | Lint + formato consistente |
| Husky + lint-staged | Quality gates pre-commit / pre-push |
| Docker + nginx | Imagen final ~25 MB, multi-stage |
| docker-compose | Orquesta frontend + API + DB + Redis + Worker |
| Dokploy + Traefik | Self-hosted PaaS con TLS automático |
freakfy/
├── api/ # Backend Go (hexagonal architecture)
│ ├── cmd/server/main.go # Entry point + config validation
│ ├── cmd/server/router.go # Route registration + /ready endpoint
│ ├── api/
│ │ ├── handlers/ # Split by capability (auth, playlist, song, user)
│ │ ├── middleware/ # Auth, request-id, body-limit, logging
│ │ └── dto/ # Request/Response DTOs
│ ├── application/
│ │ ├── commands/ # CQRS Commands
│ │ ├── queries/ # CQRS Queries
│ │ └── usecase/ # ReadFacade for orchestration
│ ├── domain/ # Pure domain (entities, interfaces, services)
│ ├── infrastructure/
│ │ └── repository/ # Repos + models + mappers (decoupled from domain)
│ └── pkg/ # Shared packages (apierror, config, response)
├── docker/
│ └── nginx.conf # Proxy /api/\* → Go API + static files
├── docs/ # Documentación técnica completa
├── public/
│ ├── fonts/ # CircularStd self-hosted
│ └── music/ # MP3s por playlist id
├── scripts/
│ └── fetch-playlists.mjs # Prebuild: genera src/generated/playlist-ids.json
├── src/
│ ├── features/ # Screaming Architecture por dominio
│ │ ├── auth/ # authStore.ts (access token en-memoria)
│ │ ├── player/ # Player.tsx, controles, store, shuffleHistory
│ │ ├── playlists/ # PlaylistCard, playlistsApi, tipos, mappers
│ │ ├── search/ # SearchView.tsx con debounce + API
│ │ └── settings/ # SettingsPanel.tsx
│ ├── shared/
│ │ ├── lib/api/ # apiFetch (auth, retry, timeout)
│ │ ├── lib/query/ # useQuery, useMutation (cache SWR)
│ │ ├── lib/utils/ # colors.ts
│ │ ├── types/api.types.ts # DTOs espejados del backend Go
│ │ └── ui/ # AsideMenu, TopBar, MobileBottomNav, Greeting...
│ ├── icons/ # SVG como .astro (paths de Lucide)
│ ├── layouts/Layout.astro # Grid global + TopBar + Player + ClientRouter
│ ├── pages/
│ │ ├── index.astro
│ │ ├── search.astro
│ │ ├── settings.astro
│ │ └── playlist/[id].astro # getStaticPaths desde generated/playlist-ids.json
│ ├── generated/
│ │ └── playlist-ids.json # Auto-generado por fetch-playlists.mjs en prebuild
│ ├── libreria/ # Datos estáticos de fallback (legacy)
│ └── styles/global.css # @theme tokens + spotify-grid utility
├── astro.config.mjs
├── docker-compose.yml # freakfy-web + freakfy-api + db + redis + worker
├── Dockerfile
├── tsconfig.json
└── vitest.config.ts
22+10+1.22+ (opcional para frontend-only)git clone https://github.com/alvaroofernaandez/freakfy.git
cd freakfy
pnpm install
pnpm dev
Abre http://localhost:4321. Si la API Go no está corriendo, la app usa los datos estáticos de src/libreria/data.ts.
# Terminal 1: API Go
cd api
go run cmd/server/main.go
# Terminal 2: Frontend
pnpm install
pnpm dev
El proxy de Vite reenvía /api/* → http://localhost:8080 automáticamente.
docker compose build
docker compose up
Abre http://localhost.
📖 Setup detallado:
docs/development/setup.md.
| Script | Descripción |
|---|---|
pnpm dev |
Servidor de desarrollo con HMR + proxy Vite → Go API |
pnpm build |
Prebuild (fetch-playlists.mjs) + astro check + build |
pnpm preview |
Preview del dist/ generado |
pnpm lint |
ESLint sobre todo el repo |
pnpm lint:fix |
ESLint con --fix |
pnpm format |
Prettier write mode |
pnpm format:check |
Prettier check mode (para CI) |
pnpm type-check |
astro check (TS + Astro + Svelte) |
pnpm test |
Vitest one-shot |
pnpm test:watch |
Vitest en modo watch |
pnpm test:coverage |
Reporte de cobertura |
El script
prebuildcorrenode scripts/fetch-playlists.mjsautomáticamente antes de cadapnpm build. Si la API Go no está disponible, usa elplaylist-ids.jsonexistente como fallback.
pnpm test # Una pasada
pnpm test:watch # Watch mode
pnpm test:coverage # Con cobertura
jsdom.@testing-library/svelte.@vitest/coverage-v8.shuffleHistory.test.ts), shape de datos estáticos.📖 Estrategia completa:
docs/testing/strategy.md.
FreakFy corre en un VPS propio gestionado con Dokploy, con docker-compose orquestando 5 servicios: el frontend nginx, la API Go, PostgreSQL, Redis y el worker Asynq.
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#2496ed",
"primaryTextColor": "#ffffff",
"primaryBorderColor": "#1d4ed8",
"lineColor": "#2496ed"
}
}}%%
flowchart LR
dev[👨💻 Desarrollador] -->|git push main| gh[GitHub]
gh -->|webhook| dkp[Dokploy VPS]
dkp -->|docker compose build| img[Imágenes]
img -->|docker compose up| nginx[nginx:alpine\nfreakfy-web]
img --> api[Go API\nfreakfy-api]
api --> db[(PostgreSQL)]
api --> cache[(Redis)]
traefik[Traefik + TLS] --> nginx
traefik --> user[👤 Usuario]
nginx -->|proxy /api/*| api
| Servicio | Imagen | Rol |
|---|---|---|
freakfy-web |
nginx:alpine | Sirve dist/ + proxy /api/* |
freakfy-api |
Go + Gin | API REST hexagonal |
freakfy-db |
postgres:16 | Base de datos principal |
freakfy-redis |
redis:7 | Cache + colas Asynq |
freakfy-worker |
Go + Asynq | Procesador de jobs en background |
| Stage | Base | Rol |
|---|---|---|
base |
node:22-alpine |
Habilita corepack (pnpm), fija WORKDIR /app |
deps |
base |
pnpm install --frozen-lockfile con cache |
build |
base |
pnpm build → dist/ |
runner |
nginx:1.27-alpine |
Copia dist/ + docker/nginx.conf |
# Full stack
docker compose build
docker compose up
# Solo frontend
docker build -t freakfy:local .
docker run --rm -p 8080:80 freakfy:local
📖 Guía completa:
docs/deployment/docker.md.
feat:, fix:, chore:, docs:, ...).feature/*, fix/*, chore/*, mergeadas a main vía PR.pre-commit corre lint-staged.pre-push corre lint + type-check + test.src/features/{nombre}/ o src/shared/.📖 Workflow completo:
docs/development/workflow.md.
| Sección | Qué contiene |
|---|---|
| 🏛️ Architecture | Screaming Architecture, islands, API client, diagramas |
| 🎵 Product | Visión, features implementadas y pendientes |
| 📊 Data | Tipos TS, DTOs Go, contratos de API, mappers |
| 🛠️ Development | Setup, scripts, Git workflow |
| 🚀 Deployment | Docker, Dokploy, CI/CD, environments |
| 🔒 Security | CSP, headers, consideraciones |
| 🧪 Testing | Estrategia con Vitest + jsdom |
| 📖 Guides | Onboarding, troubleshooting |
Índice navegable: docs/README.md.
Este proyecto es personal. Me hacía ilusión llevarlo a cabo tanto como aprendizaje (Astro, islands, Screaming Architecture, backend Go, Zustand, Tailwind 4) como para tener un espacio donde guardar ordenadamente mi música favorita de anime. No pretende competir con Spotify — pretende ser mi Spotify.
— Álvaro