@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
802 lines • 47.5 kB
JavaScript
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
import { typedError } from "./error-envelope.js";
import { parseApiErrorBody } from "./api-error-body.js";
import { formatSuccess, formatAsText, mapGapsToHints, } from "@withpica/mcp-utils";
import { buildIntegrityPressure } from "./integrity-hints.js";
const VALID_DOMAINS = [
"works",
"people",
"recordings",
"documents",
"enquiries",
"royalties",
];
export class ImportTools {
pica;
constructor(pica) {
this.pica = pica;
}
getTools() {
return [
{
definition: {
name: "pica_import_analyze",
scope: "read:catalog",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "Analyze a CSV file and get AI-suggested column mappings. " +
"Pass EITHER `file_content` (raw CSV string) OR `file_id` (from " +
"pica_upload_complete after the user uploads via pica_upload). " +
"Prefer `file_id` for non-trivial files — keeps the CSV out of the tool-arg payload. " +
"Returns parsed headers, sample rows, and AI-suggested mappings from CSV columns to PICA fields. " +
"Use this as the first step before validate and execute. " +
"→ then: pica_import_validate (check for errors), pica_import_execute (run the import)",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
inputSchema: {
type: "object",
properties: {
file_content: {
type: "string",
description: "Raw CSV content as a string. Use when the CSV is small and already in your context. " +
"Mutually exclusive with file_id — provide exactly one.",
},
file_id: {
type: "string",
description: "documents.id returned by pica_upload_complete after the user uploads a CSV via " +
"pica_upload. Server fetches the bytes from storage. Mutually exclusive with " +
"file_content — provide exactly one. Preferred for any non-trivial CSV.",
},
domain: {
type: "string",
description: "Import domain — what type of records to create",
enum: VALID_DOMAINS,
},
},
required: ["domain"],
},
},
executor: this.analyzeImport.bind(this),
},
{
definition: {
name: "pica_import_validate",
scope: "read:catalog",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "Validate all CSV rows against a field mapping before executing import. " +
"Pass EITHER `file_content` (raw CSV string) OR `file_id` (from pica_upload_complete). " +
"Returns per-row validation errors and a summary of valid/invalid rows. " +
"Run this after pica_import_analyze to check for issues before importing.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
inputSchema: {
type: "object",
properties: {
file_content: {
type: "string",
description: "Raw CSV content (same as passed to pica_import_analyze). " +
"Mutually exclusive with file_id — provide exactly one.",
},
file_id: {
type: "string",
description: "documents.id from pica_upload_complete. Server resolves to bytes via signed URL. " +
"Mutually exclusive with file_content — provide exactly one.",
},
domain: {
type: "string",
description: "Import domain",
enum: VALID_DOMAINS,
},
mapping: {
type: "array",
description: "Column mappings from pica_import_analyze (or manually adjusted). " +
"Each entry maps a CSV column to a PICA target field.",
items: {
type: "object",
properties: {
csvColumn: {
type: "string",
description: "CSV column header name",
},
targetField: {
type: "string",
description: "PICA database field name",
},
transformType: {
type: "string",
description: "Transform to apply (default: 'text'). Options: text, date, number, boolean, enum",
},
},
required: ["csvColumn", "targetField"],
},
},
},
required: ["domain", "mapping"],
},
},
executor: this.validateImport.bind(this),
},
{
definition: {
name: "pica_import_execute",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Execute a bulk import from CSV. Creates records in PICA based on the CSV data and field mapping. " +
"Pass EITHER `file_content` (raw CSV string) OR `file_id` (from pica_upload_complete). " +
"Always run pica_import_validate first to check for errors. " +
"Use dry_run: true to preview what would be imported without actually creating records. " +
"A call runs synchronously under a ~2 minute budget, so the server imports at most row_limit rows per call " +
"(default 2,000) and returns rowRange.nextOffset when the file has more rows: call again with " +
"row_offset = nextOffset, and stop when it is null. For a file over ~2,000 rows, upload it once with " +
"pica_upload_file → pica_upload_complete and pass file_id, so the CSV is not re-sent on every call.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
inputSchema: {
type: "object",
properties: {
file_content: {
type: "string",
description: "Raw CSV content. Mutually exclusive with file_id — provide exactly one.",
},
file_id: {
type: "string",
description: "documents.id from pica_upload_complete. Server resolves to bytes via signed URL. " +
"Mutually exclusive with file_content — provide exactly one.",
},
domain: {
type: "string",
description: "Import domain",
enum: VALID_DOMAINS,
},
mapping: {
type: "array",
description: "Column mappings (same format as pica_import_validate)",
items: {
type: "object",
properties: {
csvColumn: {
type: "string",
description: "Name of the column in the CSV file",
},
targetField: {
type: "string",
description: "PICA field to map the column to",
},
transformType: {
type: "string",
description: "Optional transform to apply (e.g. date, uppercase)",
},
},
required: ["csvColumn", "targetField"],
},
},
dry_run: {
type: "boolean",
description: "If true, simulates the import without creating records. Recommended before first real import.",
},
skip_invalid_rows: {
type: "boolean",
description: "If true, skips rows with validation errors instead of failing the entire import.",
},
conflict_strategy: {
type: "string",
enum: ["error", "skip"],
description: "How to handle DB unique-constraint collisions (e.g. duplicate tunecode/iswc, or two rows with the same title and artist). " +
"'skip' (default): fall back to per-row insert; the duplicate row becomes a warning, every other row in the batch still lands. " +
"'error': a single duplicate fails the whole batch — opt in only if you need all-or-nothing per batch.",
},
batch_size: {
type: "number",
description: "Number of rows to process per batch (default: 50, max: 1000)",
},
row_offset: {
type: "integer",
minimum: 0,
description: "Continuation cursor: index of the first data row to import (0-based, default 0). " +
"Pass the rowRange.nextOffset from the previous reply to continue a large file.",
},
row_limit: {
type: "integer",
minimum: 1,
maximum: 10000,
description: "Rows to import in this call (default 2,000). Keep it at the default unless the rows are trivially cheap; " +
"a call the budget ends commits what it wrote and reports nothing.",
},
},
required: ["domain", "mapping"],
},
},
executor: this.executeImport.bind(this),
},
{
definition: {
name: "pica_import_fields",
scope: "read:catalog",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "Get the available target fields for an import domain. Returns field names, labels, types, " +
"and whether they are required. Useful for understanding what columns a CSV should have. " +
"For a ready-made example CSV instead, use pica_import_template.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
inputSchema: {
type: "object",
properties: {
domain: {
type: "string",
description: "Import domain to get fields for",
enum: VALID_DOMAINS,
},
},
required: ["domain"],
},
},
executor: this.getImportFields.bind(this),
},
{
definition: {
name: "pica_import_template",
scope: "read:catalog",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "Get an example CSV template for an import domain. Returns a CSV string with headers " +
"and example rows that can be used as a starting point. " +
"For the field list — names, types, and which are required — use pica_import_fields.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
inputSchema: {
type: "object",
properties: {
domain: {
type: "string",
description: "Import domain to get template for",
enum: VALID_DOMAINS,
},
},
required: ["domain"],
},
},
executor: this.getImportTemplate.bind(this),
},
{
definition: {
name: "pica_import_streaming_link",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Import works from a Spotify or Deezer URL — extracts each track's metadata (including its ISRC) and saves it to your catalog. " +
"Use when the user asks: 'extract the isrc from this spotify link', 'save the isrc', 'get the isrc for this track', 'import this spotify link/album/playlist'. " +
"Two-step flow: first call without confirm to preview tracks, then call with confirm: true to execute.\n\n" +
"Supported platforms: Spotify (track/album/playlist), Deezer (track/album/playlist).\n" +
"Apple Music and artist profile URLs are not yet supported. " +
"For a YouTube video, use pica_import_youtube_link; this one takes Spotify and Deezer links.\n\n" +
"Each imported track creates: a work (with title, ISRC, UPC, label, artwork), " +
"a recording (with ISRC, linked to work), people records for artists, " +
"performer credits, and multimedia links. Enrichment cascades fire automatically. " +
"→ then: pica_credits_update (add songwriting credits), pica_works_inspect (check gaps)",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
inputSchema: {
type: "object",
properties: {
url: {
type: "string",
description: "Spotify or Deezer URL (track, album, or playlist). " +
"Examples: https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy, " +
"https://deezer.com/playlist/1234567",
},
confirm: {
type: "boolean",
description: "Set to true to execute the import. Omit or set false to preview tracks first.",
},
selected_track_ids: {
type: "array",
items: { type: "string" },
description: "Optional: external IDs of specific tracks to import (from the preview). " +
"If omitted, all non-duplicate tracks are imported.",
},
},
required: ["url"],
},
},
executor: this.importStreamingLink.bind(this),
},
{
definition: {
name: "pica_import_youtube_link",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Import a YouTube video as a recording attached to a matching work. " +
"Paste a YouTube URL (video ID or full watch URL) and PICA will classify the video's " +
"version type (music_video / live_performance / acoustic / cover / remix / lyric_video / " +
"alternate / master), match it to an existing work when possible, or create a new work " +
"if no match.\n\n" +
"Two-step flow: first call without confirm to preview the video + classification + " +
"work match, then call with confirm: true to execute.\n\n" +
"Each imported video creates: a recording (with version_type, youtube_video_id, duration, " +
"artist name, thumbnail as album art) and a multimedia entry linked to the work. " +
"The post-import enrichment cascade hydrates view/like counts " +
"onto the recording.\n\n" +
"Set refresh: true to re-fetch view/like/comment counts for a video that's already " +
"linked to a recording in this org instead of importing a duplicate.\n\n" +
"For a Spotify or Deezer link, use pica_import_streaming_link; this one takes YouTube URLs.\n\n" +
"→ then: pica_credits_update (add songwriting credits), pica_recordings_inspect (check gaps)",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
inputSchema: {
type: "object",
properties: {
url: {
type: "string",
description: "YouTube URL or video ID. Examples: " +
"https://www.youtube.com/watch?v=dQw4w9WgXcQ, " +
"https://youtu.be/dQw4w9WgXcQ, " +
"dQw4w9WgXcQ",
},
confirm: {
type: "boolean",
description: "Set to true to execute the import. Omit or set false to preview the video + classification first.",
},
selected_video_ids: {
type: "array",
items: { type: "string" },
description: "Optional: subset of video IDs from the preview to import. " +
"If omitted, every preview item is imported.",
},
target_work_id: {
type: "string",
description: "Optional: force-attach the recording to this work id instead of running the matcher. " +
"Use when the agent already knows which work the video belongs to.",
},
override_version_type: {
type: "string",
enum: [
"master",
"alternate_master",
"music_video",
"lyric_video",
"live_performance",
"acoustic",
"remix",
"cover",
"alternate",
],
description: "Optional: force a specific version_type on the recording instead of using the heuristic classifier.",
},
refresh: {
type: "boolean",
description: "Optional: when true and this video is already linked to a recording in this org, " +
"re-fetch its view/like/comment counts from the YouTube API and update that " +
"recording instead of creating a duplicate. Only applies with confirm: true.",
},
},
required: ["url"],
},
},
executor: this.importYouTubeLink.bind(this),
},
];
}
async analyzeImport(args) {
try {
// file_content and file_id are mutually exclusive on the route side;
// forward whichever the caller provided so the route returns the same
// 400 if both/neither are present rather than masking it here.
const result = await this.pica.imports.analyze(args.file_id
? { fileId: args.file_id, domain: args.domain }
: { fileContent: args.file_content, domain: args.domain });
const parsed = result.parsed;
const mappingCount = Object.keys(result.analysis?.mappings || {}).length;
return formatSuccess(`CSV analyzed: ${parsed.totalRows} rows, ${parsed.headers.length} columns. ` +
`${mappingCount} columns auto-mapped. ` +
`Review the mappings and run pica_import_validate to check for errors.`, {
headers: parsed.headers,
totalRows: parsed.totalRows,
sampleRows: parsed.preview.slice(0, 3),
mappings: result.analysis?.mappings,
suggestions: result.analysis?.suggestions,
});
}
catch (error) {
// Route returns 400 for caller-supplied bad CSV (header-only, empty,
// malformed). The SDK throws ApiError; without this catch the MCP
// framework stamps it as UNKNOWN_ERROR, hiding the real reason from
// agents. Surface the message instead — matches the sibling pattern
// in importStreamingLink/importYouTubeLink.
const message = error instanceof Error ? error.message : String(error);
return typedError("CSV_ANALYZE_FAILED", `CSV analyze failed: ${message}`);
}
}
async validateImport(args) {
const result = await this.pica.imports.validate(args.file_id
? {
fileId: args.file_id,
domain: args.domain,
mapping: args.mapping,
}
: {
fileContent: args.file_content,
domain: args.domain,
mapping: args.mapping,
});
// A warning does not make a row invalid, so `valid` can be true with
// warnings attached — e.g. a works column mapped to a field that has no
// home on an imported work (release_year → releases). The agent has to
// see that BEFORE it executes, on this branch, or it imports blind
// (2026-08-30: the prod user's path was exactly this tool over ChatGPT).
// The route's ValidationError carries the text in `error`; `message` is
// kept for callers that normalised it.
const warnings = result.errors.filter((e) => e.severity === "warning");
if (result.valid) {
const warningNote = warnings.length > 0
? ` ${warnings.length} warning${warnings.length === 1 ? "" : "s"} — the rows import, but: ` +
warnings
.slice(0, 10)
.map((w) => w.error ?? w.message ?? w.field)
.join("; ") +
"."
: "";
return formatSuccess(`All ${result.totalRowCount} rows are valid.${warningNote} Ready to import with pica_import_execute.`, {
validRowCount: result.validRowCount,
totalRowCount: result.totalRowCount,
...(warnings.length > 0 ? { warnings: warnings.slice(0, 20) } : {}),
});
}
return formatSuccess(`Validation: ${result.validRowCount}/${result.totalRowCount} rows valid, ` +
`${result.invalidRowCount} invalid. ` +
`Fix the errors or use skip_invalid_rows: true when importing.`, {
valid: result.valid,
validRowCount: result.validRowCount,
invalidRowCount: result.invalidRowCount,
totalRowCount: result.totalRowCount,
errorsByType: result.errorsByType,
errors: result.errors.slice(0, 20),
});
}
async executeImport(args) {
const options = {
dryRun: args.dry_run ?? false,
skipInvalidRows: args.skip_invalid_rows ?? false,
// Explicit, not inherited: the executor defaults to "skip" too, but an
// agent reading this call's echoed options must see the strategy it
// actually ran under (the 2026-08-17 import ran "error" four times).
conflictStrategy: args.conflict_strategy ?? "skip",
batchSize: args.batch_size,
rowOffset: args.row_offset,
rowLimit: args.row_limit,
};
const result = await this.pica.imports.execute(args.file_id
? {
fileId: args.file_id,
domain: args.domain,
mapping: args.mapping,
options,
}
: {
fileContent: args.file_content,
domain: args.domain,
mapping: args.mapping,
options,
});
const prefix = result.dryRun ? "[DRY RUN] " : "";
// A live run that wrote rows AND has rows that FAILED is a partial
// commit, not a failure: the created rows are already in the catalogue.
// Say so, and say what NOT to do — the 2026-08-17 agent re-sent a
// 2,239-row file three times because the reply read as "failed"
// (FIX_LOG 2026-08-24). The route computes `partial` from
// severity:"error" rows only; trust it, and only fall back to the same
// rule when talking to a server that predates the field. A skipped
// duplicate is a WARNING and must not trip this — under the default
// conflict strategy that is the ordinary clean-import case.
const hardErrors = result.errors.filter((e) => e.severity === "error").length;
const partial = typeof result.partial === "boolean"
? result.partial
: !result.dryRun &&
hardErrors > 0 &&
(result.importedCount > 0 || result.updatedCount > 0);
// ADR-314 continuation: the server imports one slice per call and says
// where the next one starts. Repeat the instruction in prose — an agent
// acts on the sentence, not on a field it did not ask about.
const range = result.rowRange;
const continuation = range && range.nextOffset !== null && range.nextOffset !== undefined
? ` Rows ${range.offset + 1}-${range.offset + range.count} of ${range.totalRowsInFile} processed; ` +
`call pica_import_execute again with row_offset: ${range.nextOffset} (same file_id or file_content, same mapping) to continue.`
: "";
return formatSuccess(`${prefix}Import ${partial ? "PARTIALLY complete" : "complete"}: ${result.importedCount} created, ` +
`${result.updatedCount} updated, ${result.skippedCount} skipped. ` +
`${result.errors.length} errors/warnings.` +
(partial
? " The created rows are saved. Do NOT re-send the whole file: read the errors below and re-send only the rows that failed."
: "") +
continuation, {
importedCount: result.importedCount,
updatedCount: result.updatedCount,
skippedCount: result.skippedCount,
dryRun: result.dryRun,
partial,
rowRange: range,
errors: result.errors.length > 0 ? result.errors.slice(0, 10) : undefined,
createdRecords: result.createdRecords.length <= 20
? result.createdRecords
: `${result.createdRecords.length} records (showing first 20)`,
summary: result.summary,
});
}
async getImportFields(args) {
const fields = await this.pica.imports.getFields(args.domain);
return formatAsText({
domain: args.domain,
fields,
});
}
async getImportTemplate(args) {
const csvContent = await this.pica.imports.getTemplate(args.domain);
return formatSuccess(`Template CSV for "${args.domain}" import. Use this as a starting point.`, { csvContent });
}
async importStreamingLink(args) {
const url = args.url;
if (!url) {
return {
content: [{ type: "text", text: "Error: url is required" }],
isError: true,
};
}
try {
if (args.confirm) {
// Execute import
const result = await this.pica.imports.streamingLinkImport(url, args.selected_track_ids);
// Generate completion hints by sampling the first created work
const hints = [];
let integrityBlock = null;
if (result.workIds?.length > 0) {
try {
const sample = await this.pica.health.getWorkCompleteness(result.workIds[0]);
hints.push(...mapGapsToHints(sample?.gaps || sample?.missing || []));
}
catch {
// Completeness check is best-effort — don't fail the import
}
// Registration integrity floor (spec 2026-07-09, Task 9) — a
// streaming import can create dozens of works in one call, so we
// deliberately do NOT run assessEntity per created work (that
// would be one round-trip per track). Instead sample the first
// created work with a SINGLE assessEntity call: streaming
// platforms never supply writer credits, so the sample's
// WORK_NO_WRITER gap (if present) is representative of the whole
// batch, and drives one aggregate hint scaled by the real
// worksCreated count from the import result — never a per-track
// loop. Best-effort: any assessor failure degrades to no
// aggregate hint, not a failed import.
const pressure = await buildIntegrityPressure(this.pica, "work", result.workIds[0]);
integrityBlock = pressure.integrity;
const sampleHasNoWriter = pressure.integrity?.gaps.some((gap) => gap.code === "WORK_NO_WRITER");
if (sampleHasNoWriter && result.worksCreated > 0) {
hints.push({
gap: "work_no_writer",
suggestion: `${result.worksCreated} imported work${result.worksCreated === 1 ? "" : "s"} likely ${result.worksCreated === 1 ? "has" : "have"} no writer — pica_credit_add per work`,
count: result.worksCreated,
severity: "critical",
});
}
// Heuristic fallback nag — streaming platforms only provide
// performer credits, not writers. Fires ONLY when neither the
// completeness sample nor the integrity assessment already
// produced a writer/credits hint (the assessed `work_no_writer`
// hint above supersedes this guess): exactly one
// writer-credits nag per import, never two.
if (!hints.some((h) => h.gap.includes("credit") || h.gap.includes("writer"))) {
hints.push({
gap: "no_songwriting_credits",
suggestion: "Add songwriting credits — streaming imports only include performer credits",
count: result.worksCreated,
severity: "critical",
});
}
}
return formatSuccess(`Streaming import complete: ${result.worksCreated} works, ` +
`${result.recordingsCreated} recordings, ${result.peopleCreated} people, ` +
`${result.creditsCreated} credits created. ` +
`${result.duplicatesSkipped} duplicates skipped.`, {
worksCreated: result.worksCreated,
recordingsCreated: result.recordingsCreated,
peopleCreated: result.peopleCreated,
creditsCreated: result.creditsCreated,
multimediaLinked: result.multimediaLinked,
duplicatesSkipped: result.duplicatesSkipped,
workIds: result.workIds,
integrity: integrityBlock,
}, hints);
}
// Preview mode
const preview = await this.pica.imports.streamingLinkPreview(url);
const trackSummary = preview.newTracks
.slice(0, 10)
.map((t) => ` - ${t.title} — ${t.artists.map((a) => a.name).join(", ")}${t.isrc ? ` [${t.isrc}]` : ""}`)
.join("\n");
// Same title, different/unknown artist: imported as new unless the
// caller deselects it — never skipped on title alone (ops 78526226).
const possibleMatches = preview.possibleMatches ?? [];
const possibleSummary = possibleMatches.length > 0
? `\n\n${possibleMatches.length} possible match(es) — same title as a work already in the catalogue under ${possibleMatches.length === 1 ? "a different or unrecorded artist" : "different or unrecorded artists"}. These WILL be imported as new; ask the user whether any is the same song first (pass selected_track_ids with the externalIds to keep, leaving that one out):\n` +
possibleMatches
.slice(0, 5)
.map((m) => ` - "${m.track.title}" vs existing "${m.existingTitle}" by ${m.existingArtist ?? "(no artist on file)"} (work ${m.existingWorkId})`)
.join("\n")
: "";
const dupSummary = preview.duplicates.length > 0
? `\n\n${preview.duplicates.length} duplicates found (will be skipped):\n` +
preview.duplicates
.slice(0, 5)
.map((d) => ` - "${d.track.title}" matches "${d.existingTitle}" (${d.matchType})`)
.join("\n")
: "";
return formatSuccess(`Found ${preview.tracks.length} tracks from ${preview.source.platform} ${preview.source.type}. ` +
`${preview.newTracks.length} new, ${preview.duplicates.length} duplicates.\n\n` +
`New tracks:\n${trackSummary}` +
(preview.newTracks.length > 10
? `\n ... and ${preview.newTracks.length - 10} more`
: "") +
dupSummary +
possibleSummary +
`\n\nTo import, call again with confirm: true.`, {
source: preview.source,
totalTracks: preview.tracks.length,
newTrackCount: preview.newTracks.length,
duplicateCount: preview.duplicates.length,
newTracks: preview.newTracks.map((t) => ({
title: t.title,
artists: t.artists.map((a) => a.name),
isrc: t.isrc,
externalId: t.externalId,
})),
duplicates: preview.duplicates.map((d) => ({
title: d.track.title,
existingTitle: d.existingTitle,
existingWorkId: d.existingWorkId,
matchType: d.matchType,
})),
possibleMatches: possibleMatches.map((m) => ({
title: m.track.title,
externalId: m.track.externalId,
existingTitle: m.existingTitle,
existingWorkId: m.existingWorkId,
existingArtist: m.existingArtist,
reason: m.reason,
})),
});
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
// ⚠️ One code was covering two unrelated causes, and the ops ledger
// could not tell them apart (ops issue 3a9d2e5a). A link the provider
// cannot resolve is USER INPUT — the remedy is "check the link" and the
// tool already says so — while a timeout is OURS. Because severity
// escalates on how many ORGS hit a signature, several people pasting
// bad links read as a spreading outage, which is how an unknown link
// came to sit in the backlog tagged `error` for a week.
//
// Splitting the code is the fix rather than capping the severity: a cap
// on the shared code would have suppressed the timeout signal too, and
// the timeout is real (a 30s ceiling that a PLAYLIST import exceeds —
// same issue, different row).
const body = parseApiErrorBody(error);
const notFound = body.status === 404 ||
body.code === "APP_SPOTIFY_NOT_FOUND" ||
/could not find this id|not found|no such (playlist|album|track)/i.test(message);
if (notFound) {
return typedError("STREAMING_LINK_NOT_FOUND", `The streaming provider could not find that link. Check it and retry — this is not a PICA fault. Underlying error: ${message}`);
}
return typedError("STREAMING_IMPORT_FAILED", `Streaming import failed: ${message}`);
}
}
async importYouTubeLink(args) {
const url = args.url;
if (!url) {
return {
content: [{ type: "text", text: "Error: url is required" }],
isError: true,
};
}
try {
if (args.confirm) {
const result = await this.pica.imports.youtubeLinkImport(url, {
selectedVideoIds: args.selected_video_ids,
targetWorkId: args.target_work_id,
overrideVersionType: args.override_version_type,
refresh: args.refresh,
});
// Completion hints pointed at filling songwriting credits +
// inspecting the new recording (same shape as the streaming
// import hints — YouTube never provides writer data, and the
// newly-created recording's gap profile is worth surfacing).
const hints = [];
if (result.recordingsCreated > 0) {
hints.push({
gap: "no_songwriting_credits",
suggestion: "YouTube imports don't include writer data — add songwriting credits via pica_credits_update",
count: result.recordingsCreated,
severity: "critical",
});
if (result.workIds?.length > 0) {
hints.push({
gap: "verify_recording",
suggestion: `Inspect the new recording${result.recordingsCreated > 1 ? "s" : ""} with pica_recordings_inspect to confirm the classification + fill any gaps`,
count: result.recordingsCreated,
severity: "important",
});
}
}
const itemSummary = result.items
.map((i) => {
if (i.status === "error")
return ` ❌ ${i.videoId}: ${i.error ?? "unknown error"}`;
if (i.status === "skipped")
return ` ⏭ ${i.videoId}: skipped`;
if (i.status === "refreshed")
return ` 🔄 ${i.videoId}: stats refreshed on recording ${i.recordingId}`;
return ` ✓ ${i.videoId}: ${i.versionType} → work ${i.workId} (${i.matched ? "matched" : "new"})`;
})
.join("\n");
return formatSuccess(`YouTube import complete: ${result.recordingsCreated} recording(s) created, ` +
`${result.worksCreated} new work(s), ${result.worksMatched} existing work(s) matched, ` +
`${result.multimediaLinked} multimedia row(s) linked, ` +
// `refreshed` arrived with ADR-289 Wave B2 — a published npm
// server can hit an older self-hosted API that omits it, which
// used to print "undefined stat refresh(es)".
`${result.refreshed ?? 0} stat refresh(es).\n\n` +
itemSummary, result, hints);
}
const preview = await this.pica.imports.youtubeLinkPreview(url);
const itemLines = preview.items
.map((item) => {
const t = item.track;
const c = item.classification;
const match = item.match;
const matchLine = match
? `matched work ${match.workId} (${match.confidence}% — ${match.reason})`
: "no work match → will create a new work";
const duration = t.durationMs
? `${Math.round(t.durationMs / 1000)}s`
: "unknown duration";
return (` • "${t.title}" — ${t.channel} (${duration}, ${t.viewCount ?? "?"} views)\n` +
` version: ${c.versionType} (confidence ${c.confidence} — ${c.reason})\n` +
` ${matchLine}`);
})
.join("\n\n");
return formatSuccess(`YouTube preview: ${preview.items.length} video(s) resolved from ${preview.source.type} "${preview.source.name}".\n\n` +
itemLines +
`\n\nTo import, call again with confirm: true. ` +
`Override the classification with override_version_type, ` +
`or attach to a specific work with target_work_id.`, preview);
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
// A channel / playlist link, or a video YouTube does not return, is the
// user's input to correct — the route answers 422 / 404 with its own
// code. Kept apart for the same reason STREAMING_LINK_NOT_FOUND is kept
// apart from STREAMING_IMPORT_FAILED: YOUTUBE_IMPORT_FAILED opens an ops
// issue, and until 2026-09-30 a pasted channel link opened one at
// `error` (ops issue a06443ef).
const body = parseApiErrorBody(error);
if (body.code === "YOUTUBE_LINK_NOT_A_VIDEO" ||
body.code === "YOUTUBE_VIDEO_NOT_FOUND") {
const remedy = body.code === "YOUTUBE_LINK_NOT_A_VIDEO"
? "ask the user for the link of a single video"
: "check the link with the user";
return typedError(body.code, `${body.message ?? message} This is not a PICA fault — ${remedy}.`);
}
return typedError("YOUTUBE_IMPORT_FAILED", `YouTube import failed: ${message}`);
}
}
}
//# sourceMappingURL=import.js.map