initial state: muzick music player + recommendation engine

This commit is contained in:
kami
2026-07-14 01:35:52 +04:00
commit 737bf19fd1
196 changed files with 32431 additions and 0 deletions
+170
View File
@@ -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);
});
+204
View File
@@ -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);
});