diff --git a/docs/plan-implementacion-dominio-musical.md b/docs/plan-implementacion-dominio-musical.md index cdd91e4..dd137dc 100644 --- a/docs/plan-implementacion-dominio-musical.md +++ b/docs/plan-implementacion-dominio-musical.md @@ -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, diff --git a/drizzle/0014_artistas_y_audio.sql b/drizzle/0014_artistas_y_audio.sql new file mode 100644 index 0000000..d44519f --- /dev/null +++ b/drizzle/0014_artistas_y_audio.sql @@ -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; diff --git a/src/lib/server/db/schema/artistas.ts b/src/lib/server/db/schema/artistas.ts new file mode 100644 index 0000000..ffa745e --- /dev/null +++ b/src/lib/server/db/schema/artistas.ts @@ -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; diff --git a/src/lib/server/db/schema/catalogo.ts b/src/lib/server/db/schema/catalogo.ts index aef451c..667960c 100644 --- a/src/lib/server/db/schema/catalogo.ts +++ b/src/lib/server/db/schema/catalogo.ts @@ -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`) ] ); diff --git a/src/lib/server/db/schema/index.ts b/src/lib/server/db/schema/index.ts index dd180fa..02094f8 100644 --- a/src/lib/server/db/schema/index.ts +++ b/src/lib/server/db/schema/index.ts @@ -9,6 +9,7 @@ export * from './comun'; export * from './base'; +export * from './artistas'; export * from './catalogo'; export * from './taller'; export * from './editorial';