UNPKG

@withpica/mcp-server

Version:

MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog

806 lines • 47.8 kB
// 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", // A write: it sends the file's headers and sample rows to a paid AI model // on PICA's own key (structure-analyzer.ts), so a client confirms before it // spends that (scope sweep W7, 2026-10-13). proxy.ts floors the route // at write:catalog (the import segment, ADMIN_SEGMENT_WRITE_FLOOR). scope: "write:catalog", audiences: ["stdio", "http", "public"], tier: "write", 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