Add artist and audio domain model

codex/redisenio-dominio-musical
dev 4 weeks ago
parent 3bcf92b638
commit 3708c84e90

@ -129,7 +129,6 @@ Relaciona un artista con una versión musical concreta.
| `rol` | `principal`, `invitado`, `coros` o `instrumentista` |
| `instrumento` | Opcional |
| `acreditado_como` | Nombre histórico de ese crédito, opcional |
| `principal` | Marca de presentación |
| `orden` | Orden del crédito |
La relación actual `cancion_persona` conservará temporalmente letra, música,

@ -0,0 +1,203 @@
-- Identidades artísticas, créditos de interpretación y representaciones de
-- audio. Todo es aditivo: las columnas audio_id/muestra_id y el rol interprete
-- anterior se mantienen hasta que las lecturas nuevas hayan demostrado paridad.
CREATE SEQUENCE "cancion_codigo_seq";--> statement-breakpoint
ALTER TABLE "cancion" ADD COLUMN "codigo" text;--> statement-breakpoint
WITH numeradas AS (
SELECT id, row_number() OVER (ORDER BY creado_en, id) AS numero
FROM cancion
)
UPDATE cancion c
SET codigo = 'CAN-' || lpad(n.numero::text, 6, '0')
FROM numeradas n
WHERE n.id = c.id;--> statement-breakpoint
SELECT setval(
'cancion_codigo_seq',
COALESCE((SELECT max(substring(codigo FROM 5)::bigint) FROM cancion), 1),
EXISTS (SELECT 1 FROM cancion)
);--> statement-breakpoint
ALTER TABLE "cancion" ALTER COLUMN "codigo" SET DEFAULT
('CAN-' || lpad(nextval('cancion_codigo_seq')::text, 6, '0'));--> statement-breakpoint
ALTER TABLE "cancion" ALTER COLUMN "codigo" SET NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "cancion_codigo_unico" ON "cancion" ("codigo");--> statement-breakpoint
ALTER TABLE "cancion" ADD COLUMN "version_codigo" text;--> statement-breakpoint
UPDATE cancion v
SET version_codigo = CASE
WHEN v.slug LIKE p.slug || '-%' THEN substring(v.slug FROM char_length(p.slug) + 2)
ELSE v.slug
END
FROM cancion p
WHERE v.version_de_id = p.id;--> statement-breakpoint
ALTER TABLE "cancion" ADD CONSTRAINT "cancion_version_codigo_presente"
CHECK (
("version_de_id" IS NULL AND "version_codigo" IS NULL)
OR ("version_de_id" IS NOT NULL AND nullif(btrim("version_codigo"), '') IS NOT NULL)
);--> statement-breakpoint
ALTER TABLE "cancion" ADD CONSTRAINT "cancion_version_no_se_vende"
CHECK ("version_de_id" IS NULL OR NOT "ala_venta");--> statement-breakpoint
ALTER TABLE "cancion" ADD CONSTRAINT "cancion_estado_conocido"
CHECK ("estado" IN ('borrador', 'publicado', 'retirado'));--> statement-breakpoint
CREATE UNIQUE INDEX "cancion_version_codigo_unico"
ON "cancion" ("version_de_id", "version_codigo")
WHERE "version_de_id" IS NOT NULL;--> statement-breakpoint
-- La vista se creó con SELECT *, pero PostgreSQL fija sus columnas en ese
-- instante. Se recrea para que incluya codigo y los campos añadidos después.
DROP VIEW "cancion_principal";--> statement-breakpoint
CREATE VIEW "cancion_principal" AS
SELECT * FROM "cancion" WHERE "version_de_id" IS NULL;--> statement-breakpoint
CREATE SEQUENCE "artista_codigo_seq";--> statement-breakpoint
CREATE TABLE "artista" (
"id" text PRIMARY KEY NOT NULL,
"codigo" text DEFAULT ('ART-' || lpad(nextval('artista_codigo_seq')::text, 6, '0')) NOT NULL,
"slug" text NOT NULL,
"tipo" text DEFAULT 'persona' NOT NULL,
"nombre" text NOT NULL,
"descripcion_corta" text DEFAULT '' NOT NULL,
"biografia" text DEFAULT '' NOT NULL,
"imagen_id" text,
"pais_codigo" char(2),
"lugar_origen" text,
"inicio" integer,
"fin" integer,
"sitio_web" text,
"estado" text DEFAULT 'borrador' NOT NULL,
"origen" text DEFAULT 'manual' NOT NULL,
"modelo_origen" text,
"importado_en" timestamp with time zone,
"revisado_por" text,
"revisado_en" timestamp with time zone,
"creado_en" timestamp with time zone DEFAULT now() NOT NULL,
"actualizado_en" timestamp with time zone,
CONSTRAINT "artista_tipo_conocido" CHECK ("tipo" IN ('persona', 'grupo', 'proyecto')),
CONSTRAINT "artista_estado_conocido" CHECK ("estado" IN ('borrador', 'publicado', 'retirado')),
CONSTRAINT "artista_origen_conocido" CHECK ("origen" IN ('manual', 'importado')),
CONSTRAINT "artista_periodo_valido" CHECK ("fin" IS NULL OR "inicio" IS NULL OR "fin" >= "inicio"),
CONSTRAINT "artista_imagen_fk" FOREIGN KEY ("imagen_id") REFERENCES "medio"("id") ON DELETE set null,
CONSTRAINT "artista_pais_fk" FOREIGN KEY ("pais_codigo") REFERENCES "pais"("codigo")
);--> statement-breakpoint
CREATE UNIQUE INDEX "artista_codigo_unico" ON "artista" ("codigo");--> statement-breakpoint
CREATE UNIQUE INDEX "artista_slug_unico" ON "artista" ("slug");--> statement-breakpoint
CREATE INDEX "artista_nombre" ON "artista" ("nombre");--> statement-breakpoint
CREATE INDEX "artista_estado" ON "artista" ("estado");--> statement-breakpoint
CREATE TABLE "artista_persona" (
"artista_id" text NOT NULL,
"persona_id" text NOT NULL,
"relacion" text NOT NULL,
"desde" integer,
"hasta" integer,
"orden" integer DEFAULT 0 NOT NULL,
CONSTRAINT "artista_persona_pk" PRIMARY KEY ("artista_id", "persona_id", "relacion"),
CONSTRAINT "artista_persona_relacion_conocida" CHECK ("relacion" IN ('identidad', 'miembro', 'fundador')),
CONSTRAINT "artista_persona_periodo_valido" CHECK ("hasta" IS NULL OR "desde" IS NULL OR "hasta" >= "desde"),
CONSTRAINT "artista_persona_artista_fk" FOREIGN KEY ("artista_id") REFERENCES "artista"("id") ON DELETE cascade,
CONSTRAINT "artista_persona_persona_fk" FOREIGN KEY ("persona_id") REFERENCES "persona"("id") ON DELETE cascade
);--> statement-breakpoint
CREATE INDEX "artista_persona_persona" ON "artista_persona" ("persona_id");--> statement-breakpoint
CREATE TABLE "artista_identificador" (
"artista_id" text NOT NULL,
"sistema" text NOT NULL,
"valor" text NOT NULL,
CONSTRAINT "artista_identificador_pk" PRIMARY KEY ("artista_id", "sistema"),
CONSTRAINT "artista_identificador_artista_fk" FOREIGN KEY ("artista_id") REFERENCES "artista"("id") ON DELETE cascade
);--> statement-breakpoint
CREATE UNIQUE INDEX "artista_identificador_externo_unico"
ON "artista_identificador" ("sistema", "valor");--> statement-breakpoint
CREATE TABLE "cancion_interprete" (
"cancion_id" text NOT NULL,
"artista_id" text NOT NULL,
"rol" text DEFAULT 'principal' NOT NULL,
"instrumento" text,
"acreditado_como" text,
"orden" integer DEFAULT 0 NOT NULL,
CONSTRAINT "cancion_interprete_pk" PRIMARY KEY ("cancion_id", "artista_id", "rol"),
CONSTRAINT "cancion_interprete_rol_conocido" CHECK ("rol" IN ('principal', 'invitado', 'coros', 'instrumentista')),
CONSTRAINT "cancion_interprete_cancion_fk" FOREIGN KEY ("cancion_id") REFERENCES "cancion"("id") ON DELETE cascade,
CONSTRAINT "cancion_interprete_artista_fk" FOREIGN KEY ("artista_id") REFERENCES "artista"("id")
);--> statement-breakpoint
CREATE INDEX "cancion_interprete_artista" ON "cancion_interprete" ("artista_id");--> statement-breakpoint
CREATE TABLE "cancion_audio" (
"cancion_id" text NOT NULL,
"medio_id" text NOT NULL,
"uso" text NOT NULL,
"formato" text NOT NULL,
"bitrate_kbps" integer,
"frecuencia_hz" integer,
"bits_muestra" integer,
"canales" integer,
"orden" integer DEFAULT 0 NOT NULL,
CONSTRAINT "cancion_audio_pk" PRIMARY KEY ("cancion_id", "uso", "formato"),
CONSTRAINT "cancion_audio_uso_conocido" CHECK ("uso" IN ('stream', 'descarga', 'master')),
CONSTRAINT "cancion_audio_bitrate_valido" CHECK ("bitrate_kbps" IS NULL OR "bitrate_kbps" > 0),
CONSTRAINT "cancion_audio_frecuencia_valida" CHECK ("frecuencia_hz" IS NULL OR "frecuencia_hz" > 0),
CONSTRAINT "cancion_audio_bits_validos" CHECK ("bits_muestra" IS NULL OR "bits_muestra" > 0),
CONSTRAINT "cancion_audio_canales_validos" CHECK ("canales" IS NULL OR "canales" > 0),
CONSTRAINT "cancion_audio_cancion_fk" FOREIGN KEY ("cancion_id") REFERENCES "cancion"("id") ON DELETE cascade,
CONSTRAINT "cancion_audio_medio_fk" FOREIGN KEY ("medio_id") REFERENCES "medio"("id") ON DELETE restrict
);--> statement-breakpoint
CREATE UNIQUE INDEX "cancion_audio_medio_unico" ON "cancion_audio" ("medio_id");--> statement-breakpoint
CREATE INDEX "cancion_audio_cancion" ON "cancion_audio" ("cancion_id");--> statement-breakpoint
-- Conserva los masters actuales en el nuevo modelo. Las muestras recortadas no
-- se migran como stream: el nuevo stream tiene que contener la canción completa.
INSERT INTO cancion_audio (cancion_id, medio_id, uso, formato)
SELECT c.id, c.audio_id, 'master',
CASE
WHEN m.mime IN ('audio/mpeg', 'audio/mp3') THEN 'mp3'
WHEN m.mime IN ('audio/wav', 'audio/x-wav') THEN 'wav'
WHEN m.mime = 'audio/flac' THEN 'flac'
WHEN m.mime IN ('audio/aac', 'audio/mp4') THEN 'aac'
ELSE 'audio'
END
FROM cancion c
INNER JOIN medio m ON m.id = c.audio_id
WHERE c.audio_id IS NOT NULL
ON CONFLICT DO NOTHING;--> statement-breakpoint
-- Migra cada nombre acreditado como intérprete a una identidad artística. Los
-- dos nombres colectivos conocidos se clasifican explícitamente; las personas
-- quedan vinculadas a su fila legal/biográfica actual.
INSERT INTO artista (
id, slug, tipo, nombre, biografia, imagen_id, pais_codigo, estado, origen,
modelo_origen, importado_en, revisado_por, revisado_en
)
SELECT gen_random_uuid()::text, p.slug,
CASE
WHEN p.nombre = 'Los del Puerto' THEN 'grupo'
WHEN p.nombre = 'Senza Paura' THEN 'proyecto'
ELSE 'persona'
END,
p.nombre, COALESCE(p.semblanza, ''), p.retrato_id, p.pais_codigo, p.estado,
p.origen, p.modelo_origen, p.importado_en, p.revisado_por, p.revisado_en
FROM persona p
WHERE EXISTS (
SELECT 1 FROM cancion_persona cp
WHERE cp.persona_id = p.id AND cp.rol = 'interprete'
);--> statement-breakpoint
INSERT INTO artista_persona (artista_id, persona_id, relacion, orden)
SELECT a.id, p.id, 'identidad', 0
FROM artista a
INNER JOIN persona p ON p.slug = a.slug
WHERE a.tipo = 'persona';--> statement-breakpoint
INSERT INTO cancion_interprete (
cancion_id, artista_id, rol, acreditado_como, orden
)
SELECT cp.cancion_id, a.id, 'principal', p.nombre, cp.orden
FROM cancion_persona cp
INNER JOIN persona p ON p.id = cp.persona_id
INNER JOIN artista a ON a.slug = p.slug
WHERE cp.rol = 'interprete'
ON CONFLICT DO NOTHING;

