Merge pull request #3 from juclopezso/lab1/team_omar_guiselle

Lab1/team omar guiselle
This commit is contained in:
juclopezso 2026-05-15 20:41:04 -05:00 committed by GitHub
commit 9b428a70d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 1781 additions and 0 deletions

238
docs/DIAGRAMA_ER.md Normal file
View 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
View 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&#xa;SQLite con Foreign Keys habilitadas&#xa;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>

View 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&#xa;last_login_at, last_access_at&#xa;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&#xa;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&#xa;last_scan_started_at&#xa;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&#xa;compilation, song_count, duration, genre&#xa;cover_art_path, mbid&#xa;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&#xa;year, size, suffix, duration, bit_rate&#xa;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&#xa;public, playlist_type, rules&#xa;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)&#xa;play_count, play_date, rating&#xa;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&#xa;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&#xa;changed_by, items, current_index&#xa;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&#xa;parent_id, num_audio_files&#xa;num_playlists, image_files&#xa;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&#xa;comment, position, changed_by&#xa;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)&#xa;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&#xa;created, last_visited, resource_ids&#xa;resource_type, visit_count&#xa;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&#xa;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)&#xa;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)&#xa;• ANNOTATION es polimórfico (item_id + item_type soportan múltiples entidades)&#xa;• SQLite con FTS5 para búsquedas full-text. Migraciones con Goose.&#xa;• 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>

View 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

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