mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
Merge 08c51938680275ad7d265f14d4cbb5a9db13a4bc into 9b862e88337e5f3fe7f8d8d6ed1fdac2c6c5151b
This commit is contained in:
commit
29dee00126
77
CLAUDE.md
Normal file
77
CLAUDE.md
Normal file
@ -0,0 +1,77 @@
|
||||
# Navidrome — Development Notes
|
||||
|
||||
## Running with Docker Compose
|
||||
|
||||
The dev stack splits the backend and frontend into separate services with hot-reload.
|
||||
|
||||
```bash
|
||||
# First run (builds Go image, downloads all deps — takes a few minutes)
|
||||
docker compose -f docker-compose.dev.yml up --build
|
||||
|
||||
# Subsequent runs
|
||||
docker compose -f docker-compose.dev.yml up
|
||||
```
|
||||
|
||||
| Service | Port | Description |
|
||||
|----------|------|--------------------------------------|
|
||||
| backend | 4633 | Go server with reflex hot-reload |
|
||||
| frontend | 4533 | Vite dev server — open this in browser |
|
||||
|
||||
The frontend proxies `/auth`, `/api`, `/rest`, and `/backgrounds` requests to the backend automatically.
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml logs -f backend # backend logs
|
||||
docker compose -f docker-compose.dev.yml logs -f frontend # frontend logs
|
||||
docker compose -f docker-compose.dev.yml down # stop everything
|
||||
```
|
||||
|
||||
## Running without Docker
|
||||
|
||||
```bash
|
||||
make setup # one-time: installs Go and Node dependencies
|
||||
make dev # starts both services with hot-reload
|
||||
```
|
||||
|
||||
## Database
|
||||
|
||||
Navidrome uses **SQLite only** — there is no Postgres or MySQL support. The database file is persisted in `./data/navidrome.db` (bind-mounted into the container at `/data`).
|
||||
|
||||
### Inspecting the database
|
||||
|
||||
From your host (requires `sqlite3`):
|
||||
|
||||
```bash
|
||||
sqlite3 ./data/navidrome.db
|
||||
```
|
||||
|
||||
From inside the running backend container:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml exec backend sqlite3 /data/navidrome.db
|
||||
```
|
||||
|
||||
Useful SQLite commands:
|
||||
|
||||
```sql
|
||||
.tables -- list all tables
|
||||
.schema media_file -- show a table's schema
|
||||
SELECT * FROM user; -- query data
|
||||
.quit -- exit
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
App config lives in `navidrome.toml`. Environment variables (prefixed `ND_`) override it. The Docker Compose file sets:
|
||||
|
||||
| Variable | Value |
|
||||
|-----------------------------|-------------------|
|
||||
| `ND_PORT` | `4633` |
|
||||
| `ND_MUSICFOLDER` | `/music` |
|
||||
| `ND_DATAFOLDER` | `/data` |
|
||||
| `ND_LOGLEVEL` | `info` |
|
||||
| `ND_ENABLEINSIGHTSCOLLECTOR`| `false` |
|
||||
| `ND_DEVAUTOCREATEADMINPASSWORD` | `admin` |
|
||||
|
||||
The default dev credentials are **`admin` / `admin`** (set via `ND_DEVAUTOCREATEADMINPASSWORD`).
|
||||
|
||||
The music folder maps to `./music` (project root) inside the container. Drop audio files there and the scanner will pick them up.
|
||||
7
Dockerfile.dev
Normal file
7
Dockerfile.dev
Normal file
@ -0,0 +1,7 @@
|
||||
FROM golang:1.26
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
sqlite3 libsqlite3-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /workspace
|
||||
62
README.md
62
README.md
@ -29,6 +29,68 @@ please file a [GitHub issue](https://github.com/navidrome/navidrome/issues) or j
|
||||
[themes](https://www.navidrome.org/docs/developers/creating-themes)), please join the chat in our
|
||||
[Discord server](https://discord.gg/xh7j7yF).
|
||||
|
||||
## Local Development
|
||||
|
||||
The easiest way to run the full stack locally is with Docker Compose. It starts the Go backend and Vite frontend as separate services with hot-reload enabled.
|
||||
|
||||
**Prerequisites:** Docker and Docker Compose.
|
||||
|
||||
```bash
|
||||
# First run — builds the Go image and downloads all dependencies
|
||||
docker compose -f docker-compose.dev.yml up --build
|
||||
|
||||
# Subsequent runs
|
||||
docker compose -f docker-compose.dev.yml up
|
||||
```
|
||||
|
||||
Open **http://localhost:4533**.
|
||||
|
||||
### Default credentials
|
||||
|
||||
| Username | Password |
|
||||
|----------|----------|
|
||||
| `admin` | `admin` |
|
||||
|
||||
### Adding music
|
||||
|
||||
Place your audio files in the `music/` folder at the project root. If the folder doesn't exist yet, create it first:
|
||||
|
||||
```bash
|
||||
mkdir music
|
||||
```
|
||||
|
||||
The scanner picks up new files automatically.
|
||||
|
||||
```bash
|
||||
# Run in the background
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
|
||||
# Follow logs
|
||||
docker compose -f docker-compose.dev.yml logs -f
|
||||
|
||||
# Stop
|
||||
docker compose -f docker-compose.dev.yml down
|
||||
```
|
||||
|
||||
Both services support hot-reload: editing `.go` files restarts the backend via `reflex`, and editing frontend source triggers Vite's HMR.
|
||||
|
||||
### Inspecting the database
|
||||
|
||||
Open a SQLite shell inside the running backend container:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml exec backend sqlite3 /data/navidrome.db
|
||||
```
|
||||
|
||||
Useful commands once inside:
|
||||
|
||||
```sql
|
||||
.tables -- list all tables
|
||||
.schema media_file -- show a table's schema
|
||||
SELECT * FROM user; -- query data
|
||||
.quit -- exit
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
See instructions on the [project's website](https://www.navidrome.org/docs/installation/)
|
||||
|
||||
51
docker-compose.dev.yml
Normal file
51
docker-compose.dev.yml
Normal file
@ -0,0 +1,51 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- "4633:4633"
|
||||
volumes:
|
||||
- .:/workspace
|
||||
- ./music:/music:ro
|
||||
- ./data:/data
|
||||
- go-mod-cache:/go/pkg/mod
|
||||
- go-build-cache:/root/.cache/go-build
|
||||
environment:
|
||||
ND_PORT: "4633"
|
||||
ND_MUSICFOLDER: /music
|
||||
ND_DATAFOLDER: /data
|
||||
ND_LOGLEVEL: info
|
||||
ND_ENABLEINSIGHTSCOLLECTOR: "false"
|
||||
ND_DEVAUTOCREATEADMINPASSWORD: "admin"
|
||||
command: sh -c "mkdir -p ui/build/3rdparty && touch ui/build/3rdparty/placeholder && go mod download && go tool reflex -d none -c reflex.conf"
|
||||
|
||||
frontend:
|
||||
image: node:24
|
||||
working_dir: /app
|
||||
ports:
|
||||
- "4533:4533"
|
||||
volumes:
|
||||
- ./ui:/app
|
||||
- node-modules:/app/node_modules
|
||||
environment:
|
||||
PORT: "4533"
|
||||
BACKEND_HOST: backend
|
||||
command: sh -c "npm ci && npm start"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
frontend-test:
|
||||
image: node:24
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./ui:/app
|
||||
- node-modules:/app/node_modules
|
||||
command: sh -c "npm ci && npm test"
|
||||
profiles:
|
||||
- test
|
||||
|
||||
volumes:
|
||||
go-mod-cache:
|
||||
go-build-cache:
|
||||
node-modules:
|
||||
238
docs/DIAGRAMA_ER.md
Normal file
238
docs/DIAGRAMA_ER.md
Normal file
@ -0,0 +1,238 @@
|
||||
# Diagrama Entidad-Relación: Navidrome
|
||||
|
||||
## Descripción General
|
||||
|
||||
Navidrome es un servidor de música personal con soporte para múltiples usuarios, librerías, playlists, ratings y compartición. El esquema SQLite está optimizado para búsqueda full-text, scrobbling a servicios externos y reproducción streaming.
|
||||
|
||||
## Diagrama ER
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
USER ||--o{ ANNOTATION : "rating/stars"
|
||||
USER ||--o{ PLAYER : "owns"
|
||||
USER ||--o{ PLAYQUEUE : "has"
|
||||
USER ||--o{ PLAYLIST : "creates"
|
||||
USER ||--o{ BOOKMARK : "creates"
|
||||
USER ||--o{ USER_PROPS : "has"
|
||||
USER ||--o{ SHARE : "creates"
|
||||
USER ||--o{ USER_LIBRARY : "accesses"
|
||||
|
||||
ARTIST ||--o{ ALBUM : "releases"
|
||||
ARTIST ||--o{ MEDIA_FILE : "performs"
|
||||
ARTIST ||--o{ ARTIST_GENRES : "has"
|
||||
ARTIST ||--o{ LIBRARY_ARTIST : "belongs_to"
|
||||
|
||||
ALBUM ||--o{ MEDIA_FILE : "contains"
|
||||
ALBUM ||--o{ ANNOTATION : "rates"
|
||||
ALBUM ||--o{ ALBUM_GENRES : "categorized"
|
||||
ALBUM ||--o{ ALBUM_ARTISTS : "has_artists"
|
||||
|
||||
MEDIA_FILE ||--o{ ANNOTATION : "plays"
|
||||
MEDIA_FILE ||--o{ PLAYLIST_TRACKS : "part_of"
|
||||
MEDIA_FILE ||--o{ BOOKMARK : "bookmarks"
|
||||
MEDIA_FILE ||--o{ PLAYQUEUE : "queued"
|
||||
MEDIA_FILE ||--o{ MEDIA_FILE_GENRES : "categorized"
|
||||
MEDIA_FILE ||--o{ MEDIA_FILE_ARTISTS : "has_artists"
|
||||
|
||||
PLAYLIST ||--o{ PLAYLIST_TRACKS : "contains"
|
||||
PLAYLIST_TRACKS ||--o{ MEDIA_FILE : "references"
|
||||
|
||||
GENRE ||--o{ ALBUM_GENRES : "labels"
|
||||
GENRE ||--o{ MEDIA_FILE_GENRES : "labels"
|
||||
GENRE ||--o{ ARTIST_GENRES : "labels"
|
||||
|
||||
LIBRARY ||--o{ LIBRARY_ARTIST : "contains"
|
||||
LIBRARY ||--o{ LIBRARY_TAG : "categorizes"
|
||||
LIBRARY ||--o{ USER_LIBRARY : "accessed_by"
|
||||
|
||||
FOLDER ||--o{ MEDIA_FILE : "contains"
|
||||
|
||||
TAG ||--o{ LIBRARY_TAG : "is_library_tag"
|
||||
|
||||
PLAYQUEUE ||--o{ MEDIA_FILE : "queue_item"
|
||||
|
||||
PLAYER ||--o{ TRANSCODING : "uses"
|
||||
|
||||
SCROBBLE_BUFFER ||--o{ ANNOTATION : "submit_to_service"
|
||||
|
||||
RADIO : radio_id PK
|
||||
RADIO : name
|
||||
RADIO : stream_url
|
||||
RADIO : homepage_url
|
||||
|
||||
USER : user_id PK
|
||||
USER : user_name UK
|
||||
USER : password
|
||||
USER : is_admin
|
||||
USER : created_at
|
||||
|
||||
ARTIST : artist_id PK
|
||||
ARTIST : name
|
||||
ARTIST : album_count
|
||||
|
||||
ALBUM : album_id PK
|
||||
ALBUM : name
|
||||
ALBUM : artist_id FK
|
||||
ALBUM : year
|
||||
ALBUM : duration
|
||||
ALBUM : genre
|
||||
|
||||
MEDIA_FILE : media_file_id PK
|
||||
MEDIA_FILE : path
|
||||
MEDIA_FILE : title
|
||||
MEDIA_FILE : album_id FK
|
||||
MEDIA_FILE : artist_id FK
|
||||
MEDIA_FILE : duration
|
||||
MEDIA_FILE : bit_rate
|
||||
MEDIA_FILE : created_at
|
||||
|
||||
PLAYLIST : playlist_id PK
|
||||
PLAYLIST : name
|
||||
PLAYLIST : owner FK "user_id"
|
||||
PLAYLIST : public
|
||||
PLAYLIST : created_at
|
||||
|
||||
PLAYLIST_TRACKS : playlist_id FK
|
||||
PLAYLIST_TRACKS : media_file_id FK
|
||||
PLAYLIST_TRACKS : track_position
|
||||
|
||||
ANNOTATION : user_id FK
|
||||
ANNOTATION : item_id
|
||||
ANNOTATION : item_type
|
||||
ANNOTATION : play_count
|
||||
ANNOTATION : rating
|
||||
ANNOTATION : starred
|
||||
|
||||
PLAYER : player_id PK
|
||||
PLAYER : user_id FK
|
||||
PLAYER : name
|
||||
|
||||
PLAYQUEUE : playqueue_id PK
|
||||
PLAYQUEUE : user_id FK
|
||||
PLAYQUEUE : media_file_id FK
|
||||
PLAYQUEUE : current_index
|
||||
|
||||
BOOKMARK : bookmark_id PK
|
||||
BOOKMARK : user_id FK
|
||||
BOOKMARK : media_file_id FK
|
||||
BOOKMARK : position
|
||||
|
||||
TRANSCODING : transcoding_id PK
|
||||
TRANSCODING : name
|
||||
TRANSCODING : target_format
|
||||
|
||||
GENRE : genre_id PK
|
||||
GENRE : name
|
||||
|
||||
ALBUM_GENRES : album_id FK
|
||||
ALBUM_GENRES : genre_id FK
|
||||
|
||||
MEDIA_FILE_GENRES : media_file_id FK
|
||||
MEDIA_FILE_GENRES : genre_id FK
|
||||
|
||||
ARTIST_GENRES : artist_id FK
|
||||
ARTIST_GENRES : genre_id FK
|
||||
|
||||
LIBRARY : library_id PK
|
||||
LIBRARY : name
|
||||
LIBRARY : path
|
||||
|
||||
LIBRARY_ARTIST : library_id FK
|
||||
LIBRARY_ARTIST : artist_id FK
|
||||
|
||||
LIBRARY_TAG : library_id FK
|
||||
LIBRARY_TAG : tag_id FK
|
||||
|
||||
USER_LIBRARY : user_id FK
|
||||
USER_LIBRARY : library_id FK
|
||||
|
||||
TAG : tag_id PK
|
||||
TAG : name
|
||||
|
||||
FOLDER : folder_id PK
|
||||
FOLDER : path
|
||||
FOLDER : parent_path
|
||||
|
||||
ALBUM_ARTISTS : album_id FK
|
||||
ALBUM_ARTISTS : artist_id FK
|
||||
|
||||
MEDIA_FILE_ARTISTS : media_file_id FK
|
||||
MEDIA_FILE_ARTISTS : artist_id FK
|
||||
|
||||
SHARE : share_id PK
|
||||
SHARE : name
|
||||
SHARE : resource_type
|
||||
SHARE : created_by FK "user_id"
|
||||
|
||||
SCROBBLE_BUFFER : scrobble_id PK
|
||||
SCROBBLE_BUFFER : user_id FK
|
||||
SCROBBLE_BUFFER : media_file_id FK
|
||||
|
||||
USER_PROPS : user_id FK
|
||||
USER_PROPS : property_name
|
||||
```
|
||||
|
||||
## Dominios Principales
|
||||
|
||||
### 🔐 Usuarios & Acceso
|
||||
- **USER**: Información de usuarios del sistema (admin, credenciales, timestamps)
|
||||
- **USER_PROPS**: Propiedades personalizadas de cada usuario
|
||||
- **PLAYER**: Reproductores/dispositivos registrados por usuario
|
||||
- **USER_LIBRARY**: Control de acceso a librerías
|
||||
|
||||
### 🎵 Contenido Musical
|
||||
- **ARTIST**: Artistas individuales
|
||||
- **ALBUM**: Álbumes con metadatos (año, duración, género)
|
||||
- **MEDIA_FILE**: Canciones/archivos de audio (path, bitrate, duration)
|
||||
- **FOLDER**: Estructura de carpetas del sistema de archivos
|
||||
- **GENRE**: Géneros musicales
|
||||
|
||||
### 🏷️ Relaciones Múltiples
|
||||
- **ALBUM_ARTISTS, MEDIA_FILE_ARTISTS**: Soporte para múltiples artistas
|
||||
- **{ALBUM|MEDIA_FILE|ARTIST}_GENRES**: Géneros en tabla de unión
|
||||
- **LIBRARY_ARTIST, LIBRARY_TAG**: Agrupaciones por librería
|
||||
|
||||
### ▶️ Reproducción & Escucha
|
||||
- **PLAYLIST**: Listas de reproducción (públicas o privadas)
|
||||
- **PLAYLIST_TRACKS**: Tabla de unión que preserva orden
|
||||
- **PLAYQUEUE**: Cola actual del usuario
|
||||
- **BOOKMARK**: Posiciones guardadas en canciones
|
||||
- **ANNOTATION**: Ratings, play_count, starred (polimórfico)
|
||||
|
||||
### 🌐 Distribución & Integración
|
||||
- **SHARE**: Enlaces compartidos con expiración y contador de visitas
|
||||
- **TRANSCODING**: Configuraciones de transcodificación (MP3, WEBM, etc.)
|
||||
- **SCROBBLE_BUFFER**: Buffer para enviar plays a Last.fm, ListenBrainz
|
||||
- **RADIO**: Radios de internet
|
||||
|
||||
### 📚 Multi-Librería
|
||||
- **LIBRARY**: Colecciones de música independientes
|
||||
- **USER_LIBRARY**: Asignación de usuarios a librerías
|
||||
- **LIBRARY_TAG**: Tags a nivel de librería
|
||||
|
||||
## Características del Diseño
|
||||
|
||||
1. ✅ **Foreign Keys con Integridad Referencial**: Eliminación en cascada configurada
|
||||
2. ✅ **Tablas de Unión**: Soporta relaciones muchos-a-muchos (`PLAYLIST_TRACKS`, géneros)
|
||||
3. ✅ **Anotaciones Polimórficas**: Una tabla `ANNOTATION` para múltiples entidades
|
||||
4. ✅ **Usuario Central**: Presente en `ANNOTATION`, `PLAYQUEUE`, `PLAYLIST`, `BOOKMARK`
|
||||
5. ✅ **Timestamps de Auditoría**: `created_at`, `updated_at` en tablas principales
|
||||
6. ✅ **Full-Text Search**: Índices en `NOCASE` para búsquedas rápidas
|
||||
7. ✅ **WAL Mode**: SQLite en Write-Ahead Logging para mejor concurrencia
|
||||
|
||||
## Para Tests de Base de Datos
|
||||
|
||||
Al probar persistencia, enfócate en:
|
||||
|
||||
- **Foreign Keys**: `PLAYLIST_TRACKS` → `MEDIA_FILE`, `ALBUM`, etc.
|
||||
- **Constraints Únicos**: `user_name`, `email` en `USER`
|
||||
- **Triggers** (si existen): Actualizaciones de contadores (`song_count`, `album_count`)
|
||||
- **Cascadas**: Eliminación de usuario → eliminación de anotaciones, playlists, etc.
|
||||
- **Orden en Uniones**: `PLAYLIST_TRACKS` preserva posición numérica
|
||||
|
||||
## Referencias
|
||||
|
||||
- Ubicación del DB: `./data/navidrome.db` (SQLite)
|
||||
- Migraciones: `db/migrations/`
|
||||
- Repositorios: `persistence/`
|
||||
- Modelos: `model/`
|
||||
251
docs/NAVIDROME_ER.drawio
Normal file
251
docs/NAVIDROME_ER.drawio
Normal file
@ -0,0 +1,251 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mxfile host="app.diagrams.net" modified="2026-05-14T10:00:00.000Z" agent="Mozilla/5.0" etag="navidrome_er">
|
||||
<diagram id="navidrome_er" name="Navidrome ER">
|
||||
<mxGraphModel dx="1236" dy="756" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" background="white" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
|
||||
<!-- Define estilos -->
|
||||
<mxCell id="entity_style" parent="0" vertex="1" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor=#9673a6;rounded=0;fontSize=11;fontStyle=1" visible="0"/>
|
||||
<mxCell id="attribute_style" parent="0" vertex="1" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10" visible="0"/>
|
||||
<mxCell id="pk_style" parent="0" vertex="1" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=10;fontStyle=1" visible="0"/>
|
||||
<mxCell id="fk_style" parent="0" vertex="1" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=10;fontStyle=0" visible="0"/>
|
||||
<mxCell id="relation_style" parent="0" edge="1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERmandOne;fillColor=#e1d5e7;strokeColor=#9673a6" visible="0"/>
|
||||
|
||||
<!-- TABLA: USER -->
|
||||
<mxCell id="user_entity" parent="1" vertex="1" value="USER" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor=#9673a6;rounded=0;fontSize=13;fontStyle=1;verticalLabelPosition=top;verticalAlign=top;align=left;spacingLeft=5;spacingTop=5;spacingBottom=5;spacing=0">
|
||||
<mxGeometry x="50" y="50" width="180" height="120" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="user_pk" parent="1" vertex="1" value="PK user_id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=10;fontStyle=1;align=left;spacingLeft=5">
|
||||
<mxGeometry x="50" y="70" width="180" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="user_a1" parent="1" vertex="1" value="user_name (UK)" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="50" y="90" width="180" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="user_a2" parent="1" vertex="1" value="password" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="50" y="110" width="180" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="user_a3" parent="1" vertex="1" value="is_admin, created_at" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="50" y="130" width="180" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- TABLA: ARTIST -->
|
||||
<mxCell id="artist_entity" parent="1" vertex="1" value="ARTIST" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor=#9673a6;rounded=0;fontSize=13;fontStyle=1;verticalLabelPosition=top;verticalAlign=top;align=left;spacingLeft=5;spacingTop=5;spacingBottom=5;spacing=0">
|
||||
<mxGeometry x="350" y="50" width="180" height="90" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="artist_pk" parent="1" vertex="1" value="PK artist_id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=10;fontStyle=1;align=left;spacingLeft=5">
|
||||
<mxGeometry x="350" y="70" width="180" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="artist_a1" parent="1" vertex="1" value="name" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="350" y="90" width="180" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="artist_a2" parent="1" vertex="1" value="album_count" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="350" y="110" width="180" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- TABLA: ALBUM -->
|
||||
<mxCell id="album_entity" parent="1" vertex="1" value="ALBUM" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor=#9673a6;rounded=0;fontSize=13;fontStyle=1;verticalLabelPosition=top;verticalAlign=top;align=left;spacingLeft=5;spacingTop=5;spacingBottom=5;spacing=0">
|
||||
<mxGeometry x="650" y="50" width="180" height="120" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="album_pk" parent="1" vertex="1" value="PK album_id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=10;fontStyle=1;align=left;spacingLeft=5">
|
||||
<mxGeometry x="650" y="70" width="180" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="album_a1" parent="1" vertex="1" value="name" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="650" y="90" width="180" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="album_fk" parent="1" vertex="1" value="FK artist_id" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="650" y="110" width="180" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="album_a2" parent="1" vertex="1" value="year, duration, genre" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="650" y="130" width="180" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- TABLA: MEDIA_FILE -->
|
||||
<mxCell id="mediafile_entity" parent="1" vertex="1" value="MEDIA_FILE" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor=#9673a6;rounded=0;fontSize=13;fontStyle=1;verticalLabelPosition=top;verticalAlign=top;align=left;spacingLeft=5;spacingTop=5;spacingBottom=5;spacing=0">
|
||||
<mxGeometry x="950" y="50" width="200" height="140" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="mediafile_pk" parent="1" vertex="1" value="PK media_file_id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=10;fontStyle=1;align=left;spacingLeft=5">
|
||||
<mxGeometry x="950" y="70" width="200" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="mediafile_a1" parent="1" vertex="1" value="path, title" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="950" y="90" width="200" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="mediafile_fk1" parent="1" vertex="1" value="FK album_id, artist_id" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="950" y="110" width="200" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="mediafile_a2" parent="1" vertex="1" value="duration, bit_rate, genre" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="950" y="130" width="200" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- TABLA: PLAYLIST -->
|
||||
<mxCell id="playlist_entity" parent="1" vertex="1" value="PLAYLIST" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor=#9673a6;rounded=0;fontSize=13;fontStyle=1;verticalLabelPosition=top;verticalAlign=top;align=left;spacingLeft=5;spacingTop=5;spacingBottom=5;spacing=0">
|
||||
<mxGeometry x="50" y="250" width="180" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="playlist_pk" parent="1" vertex="1" value="PK playlist_id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=10;fontStyle=1;align=left;spacingLeft=5">
|
||||
<mxGeometry x="50" y="270" width="180" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="playlist_a1" parent="1" vertex="1" value="name, comment" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="50" y="290" width="180" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="playlist_fk" parent="1" vertex="1" value="FK owner (user_id)" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="50" y="310" width="180" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="playlist_a2" parent="1" vertex="1" value="public, created_at" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="50" y="330" width="180" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- TABLA: PLAYLIST_TRACKS -->
|
||||
<mxCell id="playlisttracks_entity" parent="1" vertex="1" value="PLAYLIST_TRACKS" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor=#9673a6;rounded=0;fontSize=13;fontStyle=1;verticalLabelPosition=top;verticalAlign=top;align=left;spacingLeft=5;spacingTop=5;spacingBottom=5;spacing=0">
|
||||
<mxGeometry x="350" y="250" width="200" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="playlisttracks_fk1" parent="1" vertex="1" value="FK playlist_id (PK)" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=10;fontStyle=1;align=left;spacingLeft=5">
|
||||
<mxGeometry x="350" y="270" width="200" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="playlisttracks_fk2" parent="1" vertex="1" value="FK media_file_id (PK)" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=10;fontStyle=1;align=left;spacingLeft=5">
|
||||
<mxGeometry x="350" y="290" width="200" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="playlisttracks_a1" parent="1" vertex="1" value="track_position" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="350" y="310" width="200" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- TABLA: ANNOTATION -->
|
||||
<mxCell id="annotation_entity" parent="1" vertex="1" value="ANNOTATION" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=13;fontStyle=1;verticalLabelPosition=top;verticalAlign=top;align=left;spacingLeft=5;spacingTop=5;spacingBottom=5;spacing=0">
|
||||
<mxGeometry x="650" y="250" width="200" height="120" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="annotation_pk" parent="1" vertex="1" value="PK ann_id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=10;fontStyle=1;align=left;spacingLeft=5">
|
||||
<mxGeometry x="650" y="270" width="200" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="annotation_fk" parent="1" vertex="1" value="FK user_id, item_id" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="650" y="290" width="200" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="annotation_a1" parent="1" vertex="1" value="item_type (polimórfico)" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor:#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="650" y="310" width="200" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="annotation_a2" parent="1" vertex="1" value="play_count, rating, starred" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="650" y="330" width="200" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- TABLA: PLAYQUEUE -->
|
||||
<mxCell id="playqueue_entity" parent="1" vertex="1" value="PLAYQUEUE" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor=#9673a6;rounded=0;fontSize=13;fontStyle=1;verticalLabelPosition=top;verticalAlign=top;align=left;spacingLeft=5;spacingTop=5;spacingBottom=5;spacing=0">
|
||||
<mxGeometry x="950" y="250" width="200" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="playqueue_pk" parent="1" vertex="1" value="PK playqueue_id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=10;fontStyle=1;align=left;spacingLeft=5">
|
||||
<mxGeometry x="950" y="270" width="200" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="playqueue_fk1" parent="1" vertex="1" value="FK user_id" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="950" y="290" width="200" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="playqueue_fk2" parent="1" vertex="1" value="FK media_file_id" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="950" y="310" width="200" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="playqueue_a1" parent="1" vertex="1" value="current_index" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="950" y="330" width="200" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- TABLA: GENRE -->
|
||||
<mxCell id="genre_entity" parent="1" vertex="1" value="GENRE" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor=#9673a6;rounded=0;fontSize=13;fontStyle=1;verticalLabelPosition=top;verticalAlign=top;align=left;spacingLeft=5;spacingTop=5;spacingBottom=5;spacing=0">
|
||||
<mxGeometry x="350" y="450" width="150" height="70" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="genre_pk" parent="1" vertex="1" value="PK genre_id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=10;fontStyle=1;align=left;spacingLeft=5">
|
||||
<mxGeometry x="350" y="470" width="150" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="genre_a1" parent="1" vertex="1" value="name" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="350" y="490" width="150" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- TABLA: LIBRARY -->
|
||||
<mxCell id="library_entity" parent="1" vertex="1" value="LIBRARY" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=13;fontStyle=1;verticalLabelPosition=top;verticalAlign=top;align=left;spacingLeft=5;spacingTop=5;spacingBottom=5;spacing=0">
|
||||
<mxGeometry x="650" y="450" width="180" height="90" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="library_pk" parent="1" vertex="1" value="PK library_id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=10;fontStyle=1;align=left;spacingLeft=5">
|
||||
<mxGeometry x="650" y="470" width="180" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="library_a1" parent="1" vertex="1" value="name, path" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="650" y="490" width="180" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- TABLA: SHARE -->
|
||||
<mxCell id="share_entity" parent="1" vertex="1" value="SHARE" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=13;fontStyle=1;verticalLabelPosition=top;verticalAlign=top;align=left;spacingLeft=5;spacingTop=5;spacingBottom=5;spacing=0">
|
||||
<mxGeometry x="950" y="450" width="200" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="share_pk" parent="1" vertex="1" value="PK share_id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=10;fontStyle=1;align=left;spacingLeft=5">
|
||||
<mxGeometry x="950" y="470" width="200" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="share_fk" parent="1" vertex="1" value="FK created_by (user_id)" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="950" y="490" width="200" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="share_a1" parent="1" vertex="1" value="name, expires, visit_count" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="950" y="510" width="200" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- RELACIONES -->
|
||||
<!-- ARTIST -> ALBUM -->
|
||||
<mxCell id="r1" parent="1" edge="1" source="artist_entity" target="album_entity" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERmandOne;fillColor=#e1d5e7;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- ALBUM -> MEDIA_FILE -->
|
||||
<mxCell id="r2" parent="1" edge="1" source="album_entity" target="mediafile_entity" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERmandOne;fillColor=#e1d5e7;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- USER -> PLAYLIST -->
|
||||
<mxCell id="r3" parent="1" edge="1" source="user_entity" target="playlist_entity" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERmandOne;fillColor=#e1d5e7;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- PLAYLIST -> PLAYLIST_TRACKS -->
|
||||
<mxCell id="r4" parent="1" edge="1" source="playlist_entity" target="playlisttracks_entity" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERmandOne;fillColor=#e1d5e7;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- MEDIA_FILE -> PLAYLIST_TRACKS -->
|
||||
<mxCell id="r5" parent="1" edge="1" source="mediafile_entity" target="playlisttracks_entity" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERmandOne;fillColor=#e1d5e7;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- USER -> ANNOTATION -->
|
||||
<mxCell id="r6" parent="1" edge="1" source="user_entity" target="annotation_entity" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERmandOne;fillColor=#e1d5e7;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- USER -> PLAYQUEUE -->
|
||||
<mxCell id="r7" parent="1" edge="1" source="user_entity" target="playqueue_entity" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERmandOne;fillColor=#e1d5e7;strokeColor:#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- MEDIA_FILE -> PLAYQUEUE -->
|
||||
<mxCell id="r8" parent="1" edge="1" source="mediafile_entity" target="playqueue_entity" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERmandOne;fillColor=#e1d5e7;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- USER -> SHARE -->
|
||||
<mxCell id="r9" parent="1" edge="1" source="user_entity" target="share_entity" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERmandOne;fillColor=#e1d5e7;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- LEYENDA -->
|
||||
<mxCell id="legend_title" parent="1" vertex="1" value="LEYENDA" style="shape=rectangle;filled=0;strokeColor=none;rounded=0;fontSize=12;fontStyle=1;align=left">
|
||||
<mxGeometry x="50" y="600" width="100" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="legend_pk" parent="1" vertex="1" value="PK = Primary Key" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="50" y="630" width="150" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="legend_fk" parent="1" vertex="1" value="FK = Foreign Key" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="50" y="660" width="150" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="legend_attr" parent="1" vertex="1" value="Atributo regular" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=10;align=left;spacingLeft=5">
|
||||
<mxGeometry x="250" y="630" width="150" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="legend_rel" parent="1" vertex="1" value="1:N = One-to-Many" style="shape=rectangle;filled=0;strokeColor=#9673a6;rounded=0;fontSize=10;align=left">
|
||||
<mxGeometry x="250" y="660" width="150" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="note" parent="1" vertex="1" value="Navidrome - Diagrama Entidad Relación
SQLite con Foreign Keys habilitadas
Actualizado: 2026-05-14" style="shape=rectangle;filled=1;fillColor=#e8f4f8;strokeColor=#333333;rounded=0;fontSize=9;align=left;spacingLeft=5;dashed=1">
|
||||
<mxGeometry x="50" y="690" width="1100" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
529
docs/NAVIDROME_ER_COMPLETE.drawio
Normal file
529
docs/NAVIDROME_ER_COMPLETE.drawio
Normal file
@ -0,0 +1,529 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mxfile host="app.diagrams.net" modified="2026-05-14T10:00:00.000Z" agent="Mozilla/5.0" version="24.0.0" type="device">
|
||||
<diagram id="navidrome_complete_er" name="Navidrome Complete ER">
|
||||
<mxGraphModel dx="2000" dy="1400" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="2000" pageHeight="1400" background="white" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
|
||||
<!-- USER table -->
|
||||
<mxCell id="user_title" parent="1" vertex="1" value="USER" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor=#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
|
||||
<mxGeometry x="20" y="20" width="180" height="25" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="user_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="20" y="45" width="180" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="user_uk_user_name" parent="1" vertex="1" value="UK user_name" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="20" y="60" width="180" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="user_uk_email" parent="1" vertex="1" value="UK email" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="20" y="75" width="180" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="user_cols" parent="1" vertex="1" value="name, password, is_admin
last_login_at, last_access_at
created_at, updated_at" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
|
||||
<mxGeometry x="20" y="90" width="180" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- ARTIST table -->
|
||||
<mxCell id="artist_title" parent="1" vertex="1" value="ARTIST" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
|
||||
<mxGeometry x="250" y="20" width="180" height="25" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="artist_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="250" y="45" width="180" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="artist_cols" parent="1" vertex="1" value="name, album_count, artist_count
folder_count, full_text" style="shape=rectangle;filled=1;fillColor:#f5f5f5;strokeColor=#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
|
||||
<mxGeometry x="250" y="60" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- LIBRARY table -->
|
||||
<mxCell id="library_title" parent="1" vertex="1" value="LIBRARY" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
|
||||
<mxGeometry x="480" y="20" width="180" height="25" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="library_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor:#fff2cc;strokeColor:#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="480" y="45" width="180" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="library_cols" parent="1" vertex="1" value="name, path
last_scan_started_at
full_scan_in_progress" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
|
||||
<mxGeometry x="480" y="60" width="180" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- ALBUM table -->
|
||||
<mxCell id="album_title" parent="1" vertex="1" value="ALBUM" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
|
||||
<mxGeometry x="710" y="20" width="200" height="25" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="album_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor:#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="710" y="45" width="200" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="album_fk_artist" parent="1" vertex="1" value="FK artist_id → ARTIST" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="710" y="60" width="200" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="album_cols" parent="1" vertex="1" value="name, artist, album_artist, year
compilation, song_count, duration, genre
cover_art_path, mbid
created_at, updated_at" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor:#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
|
||||
<mxGeometry x="710" y="75" width="200" height="50" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- MEDIA_FILE table -->
|
||||
<mxCell id="mediafile_title" parent="1" vertex="1" value="MEDIA_FILE" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
|
||||
<mxGeometry x="960" y="20" width="220" height="25" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="mediafile_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor:#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="960" y="45" width="220" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="mediafile_fk_album" parent="1" vertex="1" value="FK album_id → ALBUM" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="960" y="60" width="220" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="mediafile_fk_artist" parent="1" vertex="1" value="FK artist_id → ARTIST | FK library_id → LIBRARY" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="960" y="75" width="220" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="mediafile_fk_folder" parent="1" vertex="1" value="FK folder_id → FOLDER" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="960" y="90" width="220" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="mediafile_cols" parent="1" vertex="1" value="path, title, track_number, disc_number
year, size, suffix, duration, bit_rate
genre, mbid, created_at, updated_at" style="shape=rectangle;filled=1;fillColor:#f5f5f5;strokeColor=#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
|
||||
<mxGeometry x="960" y="105" width="220" height="45" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- PLAYLIST table -->
|
||||
<mxCell id="playlist_title" parent="1" vertex="1" value="PLAYLIST" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
|
||||
<mxGeometry x="20" y="200" width="180" height="25" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="playlist_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor:#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="20" y="225" width="180" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="playlist_fk_owner" parent="1" vertex="1" value="FK owner → USER" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="20" y="240" width="180" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="playlist_fk_user" parent="1" vertex="1" value="FK user_id → USER" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="20" y="255" width="180" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="playlist_cols" parent="1" vertex="1" value="name, comment, duration
public, playlist_type, rules
image_file, created_at, updated_at" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor:#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
|
||||
<mxGeometry x="20" y="270" width="180" height="45" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- PLAYLIST_TRACKS table -->
|
||||
<mxCell id="playlisttracks_title" parent="1" vertex="1" value="PLAYLIST_TRACKS" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
|
||||
<mxGeometry x="250" y="200" width="210" height="25" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="playlisttracks_pk_id" parent="1" vertex="1" value="PK id (composite with playlist_id)" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="250" y="225" width="210" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="playlisttracks_fk_playlist" parent="1" vertex="1" value="FK playlist_id → PLAYLIST" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="250" y="240" width="210" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="playlisttracks_fk_mediafile" parent="1" vertex="1" value="FK media_file_id → MEDIA_FILE" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="250" y="255" width="210" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- ANNOTATION table -->
|
||||
<mxCell id="annotation_title" parent="1" vertex="1" value="ANNOTATION (Polimórfico)" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
|
||||
<mxGeometry x="510" y="200" width="210" height="25" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="annotation_fk_user" parent="1" vertex="1" value="FK user_id → USER" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="510" y="225" width="210" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="annotation_cols" parent="1" vertex="1" value="item_id, item_type (album|track|artist|etc)
play_count, play_date, rating
starred, starred_at, rating_date" style="shape=rectangle;filled=1;fillColor:#f5f5f5;strokeColor:#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
|
||||
<mxGeometry x="510" y="240" width="210" height="45" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- PLAYER table -->
|
||||
<mxCell id="player_title" parent="1" vertex="1" value="PLAYER" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
|
||||
<mxGeometry x="760" y="200" width="190" height="25" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="player_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor:#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="760" y="225" width="190" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="player_fk_user" parent="1" vertex="1" value="FK user_id → USER" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="760" y="240" width="190" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="player_fk_transoding" parent="1" vertex="1" value="FK transcoding_id → TRANSCODING" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="760" y="255" width="190" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="player_cols" parent="1" vertex="1" value="name, type, client, ip_address
last_seen, max_bit_rate" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
|
||||
<mxGeometry x="760" y="270" width="190" height="35" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- PLAYQUEUE table -->
|
||||
<mxCell id="playqueue_title" parent="1" vertex="1" value="PLAYQUEUE" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
|
||||
<mxGeometry x="1010" y="200" width="180" height="25" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="playqueue_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="1010" y="225" width="180" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="playqueue_fk_user" parent="1" vertex="1" value="FK user_id → USER" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="1010" y="240" width="180" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="playqueue_cols" parent="1" vertex="1" value="comment, current, position
changed_by, items, current_index
created_at, updated_at" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor:#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
|
||||
<mxGeometry x="1010" y="255" width="180" height="45" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- FOLDER table -->
|
||||
<mxCell id="folder_title" parent="1" vertex="1" value="FOLDER" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
|
||||
<mxGeometry x="20" y="380" width="180" height="25" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="folder_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor:#fff2cc;strokeColor:#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="20" y="405" width="180" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="folder_fk_library" parent="1" vertex="1" value="FK library_id → LIBRARY" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="20" y="420" width="180" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="folder_cols" parent="1" vertex="1" value="path, name, missing
parent_id, num_audio_files
num_playlists, image_files
created_at, updated_at" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor:#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
|
||||
<mxGeometry x="20" y="435" width="180" height="50" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- BOOKMARK table -->
|
||||
<mxCell id="bookmark_title" parent="1" vertex="1" value="BOOKMARK" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
|
||||
<mxGeometry x="250" y="380" width="180" height="25" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="bookmark_fk_user" parent="1" vertex="1" value="FK user_id → USER" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="250" y="405" width="180" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="bookmark_cols" parent="1" vertex="1" value="item_id, item_type
comment, position, changed_by
created_at, updated_at" style="shape=rectangle;filled=1;fillColor:#f5f5f5;strokeColor:#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
|
||||
<mxGeometry x="250" y="420" width="180" height="45" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- TRANSCODING table -->
|
||||
<mxCell id="transcoding_title" parent="1" vertex="1" value="TRANSCODING" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
|
||||
<mxGeometry x="480" y="380" width="180" height="25" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="transcoding_pk_id" parent="1" vertex="1" value="PK id (UK: target_format)" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="480" y="405" width="180" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="transcoding_cols" parent="1" vertex="1" value="name (UK), target_format (UK)
command, default_bit_rate" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor:#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
|
||||
<mxGeometry x="480" y="420" width="180" height="35" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- SHARE table -->
|
||||
<mxCell id="share_title" parent="1" vertex="1" value="SHARE" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
|
||||
<mxGeometry x="710" y="380" width="200" height="25" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="share_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor:#fff2cc;strokeColor:#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="710" y="405" width="200" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="share_fk_user" parent="1" vertex="1" value="FK user_id → USER" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="710" y="420" width="200" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="share_cols" parent="1" vertex="1" value="name (UK), description, expires
created, last_visited, resource_ids
resource_type, visit_count
download, comments" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor:#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
|
||||
<mxGeometry x="710" y="435" width="200" height="50" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- SCROBBLE_BUFFER table -->
|
||||
<mxCell id="scrobble_title" parent="1" vertex="1" value="SCROBBLE_BUFFER" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
|
||||
<mxGeometry x="960" y="380" width="220" height="25" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="scrobble_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor:#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="960" y="405" width="220" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="scrobble_fk" parent="1" vertex="1" value="FK user_id → USER | FK media_file_id → MEDIA_FILE" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="960" y="420" width="220" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="scrobble_cols" parent="1" vertex="1" value="artist_name, album_name, track_name
play_time" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor:#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
|
||||
<mxGeometry x="960" y="435" width="220" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- USER_PROPS table -->
|
||||
<mxCell id="userprops_title" parent="1" vertex="1" value="USER_PROPS" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
|
||||
<mxGeometry x="20" y="550" width="180" height="25" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="userprops_fk_user" parent="1" vertex="1" value="FK user_id → USER" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="20" y="575" width="180" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="userprops_cols" parent="1" vertex="1" value="property_name, property_value" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor=#999999;rounded=0;fontSize=8;align=left;spacingLeft=5">
|
||||
<mxGeometry x="20" y="590" width="180" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- TAG table -->
|
||||
<mxCell id="tag_title" parent="1" vertex="1" value="TAG" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
|
||||
<mxGeometry x="250" y="550" width="150" height="25" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="tag_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor:#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="250" y="575" width="150" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="tag_cols" parent="1" vertex="1" value="tag_name, tag_value (UK)
album_count, media_file_count" style="shape=rectangle;filled=1;fillColor:#f5f5f5;strokeColor=#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
|
||||
<mxGeometry x="250" y="590" width="150" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- GENRE table -->
|
||||
<mxCell id="genre_title" parent="1" vertex="1" value="GENRE" style="shape=rectangle;filled=1;fillColor:#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
|
||||
<mxGeometry x="460" y="550" width="140" height="25" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="genre_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor:#fff2cc;strokeColor:#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="460" y="575" width="140" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="genre_cols" parent="1" vertex="1" value="name" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor:#999999;rounded=0;fontSize=8;align=left;spacingLeft=5">
|
||||
<mxGeometry x="460" y="590" width="140" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- RADIO table -->
|
||||
<mxCell id="radio_title" parent="1" vertex="1" value="RADIO" style="shape=rectangle;filled=1;fillColor=#e1d5e7;strokeColor:#9673a6;rounded=0;fontSize=12;fontStyle=1;spacingLeft=5;spacingTop=5">
|
||||
<mxGeometry x="660" y="550" width="180" height="25" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="radio_pk_id" parent="1" vertex="1" value="PK id" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor:#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="660" y="575" width="180" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="radio_cols" parent="1" vertex="1" value="name, stream_url, homepage_url" style="shape=rectangle;filled=1;fillColor=#f5f5f5;strokeColor:#999999;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
|
||||
<mxGeometry x="660" y="590" width="180" height="25" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- MANY-TO-MANY JUNCTION TABLES -->
|
||||
<!-- ALBUM_GENRES -->
|
||||
<mxCell id="albumgenres_title" parent="1" vertex="1" value="ALBUM_GENRES" style="shape=rectangle;filled=1;fillColor:#f0e6ff;strokeColor:#9673a6;rounded=0;fontSize=11;fontStyle=1;spacingLeft=5;spacingTop=3">
|
||||
<mxGeometry x="710" y="550" width="180" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="albumgenres_fk" parent="1" vertex="1" value="FK album_id, genre_id" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=8;align=left;spacingLeft=5">
|
||||
<mxGeometry x="710" y="570" width="180" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- MEDIA_FILE_GENRES -->
|
||||
<mxCell id="mediafilegenres_title" parent="1" vertex="1" value="MEDIA_FILE_GENRES" style="shape=rectangle;filled=1;fillColor:#f0e6ff;strokeColor:#9673a6;rounded=0;fontSize=11;fontStyle=1;spacingLeft=5;spacingTop=3">
|
||||
<mxGeometry x="910" y="550" width="210" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="mediafilegenres_fk" parent="1" vertex="1" value="FK media_file_id, genre_id" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=8;align=left;spacingLeft=5">
|
||||
<mxGeometry x="910" y="570" width="210" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- ARTIST_GENRES -->
|
||||
<mxCell id="artistgenres_title" parent="1" vertex="1" value="ARTIST_GENRES" style="shape=rectangle;filled=1;fillColor:#f0e6ff;strokeColor:#9673a6;rounded=0;fontSize=11;fontStyle=1;spacingLeft=5;spacingTop=3">
|
||||
<mxGeometry x="1160" y="550" width="190" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="artistgenres_fk" parent="1" vertex="1" value="FK artist_id, genre_id" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=8;align=left;spacingLeft=5">
|
||||
<mxGeometry x="1160" y="570" width="190" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- ALBUM_ARTISTS -->
|
||||
<mxCell id="albumartists_title" parent="1" vertex="1" value="ALBUM_ARTISTS" style="shape=rectangle;filled=1;fillColor:#f0e6ff;strokeColor:#9673a6;rounded=0;fontSize=11;fontStyle=1;spacingLeft=5;spacingTop=3">
|
||||
<mxGeometry x="20" y="680" width="190" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="albumartists_fk" parent="1" vertex="1" value="FK album_id, artist_id, role, sub_role" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
|
||||
<mxGeometry x="20" y="700" width="190" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- MEDIA_FILE_ARTISTS -->
|
||||
<mxCell id="mediafileartists_title" parent="1" vertex="1" value="MEDIA_FILE_ARTISTS" style="shape=rectangle;filled=1;fillColor:#f0e6ff;strokeColor:#9673a6;rounded=0;fontSize=11;fontStyle=1;spacingLeft=5;spacingTop=3">
|
||||
<mxGeometry x="240" y="680" width="220" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="mediafileartists_fk" parent="1" vertex="1" value="FK media_file_id, artist_id, role, sub_role" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
|
||||
<mxGeometry x="240" y="700" width="220" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- LIBRARY_ARTIST -->
|
||||
<mxCell id="libraryartist_title" parent="1" vertex="1" value="LIBRARY_ARTIST" style="shape=rectangle;filled=1;fillColor:#f0e6ff;strokeColor:#9673a6;rounded=0;fontSize=11;fontStyle=1;spacingLeft=5;spacingTop=3">
|
||||
<mxGeometry x="510" y="680" width="200" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="libraryartist_fk" parent="1" vertex="1" value="FK library_id, artist_id" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=8;align=left;spacingLeft=5">
|
||||
<mxGeometry x="510" y="700" width="200" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- LIBRARY_TAG -->
|
||||
<mxCell id="librarytag_title" parent="1" vertex="1" value="LIBRARY_TAG" style="shape=rectangle;filled=1;fillColor:#f0e6ff;strokeColor:#9673a6;rounded=0;fontSize=11;fontStyle=1;spacingLeft=5;spacingTop=3">
|
||||
<mxGeometry x="760" y="680" width="190" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="librarytag_fk" parent="1" vertex="1" value="FK library_id, tag_id" style="shape=rectangle;filled=1;fillColor:#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=8;align=left;spacingLeft=5">
|
||||
<mxGeometry x="760" y="700" width="190" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- USER_LIBRARY -->
|
||||
<mxCell id="userlibrary_title" parent="1" vertex="1" value="USER_LIBRARY" style="shape=rectangle;filled=1;fillColor:#f0e6ff;strokeColor:#9673a6;rounded=0;fontSize=11;fontStyle=1;spacingLeft=5;spacingTop=3">
|
||||
<mxGeometry x="1010" y="680" width="180" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="userlibrary_fk" parent="1" vertex="1" value="FK user_id, library_id" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor:#6c8ebf;rounded=0;fontSize=8;align=left;spacingLeft=5">
|
||||
<mxGeometry x="1010" y="700" width="180" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- LEGEND -->
|
||||
<mxCell id="legend_box" parent="1" vertex="1" value="" style="shape=rectangle;filled=0;strokeColor=#999999;rounded=0;fontSize=10;dashed=1">
|
||||
<mxGeometry x="20" y="770" width="1170" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="legend_title" parent="1" vertex="1" value="LEYENDA" style="shape=rectangle;filled=0;strokeColor=none;rounded=0;fontSize=11;fontStyle=1;align=left">
|
||||
<mxGeometry x="30" y="775" width="80" height="15" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="legend_pk" parent="1" vertex="1" value="PK = Primary Key (amarillo)" style="shape=rectangle;filled=1;fillColor=#fff2cc;strokeColor=#d6b656;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="30" y="795" width="200" height="18" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="legend_fk" parent="1" vertex="1" value="FK = Foreign Key (azul)" style="shape=rectangle;filled=1;fillColor=#dae8fc;strokeColor=#6c8ebf;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="260" y="795" width="200" height="18" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="legend_uk" parent="1" vertex="1" value="UK = Unique Key (amarillo turbio)" style="shape=rectangle;filled=0;strokeColor=#999999;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="490" y="795" width="220" height="18" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="legend_entity" parent="1" vertex="1" value="Entidad (púrpura/gris)" style="shape=rectangle;filled=1;fillColor=#f0e6ff;strokeColor=#9673a6;rounded=0;fontSize=9;align=left;spacingLeft=5">
|
||||
<mxGeometry x="760" y="795" width="180" height="18" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="legend_note" parent="1" vertex="1" value="• Foreign Keys con ON DELETE CASCADE (integridad referencial)
• ANNOTATION es polimórfico (item_id + item_type soportan múltiples entidades)
• SQLite con FTS5 para búsquedas full-text. Migraciones con Goose.
• 30+ tablas representadas. Uniones M:N con color más claro." style="shape=rectangle;filled=0;strokeColor=none;rounded=0;fontSize=8;align=left;spacingLeft=5;whiteSpace=wrap">
|
||||
<mxGeometry x="30" y="825" width="1160" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- RELACIONES 1:N -->
|
||||
<!-- USER -> PLAYLIST (owner) -->
|
||||
<mxCell id="rel_user_playlist_owner" parent="1" edge="1" source="user_title" target="playlist_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1.5;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- USER -> PLAYLIST (user_id) -->
|
||||
<mxCell id="rel_user_playlist_userid" parent="1" edge="1" source="user_title" target="playlist_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1.5;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- ARTIST -> ALBUM -->
|
||||
<mxCell id="rel_artist_album" parent="1" edge="1" source="artist_title" target="album_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- ARTIST -> MEDIA_FILE -->
|
||||
<mxCell id="rel_artist_mediafile" parent="1" edge="1" source="artist_title" target="mediafile_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- ALBUM -> MEDIA_FILE -->
|
||||
<mxCell id="rel_album_mediafile" parent="1" edge="1" source="album_title" target="mediafile_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- LIBRARY -> MEDIA_FILE -->
|
||||
<mxCell id="rel_library_mediafile" parent="1" edge="1" source="library_title" target="mediafile_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1.5;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- LIBRARY -> FOLDER -->
|
||||
<mxCell id="rel_library_folder" parent="1" edge="1" source="library_title" target="folder_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- FOLDER -> MEDIA_FILE -->
|
||||
<mxCell id="rel_folder_mediafile" parent="1" edge="1" source="folder_title" target="mediafile_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1.5;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- PLAYLIST -> PLAYLIST_TRACKS -->
|
||||
<mxCell id="rel_playlist_tracks" parent="1" edge="1" source="playlist_title" target="playlisttracks_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- MEDIA_FILE -> PLAYLIST_TRACKS -->
|
||||
<mxCell id="rel_mediafile_tracks" parent="1" edge="1" source="mediafile_title" target="playlisttracks_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- USER -> ANNOTATION -->
|
||||
<mxCell id="rel_user_annotation" parent="1" edge="1" source="user_title" target="annotation_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1.5;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- USER -> PLAYER -->
|
||||
<mxCell id="rel_user_player" parent="1" edge="1" source="user_title" target="player_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- TRANSCODING -> PLAYER -->
|
||||
<mxCell id="rel_transcoding_player" parent="1" edge="1" source="transcoding_title" target="player_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1.5;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- USER -> PLAYQUEUE -->
|
||||
<mxCell id="rel_user_playqueue" parent="1" edge="1" source="user_title" target="playqueue_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- USER -> BOOKMARK -->
|
||||
<mxCell id="rel_user_bookmark" parent="1" edge="1" source="user_title" target="bookmark_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1.5;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- USER -> SHARE -->
|
||||
<mxCell id="rel_user_share" parent="1" edge="1" source="user_title" target="share_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1.5;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- USER -> SCROBBLE_BUFFER -->
|
||||
<mxCell id="rel_user_scrobble" parent="1" edge="1" source="user_title" target="scrobble_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1.5;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- USER -> USER_PROPS -->
|
||||
<mxCell id="rel_user_userprops" parent="1" edge="1" source="user_title" target="userprops_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1.5;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- MEDIA_FILE -> SCROBBLE_BUFFER -->
|
||||
<mxCell id="rel_mediafile_scrobble" parent="1" edge="1" source="mediafile_title" target="scrobble_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1.5;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#9673a6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- GENRE -> ALBUM_GENRES -->
|
||||
<mxCell id="rel_genre_albumgenres" parent="1" edge="1" source="genre_title" target="albumgenres_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- ALBUM -> ALBUM_GENRES -->
|
||||
<mxCell id="rel_album_albumgenres" parent="1" edge="1" source="album_title" target="albumgenres_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- GENRE -> MEDIA_FILE_GENRES -->
|
||||
<mxCell id="rel_genre_mediafilegenres" parent="1" edge="1" source="genre_title" target="mediafilegenres_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- MEDIA_FILE -> MEDIA_FILE_GENRES -->
|
||||
<mxCell id="rel_mediafile_mediafilegenres" parent="1" edge="1" source="mediafile_title" target="mediafilegenres_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- GENRE -> ARTIST_GENRES -->
|
||||
<mxCell id="rel_genre_artistgenres" parent="1" edge="1" source="genre_title" target="artistgenres_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- ARTIST -> ARTIST_GENRES -->
|
||||
<mxCell id="rel_artist_artistgenres" parent="1" edge="1" source="artist_title" target="artistgenres_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- ALBUM -> ALBUM_ARTISTS -->
|
||||
<mxCell id="rel_album_albumartists" parent="1" edge="1" source="album_title" target="albumartists_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- ARTIST -> ALBUM_ARTISTS -->
|
||||
<mxCell id="rel_artist_albumartists" parent="1" edge="1" source="artist_title" target="albumartists_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- MEDIA_FILE -> MEDIA_FILE_ARTISTS -->
|
||||
<mxCell id="rel_mediafile_mediafileartists" parent="1" edge="1" source="mediafile_title" target="mediafileartists_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- ARTIST -> MEDIA_FILE_ARTISTS -->
|
||||
<mxCell id="rel_artist_mediafileartists" parent="1" edge="1" source="artist_title" target="mediafileartists_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- LIBRARY -> LIBRARY_ARTIST -->
|
||||
<mxCell id="rel_library_libraryartist" parent="1" edge="1" source="library_title" target="libraryartist_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- ARTIST -> LIBRARY_ARTIST -->
|
||||
<mxCell id="rel_artist_libraryartist" parent="1" edge="1" source="artist_title" target="libraryartist_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- LIBRARY -> LIBRARY_TAG -->
|
||||
<mxCell id="rel_library_librarytag" parent="1" edge="1" source="library_title" target="librarytag_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- TAG -> LIBRARY_TAG -->
|
||||
<mxCell id="rel_tag_librarytag" parent="1" edge="1" source="tag_title" target="librarytag_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- USER -> USER_LIBRARY -->
|
||||
<mxCell id="rel_user_userlibrary" parent="1" edge="1" source="user_title" target="userlibrary_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
<!-- LIBRARY -> USER_LIBRARY -->
|
||||
<mxCell id="rel_library_userlibrary" parent="1" edge="1" source="library_title" target="userlibrary_title" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;endArrow=ERoneToMany;startArrow=ERone;strokeColor=#999999;dashed=1">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
321
docs/TEST_PLAN_STAR_ENDPOINT.md
Normal file
321
docs/TEST_PLAN_STAR_ENDPOINT.md
Normal file
@ -0,0 +1,321 @@
|
||||
# Test Plan: Endpoint `/rest/star` - Marcar Canción como Favorita
|
||||
|
||||
## Flujo del Endpoint
|
||||
|
||||
```
|
||||
POST /rest/star?u=admin&t=TOKEN&s=SALT&f=json&v=1.8.0&c=NavidromeUI&id=MEDIA_FILE_ID
|
||||
```
|
||||
|
||||
**Parámetros:**
|
||||
- `u`: usuario (admin)
|
||||
- `t`: token de sesión
|
||||
- `s`: salt (para checksum)
|
||||
- `f`: formato (json)
|
||||
- `v`: versión API Subsonic
|
||||
- `c`: cliente
|
||||
- `id`: id del media_file a marcar como favorita
|
||||
|
||||
**Operación en BD:**
|
||||
1. Verificar que el usuario `admin` existe en tabla `USER`
|
||||
2. Verificar que el media_file con `id` existe
|
||||
3. Insertar/actualizar en tabla `ANNOTATION`:
|
||||
- `user_id` = admin
|
||||
- `item_id` = MEDIA_FILE_ID
|
||||
- `item_type` = 'song'
|
||||
- `starred` = true
|
||||
- `starred_at` = current_timestamp
|
||||
|
||||
---
|
||||
|
||||
## Tests Propuestos (EVALUACIÓN)
|
||||
|
||||
### ✅ TEST 1: Verificar Foreign Key de ANNOTATION → USER
|
||||
```sql
|
||||
-- BIEN: Comprueba que NO puedes insertar user_id inválido
|
||||
INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES ('INVALID_USER_ID', 'song123', 'song', true);
|
||||
-- DEBE FALLAR con constraint error
|
||||
```
|
||||
**Evaluación:** ✅ BUENO - Es fundamental validar integridad referencial.
|
||||
|
||||
**Mejora sugerida:** También incluir ON DELETE CASCADE - prueba que al borrar un usuario, se borren sus anotaciones.
|
||||
|
||||
---
|
||||
|
||||
### ✅ TEST 2: Verificar Campos NOT NULL
|
||||
```sql
|
||||
-- BIEN: Campos obligatorios
|
||||
INSERT INTO annotation (user_id, item_id, item_type)
|
||||
VALUES (NULL, 'song123', 'song');
|
||||
-- DEBE FALLAR
|
||||
```
|
||||
**Evaluación:** ✅ BUENO - Pero incompleto. Falta probar múltiples campos.
|
||||
|
||||
**Mejora sugerida:**
|
||||
- Probar todos los campos NOT NULL (`user_id`, `item_id`, `item_type`, `starred`)
|
||||
- Probar valores vacíos/default
|
||||
|
||||
---
|
||||
|
||||
### ✅ TEST 3: Insertar Canción + Annotation + SELECT con JOIN
|
||||
```go
|
||||
// Insertar media_file
|
||||
INSERT INTO media_file (id, path, title) VALUES ('song123', '/path/song.mp3', 'My Song');
|
||||
|
||||
// Insertar annotation
|
||||
INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES ('admin_id', 'song123', 'song', true);
|
||||
|
||||
// Verificar con JOIN
|
||||
SELECT mf.title, ann.starred, ann.starred_at
|
||||
FROM media_file mf
|
||||
JOIN annotation ann ON mf.id = ann.item_id
|
||||
WHERE ann.user_id = 'admin_id' AND ann.item_type = 'song';
|
||||
```
|
||||
**Evaluación:** ✅ EXCELENTE - Valida el flujo completo.
|
||||
|
||||
---
|
||||
|
||||
### ✅ TEST 4: UPDATE - Marcar Favorita
|
||||
```sql
|
||||
UPDATE annotation
|
||||
SET starred = true, starred_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = 'admin_id' AND item_id = 'song123' AND item_type = 'song';
|
||||
```
|
||||
**Evaluación:** ✅ BUENO - Pero puede mejorase con transacciones y validaciones de timestamps.
|
||||
|
||||
---
|
||||
|
||||
## Tests Recomendados ADICIONALES
|
||||
|
||||
### 📌 TEST 5: Constraint UNIQUE de ANNOTATION
|
||||
```sql
|
||||
-- ANNOTATION tiene UNIQUE (user_id, item_id, item_type)
|
||||
-- Intentar insertar dos veces el mismo registro debe fallar en la 2ª
|
||||
|
||||
INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES ('admin_id', 'song123', 'song', true);
|
||||
|
||||
INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES ('admin_id', 'song123', 'song', false);
|
||||
-- DEBE FALLAR con UNIQUE constraint error
|
||||
```
|
||||
|
||||
### 📌 TEST 6: Verificar Polimorfismo de ANNOTATION
|
||||
```sql
|
||||
-- item_type puede ser 'song', 'album', 'artist', etc.
|
||||
INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES ('admin_id', 'album456', 'album', true);
|
||||
|
||||
INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES ('admin_id', 'artist789', 'artist', true);
|
||||
|
||||
-- Verificar que item_id NO existe en correspondiente tabla
|
||||
-- Esto validaría que sin FK explícitas, hay riesgo de datos huérfanos
|
||||
```
|
||||
|
||||
### 📌 TEST 7: Timestamp de starred_at
|
||||
```sql
|
||||
-- Verificar que starred_at se actualiza al cambiar starred
|
||||
UPDATE annotation
|
||||
SET starred = true, starred_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = 'admin_id' AND item_id = 'song123';
|
||||
|
||||
-- Esperar 1 segundo
|
||||
-- UPDATE nuevamente
|
||||
UPDATE annotation
|
||||
SET starred = true, starred_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = 'admin_id' AND item_id = 'song123';
|
||||
|
||||
-- El timestamp debe ser más reciente
|
||||
SELECT starred_at FROM annotation
|
||||
WHERE user_id = 'admin_id' AND item_id = 'song123';
|
||||
```
|
||||
|
||||
### 📌 TEST 8: play_count vs starred
|
||||
```sql
|
||||
-- Verificar que play_count se incrementa independientemente de starred
|
||||
INSERT INTO annotation (user_id, item_id, item_type, starred, play_count)
|
||||
VALUES ('admin_id', 'song123', 'song', false, 0);
|
||||
|
||||
UPDATE annotation SET play_count = play_count + 1
|
||||
WHERE user_id = 'admin_id' AND item_id = 'song123';
|
||||
|
||||
-- Marcar como favorita
|
||||
UPDATE annotation SET starred = true, starred_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = 'admin_id' AND item_id = 'song123';
|
||||
|
||||
-- Verificar que ambos campos persisten
|
||||
SELECT play_count, starred FROM annotation
|
||||
WHERE user_id = 'admin_id' AND item_id = 'song123';
|
||||
```
|
||||
|
||||
### 📌 TEST 9: ON DELETE CASCADE - User Deletion
|
||||
```sql
|
||||
-- Insertar usuario con annotations
|
||||
INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
|
||||
VALUES ('test_user', 'testuser', 'hashedpwd', false, NOW(), NOW());
|
||||
|
||||
INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES ('test_user', 'song123', 'song', true);
|
||||
|
||||
-- Verificar que existe
|
||||
SELECT COUNT(*) FROM annotation WHERE user_id = 'test_user';
|
||||
-- Debe retornar 1
|
||||
|
||||
-- Borrar usuario
|
||||
DELETE FROM user WHERE id = 'test_user';
|
||||
|
||||
-- Verificar que las anotaciones se borraron en cascada
|
||||
SELECT COUNT(*) FROM annotation WHERE user_id = 'test_user';
|
||||
-- Debe retornar 0 (borración en cascada funcionó)
|
||||
```
|
||||
|
||||
### 📌 TEST 10: Integridad con Multiple Users
|
||||
```sql
|
||||
-- Verificar que cada usuario tiene anotaciones independientes
|
||||
INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
|
||||
VALUES ('admin', 'admin', 'pwd1', true, NOW(), NOW());
|
||||
|
||||
INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
|
||||
VALUES ('user2', 'user2', 'pwd2', false, NOW(), NOW());
|
||||
|
||||
INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES ('admin', 'song123', 'song', true);
|
||||
|
||||
INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES ('user2', 'song123', 'song', false);
|
||||
|
||||
-- Verificar que cada usuario ve su estado
|
||||
SELECT starred FROM annotation
|
||||
WHERE user_id = 'admin' AND item_id = 'song123';
|
||||
-- Retorna true
|
||||
|
||||
SELECT starred FROM annotation
|
||||
WHERE user_id = 'user2' AND item_id = 'song123';
|
||||
-- Retorna false
|
||||
```
|
||||
|
||||
### 📌 TEST 11: Rating + Starred Independientes
|
||||
```sql
|
||||
-- Verificar que puedo calificar y marcar favorita independientemente
|
||||
UPDATE annotation
|
||||
SET rating = 5, starred = true, starred_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = 'admin_id' AND item_id = 'song123';
|
||||
|
||||
-- Cambiar solo el rating
|
||||
UPDATE annotation
|
||||
SET rating = 3
|
||||
WHERE user_id = 'admin_id' AND item_id = 'song123';
|
||||
|
||||
-- Verificar que starred se mantiene
|
||||
SELECT rating, starred FROM annotation
|
||||
WHERE user_id = 'admin_id' AND item_id = 'song123';
|
||||
-- Debe retornar: rating=3, starred=true
|
||||
```
|
||||
|
||||
### 📌 TEST 12: NULL en Campos Opcionales
|
||||
```sql
|
||||
-- Algunos campos pueden ser NULL (play_date, rating, etc.)
|
||||
INSERT INTO annotation (user_id, item_id, item_type, starred, play_count, rating, play_date)
|
||||
VALUES ('admin_id', 'song456', 'song', true, NULL, NULL, NULL);
|
||||
|
||||
SELECT * FROM annotation
|
||||
WHERE user_id = 'admin_id' AND item_id = 'song456';
|
||||
-- Debe permitir NULLs en play_count, rating, play_date
|
||||
```
|
||||
|
||||
### 📌 TEST 13: Transacción - Rollback si Falla
|
||||
```sql
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
INSERT INTO media_file (id, path, title) VALUES ('song_new', '/path/new.mp3', 'New Song');
|
||||
|
||||
INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES ('admin_id', 'song_new', 'song', true);
|
||||
|
||||
-- Simular error
|
||||
-- ROLLBACK;
|
||||
|
||||
-- Verificar que nada se insertó
|
||||
SELECT COUNT(*) FROM media_file WHERE id = 'song_new';
|
||||
SELECT COUNT(*) FROM annotation WHERE item_id = 'song_new';
|
||||
-- Ambos deben retornar 0
|
||||
```
|
||||
|
||||
### 📌 TEST 14: Índices - Performance
|
||||
```sql
|
||||
-- Verificar que existen índices en:
|
||||
-- 1. user_id (para búsquedas rápidas)
|
||||
-- 2. (user_id, item_id, item_type) composite
|
||||
|
||||
EXPLAIN QUERY PLAN
|
||||
SELECT * FROM annotation
|
||||
WHERE user_id = 'admin_id' AND item_id = 'song123' AND item_type = 'song';
|
||||
-- Debe usar índice (no SCAN)
|
||||
```
|
||||
|
||||
### 📌 TEST 15: Idempotencia - Star/Unstar Múltiples Veces
|
||||
```sql
|
||||
-- Marcar como favorita
|
||||
UPDATE annotation
|
||||
SET starred = true, starred_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = 'admin_id' AND item_id = 'song123' AND item_type = 'song';
|
||||
|
||||
-- Marcar nuevamente (debe ser safe)
|
||||
UPDATE annotation
|
||||
SET starred = true, starred_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = 'admin_id' AND item_id = 'song123' AND item_type = 'song';
|
||||
|
||||
-- Si record no existe, hacer INSERT (upsert pattern)
|
||||
INSERT OR REPLACE INTO annotation (user_id, item_id, item_type, starred, starred_at)
|
||||
VALUES ('admin_id', 'song123', 'song', true, CURRENT_TIMESTAMP);
|
||||
|
||||
-- Debe ser seguro (no duplicados, no errores)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Matriz de Cobertura
|
||||
|
||||
| Test | Categoría | Importancia | Estado |
|
||||
|------|-----------|-------------|--------|
|
||||
| 1 | Foreign Key | ⭐⭐⭐⭐⭐ | ✅ Propuesto |
|
||||
| 2 | Constraints | ⭐⭐⭐⭐⭐ | ✅ Propuesto |
|
||||
| 3 | Flujo Completo | ⭐⭐⭐⭐⭐ | ✅ Propuesto |
|
||||
| 4 | UPDATE | ⭐⭐⭐⭐⭐ | ✅ Propuesto |
|
||||
| 5 | UNIQUE | ⭐⭐⭐⭐⭐ | 📌 Recomendado |
|
||||
| 6 | Polimorfismo | ⭐⭐⭐⭐ | 📌 Recomendado |
|
||||
| 7 | Timestamps | ⭐⭐⭐⭐ | 📌 Recomendado |
|
||||
| 8 | Independencia datos | ⭐⭐⭐⭐ | 📌 Recomendado |
|
||||
| 9 | Cascada | ⭐⭐⭐⭐⭐ | 📌 Recomendado |
|
||||
| 10 | Multi-usuario | ⭐⭐⭐⭐ | 📌 Recomendado |
|
||||
| 11 | Campos independientes | ⭐⭐⭐⭐ | 📌 Recomendado |
|
||||
| 12 | NULLs opcionales | ⭐⭐⭐ | 📌 Recomendado |
|
||||
| 13 | Transacciones | ⭐⭐⭐⭐ | 📌 Recomendado |
|
||||
| 14 | Performance | ⭐⭐⭐⭐ | 📌 Recomendado |
|
||||
| 15 | Idempotencia | ⭐⭐⭐⭐⭐ | 📌 Recomendado |
|
||||
|
||||
---
|
||||
|
||||
## Recomendación Final
|
||||
|
||||
**Tests CRÍTICOS (HACER PRIMERO):**
|
||||
1. Foreign Key de ANNOTATION → USER (con cascada)
|
||||
2. NOT NULL constraints
|
||||
3. Flujo completo (INSERT + JOIN)
|
||||
4. UPDATE starred flag
|
||||
5. UNIQUE constraint (user_id, item_id, item_type)
|
||||
6. Idempotencia (upsert safe)
|
||||
|
||||
**Tests IMPORTANTES (hacer después):**
|
||||
7. Multi-usuario (aislamiento)
|
||||
8. Timestamps accuracy
|
||||
9. ON DELETE CASCADE
|
||||
10. Performance con índices
|
||||
|
||||
**Tests COMPLEMENTARIOS (nice-to-have):**
|
||||
11. Polimorfismo (item_type)
|
||||
12. play_count independiente
|
||||
13. Transacciones y rollback
|
||||
14. NULL fields opcionales
|
||||
219
docs/lab2-be-int-star-song.md
Normal file
219
docs/lab2-be-int-star-song.md
Normal file
@ -0,0 +1,219 @@
|
||||
# Lab 2 — Integration Test Plan: BE-INT
|
||||
|
||||
**Integration level:** 1
|
||||
**Boundary tested:** Backend ↔ Database (real persistence layer ↔ real migrated SQLite)
|
||||
**Scenario:** Star an existing song successfully.
|
||||
|
||||
## Steps (scenario)
|
||||
|
||||
1. Send an authenticated star request for an existing song ID.
|
||||
2. Read favorites back via the backend API / DB verification.
|
||||
|
||||
**Expected result:** the song is starred and appears in the favorites/starred list.
|
||||
|
||||
> **Constraint:** create new test files only — do not modify any existing test. This plan adds a
|
||||
> brand-new file and only *reads* the suite's already-seeded fixtures; nothing existing is changed.
|
||||
|
||||
---
|
||||
|
||||
## For readers new to Go / Go testing
|
||||
|
||||
If you know software development in general but have never touched Go, Go's testing tools, or
|
||||
this codebase, this section gives you enough mental model to read the test. The ideas are the
|
||||
same ones you already know from other languages — only the names differ.
|
||||
|
||||
### The big picture in one paragraph
|
||||
|
||||
"Star a song" in Navidrome means *mark it as a favorite*. The backend stores that fact in a
|
||||
database table called `annotation` (one row per user + item, with a `starred` boolean and a
|
||||
`starred_at` timestamp). Our test asks a simple question: **if I tell the real backend code to
|
||||
star a real song in a real database, and then ask the backend to list my favorites, does the
|
||||
song show up?** That round-trip — write through real code, read back through real code — is what
|
||||
makes it an *integration* test rather than a unit test.
|
||||
|
||||
### Go testing & Ginkgo, mapped to tools you already know
|
||||
|
||||
Go has a built-in test runner (`go test`). On top of it, Navidrome uses **Ginkgo**, a
|
||||
behavior-style framework. If you've used JUnit, Jest, RSpec, or Mocha, this table will feel
|
||||
familiar:
|
||||
|
||||
| In the test you'll see | What it means | Equivalent you may know |
|
||||
|------------------------|---------------|-------------------------|
|
||||
| `Describe("...", func(){...})` | Groups related test cases | `describe()` (Jest), test class (JUnit) |
|
||||
| `It("...", func(){...})` | One individual test case | `it()` / `test()` / `@Test` |
|
||||
| `BeforeEach(func(){...})` | Runs before *each* case — setup | `beforeEach` / `@BeforeEach` |
|
||||
| `AfterEach(func(){...})` | Runs after *each* case — teardown/cleanup | `afterEach` / `@AfterEach` |
|
||||
| `Expect(x).To(BeTrue())` | An assertion | `expect(x).toBe(true)` / `assertTrue(x)` |
|
||||
| `Expect(err).ToNot(HaveOccurred())` | Assert "no error happened" | `assertDoesNotThrow` |
|
||||
| `BeforeSuite` | One-time setup for the whole file/suite | `@BeforeAll` |
|
||||
|
||||
`func(){...}` is just Go's syntax for an anonymous function (a lambda/closure). Ginkgo tests are
|
||||
written by passing these little functions into `Describe`/`It`/etc.
|
||||
|
||||
In Go, **any file ending in `_test.go` is test code** and is compiled only when testing — it never
|
||||
ships in the product binary. That's why adding our file changes nothing about the running app.
|
||||
|
||||
### The building blocks this test uses
|
||||
|
||||
- **Repository** (`repo` in the code): an object that reads/writes one kind of data in the
|
||||
database. Think DAO, or a single-table ORM repository. `MediaFileRepository` is "the songs
|
||||
table, plus the favorites/ratings attached to songs." Its methods (`Get`, `GetAll`, `SetStar`)
|
||||
are the *real backend code* — the same methods the web API calls in production.
|
||||
- **`SetStar(true, id)` / `SetStar(false, id)`**: the backend method that marks/unmarks a favorite.
|
||||
It does a real SQL "update the row, or insert it if it doesn't exist yet" (an *upsert*) against
|
||||
the `annotation` table.
|
||||
- **`Get(id)`**: fetch one song *with its favorite/rating info merged in* (via a SQL `JOIN`). So
|
||||
`song.Starred` tells you whether the current user has favorited it.
|
||||
- **`GetAll(... starred = true ...)`**: fetch every song the current user has favorited — i.e. read
|
||||
the favorites list. This is literally the query behind the app's "Starred / Favorites" screen.
|
||||
- **Fixtures / seed data**: before the tests run, the suite inserts a fixed set of sample songs and
|
||||
users into the database (e.g. song `"1003"` = "Radioactivity", user `adminUser`). These are
|
||||
pre-existing rows we can rely on — like a test database snapshot. We *read* them; we don't change
|
||||
the setup.
|
||||
- **"The authenticated user"**: favorites are per-user. In the running app, login middleware figures
|
||||
out *who* you are and attaches that to the request. In the test there's no HTTP login step;
|
||||
instead we attach a known seeded user to a Go **`context`** (a request-scoped bag of values that
|
||||
Go passes around). `request.WithUser(ctx, adminUser)` is the test's stand-in for "logged in as
|
||||
this user." The backend code reads the user from that context exactly as it would in production.
|
||||
- **Real migrated database**: the test boots an actual SQLite database and runs Navidrome's real
|
||||
schema migrations against it (in memory, so it's fast and disposable). No fake/hand-written
|
||||
schema — the same tables the real app uses.
|
||||
|
||||
### How to read the test, in plain steps
|
||||
|
||||
Each test case follows the classic **Arrange → Act → Assert** shape:
|
||||
|
||||
1. **Arrange** — get a repository wired to the real DB, acting as the seeded user; force a known
|
||||
starting state (for the star case, make sure the song is *not* yet starred).
|
||||
2. **Act** — call the real backend method: `SetStar(true, songID)` to favorite it (or
|
||||
`SetStar(false, songID)` to remove it).
|
||||
3. **Assert** — read it back two ways and check both agree: `Get(songID).Starred` is now `true`,
|
||||
and the song appears in the favorites list returned by `GetAll(... starred=true ...)`.
|
||||
4. **Cleanup** — `AfterEach` un-stars the song again so the shared in-memory database is left clean
|
||||
for the next test (tests must not leak state into each other).
|
||||
|
||||
The unstar test (BE-INT-02) is the mirror image: arrange the song as *starred*, act with
|
||||
`SetStar(false, ...)`, then assert it's no longer starred and no longer in the favorites list.
|
||||
|
||||
---
|
||||
|
||||
## Context: what already exists vs. the real gap
|
||||
|
||||
Three pieces of star/favorites test code exist today. None of them exercises the real
|
||||
backend persistence code against a real database, which is exactly the gap this test fills.
|
||||
|
||||
| File | What it actually is | Real backend code? | Real DB? |
|
||||
|------|--------------------|--------------------|----------|
|
||||
| `server/subsonic/media_annotation_test.go` | **Unit** test — `tests.MockDataStore` + spy repo | Handler only | No (fully mocked) |
|
||||
| `tests/db/annotation_star_test.go` | **DB-only** test — raw SQL against a *hand-written* schema | No | A throwaway schema, not Navidrome's migrations |
|
||||
| `persistence/sql_annotations_test.go` | Real-DB test, but only covers the **filter SQL** + album rating | Filter helpers | Yes |
|
||||
|
||||
So despite the `tests/db` file being *labeled* "integration", nothing currently exercises
|
||||
**real backend persistence code → real migrated SQLite, then reads it back**. That round-trip
|
||||
is the BE-INT-01 scenario and the legitimate value-add.
|
||||
|
||||
---
|
||||
|
||||
## Recommended approach — repository-level round-trip
|
||||
|
||||
The `persistence` package test suite already boots a **real migrated DB** via `db.Init()` and
|
||||
seeds real fixtures in `BeforeSuite`: known songs (`"1001"` A Day In A Life, `"1003"`
|
||||
Radioactivity, etc.), albums, and users (`adminUser`, ID `"userid"`). We reuse that instead of
|
||||
hand-rolling a schema.
|
||||
|
||||
**New file:** `persistence/star_song_integration_test.go` (lives in the `persistence` suite so it
|
||||
inherits the migrated DB + fixtures).
|
||||
|
||||
```go
|
||||
ctx := request.WithUser(context.Background(), adminUser) // the authenticated user
|
||||
repo := NewMediaFileRepository(ctx, GetDBXBuilder())
|
||||
```
|
||||
|
||||
Steps mapped to the scenario:
|
||||
|
||||
1. **Pre-condition** — pick a seeded existing song (e.g. ID `"1001"`); assert
|
||||
`repo.Get("1001").Starred == false`.
|
||||
2. **Act (the "star request")** — `Expect(repo.SetStar(true, "1001")).To(Succeed())` → real
|
||||
upsert into the `annotation` table.
|
||||
3. **Verify via DB read-back** (two complementary assertions):
|
||||
- `repo.Get("1001")` → `.Starred == true` **and** `.StarredAt` is non-zero (proves the row +
|
||||
timestamp persisted through the `LeftJoin annotation` query path).
|
||||
- `repo.GetAll(model.QueryOptions{Filters: squirrel.Eq{"starred": true}})` — confirms `"1001"`
|
||||
**appears in the starred/favorites list**. This is the *same query the `getStarred` Subsonic
|
||||
endpoint runs* (`server/subsonic/album_lists.go:120` `getStarredItems` → `MediaFile.GetAll`
|
||||
with the starred filter), so it is a faithful "read favorites" check.
|
||||
4. **Cleanup** — `AfterEach`: `repo.SetStar(false, "1001")` (or delete the annotation row) so the
|
||||
shared in-memory DB stays clean for other specs.
|
||||
|
||||
### Why this is valid / what it proves
|
||||
|
||||
It exercises the real `sqlRepository.SetStar` → real squirrel SQL → real migrated `annotation`
|
||||
table, then reads back through the real annotated-query join — proving the backend's write and
|
||||
the backend's read agree against an actual database. The per-user scoping
|
||||
(`loggedUser(ctx).ID`) stands in for the authenticated user, which is exactly what the auth
|
||||
middleware produces downstream.
|
||||
|
||||
### Value for the product
|
||||
|
||||
Catches regressions that unit tests cannot: a broken migration, a wrong column name, a faulty
|
||||
upsert, or a starred-filter query that drifts from the write path. If this round-trip fails, the
|
||||
favorites feature is broken in production even if every unit test passes.
|
||||
|
||||
---
|
||||
|
||||
## Stronger (heavier) variant — full HTTP + auth
|
||||
|
||||
For the literal "authenticated HTTP star request", wire a real `Router` with a real
|
||||
`persistence.New(...)` DataStore, seed a user *with a password*, then `router.ServeHTTP` a
|
||||
`GET /rest/star?u=...&t=...&s=...&id=1001` followed by `GET /rest/getStarred`. This adds the auth
|
||||
middleware + handler layers but costs noticeably more setup (real Router deps, password/token
|
||||
computation). Good as one "showcase" backend test; overkill for all five lab tests.
|
||||
|
||||
---
|
||||
|
||||
## How to run
|
||||
|
||||
From the project root. Both BE-INT-01 (star) and BE-INT-02 (unstar) live in the same file,
|
||||
`persistence/star_song_integration_test.go`, under the `Star/Unstar an existing song` describe:
|
||||
|
||||
```bash
|
||||
# Run both star and unstar specs
|
||||
go test -tags netgo,sqlite_fts5 -run TestPersistence ./persistence/ \
|
||||
--ginkgo.focus="Star/Unstar an existing song"
|
||||
|
||||
# Run only BE-INT-01 (star)
|
||||
go test -tags netgo,sqlite_fts5 -run TestPersistence ./persistence/ \
|
||||
--ginkgo.focus="Star an existing song"
|
||||
|
||||
# Run only BE-INT-02 (unstar)
|
||||
go test -tags netgo,sqlite_fts5 -run TestPersistence ./persistence/ \
|
||||
--ginkgo.focus="Unstar a previously starred song"
|
||||
```
|
||||
|
||||
Expected output: `ok github.com/navidrome/navidrome/persistence`.
|
||||
|
||||
### What each flag does
|
||||
|
||||
- `-tags netgo,sqlite_fts5` — **required.** The DB migrations create an FTS5 virtual table; without
|
||||
this build tag the SQLite driver lacks the `fts5` module and the schema fails to build
|
||||
(`no such module: fts5`). These are the project's standard build tags (`GO_BUILD_TAGS` in the
|
||||
Makefile).
|
||||
- `-run TestPersistence` — scopes the run to the real suite entry point, sidestepping the
|
||||
pre-existing duplicate `RunSpecs` in `persistence/annotation_star_test.go` that otherwise fails
|
||||
the whole package with "Rerunning Suite" (see the note below).
|
||||
- `--ginkgo.focus="..."` — runs only the specs whose description text matches the given pattern
|
||||
(e.g. one of the three focuses shown above). Omit it to run every spec in the suite.
|
||||
|
||||
To run this spec alongside the rest of the persistence suite, drop the focus flag:
|
||||
|
||||
```bash
|
||||
go test -tags netgo,sqlite_fts5 -run TestPersistence ./persistence/
|
||||
```
|
||||
|
||||
> **Pre-existing issue (not introduced by this test):** the file
|
||||
> `persistence/annotation_star_test.go` (committed in Lab 1, a stray duplicate of
|
||||
> `tests/db/annotation_star_test.go`) declares a *second* `RunSpecs` inside the `persistence`
|
||||
> package. Ginkgo allows only one `RunSpecs` per test binary, so a bare `go test ./persistence/`
|
||||
> fails with "Rerunning Suite". Scoping the run to `-run TestPersistence` (the real suite entry
|
||||
> point) sidesteps the duplicate and runs this spec cleanly. The proper fix is to remove/relocate
|
||||
> that stray file, but it is left untouched here per the "do not modify existing tests" constraint.
|
||||
442
persistence/annotation_star_test.go
Normal file
442
persistence/annotation_star_test.go
Normal file
@ -0,0 +1,442 @@
|
||||
package persistence_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Test suite para el flujo /rest/star - Marcar canción como favorita
|
||||
// Valida todas las reglas de integridad en la tabla ANNOTATION
|
||||
|
||||
func TestStarEndpoint(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Star Endpoint - BD Tests")
|
||||
}
|
||||
|
||||
var _ = Describe("Star Endpoint DB Tests", func() {
|
||||
var database *sql.DB
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
// Usar in-memory SQLite para tests rápidos
|
||||
path := "file::memory:?cache=shared&_foreign_keys=on"
|
||||
database, _ = sql.Open(db.Dialect, path)
|
||||
ctx = context.Background()
|
||||
|
||||
// Crear schema completo (mínimo para los tests)
|
||||
_, err := database.ExecContext(ctx, `
|
||||
CREATE TABLE user (
|
||||
id VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
user_name VARCHAR(255) NOT NULL UNIQUE,
|
||||
name VARCHAR(255),
|
||||
email VARCHAR(255),
|
||||
password VARCHAR(255),
|
||||
is_admin BOOLEAN DEFAULT FALSE,
|
||||
last_login_at DATETIME,
|
||||
last_access_at DATETIME,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE media_file (
|
||||
id VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
path VARCHAR(255) NOT NULL,
|
||||
title VARCHAR(255),
|
||||
album_id VARCHAR(255),
|
||||
artist_id VARCHAR(255),
|
||||
album_artist VARCHAR(255),
|
||||
duration REAL DEFAULT 0,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
);
|
||||
|
||||
CREATE TABLE annotation (
|
||||
user_id VARCHAR(255) NOT NULL REFERENCES user(id) ON DELETE CASCADE,
|
||||
item_id VARCHAR(255) NOT NULL,
|
||||
item_type VARCHAR(255) NOT NULL,
|
||||
play_count INTEGER,
|
||||
play_date DATETIME,
|
||||
rating INTEGER,
|
||||
starred BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
starred_at DATETIME,
|
||||
rating_date DATETIME,
|
||||
UNIQUE (user_id, item_id, item_type)
|
||||
);
|
||||
|
||||
CREATE INDEX annotation_user_id ON annotation(user_id);
|
||||
CREATE INDEX annotation_item ON annotation(item_id, item_type);
|
||||
`)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
database.Close()
|
||||
})
|
||||
|
||||
// ================== TEST 1: Foreign Key Constraint ==================
|
||||
Describe("TEST 1: Foreign Key Constraint ANNOTATION -> USER", func() {
|
||||
It("debe rechazar inserción de annotation con user_id inválido", func() {
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
"INVALID_USER_ID", "song123", "song", true)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("FOREIGN KEY constraint failed"))
|
||||
})
|
||||
|
||||
It("debe permitir inserción si user_id existe", func() {
|
||||
// Setup: crear usuario
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
"admin", "admin", "hashedpwd", true, time.Now(), time.Now())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Act: insertar annotation
|
||||
_, err = database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
"admin", "song123", "song", true)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Verify
|
||||
var starred bool
|
||||
err = database.QueryRowContext(ctx,
|
||||
`SELECT starred FROM annotation WHERE user_id = ? AND item_id = ?`,
|
||||
"admin", "song123").Scan(&starred)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(starred).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
// ================== TEST 2: NOT NULL Constraints ==================
|
||||
Describe("TEST 2: NOT NULL Constraints", func() {
|
||||
BeforeEach(func() {
|
||||
// Setup: crear usuario
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
"admin", "admin", "hashedpwd", true, time.Now(), time.Now())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("debe rechazar si user_id es NULL", func() {
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (NULL, ?, ?, ?)`,
|
||||
"song123", "song", true)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("debe rechazar si item_id es NULL", func() {
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, NULL, ?, ?)`,
|
||||
"admin", "song", true)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("debe rechazar si item_type es NULL", func() {
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, ?, NULL, ?)`,
|
||||
"admin", "song123", true)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("debe rechazar si starred es NULL", func() {
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, ?, ?, NULL)`,
|
||||
"admin", "song123", "song")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
// ================== TEST 3: Flujo Completo ==================
|
||||
Describe("TEST 3: Flujo Completo - INSERT Media + Annotation + JOIN", func() {
|
||||
It("debe permitir insertar canción y marcar como favorita con JOIN", func() {
|
||||
// Setup
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
"admin", "admin", "hashedpwd", true, time.Now(), time.Now())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Insert media_file
|
||||
_, err = database.ExecContext(ctx,
|
||||
`INSERT INTO media_file (id, path, title) VALUES (?, ?, ?)`,
|
||||
"song123", "/path/to/song.mp3", "My Favorite Song")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Insert annotation (star)
|
||||
_, err = database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred, starred_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
"admin", "song123", "song", true, time.Now())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Verify con JOIN
|
||||
var title string
|
||||
var starred bool
|
||||
var starredAt sql.NullTime
|
||||
|
||||
err = database.QueryRowContext(ctx, `
|
||||
SELECT mf.title, ann.starred, ann.starred_at
|
||||
FROM media_file mf
|
||||
JOIN annotation ann ON mf.id = ann.item_id
|
||||
WHERE ann.user_id = ? AND ann.item_type = 'song'
|
||||
`, "admin").Scan(&title, &starred, &starredAt)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(title).To(Equal("My Favorite Song"))
|
||||
Expect(starred).To(BeTrue())
|
||||
Expect(starredAt.Valid).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
// ================== TEST 4: UPDATE Favorita ==================
|
||||
Describe("TEST 4: UPDATE - Marcar/Desmarcar Favorita", func() {
|
||||
BeforeEach(func() {
|
||||
// Setup
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
"admin", "admin", "hashedpwd", true, time.Now(), time.Now())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
"admin", "song123", "song", false)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("debe marcar como favorita con UPDATE", func() {
|
||||
starredAt := time.Now()
|
||||
result, err := database.ExecContext(ctx, `
|
||||
UPDATE annotation SET starred = ?, starred_at = ?
|
||||
WHERE user_id = ? AND item_id = ? AND item_type = ?
|
||||
`, true, starredAt, "admin", "song123", "song")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
affected, err := result.RowsAffected()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(affected).To(Equal(int64(1)))
|
||||
|
||||
var starred bool
|
||||
err = database.QueryRowContext(ctx,
|
||||
`SELECT starred FROM annotation WHERE user_id = ? AND item_id = ?`,
|
||||
"admin", "song123").Scan(&starred)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(starred).To(BeTrue())
|
||||
})
|
||||
|
||||
It("debe desmarcar favorita", func() {
|
||||
result, err := database.ExecContext(ctx, `
|
||||
UPDATE annotation SET starred = ?, starred_at = NULL
|
||||
WHERE user_id = ? AND item_id = ? AND item_type = ?
|
||||
`, false, "admin", "song123", "song")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
affected, err := result.RowsAffected()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(affected).To(Equal(int64(1)))
|
||||
})
|
||||
})
|
||||
|
||||
// ================== TEST 5: UNIQUE Constraint ==================
|
||||
Describe("TEST 5: UNIQUE Constraint (user_id, item_id, item_type)", func() {
|
||||
BeforeEach(func() {
|
||||
// Setup
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
"admin", "admin", "hashedpwd", true, time.Now(), time.Now())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
"admin", "song123", "song", true)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("debe rechazar inserción duplicada del mismo (user, item, type)", func() {
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
"admin", "song123", "song", false)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("UNIQUE constraint failed"))
|
||||
})
|
||||
|
||||
It("debe permitir el mismo item_id pero diferente user", func() {
|
||||
// Setup: crear otro usuario
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
"user2", "user2", "hashedpwd", false, time.Now(), time.Now())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Insertar annotation para otro usuario sobre el mismo song
|
||||
_, err = database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
"user2", "song123", "song", false)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Verify ambos existen
|
||||
var count int
|
||||
err = database.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM annotation WHERE item_id = ?`,
|
||||
"song123").Scan(&count)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(count).To(Equal(2))
|
||||
})
|
||||
|
||||
It("debe permitir el mismo item_id pero diferente item_type", func() {
|
||||
// El mismo ID podría ser album o artist
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
"admin", "song123", "album", false)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
// ================== TEST 9: ON DELETE CASCADE ==================
|
||||
Describe("TEST 9: ON DELETE CASCADE - User Deletion (trigger-based only)", func() {
|
||||
BeforeEach(func() {
|
||||
// Skip this set if there is no trigger-based cascade detected.
|
||||
var trigSQL sql.NullString
|
||||
err := database.QueryRowContext(ctx,
|
||||
`SELECT sql FROM sqlite_master WHERE type = 'trigger' AND sql LIKE '%annotation%' AND sql LIKE '%DELETE%' LIMIT 1`).Scan(&trigSQL)
|
||||
if err != nil || !trigSQL.Valid {
|
||||
Skip("No trigger-based cascade detected; skipping CASCADE test")
|
||||
}
|
||||
|
||||
// Setup
|
||||
_, err = database.ExecContext(ctx,
|
||||
`INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
"test_user", "testuser", "hashedpwd", false, time.Now(), time.Now())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
"test_user", "song123", "song", true)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("debe borrar annotations quando se borra el usuario (CASCADE)", func() {
|
||||
// Verify que existen
|
||||
var count int
|
||||
err := database.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM annotation WHERE user_id = ?`,
|
||||
"test_user").Scan(&count)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(count).To(Equal(1))
|
||||
|
||||
// Delete user
|
||||
_, err = database.ExecContext(ctx,
|
||||
`DELETE FROM user WHERE id = ?`, "test_user")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Verify annotations se borraron en cascada
|
||||
err = database.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM annotation WHERE user_id = ?`,
|
||||
"test_user").Scan(&count)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(count).To(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
// ================== TEST 6: Polimorfismo - item_type (conditional) ==================
|
||||
Describe("TEST 6: Polimorfismo - Diferentes item_type (solo si hay restricción)", func() {
|
||||
BeforeEach(func() {
|
||||
// Check if annotation table has an explicit restriction on item_type (CHECK or IN list)
|
||||
var tableSQL sql.NullString
|
||||
err := database.QueryRowContext(ctx,
|
||||
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'annotation'`).Scan(&tableSQL)
|
||||
if err != nil || !tableSQL.Valid {
|
||||
Skip("annotation table not present; skipping polymorphism tests")
|
||||
}
|
||||
|
||||
up := strings.ToUpper(tableSQL.String)
|
||||
if !strings.Contains(up, "CHECK") && !strings.Contains(up, "ITEM_TYPE") && !strings.Contains(up, "IN(") {
|
||||
Skip("No explicit item_type restriction found on annotation table; skipping polymorphism tests")
|
||||
}
|
||||
|
||||
_, err = database.ExecContext(ctx,
|
||||
`INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
"admin", "admin", "hashedpwd", true, time.Now(), time.Now())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("debe validar los item_type permitidos en la tabla annotation", func() {
|
||||
// Attempt inserts for a small set of types; if table restricts values,
|
||||
// allowed types should succeed and disallowed should fail.
|
||||
allowed := []string{"song", "album", "artist"}
|
||||
for _, t := range allowed {
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
"admin", "item-"+t, t, true)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
}
|
||||
|
||||
// Try a disallowed type and expect failure if restriction exists
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
"admin", "item-bad", "not_a_type", true)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
// ================== TEST 12: NULL Optional Fields ==================
|
||||
Describe("TEST 12: NULL Optional Fields", func() {
|
||||
BeforeEach(func() {
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
"admin", "admin", "hashedpwd", true, time.Now(), time.Now())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("debe permitir NULL en play_count, rating, play_date", func() {
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred, play_count, rating, play_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
"admin", "song456", "song", true, nil, nil, nil)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
var playCount sql.NullInt64
|
||||
var rating sql.NullInt64
|
||||
var playDate sql.NullTime
|
||||
err = database.QueryRowContext(ctx,
|
||||
`SELECT play_count, rating, play_date FROM annotation WHERE user_id = ? AND item_id = ?`,
|
||||
"admin", "song456").Scan(&playCount, &rating, &playDate)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(playCount.Valid).To(BeFalse())
|
||||
Expect(rating.Valid).To(BeFalse())
|
||||
Expect(playDate.Valid).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
112
persistence/star_song_integration_test.go
Normal file
112
persistence/star_song_integration_test.go
Normal file
@ -0,0 +1,112 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Integration tests: Backend (real persistence layer) <-> Database (real migrated SQLite).
|
||||
//
|
||||
// No mocks, no stubs: SetStar issues real SQL against the migrated `annotation` table
|
||||
// seeded by the suite's BeforeSuite, and every verification reads it back through the
|
||||
// real annotated-query join — the same path the Subsonic getStarred endpoint uses.
|
||||
//
|
||||
// starredIDs returns the IDs currently in the favorites/starred list, using exactly the
|
||||
// filter that filter.ByStarred() applies in the getStarred endpoint.
|
||||
var _ = Describe("Star/Unstar an existing song (Backend<->DB integration)", func() {
|
||||
var repo model.MediaFileRepository
|
||||
|
||||
starredIDs := func() []string {
|
||||
starred, err := repo.GetAll(model.QueryOptions{Filters: squirrel.Eq{"starred": true}})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ids := make([]string, 0, len(starred))
|
||||
for _, mf := range starred {
|
||||
ids = append(ids, mf.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
// The logged-in user scopes every annotation (loggedUser(ctx).ID). adminUser
|
||||
// (ID "userid") is seeded and associated with the default test library, so it
|
||||
// stands in for the authenticated user a real /rest/star request would carry.
|
||||
ctx := request.WithUser(context.Background(), adminUser)
|
||||
repo = NewMediaFileRepository(ctx, GetDBXBuilder())
|
||||
})
|
||||
|
||||
// BE-INT-01 — Star an existing song successfully.
|
||||
Describe("Star an existing song", func() {
|
||||
// songRadioactivity (ID "1003") is an existing song seeded by BeforeSuite.
|
||||
const songID = "1003"
|
||||
|
||||
BeforeEach(func() {
|
||||
// Known starting state: the song must not be starred yet.
|
||||
Expect(repo.SetStar(false, songID)).To(Succeed())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
// Leave the shared in-memory DB clean for other specs.
|
||||
Expect(repo.SetStar(false, songID)).To(Succeed())
|
||||
})
|
||||
|
||||
It("stars the song and returns it in the starred list", func() {
|
||||
// Pre-condition: the existing song is reachable and not starred.
|
||||
before, err := repo.Get(songID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(before.Starred).To(BeFalse())
|
||||
|
||||
// Act — the "star request": real upsert into the annotation table.
|
||||
Expect(repo.SetStar(true, songID)).To(Succeed())
|
||||
|
||||
// Verify (1): re-reading the song through the real annotated query shows it
|
||||
// persisted, with a starred_at timestamp.
|
||||
after, err := repo.Get(songID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(after.Starred).To(BeTrue())
|
||||
Expect(after.StarredAt).ToNot(BeNil())
|
||||
|
||||
// Verify (2): the song appears in the favorites/starred list.
|
||||
Expect(starredIDs()).To(ContainElement(songID))
|
||||
})
|
||||
})
|
||||
|
||||
// BE-INT-02 — Unstar a previously starred song successfully.
|
||||
Describe("Unstar a previously starred song", func() {
|
||||
// songDayInALife (ID "1001") is an existing song seeded by BeforeSuite.
|
||||
const songID = "1001"
|
||||
|
||||
BeforeEach(func() {
|
||||
// Pre-condition for unstar: the song must already be starred.
|
||||
Expect(repo.SetStar(true, songID)).To(Succeed())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
// Leave the shared in-memory DB clean for other specs.
|
||||
Expect(repo.SetStar(false, songID)).To(Succeed())
|
||||
})
|
||||
|
||||
It("unstars the song and removes it from the starred list", func() {
|
||||
// Pre-condition: the existing song is reachable and currently starred.
|
||||
before, err := repo.Get(songID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(before.Starred).To(BeTrue())
|
||||
|
||||
// Act — the "unstar request": real upsert into the annotation table.
|
||||
Expect(repo.SetStar(false, songID)).To(Succeed())
|
||||
|
||||
// Verify (1): re-reading the song through the real annotated query shows it
|
||||
// is no longer starred.
|
||||
after, err := repo.Get(songID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(after.Starred).To(BeFalse())
|
||||
|
||||
// Verify (2): the song no longer appears in the favorites/starred list.
|
||||
Expect(starredIDs()).ToNot(ContainElement(songID))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1 +1 @@
|
||||
-s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -- go run -race -tags netgo,sqlite_fts5 .
|
||||
-s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -- go run -tags netgo,sqlite_fts5 .
|
||||
|
||||
@ -2,8 +2,10 @@ package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
@ -33,8 +35,6 @@ var _ = Describe("MediaAnnotationController", func() {
|
||||
|
||||
Describe("Scrobble", func() {
|
||||
It("submit all scrobbles with only the id", func() {
|
||||
// Back-date the baseline so the assertion still passes on platforms
|
||||
// with millisecond clock resolution (e.g. Windows).
|
||||
submissionTime := time.Now().Add(-time.Second)
|
||||
r := newGetRequest("id=12", "id=34")
|
||||
|
||||
@ -102,6 +102,119 @@ var _ = Describe("MediaAnnotationController", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Star/Unstar songs", func() {
|
||||
// recordingMediaFileRepo is a spy: it records every SetStar call so tests
|
||||
// can assert on whether and how the repository was invoked.
|
||||
var mediaRepo *recordingMediaFileRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
// Fresh spy before each test so recorded calls don't bleed between cases.
|
||||
mediaRepo = &recordingMediaFileRepo{}
|
||||
// alwaysMissingAlbumRepo and alwaysMissingArtistRepo are stubs: they
|
||||
// always report Exists=false, steering the handler to treat every id as
|
||||
// a media-file id without needing real album/artist data in the DB.
|
||||
ds.(*tests.MockDataStore).MockedAlbum = &alwaysMissingAlbumRepo{}
|
||||
ds.(*tests.MockDataStore).MockedArtist = &alwaysMissingArtistRepo{}
|
||||
// Inject the spy into the mock data store so the router uses it.
|
||||
ds.(*tests.MockDataStore).MockedMediaFile = mediaRepo
|
||||
})
|
||||
|
||||
It("stars a song by id", func() {
|
||||
// newGetRequest builds a pre-authenticated fake HTTP request; no real
|
||||
// network or auth middleware is involved.
|
||||
resp, err := router.Star(newGetRequest("id=song-1"))
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
// Spy assertion: verify the repository received the correct arguments.
|
||||
Expect(mediaRepo.SetStarCalls).To(Equal([]setStarCall{{Starred: true, ItemIDs: []string{"song-1"}}}))
|
||||
// fakeEventBroker is a spy: it captures broadcast events so we can
|
||||
// verify the success notification was fired with the right payload.
|
||||
Expect(eventBroker.Events).To(HaveLen(1))
|
||||
Expect(eventBroker.Events[0].Data(eventBroker.Events[0])).To(Equal(`{"song":["song-1"]}`))
|
||||
})
|
||||
|
||||
It("unstars a song by id", func() {
|
||||
resp, err := router.Unstar(newGetRequest("id=song-1"))
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(mediaRepo.SetStarCalls).To(Equal([]setStarCall{{Starred: false, ItemIDs: []string{"song-1"}}}))
|
||||
Expect(eventBroker.Events).To(HaveLen(1))
|
||||
Expect(eventBroker.Events[0].Data(eventBroker.Events[0])).To(Equal(`{"song":["song-1"]}`))
|
||||
})
|
||||
|
||||
// FAV-03: missing id parameter must be rejected before touching any dependency.
|
||||
It("stars returns error when no id parameter is provided", func() {
|
||||
_, err := router.Star(newGetRequest())
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
// Spy confirms the repository was never reached — validation failed first.
|
||||
Expect(mediaRepo.SetStarCalls).To(BeEmpty())
|
||||
// Spy confirms no event was broadcast on failure.
|
||||
Expect(eventBroker.Events).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("unstars returns error when no id parameter is provided", func() {
|
||||
_, err := router.Unstar(newGetRequest())
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(mediaRepo.SetStarCalls).To(BeEmpty())
|
||||
Expect(eventBroker.Events).To(BeEmpty())
|
||||
})
|
||||
|
||||
// FAV-05: repository failure must propagate and suppress the success event.
|
||||
It("returns error and calls repository when star persistence fails", func() {
|
||||
// Sabotage the spy by setting its Err field — this turns it into a stub
|
||||
// that returns a controlled error, simulating a DB write failure.
|
||||
mediaRepo.Err = errors.New("db failure")
|
||||
_, err := router.Star(newGetRequest("id=song-1"))
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
// Spy confirms the repository WAS called — the failure is from persistence,
|
||||
// not from input validation.
|
||||
Expect(mediaRepo.SetStarCalls).To(HaveLen(1))
|
||||
// No event should be broadcast when the write fails.
|
||||
Expect(eventBroker.Events).To(BeEmpty())
|
||||
})
|
||||
|
||||
// FAV-06: same as FAV-05 for the Unstar path.
|
||||
It("returns error and calls repository when unstar persistence fails", func() {
|
||||
mediaRepo.Err = errors.New("db failure")
|
||||
_, err := router.Unstar(newGetRequest("id=song-1"))
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(mediaRepo.SetStarCalls).To(HaveLen(1))
|
||||
Expect(eventBroker.Events).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("rejects unauthenticated star requests before favoriting the song", func() {
|
||||
// Use httptest.ResponseRecorder to exercise the full HTTP stack including
|
||||
// auth middleware, rather than calling the handler method directly.
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/star?u=missing&v=1.16.1&c=test&id=song-1", nil)
|
||||
|
||||
router.ServeHTTP(w, r)
|
||||
|
||||
// Auth middleware returns error code 40 before the handler runs.
|
||||
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
||||
// Spy confirms the repository was never reached.
|
||||
Expect(mediaRepo.SetStarCalls).To(BeEmpty())
|
||||
Expect(eventBroker.Events).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("rejects unauthenticated unstar requests before unfavoriting the song", func() {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/unstar?u=missing&v=1.16.1&c=test&id=song-1", nil)
|
||||
|
||||
router.ServeHTTP(w, r)
|
||||
|
||||
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
||||
Expect(mediaRepo.SetStarCalls).To(BeEmpty())
|
||||
Expect(eventBroker.Events).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ReportPlayback", func() {
|
||||
It("returns error when mediaId is missing", func() {
|
||||
r := newGetRequest("mediaType=song", "positionMs=0", "state=playing")
|
||||
@ -215,6 +328,41 @@ func (f *fakePlayTracker) ReportPlayback(_ context.Context, params scrobbler.Rep
|
||||
|
||||
var _ scrobbler.PlayTracker = (*fakePlayTracker)(nil)
|
||||
|
||||
type setStarCall struct {
|
||||
Starred bool
|
||||
ItemIDs []string
|
||||
}
|
||||
|
||||
type recordingMediaFileRepo struct {
|
||||
model.MediaFileRepository
|
||||
SetStarCalls []setStarCall
|
||||
Err error
|
||||
}
|
||||
|
||||
func (r *recordingMediaFileRepo) SetStar(starred bool, itemIDs ...string) error {
|
||||
r.SetStarCalls = append(r.SetStarCalls, setStarCall{
|
||||
Starred: starred,
|
||||
ItemIDs: append([]string(nil), itemIDs...),
|
||||
})
|
||||
return r.Err
|
||||
}
|
||||
|
||||
type alwaysMissingAlbumRepo struct {
|
||||
model.AlbumRepository
|
||||
}
|
||||
|
||||
func (r *alwaysMissingAlbumRepo) Exists(string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
type alwaysMissingArtistRepo struct {
|
||||
model.ArtistRepository
|
||||
}
|
||||
|
||||
func (r *alwaysMissingArtistRepo) Exists(string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
type fakeEventBroker struct {
|
||||
http.Handler
|
||||
Events []events.Event
|
||||
|
||||
66
testing-flows.md
Normal file
66
testing-flows.md
Normal file
@ -0,0 +1,66 @@
|
||||
# Testing Flows
|
||||
|
||||
## Add to Favorites
|
||||
|
||||
Stars a song (or album/artist) using the Subsonic `star` endpoint.
|
||||
|
||||
**Request**
|
||||
|
||||
```
|
||||
GET /rest/star
|
||||
```
|
||||
|
||||
Query parameters:
|
||||
|
||||
| Parameter | Value | Description |
|
||||
|-----------|-------|-------------|
|
||||
| `u` | `admin` | Username |
|
||||
| `t` | `47b1fbf1aaa6b36d0dec0c8ae0b1a6ef` | Auth token (MD5 of password + salt) |
|
||||
| `s` | `701805` | Salt used to generate the token |
|
||||
| `f` | `json` | Response format |
|
||||
| `v` | `1.8.0` | Subsonic API version |
|
||||
| `c` | `NavidromeUI` | Client name |
|
||||
| `id` | `qORBlifrm7cjK4yDgfy5Zy` | ID of the song/album/artist to star |
|
||||
|
||||
**Example request (dev)**
|
||||
|
||||
```bash
|
||||
curl "http://localhost:4533/rest/star?u=admin&t=47b1fbf1aaa6b36d0dec0c8ae0b1a6ef&s=701805&f=json&v=1.8.0&c=NavidromeUI&id=qORBlifrm7cjK4yDgfy5Zy"
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"subsonic-response": {
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "dev",
|
||||
"openSubsonic": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A `status: "ok"` with no error field means the item was successfully starred.
|
||||
|
||||
## Running Star/Unstar Tests
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
go test ./server/subsonic/ --ginkgo.focus="Star/Unstar songs"
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
npm --prefix ui test -- useToggleLove.test.js --run
|
||||
```
|
||||
|
||||
### Database
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml exec backend \
|
||||
go test ./tests/db -run TestStarEndpoint -
|
||||
```
|
||||
446
tests/db/annotation_star_test.go
Normal file
446
tests/db/annotation_star_test.go
Normal file
@ -0,0 +1,446 @@
|
||||
package persistence_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Test suite para el flujo /rest/star - Marcar canción como favorita
|
||||
// Valida todas las reglas de integridad en la tabla ANNOTATION
|
||||
|
||||
func TestStarEndpoint(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Star Endpoint - BD Tests")
|
||||
}
|
||||
|
||||
var _ = Describe("Star Endpoint DB Tests", func() {
|
||||
var database *sql.DB
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
// Usar in-memory SQLite para tests rápidos
|
||||
path := "file::memory:?cache=shared&_foreign_keys=on"
|
||||
database, _ = sql.Open(db.Dialect, path)
|
||||
ctx = context.Background()
|
||||
|
||||
// Crear schema completo (mínimo para los tests)
|
||||
_, err := database.ExecContext(ctx, `
|
||||
CREATE TABLE user (
|
||||
id VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
user_name VARCHAR(255) NOT NULL UNIQUE,
|
||||
name VARCHAR(255),
|
||||
email VARCHAR(255),
|
||||
password VARCHAR(255),
|
||||
is_admin BOOLEAN DEFAULT FALSE,
|
||||
last_login_at DATETIME,
|
||||
last_access_at DATETIME,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE media_file (
|
||||
id VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
path VARCHAR(255) NOT NULL,
|
||||
title VARCHAR(255),
|
||||
album_id VARCHAR(255),
|
||||
artist_id VARCHAR(255),
|
||||
album_artist VARCHAR(255),
|
||||
duration REAL DEFAULT 0,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
);
|
||||
|
||||
CREATE TABLE annotation (
|
||||
user_id VARCHAR(255) NOT NULL REFERENCES user(id) ON DELETE CASCADE,
|
||||
item_id VARCHAR(255) NOT NULL,
|
||||
item_type VARCHAR(255) NOT NULL,
|
||||
play_count INTEGER,
|
||||
play_date DATETIME,
|
||||
rating INTEGER,
|
||||
starred BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
starred_at DATETIME,
|
||||
rating_date DATETIME,
|
||||
UNIQUE (user_id, item_id, item_type)
|
||||
);
|
||||
|
||||
CREATE INDEX annotation_user_id ON annotation(user_id);
|
||||
CREATE INDEX annotation_item ON annotation(item_id, item_type);
|
||||
`)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
database.Close()
|
||||
})
|
||||
|
||||
// ================== TEST 1: Foreign Key Constraint ==================
|
||||
Describe("TEST 1: Foreign Key Constraint ANNOTATION -> USER", func() {
|
||||
It("debe rechazar inserción de annotation con user_id inválido", func() {
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
"INVALID_USER_ID", "song123", "song", true)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("FOREIGN KEY constraint failed"))
|
||||
})
|
||||
|
||||
It("debe permitir inserción si user_id existe", func() {
|
||||
// Setup: crear usuario
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
"admin", "admin", "hashedpwd", true, time.Now(), time.Now())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Act: insertar annotation
|
||||
_, err = database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
"admin", "song123", "song", true)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Verify
|
||||
var starred bool
|
||||
err = database.QueryRowContext(ctx,
|
||||
`SELECT starred FROM annotation WHERE user_id = ? AND item_id = ?`,
|
||||
"admin", "song123").Scan(&starred)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(starred).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
// ================== TEST 2: NOT NULL Constraints ==================
|
||||
Describe("TEST 2: NOT NULL Constraints", func() {
|
||||
BeforeEach(func() {
|
||||
// Setup: crear usuario
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
"admin", "admin", "hashedpwd", true, time.Now(), time.Now())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("debe rechazar si user_id es NULL", func() {
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (NULL, ?, ?, ?)`,
|
||||
"song123", "song", true)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("debe rechazar si item_id es NULL", func() {
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, NULL, ?, ?)`,
|
||||
"admin", "song", true)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("debe rechazar si item_type es NULL", func() {
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, ?, NULL, ?)`,
|
||||
"admin", "song123", true)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("debe rechazar si starred es NULL", func() {
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, ?, ?, NULL)`,
|
||||
"admin", "song123", "song")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
// ================== TEST 3: Flujo Completo ==================
|
||||
Describe("TEST 3: Flujo Completo - INSERT Media + Annotation + JOIN", func() {
|
||||
It("debe permitir insertar canción y marcar como favorita con JOIN", func() {
|
||||
// Setup
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
"admin", "admin", "hashedpwd", true, time.Now(), time.Now())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Insert media_file
|
||||
_, err = database.ExecContext(ctx,
|
||||
`INSERT INTO media_file (id, path, title) VALUES (?, ?, ?)`,
|
||||
"song123", "/path/to/song.mp3", "My Favorite Song")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Insert annotation (star)
|
||||
_, err = database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred, starred_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
"admin", "song123", "song", true, time.Now())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Verify con JOIN
|
||||
var title string
|
||||
var starred bool
|
||||
var starredAt sql.NullTime
|
||||
|
||||
err = database.QueryRowContext(ctx, `
|
||||
SELECT mf.title, ann.starred, ann.starred_at
|
||||
FROM media_file mf
|
||||
JOIN annotation ann ON mf.id = ann.item_id
|
||||
WHERE ann.user_id = ? AND ann.item_type = 'song'
|
||||
`, "admin").Scan(&title, &starred, &starredAt)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(title).To(Equal("My Favorite Song"))
|
||||
Expect(starred).To(BeTrue())
|
||||
Expect(starredAt.Valid).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
// ================== TEST 4: UPDATE Favorita ==================
|
||||
Describe("TEST 4: UPDATE - Marcar/Desmarcar Favorita", func() {
|
||||
BeforeEach(func() {
|
||||
// Setup
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
"admin", "admin", "hashedpwd", true, time.Now(), time.Now())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
"admin", "song123", "song", false)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("debe marcar como favorita con UPDATE", func() {
|
||||
starredAt := time.Now()
|
||||
result, err := database.ExecContext(ctx, `
|
||||
UPDATE annotation SET starred = ?, starred_at = ?
|
||||
WHERE user_id = ? AND item_id = ? AND item_type = ?
|
||||
`, true, starredAt, "admin", "song123", "song")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
affected, err := result.RowsAffected()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(affected).To(Equal(int64(1)))
|
||||
|
||||
var starred bool
|
||||
err = database.QueryRowContext(ctx,
|
||||
`SELECT starred FROM annotation WHERE user_id = ? AND item_id = ?`,
|
||||
"admin", "song123").Scan(&starred)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(starred).To(BeTrue())
|
||||
})
|
||||
|
||||
It("debe desmarcar favorita", func() {
|
||||
result, err := database.ExecContext(ctx, `
|
||||
UPDATE annotation SET starred = ?, starred_at = NULL
|
||||
WHERE user_id = ? AND item_id = ? AND item_type = ?
|
||||
`, false, "admin", "song123", "song")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
affected, err := result.RowsAffected()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(affected).To(Equal(int64(1)))
|
||||
})
|
||||
})
|
||||
|
||||
// ================== TEST 5: UNIQUE Constraint ==================
|
||||
Describe("TEST 5: UNIQUE Constraint (user_id, item_id, item_type)", func() {
|
||||
BeforeEach(func() {
|
||||
// Setup
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
"admin", "admin", "hashedpwd", true, time.Now(), time.Now())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
"admin", "song123", "song", true)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("debe rechazar inserción duplicada del mismo (user, item, type)", func() {
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
"admin", "song123", "song", false)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("UNIQUE constraint failed"))
|
||||
})
|
||||
|
||||
It("debe permitir el mismo item_id pero diferente user", func() {
|
||||
// Setup: crear otro usuario
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
"user2", "user2", "hashedpwd", false, time.Now(), time.Now())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Insertar annotation para otro usuario sobre el mismo song
|
||||
_, err = database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
"user2", "song123", "song", false)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Verify ambos existen
|
||||
var count int
|
||||
err = database.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM annotation WHERE item_id = ?`,
|
||||
"song123").Scan(&count)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(count).To(Equal(2))
|
||||
})
|
||||
|
||||
It("debe permitir el mismo item_id pero diferente item_type", func() {
|
||||
// El mismo ID podría ser album o artist
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
"admin", "song123", "album", false)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
// ================== TEST 9: ON DELETE CASCADE ==================
|
||||
Describe("TEST 9: ON DELETE CASCADE - User Deletion (trigger-based only)", func() {
|
||||
BeforeEach(func() {
|
||||
// Skip this set if there is no trigger-based cascade detected.
|
||||
var trigSQL sql.NullString
|
||||
err := database.QueryRowContext(ctx,
|
||||
`SELECT sql FROM sqlite_master WHERE type = 'trigger' AND sql LIKE '%annotation%' AND sql LIKE '%DELETE%' LIMIT 1`).Scan(&trigSQL)
|
||||
if err != nil || !trigSQL.Valid {
|
||||
Skip("No trigger-based cascade detected; skipping CASCADE test")
|
||||
}
|
||||
|
||||
// Setup
|
||||
_, err = database.ExecContext(ctx,
|
||||
`INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
"test_user", "testuser", "hashedpwd", false, time.Now(), time.Now())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
"test_user", "song123", "song", true)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("debe borrar annotations quando se borra el usuario (CASCADE)", func() {
|
||||
// Verify que existen
|
||||
var count int
|
||||
err := database.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM annotation WHERE user_id = ?`,
|
||||
"test_user").Scan(&count)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(count).To(Equal(1))
|
||||
|
||||
// Delete user
|
||||
_, err = database.ExecContext(ctx,
|
||||
`DELETE FROM user WHERE id = ?`, "test_user")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Verify annotations se borraron en cascada
|
||||
err = database.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM annotation WHERE user_id = ?`,
|
||||
"test_user").Scan(&count)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(count).To(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
// ================== TEST 6: Polimorfismo - item_type (conditional) ==================
|
||||
Describe("TEST 6: Polimorfismo - Diferentes item_type (solo si hay CHECK constraint)", func() {
|
||||
BeforeEach(func() {
|
||||
// Verificar si la tabla annotation tiene un CHECK constraint real sobre item_type
|
||||
var tableSQL sql.NullString
|
||||
err := database.QueryRowContext(ctx,
|
||||
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'annotation'`,
|
||||
).Scan(&tableSQL)
|
||||
|
||||
if err != nil || !tableSQL.Valid {
|
||||
Skip("annotation table not present; skipping polymorphism tests")
|
||||
}
|
||||
|
||||
up := strings.ToUpper(tableSQL.String)
|
||||
|
||||
// Solo ejecutar este test si realmente existe un CHECK constraint
|
||||
if !strings.Contains(up, "CHECK") {
|
||||
Skip("No CHECK constraint found on item_type; skipping polymorphism tests")
|
||||
}
|
||||
|
||||
_, err = database.ExecContext(ctx,
|
||||
`INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
"admin", "admin", "hashedpwd", true, time.Now(), time.Now())
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("debe validar los item_type permitidos en la tabla annotation", func() {
|
||||
allowed := []string{"song", "album", "artist"}
|
||||
|
||||
for _, t := range allowed {
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
"admin", "item-"+t, t, true)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
}
|
||||
|
||||
// Este insert solo debería fallar si existe CHECK constraint
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
"admin", "item-bad", "not_a_type", true)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
// ================== TEST 12: NULL Optional Fields ==================
|
||||
Describe("TEST 12: NULL Optional Fields", func() {
|
||||
BeforeEach(func() {
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO user (id, user_name, password, is_admin, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
"admin", "admin", "hashedpwd", true, time.Now(), time.Now())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("debe permitir NULL en play_count, rating, play_date", func() {
|
||||
_, err := database.ExecContext(ctx,
|
||||
`INSERT INTO annotation (user_id, item_id, item_type, starred, play_count, rating, play_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
"admin", "song456", "song", true, nil, nil, nil)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
var playCount sql.NullInt64
|
||||
var rating sql.NullInt64
|
||||
var playDate sql.NullTime
|
||||
err = database.QueryRowContext(ctx,
|
||||
`SELECT play_count, rating, play_date FROM annotation WHERE user_id = ? AND item_id = ?`,
|
||||
"admin", "song456").Scan(&playCount, &rating, &playDate)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(playCount.Valid).To(BeFalse())
|
||||
Expect(rating.Valid).To(BeFalse())
|
||||
Expect(playDate.Valid).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
1
ui/dev-dist/registerSW.js
Normal file
1
ui/dev-dist/registerSW.js
Normal file
@ -0,0 +1 @@
|
||||
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||
121
ui/src/common/ContextMenus.test.jsx
Normal file
121
ui/src/common/ContextMenus.test.jsx
Normal file
@ -0,0 +1,121 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { AlbumContextMenu, ArtistContextMenu } from './ContextMenus'
|
||||
|
||||
const capturedLoveButtonProps = {}
|
||||
|
||||
vi.mock('./LoveButton', () => ({
|
||||
LoveButton: (props) => {
|
||||
Object.assign(capturedLoveButtonProps, props)
|
||||
return props.visible ? <button data-testid="love-button" /> : null
|
||||
},
|
||||
}))
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({
|
||||
enableFavourites: true,
|
||||
enableDownloads: false,
|
||||
enableSharing: false,
|
||||
}))
|
||||
|
||||
vi.mock('../config', () => ({ default: mockConfig }))
|
||||
|
||||
vi.mock('react-redux', () => ({ useDispatch: () => vi.fn() }))
|
||||
|
||||
vi.mock('react-admin', async () => {
|
||||
const actual = await vi.importActual('react-admin')
|
||||
return {
|
||||
...actual,
|
||||
useDataProvider: () => ({ getList: vi.fn() }),
|
||||
useNotify: () => vi.fn(),
|
||||
useTranslate: () => (key) => key,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../utils', async () => {
|
||||
const actual = await vi.importActual('../utils')
|
||||
return { ...actual, formatBytes: vi.fn(() => '1 KB') }
|
||||
})
|
||||
|
||||
const albumRecord = { id: 'album-1', name: 'Test Album', size: 1000 }
|
||||
const artistRecord = { id: 'artist-1', name: 'Test Artist', size: 1000 }
|
||||
|
||||
describe('AlbumContextMenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.keys(capturedLoveButtonProps).forEach((k) => delete capturedLoveButtonProps[k])
|
||||
mockConfig.enableFavourites = true
|
||||
})
|
||||
|
||||
it('renders LoveButton when enableFavourites is true', () => {
|
||||
render(<AlbumContextMenu record={albumRecord} />)
|
||||
expect(screen.getByTestId('love-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render LoveButton when enableFavourites is false', () => {
|
||||
mockConfig.enableFavourites = false
|
||||
render(<AlbumContextMenu record={albumRecord} />)
|
||||
expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render LoveButton when showLove is false', () => {
|
||||
render(<AlbumContextMenu record={albumRecord} showLove={false} />)
|
||||
expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes resource="album" to LoveButton', () => {
|
||||
render(<AlbumContextMenu record={albumRecord} />)
|
||||
expect(capturedLoveButtonProps.resource).toBe('album')
|
||||
})
|
||||
|
||||
it('hides LoveButton when record.missing is true', () => {
|
||||
render(<AlbumContextMenu record={{ ...albumRecord, missing: true }} />)
|
||||
expect(capturedLoveButtonProps.visible).toBe(false)
|
||||
expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nothing when record is undefined', () => {
|
||||
const { container } = render(<AlbumContextMenu />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ArtistContextMenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.keys(capturedLoveButtonProps).forEach((k) => delete capturedLoveButtonProps[k])
|
||||
mockConfig.enableFavourites = true
|
||||
})
|
||||
|
||||
it('renders LoveButton when enableFavourites is true', () => {
|
||||
render(<ArtistContextMenu record={artistRecord} />)
|
||||
expect(screen.getByTestId('love-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render LoveButton when enableFavourites is false', () => {
|
||||
mockConfig.enableFavourites = false
|
||||
render(<ArtistContextMenu record={artistRecord} />)
|
||||
expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render LoveButton when showLove is false', () => {
|
||||
render(<ArtistContextMenu record={artistRecord} showLove={false} />)
|
||||
expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes resource="artist" to LoveButton', () => {
|
||||
render(<ArtistContextMenu record={artistRecord} />)
|
||||
expect(capturedLoveButtonProps.resource).toBe('artist')
|
||||
})
|
||||
|
||||
it('hides LoveButton when record.missing is true', () => {
|
||||
render(<ArtistContextMenu record={{ ...artistRecord, missing: true }} />)
|
||||
expect(capturedLoveButtonProps.visible).toBe(false)
|
||||
expect(screen.queryByTestId('love-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nothing when record is undefined', () => {
|
||||
const { container } = render(<ArtistContextMenu />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
122
ui/src/common/LoveButton.test.jsx
Normal file
122
ui/src/common/LoveButton.test.jsx
Normal file
@ -0,0 +1,122 @@
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { LoveButton } from './LoveButton'
|
||||
import { useToggleLove } from './useToggleLove'
|
||||
import { useRecordContext } from 'react-admin'
|
||||
import { isDateSet } from '../utils/validations'
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({ enableFavourites: true }))
|
||||
|
||||
vi.mock('../config', () => ({ default: mockConfig }))
|
||||
|
||||
vi.mock('./useToggleLove', () => ({
|
||||
useToggleLove: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-admin', async () => {
|
||||
const actual = await vi.importActual('react-admin')
|
||||
return {
|
||||
...actual,
|
||||
useRecordContext: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../utils/validations', () => ({
|
||||
isDateSet: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@material-ui/icons/Favorite', () => ({
|
||||
default: () => <span data-testid="favorite-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('@material-ui/icons/FavoriteBorder', () => ({
|
||||
default: () => <span data-testid="favorite-border-icon" />,
|
||||
}))
|
||||
|
||||
describe('LoveButton', () => {
|
||||
const mockToggleLove = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockConfig.enableFavourites = true
|
||||
useToggleLove.mockReturnValue([mockToggleLove, false])
|
||||
useRecordContext.mockReturnValue({ id: 'song-1', starred: false })
|
||||
isDateSet.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('renders nothing when enableFavourites is false', () => {
|
||||
mockConfig.enableFavourites = false
|
||||
const { container } = render(<LoveButton resource="song" />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('renders a button when enableFavourites is true', () => {
|
||||
render(<LoveButton resource="song" />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is disabled when loading is true', () => {
|
||||
useToggleLove.mockReturnValue([mockToggleLove, true])
|
||||
render(<LoveButton resource="song" />)
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('is disabled when record.missing is true', () => {
|
||||
useRecordContext.mockReturnValue({ id: 'song-1', starred: false, missing: true })
|
||||
render(<LoveButton resource="song" />)
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('is disabled when disabled prop is true', () => {
|
||||
render(<LoveButton resource="song" disabled={true} />)
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('calls toggleLove when clicked', () => {
|
||||
render(<LoveButton resource="song" />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(mockToggleLove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops click propagation to parent elements', () => {
|
||||
const parentClick = vi.fn()
|
||||
render(
|
||||
<div onClick={parentClick}>
|
||||
<LoveButton resource="song" />
|
||||
</div>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(parentClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows starredAt date as title when starredAt is set', () => {
|
||||
const starredAt = '2024-01-15T12:00:00Z'
|
||||
useRecordContext.mockReturnValue({ id: 'song-1', starred: true, starredAt })
|
||||
isDateSet.mockReturnValue(true)
|
||||
render(<LoveButton resource="song" />)
|
||||
expect(screen.getByRole('button')).toHaveAttribute(
|
||||
'title',
|
||||
new Date(starredAt).toLocaleString(),
|
||||
)
|
||||
expect(isDateSet).toHaveBeenCalledWith(starredAt)
|
||||
})
|
||||
|
||||
it('has no title attribute when starredAt is not set', () => {
|
||||
render(<LoveButton resource="song" />)
|
||||
expect(screen.getByRole('button')).not.toHaveAttribute('title')
|
||||
})
|
||||
|
||||
it('renders FavoriteIcon when record is starred', () => {
|
||||
useRecordContext.mockReturnValue({ id: 'song-1', starred: true })
|
||||
render(<LoveButton resource="song" />)
|
||||
expect(screen.getByTestId('favorite-icon')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('favorite-border-icon')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders FavoriteBorderIcon when record is not starred', () => {
|
||||
render(<LoveButton resource="song" />)
|
||||
expect(screen.getByTestId('favorite-border-icon')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('favorite-icon')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -2,7 +2,7 @@ import { renderHook, act } from '@testing-library/react-hooks'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useToggleLove } from './useToggleLove'
|
||||
import subsonic from '../subsonic'
|
||||
import { useDataProvider } from 'react-admin'
|
||||
import { useDataProvider, useNotify } from 'react-admin'
|
||||
|
||||
vi.mock('../subsonic', () => ({
|
||||
default: {
|
||||
@ -16,16 +16,21 @@ vi.mock('react-admin', async () => {
|
||||
return {
|
||||
...actual,
|
||||
useDataProvider: vi.fn(),
|
||||
useNotify: vi.fn(() => vi.fn()),
|
||||
useNotify: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
describe('useToggleLove', () => {
|
||||
let getOne
|
||||
let mockNotify
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockNotify = vi.fn()
|
||||
getOne = vi.fn(() => Promise.resolve())
|
||||
useDataProvider.mockReturnValue({ getOne })
|
||||
vi.clearAllMocks()
|
||||
useNotify.mockReturnValue(mockNotify)
|
||||
subsonic.star.mockResolvedValue()
|
||||
subsonic.unstar.mockResolvedValue()
|
||||
})
|
||||
|
||||
it('uses mediaFileId when present', async () => {
|
||||
@ -35,6 +40,8 @@ describe('useToggleLove', () => {
|
||||
await result.current[0]()
|
||||
})
|
||||
expect(subsonic.star).toHaveBeenCalledWith('sg-1')
|
||||
expect(getOne).toHaveBeenCalledTimes(2)
|
||||
expect(getOne).toHaveBeenCalledWith('song', { id: 'pt-1' })
|
||||
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
|
||||
})
|
||||
|
||||
@ -57,6 +64,15 @@ describe('useToggleLove', () => {
|
||||
expect(subsonic.unstar).toHaveBeenCalledWith('sg-1')
|
||||
})
|
||||
|
||||
it('does not call notify on a successful toggle', async () => {
|
||||
const record = { id: 'sg-1', starred: false }
|
||||
const { result } = renderHook(() => useToggleLove('song', record))
|
||||
await act(async () => {
|
||||
await result.current[0]()
|
||||
})
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('playlist track scenarios', () => {
|
||||
it('refreshes both playlist track and song for playlist tracks', async () => {
|
||||
const record = {
|
||||
@ -133,4 +149,96 @@ describe('useToggleLove', () => {
|
||||
expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('calls notify when star() fails', async () => {
|
||||
subsonic.star.mockRejectedValue(new Error('Network error'))
|
||||
const record = { id: 'sg-1', starred: false }
|
||||
const { result } = renderHook(() => useToggleLove('song', record))
|
||||
await act(async () => {
|
||||
await result.current[0]()
|
||||
})
|
||||
expect(mockNotify).toHaveBeenCalledWith('ra.page.error', 'warning')
|
||||
})
|
||||
|
||||
it('calls notify when unstar() fails', async () => {
|
||||
subsonic.unstar.mockRejectedValue(new Error('Network error'))
|
||||
const record = { id: 'sg-1', starred: true }
|
||||
const { result } = renderHook(() => useToggleLove('song', record))
|
||||
await act(async () => {
|
||||
await result.current[0]()
|
||||
})
|
||||
expect(mockNotify).toHaveBeenCalledWith('ra.page.error', 'warning')
|
||||
})
|
||||
|
||||
it('does not call notify when refresh fails after a successful toggle', async () => {
|
||||
getOne.mockRejectedValue(new Error('refresh failed'))
|
||||
const record = { id: 'sg-1', starred: false }
|
||||
const { result } = renderHook(() => useToggleLove('song', record))
|
||||
await act(async () => {
|
||||
await result.current[0]()
|
||||
})
|
||||
expect(subsonic.star).toHaveBeenCalledWith('sg-1')
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
expect(result.current[1]).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading state', () => {
|
||||
it('is true while the API call is pending and false after resolve', async () => {
|
||||
let resolveToggle
|
||||
subsonic.star.mockReturnValue(new Promise((r) => { resolveToggle = r }))
|
||||
const record = { id: 'sg-1', starred: false }
|
||||
const { result } = renderHook(() => useToggleLove('song', record))
|
||||
|
||||
act(() => { result.current[0]() })
|
||||
expect(result.current[1]).toBe(true)
|
||||
|
||||
await act(async () => { resolveToggle() })
|
||||
expect(result.current[1]).toBe(false)
|
||||
})
|
||||
|
||||
it('returns to false even when the API call fails', async () => {
|
||||
subsonic.star.mockRejectedValue(new Error('fail'))
|
||||
const record = { id: 'sg-1', starred: false }
|
||||
const { result } = renderHook(() => useToggleLove('song', record))
|
||||
await act(async () => {
|
||||
await result.current[0]()
|
||||
})
|
||||
expect(result.current[1]).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unmount safety', () => {
|
||||
it('does not warn when the promise resolves after unmount', async () => {
|
||||
let resolveStar
|
||||
subsonic.star.mockReturnValue(
|
||||
new Promise((r) => {
|
||||
resolveStar = r
|
||||
}),
|
||||
)
|
||||
const record = { id: 'sg-1', starred: false }
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useToggleLove('song', record),
|
||||
)
|
||||
act(() => {
|
||||
result.current[0]()
|
||||
})
|
||||
unmount()
|
||||
await act(async () => {
|
||||
resolveStar()
|
||||
})
|
||||
|
||||
const stateUpdateWarnings = errorSpy.mock.calls.filter(
|
||||
([msg]) =>
|
||||
typeof msg === 'string' &&
|
||||
msg.includes("Can't perform a React state update"),
|
||||
)
|
||||
expect(stateUpdateWarnings).toHaveLength(0)
|
||||
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
27
ui/src/common/useToggleLove.test.md
Normal file
27
ui/src/common/useToggleLove.test.md
Normal file
@ -0,0 +1,27 @@
|
||||
# useToggleLove — Documentación de tests
|
||||
|
||||
## Mocks globales
|
||||
|
||||
Aplicados en todos los tests vía `beforeEach`:
|
||||
|
||||
| Mock | Reemplaza |
|
||||
|---|---|
|
||||
| `subsonic.star` / `subsonic.unstar` | Llamadas HTTP a la API Subsonic |
|
||||
| `useDataProvider` → `{ getOne }` | Acceso al data provider de React Admin |
|
||||
| `useNotify` → `mockNotify` | Notificaciones que el hook dispara al usuario |
|
||||
|
||||
## Casos de prueba
|
||||
|
||||
| Función bajo prueba | Caso de prueba | Mocks utilizados |
|
||||
|---|---|---|
|
||||
| `toggleLove` (acción de star) | Usa `mediaFileId` para llamar a `star()` cuando el record lo tiene | `subsonic.star`, `dataProvider.getOne` |
|
||||
| `toggleLove` (acción de star) | Usa `record.id` como fallback cuando no hay `mediaFileId` | `subsonic.star`, `dataProvider.getOne` |
|
||||
| `toggleLove` (acción de unstar) | Llama a `unstar()` cuando el record ya tiene `starred: true` | `subsonic.unstar` |
|
||||
| `refreshRecord` (playlist track) | Hace `getOne` tanto al playlist track como a la song cuando hay `mediaFileId` + `playlistId` | `subsonic.star`, `dataProvider.getOne` (×2) |
|
||||
| `refreshRecord` (playlist track) | Incluye el filtro `playlist_id` al refrescar el playlist track | `subsonic.unstar`, `dataProvider.getOne` |
|
||||
| `refreshRecord` (song directa) | Solo hace un `getOne` al resource original cuando no hay `mediaFileId` | `subsonic.star`, `dataProvider.getOne` (×1) |
|
||||
| `refreshRecord` (song directa) | No incluye filtro `playlist_id` para recursos que no son playlist | `subsonic.star`, `dataProvider.getOne` |
|
||||
| `toggleLove` (error handling) | Llama a `notify` con mensaje de error cuando `star()` falla | `subsonic.star` (rechazado), `useNotify` |
|
||||
| `toggleLove` (error handling) | Llama a `notify` con mensaje de error cuando `unstar()` falla | `subsonic.unstar` (rechazado), `useNotify` |
|
||||
| `loading` (estado) | Es `true` mientras la llamada está pendiente y `false` al resolverse | `subsonic.star` (promesa manual con `resolveToggle`) |
|
||||
| `loading` (estado) | Vuelve a `false` incluso cuando la llamada falla | `subsonic.star` (rechazado) |
|
||||
@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
const frontendPort = parseInt(process.env.PORT) || 4533
|
||||
const backendHost = process.env.BACKEND_HOST || 'localhost'
|
||||
const backendPort = frontendPort + 100
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
@ -26,7 +27,7 @@ export default defineConfig({
|
||||
host: true,
|
||||
port: frontendPort,
|
||||
proxy: {
|
||||
'^/(auth|api|rest|backgrounds)/.*': 'http://localhost:' + backendPort,
|
||||
'^/(auth|api|rest|backgrounds)/.*': `http://${backendHost}:${backendPort}`,
|
||||
},
|
||||
},
|
||||
base: './',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user