@ -0,0 +1,102 @@
/** Identidades artísticas públicas y su vínculo opcional con personas físicas. */
import { sql } from 'drizzle-orm';
import {
char,
check,
index,
integer,
pgTable,
primaryKey,
text,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { medio, pais, persona } from './base';
import { ahora, estado, id, instante, procedencia } from './comun';
export const TIPOS_ARTISTA = ['persona', 'grupo', 'proyecto'] as const;
export const RELACIONES_ARTISTA_PERSONA = ['identidad', 'miembro', 'fundador'] as const;
/**
* Una identidad acreditada ante el público. No equivale necesariamente a una
* persona física: una banda y un proyecto también interpretan grabaciones.
*/
export const artista = pgTable(
'artista',
{
id: id(),
codigo: text('codigo')
.notNull()
.default(sql`'ART-' || lpad(nextval('artista_codigo_seq')::text, 6, '0')`),
slug: text('slug').notNull(),
tipo: text('tipo', { enum: TIPOS_ARTISTA }).notNull().default('persona'),
nombre: text('nombre').notNull(),
descripcionCorta: text('descripcion_corta').notNull().default(''),
biografia: text('biografia').notNull().default(''),
imagenId: text('imagen_id').references(() => medio.id, { onDelete: 'set null' }),
paisCodigo: char('pais_codigo', { length: 2 }).references(() => pais.codigo),
lugarOrigen: text('lugar_origen'),
inicio: integer('inicio'),
fin: integer('fin'),
sitioWeb: text('sitio_web'),
estado: estado(),
...procedencia(),
creadoEn: ahora('creado_en'),
actualizadoEn: instante('actualizado_en')
},
(t) => [
uniqueIndex('artista_codigo_unico').on(t.codigo),
uniqueIndex('artista_slug_unico').on(t.slug),
index('artista_nombre').on(t.nombre),
index('artista_estado').on(t.estado),
check('artista_tipo_conocido', sql`${t.tipo} in ('persona', 'grupo', 'proyecto')`),
check('artista_estado_conocido', sql`${t.estado} in ('borrador', 'publicado', 'retirado')`),
check(
'artista_periodo_valido',
sql`${t.fin} is null or ${t.inicio} is null or ${t.fin} >= ${t.inicio}`
)
]
);
/** Personas físicas que forman o encarnan una identidad artística. */
export const artistaPersona = pgTable(
'artista_persona',
{
artistaId: text('artista_id')
.notNull()
.references(() => artista.id, { onDelete: 'cascade' }),
personaId: text('persona_id')
.notNull()
.references(() => persona.id, { onDelete: 'cascade' }),
relacion: text('relacion', { enum: RELACIONES_ARTISTA_PERSONA }).notNull(),
desde: integer('desde'),
hasta: integer('hasta'),
orden: integer('orden').notNull().default(0)
},
(t) => [
primaryKey({ columns: [t.artistaId, t.personaId, t.relacion] }),
index('artista_persona_persona').on(t.personaId),
check(
'artista_persona_periodo_valido',
sql`${t.hasta} is null or ${t.desde} is null or ${t.hasta} >= ${t.desde}`
)
]
);
/** Identificadores dados por catálogos y plataformas externas. */
export const artistaIdentificador = pgTable(
'artista_identificador',
{
artistaId: text('artista_id')
.notNull()
.references(() => artista.id, { onDelete: 'cascade' }),
sistema: text('sistema').notNull(),
valor: text('valor').notNull()
},
(t) => [
primaryKey({ columns: [t.artistaId, t.sistema] }),
uniqueIndex('artista_identificador_externo_unico').on(t.sistema, t.valor)
]
);
export type Artista = typeof artista.$inferSelect;

@ -12,6 +12,7 @@ import { sql } from 'drizzle-orm';
import {
boolean,
char,
check,
index,
integer,
numeric,
@ -22,6 +23,7 @@ import {
} from 'drizzle-orm/pg-core';
import { ahora, estado, id, instante } from './comun';
import { medio, pais, persona } from './base';
import { artista } from './artistas';
/**
* Una sección del catálogo: baladas románticas, clásica, rock, pop melódico.
@ -122,6 +124,9 @@ export const cancion = pgTable(
'cancion',
{
id: id(),
codigo: text('codigo')
.notNull()
.default(sql`'CAN-' || lpad(nextval('cancion_codigo_seq')::text, 6, '0')`),
slug: text('slug').notNull().unique(),
titulo: text('titulo').notNull(),
@ -142,6 +147,8 @@ export const cancion = pgTable(
versionNombre: text('version_nombre'),
/** Qué tiene de particular ese arreglo. Va en la ficha, junto al nombre. */
versionNota: text('version_nota'),
/** Código corto, único entre las versiones de una misma principal. */
versionCodigo: text('version_codigo'),
/** Orden entre las versiones de un mismo tema. */
versionOrden: integer('version_orden').notNull().default(0),
@ -182,10 +189,82 @@ export const cancion = pgTable(
actualizadoEn: instante('actualizado_en')
},
(t) => [
uniqueIndex('cancion_codigo_unico').on(t.codigo),
index('cancion_estilo').on(t.estiloId),
index('cancion_album').on(t.albumId),
index('cancion_version_de').on(t.versionDeId),
index('cancion_destacada').on(t.destacada)
index('cancion_destacada').on(t.destacada),
uniqueIndex('cancion_version_codigo_unico')
.on(t.versionDeId, t.versionCodigo)
.where(sql`${t.versionDeId} is not null`),
check(
'cancion_version_codigo_presente',
sql`(${t.versionDeId} is null and ${t.versionCodigo} is null) or (${t.versionDeId} is not null and nullif(btrim(${t.versionCodigo}), '') is not null)`
),
check('cancion_version_no_se_vende', sql`${t.versionDeId} is null or not ${t.alaVenta}`),
check('cancion_estado_conocido', sql`${t.estado} in ('borrador', 'publicado', 'retirado')`)
]
);
export const ROLES_INTERPRETE = ['principal', 'invitado', 'coros', 'instrumentista'] as const;
/** Intérpretes acreditados en una versión musical concreta. */
export const cancionInterprete = pgTable(
'cancion_interprete',
{
cancionId: text('cancion_id')
.notNull()
.references(() => cancion.id, { onDelete: 'cascade' }),
artistaId: text('artista_id')
.notNull()
.references(() => artista.id),
rol: text('rol', { enum: ROLES_INTERPRETE }).notNull().default('principal'),
instrumento: text('instrumento'),
acreditadoComo: text('acreditado_como'),
orden: integer('orden').notNull().default(0)
},
(t) => [
primaryKey({ columns: [t.cancionId, t.artistaId, t.rol] }),
index('cancion_interprete_artista').on(t.artistaId),
check(
'cancion_interprete_rol_conocido',
sql`${t.rol} in ('principal', 'invitado', 'coros', 'instrumentista')`
)
]
);
export const USOS_AUDIO = ['stream', 'descarga', 'master'] as const;
/** Representaciones técnicas de audio de una versión musical. */
export const cancionAudio = pgTable(
'cancion_audio',
{
cancionId: text('cancion_id')
.notNull()
.references(() => cancion.id, { onDelete: 'cascade' }),
medioId: text('medio_id')
.notNull()
.references(() => medio.id, { onDelete: 'restrict' }),
uso: text('uso', { enum: USOS_AUDIO }).notNull(),
formato: text('formato').notNull(),
bitrateKbps: integer('bitrate_kbps'),
frecuenciaHz: integer('frecuencia_hz'),
bitsMuestra: integer('bits_muestra'),
canales: integer('canales'),
orden: integer('orden').notNull().default(0)
},
(t) => [
primaryKey({ columns: [t.cancionId, t.uso, t.formato] }),
uniqueIndex('cancion_audio_medio_unico').on(t.medioId),
index('cancion_audio_cancion').on(t.cancionId),
check('cancion_audio_uso_conocido', sql`${t.uso} in ('stream', 'descarga', 'master')`),
check('cancion_audio_bitrate_valido', sql`${t.bitrateKbps} is null or ${t.bitrateKbps} > 0`),
check(
'cancion_audio_frecuencia_valida',
sql`${t.frecuenciaHz} is null or ${t.frecuenciaHz} > 0`
),
check('cancion_audio_bits_validos', sql`${t.bitsMuestra} is null or ${t.bitsMuestra} > 0`),
check('cancion_audio_canales_validos', sql`${t.canales} is null or ${t.canales} > 0`)
]
);

@ -9,6 +9,7 @@
export * from './comun';
export * from './base';
export * from './artistas';
export * from './catalogo';
export * from './taller';
export * from './editorial';

Loading…
Cancel
Save

Powered by TurnKey Linux.