initial state: muzick music player + recommendation engine
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* One-off cleanup: case-insensitive dedupe of artists AND albums.
|
||||
*
|
||||
* normalize_artist() preserves case, so case/punctuation variants of the same
|
||||
* artist ("Acryl madness" vs "Acryl Madness", "Booker" vs "BOOKER") were stored
|
||||
* as distinct rows. Each variant carried its own album, producing duplicate —
|
||||
* and often empty — albums (e.g. two "Before Neon 2", two "BROKESTAR").
|
||||
*
|
||||
* This:
|
||||
* 1. Merges artists sharing lower(normalized_name) into one keeper (prefers a
|
||||
* row with an MBID, then an image, then the most track links, then oldest).
|
||||
* track_artists / albums / artist_similar / artist_aliases are moved over;
|
||||
* losing artist rows are deleted.
|
||||
* 2. Merges albums sharing (artist_id, lower(title)) into one keeper (prefers a
|
||||
* row with artwork, then the most tracks, then oldest). Tracks are moved to
|
||||
* the keeper BEFORE the empty duplicate is deleted (albums cascade-delete
|
||||
* their tracks, so order matters).
|
||||
* 3. Drops redundant 'featured' track_artists where the same artist is already
|
||||
* 'main' on that track (case variants previously looked like a feature).
|
||||
*
|
||||
* Transactional + idempotent. Run:
|
||||
* DATABASE_URL=... npx tsx src/scripts/dedup-artists-albums.ts [--dry-run]
|
||||
*/
|
||||
import { Client as PgClient } from 'pg';
|
||||
|
||||
const DRY_RUN = process.argv.includes('--dry-run');
|
||||
|
||||
async function main() {
|
||||
const pg = new PgClient({ connectionString: process.env.DATABASE_URL });
|
||||
await pg.connect();
|
||||
console.log(`[Dedup] Connected${DRY_RUN ? ' (DRY RUN)' : ''}`);
|
||||
|
||||
if (!DRY_RUN) await pg.query('BEGIN');
|
||||
|
||||
let artistsMerged = 0;
|
||||
let albumsMerged = 0;
|
||||
|
||||
// --- 1. Artist dedupe (case-insensitive) ---------------------------------
|
||||
const artistGroups = await pg.query<{ ids: string[] }>(
|
||||
`SELECT array_agg(id ORDER BY
|
||||
CASE WHEN mbid IS NOT NULL THEN 0 ELSE 1 END,
|
||||
CASE WHEN image_path IS NOT NULL AND image_path <> '' THEN 0 ELSE 1 END,
|
||||
(SELECT count(*) FROM track_artists ta WHERE ta.artist_id = artists.id) DESC,
|
||||
created_at) AS ids
|
||||
FROM artists
|
||||
GROUP BY lower(normalized_name)
|
||||
HAVING count(*) > 1`
|
||||
);
|
||||
|
||||
for (const { ids } of artistGroups.rows) {
|
||||
const keep = ids[0];
|
||||
for (const loser of ids.slice(1)) {
|
||||
if (DRY_RUN) { artistsMerged++; continue; }
|
||||
|
||||
// track_artists: copy to keeper, drop loser's.
|
||||
await pg.query(
|
||||
`INSERT INTO track_artists (track_id, artist_id, role)
|
||||
SELECT track_id, $1, role FROM track_artists WHERE artist_id = $2
|
||||
ON CONFLICT (track_id, artist_id, role) DO NOTHING`,
|
||||
[keep, loser]
|
||||
);
|
||||
await pg.query(`DELETE FROM track_artists WHERE artist_id = $1`, [loser]);
|
||||
|
||||
// artist_similar + aliases: move with conflict-skip.
|
||||
await pg.query(
|
||||
`INSERT INTO artist_similar (artist_id, similar_name, match, fetched_at)
|
||||
SELECT $1, similar_name, match, fetched_at FROM artist_similar WHERE artist_id = $2
|
||||
ON CONFLICT (artist_id, similar_name) DO NOTHING`,
|
||||
[keep, loser]
|
||||
);
|
||||
await pg.query(`DELETE FROM artist_similar WHERE artist_id = $1`, [loser]);
|
||||
await pg.query(
|
||||
`INSERT INTO artist_aliases (artist_id, alias)
|
||||
SELECT $1, alias FROM artist_aliases WHERE artist_id = $2
|
||||
ON CONFLICT (artist_id, alias) DO NOTHING`,
|
||||
[keep, loser]
|
||||
);
|
||||
await pg.query(`DELETE FROM artist_aliases WHERE artist_id = $1`, [loser]);
|
||||
|
||||
// Albums: move to keeper where no same-title album exists; otherwise fold
|
||||
// tracks into the keeper's matching album, then drop the empty duplicate.
|
||||
const loserAlbums = await pg.query<{ id: string; title: string }>(
|
||||
`SELECT id, title FROM albums WHERE artist_id = $1`,
|
||||
[loser]
|
||||
);
|
||||
for (const la of loserAlbums.rows) {
|
||||
const match = await pg.query<{ id: string }>(
|
||||
`SELECT id FROM albums WHERE artist_id = $1 AND lower(title) = lower($2) LIMIT 1`,
|
||||
[keep, la.title]
|
||||
);
|
||||
if (match.rows.length === 0) {
|
||||
await pg.query(`UPDATE albums SET artist_id = $1 WHERE id = $2`, [keep, la.id]);
|
||||
} else {
|
||||
await foldAlbum(pg, match.rows[0].id, la.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Carry over an image if the keeper lacks one.
|
||||
await pg.query(
|
||||
`UPDATE artists k SET image_path = l.image_path
|
||||
FROM artists l
|
||||
WHERE k.id = $1 AND l.id = $2
|
||||
AND (k.image_path IS NULL OR k.image_path = '')
|
||||
AND l.image_path IS NOT NULL AND l.image_path <> ''`,
|
||||
[keep, loser]
|
||||
);
|
||||
|
||||
await pg.query(`DELETE FROM artists WHERE id = $1`, [loser]);
|
||||
artistsMerged++;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2. Album dedupe (case-insensitive, within an artist) -----------------
|
||||
const albumGroups = await pg.query<{ ids: string[] }>(
|
||||
`SELECT array_agg(id ORDER BY
|
||||
CASE WHEN artwork_id IS NOT NULL AND artwork_id <> '' THEN 0 ELSE 1 END,
|
||||
(SELECT count(*) FROM tracks t WHERE t.album_id = albums.id) DESC,
|
||||
id) AS ids
|
||||
FROM albums
|
||||
WHERE artist_id IS NOT NULL
|
||||
GROUP BY artist_id, lower(title)
|
||||
HAVING count(*) > 1`
|
||||
);
|
||||
for (const { ids } of albumGroups.rows) {
|
||||
const keep = ids[0];
|
||||
for (const loser of ids.slice(1)) {
|
||||
if (DRY_RUN) { albumsMerged++; continue; }
|
||||
await foldAlbum(pg, keep, loser);
|
||||
albumsMerged++;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. Drop redundant 'featured' where the artist is also 'main' ---------
|
||||
let redundant = 0;
|
||||
if (!DRY_RUN) {
|
||||
const r = await pg.query(
|
||||
`DELETE FROM track_artists f
|
||||
WHERE f.role = 'featured'
|
||||
AND EXISTS (SELECT 1 FROM track_artists m
|
||||
WHERE m.track_id = f.track_id AND m.artist_id = f.artist_id AND m.role = 'main')`
|
||||
);
|
||||
redundant = r.rowCount ?? 0;
|
||||
}
|
||||
|
||||
if (!DRY_RUN) await pg.query('COMMIT');
|
||||
console.log(`[Dedup] Done: artistsMerged=${artistsMerged} albumsMerged=${albumsMerged} redundantFeatured=${redundant}`);
|
||||
await pg.end();
|
||||
}
|
||||
|
||||
/** Move all tracks + (missing) artwork from loserAlbum into keeperAlbum, then delete loserAlbum. */
|
||||
async function foldAlbum(pg: PgClient, keeperId: string, loserId: string): Promise<void> {
|
||||
if (keeperId === loserId) return;
|
||||
await pg.query(`UPDATE tracks SET album_id = $1 WHERE album_id = $2`, [keeperId, loserId]);
|
||||
await pg.query(
|
||||
`UPDATE albums k SET artwork_id = l.artwork_id, year = COALESCE(k.year, l.year)
|
||||
FROM albums l
|
||||
WHERE k.id = $1 AND l.id = $2
|
||||
AND (k.artwork_id IS NULL OR k.artwork_id = '')
|
||||
AND l.artwork_id IS NOT NULL AND l.artwork_id <> ''`,
|
||||
[keeperId, loserId]
|
||||
);
|
||||
await pg.query(`DELETE FROM albums WHERE id = $1`, [loserId]);
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error('[Dedup] Failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* One-off cleanup: repair track rows corrupted by an earlier buggy
|
||||
* `metadata_refresh` worker job.
|
||||
*
|
||||
* The bug ran on EVERY refresh and concatenated (no separator) corruption
|
||||
* markers directly onto the real values:
|
||||
* - tracks.title += ' (Enriched)'
|
||||
* - tracks.artist += 'Unknown Artist'
|
||||
* Because it ran repeatedly, corruption can be stacked, e.g.
|
||||
* title = "Song (Enriched) (Enriched)"
|
||||
* artist = "RealName Unknown ArtistUnknown Artist"
|
||||
*
|
||||
* The repair logic (PASS 1 authoritative rescan + PASS 2 defensive SQL strip,
|
||||
* plus issue tracking) now lives in IntegrityService.runSweep(). This script is
|
||||
* just a run-once-and-exit entry point that delegates to it, so the regex/rescan
|
||||
* logic is never duplicated. Safe to run multiple times (idempotent).
|
||||
*/
|
||||
import { Client as PgClient } from 'pg';
|
||||
import { IntegrityService } from '../integrity.service.js';
|
||||
import { queue } from '../queue.js';
|
||||
|
||||
async function main() {
|
||||
const pgClient = new PgClient({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
await pgClient.connect();
|
||||
console.log('[Repair] Connected to PostgreSQL');
|
||||
|
||||
const integrity = new IntegrityService(pgClient, queue);
|
||||
const summary = await integrity.runSweep();
|
||||
console.log(
|
||||
`[Repair] Done: detected=${summary.detected} fixed=${summary.fixed} needsReview=${summary.needsReview}`
|
||||
);
|
||||
|
||||
await pgClient.end();
|
||||
console.log('[Repair] Connection closed.');
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error('[Repair] Failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* One-off cleanup: split combined collaboration artist rows into the real
|
||||
* individual artists they name, and dedupe the result.
|
||||
*
|
||||
* Background: tags routinely store several artists in one string
|
||||
* ("Booker & ЗАМАЙ", "STED.D; Alphavite", "21 Savage & Metro Boomin"). An
|
||||
* earlier normalize_artist only handled commas + feat suffixes, so these were
|
||||
* persisted as a single bogus artist row — duplicating the real artists (which
|
||||
* also exist on their own) and producing two-artists-in-one entries.
|
||||
*
|
||||
* What this does, per combined artist row C (name splits into >1 artist):
|
||||
* 1. Resolve/create a canonical artist row for each component, keyed by
|
||||
* normalize_artist() so "Booker & X" folds into the existing "Booker".
|
||||
* 2. For every track linked to C (via track_artists), link it to all the real
|
||||
* components instead: first = 'main', the rest = 'featured'.
|
||||
* 3. Reassign C's albums to its primary component.
|
||||
* 4. Delete C (cascades its track_artists / artist_similar / aliases).
|
||||
* Then a final pass merges any artists that share a normalized_name (preferring
|
||||
* the row with an MBID, then a canonical_name).
|
||||
*
|
||||
* Idempotent: re-running after a clean run is a no-op (no row will split into
|
||||
* more than one component once the data is fixed). Wrapped in a transaction —
|
||||
* either the whole cleanup commits or nothing does.
|
||||
*
|
||||
* Run: DATABASE_URL=... npx tsx src/scripts/split-collab-artists.ts
|
||||
* (or, built) node dist/scripts/split-collab-artists.js
|
||||
* Add --dry-run to print what would change without writing.
|
||||
*/
|
||||
import { Client as PgClient } from 'pg';
|
||||
import { splitArtistNames } from '../utils/artist-names.js';
|
||||
|
||||
interface ArtistRow {
|
||||
id: string;
|
||||
name: string;
|
||||
mbid: string | null;
|
||||
canonical_name: string | null;
|
||||
}
|
||||
|
||||
const DRY_RUN = process.argv.includes('--dry-run');
|
||||
|
||||
async function main() {
|
||||
const pg = new PgClient({ connectionString: process.env.DATABASE_URL });
|
||||
await pg.connect();
|
||||
console.log(`[SplitCollab] Connected${DRY_RUN ? ' (DRY RUN — no writes)' : ''}`);
|
||||
|
||||
const { rows: artists } = await pg.query<ArtistRow>(
|
||||
'SELECT id, name, mbid, canonical_name FROM artists'
|
||||
);
|
||||
|
||||
// Identify the combined rows: name names more than one artist.
|
||||
const combined = artists.filter((a) => splitArtistNames(a.name).length > 1);
|
||||
console.log(`[SplitCollab] ${artists.length} artists total, ${combined.length} combined rows to split`);
|
||||
|
||||
if (!DRY_RUN) await pg.query('BEGIN');
|
||||
|
||||
let tracksRelinked = 0;
|
||||
let createdArtists = 0;
|
||||
|
||||
/**
|
||||
* Resolve a single artist name to a canonical artist id, by normalized_name.
|
||||
* Creates the row if no match exists. Returns the id.
|
||||
*/
|
||||
async function resolveArtistId(name: string): Promise<string> {
|
||||
const existing = await pg.query(
|
||||
`SELECT id FROM artists
|
||||
WHERE normalized_name = normalize_artist($1)
|
||||
ORDER BY CASE WHEN mbid IS NOT NULL THEN 0 ELSE 1 END,
|
||||
CASE WHEN canonical_name IS NOT NULL THEN 0 ELSE 1 END,
|
||||
created_at
|
||||
LIMIT 1`,
|
||||
[name]
|
||||
);
|
||||
if (existing.rows.length > 0) return existing.rows[0].id;
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log(`[SplitCollab] would create artist "${name}"`);
|
||||
return `dry-${name}`;
|
||||
}
|
||||
const inserted = await pg.query(
|
||||
`INSERT INTO artists (name, canonical_name, sort_name)
|
||||
VALUES ($1, $1, $1) RETURNING id`,
|
||||
[name]
|
||||
);
|
||||
createdArtists++;
|
||||
return inserted.rows[0].id;
|
||||
}
|
||||
|
||||
for (const c of combined) {
|
||||
const components = splitArtistNames(c.name);
|
||||
console.log(`[SplitCollab] "${c.name}" -> [${components.join(' | ')}]`);
|
||||
|
||||
const componentIds: string[] = [];
|
||||
for (const name of components) componentIds.push(await resolveArtistId(name));
|
||||
const primaryId = componentIds[0];
|
||||
|
||||
if (DRY_RUN) continue;
|
||||
|
||||
// Tracks currently attributed to the combined row.
|
||||
const { rows: links } = await pg.query<{ track_id: string }>(
|
||||
'SELECT DISTINCT track_id FROM track_artists WHERE artist_id = $1',
|
||||
[c.id]
|
||||
);
|
||||
|
||||
for (const { track_id } of links) {
|
||||
// primary = main, the rest = featured. Skip self-conflicts.
|
||||
await pg.query(
|
||||
`INSERT INTO track_artists (track_id, artist_id, role)
|
||||
VALUES ($1, $2, 'main') ON CONFLICT (track_id, artist_id, role) DO NOTHING`,
|
||||
[track_id, primaryId]
|
||||
);
|
||||
for (const featId of componentIds.slice(1)) {
|
||||
if (featId === primaryId) continue;
|
||||
await pg.query(
|
||||
`INSERT INTO track_artists (track_id, artist_id, role)
|
||||
VALUES ($1, $2, 'featured') ON CONFLICT (track_id, artist_id, role) DO NOTHING`,
|
||||
[track_id, featId]
|
||||
);
|
||||
}
|
||||
tracksRelinked++;
|
||||
}
|
||||
|
||||
// Reassign the combined row's albums to the primary artist, but only where
|
||||
// that wouldn't collide with an existing (primary, title) album. Colliding
|
||||
// albums are dropped (the primary already has that album).
|
||||
await pg.query(
|
||||
`UPDATE albums a SET artist_id = $1
|
||||
WHERE a.artist_id = $2
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM albums b WHERE b.artist_id = $1 AND b.title = a.title
|
||||
)`,
|
||||
[primaryId, c.id]
|
||||
);
|
||||
|
||||
// Drop the combined artist (cascades remaining track_artists/aliases/similar
|
||||
// and any colliding albums via ON DELETE CASCADE).
|
||||
await pg.query('DELETE FROM artists WHERE id = $1', [c.id]);
|
||||
}
|
||||
|
||||
// Final dedupe: collapse any artists sharing a normalized_name into one,
|
||||
// preferring the row with an MBID, then a canonical_name, then oldest.
|
||||
let dedupMerged = 0;
|
||||
if (!DRY_RUN) {
|
||||
const { rows: dups } = await pg.query<{ ids: string[] }>(
|
||||
`SELECT array_agg(id ORDER BY
|
||||
CASE WHEN mbid IS NOT NULL THEN 0 ELSE 1 END,
|
||||
CASE WHEN canonical_name IS NOT NULL THEN 0 ELSE 1 END,
|
||||
created_at) AS ids
|
||||
FROM artists
|
||||
GROUP BY normalized_name
|
||||
HAVING COUNT(*) > 1`
|
||||
);
|
||||
for (const { ids } of dups) {
|
||||
const keepId = ids[0];
|
||||
for (const mergeId of ids.slice(1)) {
|
||||
await pg.query(
|
||||
`INSERT INTO track_artists (track_id, artist_id, role)
|
||||
SELECT track_id, $1, role FROM track_artists WHERE artist_id = $2
|
||||
ON CONFLICT (track_id, artist_id, role) DO NOTHING`,
|
||||
[keepId, mergeId]
|
||||
);
|
||||
await pg.query('DELETE FROM track_artists WHERE artist_id = $1', [mergeId]);
|
||||
await pg.query(
|
||||
`UPDATE albums a SET artist_id = $1
|
||||
WHERE a.artist_id = $2
|
||||
AND NOT EXISTS (SELECT 1 FROM albums b WHERE b.artist_id = $1 AND b.title = a.title)`,
|
||||
[keepId, mergeId]
|
||||
);
|
||||
await pg.query('DELETE FROM artists WHERE id = $1', [mergeId]);
|
||||
dedupMerged++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop redundant 'featured' links where the same artist is already 'main' on
|
||||
// that track (can happen when a stale pre-split 'main' link coexists with a
|
||||
// 'featured' link added from a collaboration row).
|
||||
let redundantRemoved = 0;
|
||||
if (!DRY_RUN) {
|
||||
const res = await pg.query(
|
||||
`DELETE FROM track_artists f
|
||||
WHERE f.role = 'featured'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM track_artists m
|
||||
WHERE m.track_id = f.track_id AND m.artist_id = f.artist_id AND m.role = 'main'
|
||||
)`
|
||||
);
|
||||
redundantRemoved = res.rowCount ?? 0;
|
||||
}
|
||||
|
||||
if (!DRY_RUN) await pg.query('COMMIT');
|
||||
|
||||
console.log(
|
||||
`[SplitCollab] Done: split=${combined.length} relinkedTracks=${tracksRelinked} ` +
|
||||
`createdArtists=${createdArtists} dedupMerged=${dedupMerged} redundantFeaturedRemoved=${redundantRemoved}`
|
||||
);
|
||||
await pg.end();
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch(async (err) => {
|
||||
console.error('[SplitCollab] Failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user