diff --git a/cli/csv-runner.ts b/cli/csv-runner.ts new file mode 100644 index 0000000..f4a0b6f --- /dev/null +++ b/cli/csv-runner.ts @@ -0,0 +1,49 @@ +import { CsvDocument } from "~/lib/format/csv/mod.ts"; +import { PendingEntry, PipelineOptions, PipelineSummary, runPipeline } from "~/cli/pipeline.ts"; +import { loadCsvDocument, markSkipped } from "~/lib/format/csv/io.ts"; +import { describe } from "~/lib/errors.ts"; +import { symbols } from "~/cli/formatter.ts"; +import { dim } from "@std/fmt/colors"; + +type CsvContext = { lineIndex: number }; + +function toPendingEntry(entry: CsvDocument["pending"][number]): PendingEntry { + return { + meta: { + artist: entry.track.title, + album: entry.track.album || undefined, + title: entry.track.title, + timestamp: Math.floor(new Date(entry.track.date).getTime() / 1000), + }, + context: { + lineIndex: entry.lineIndex, + }, + }; +} + +export async function runCsvPipeline( + path: string, + opts: PipelineOptions, + onProgress: (current: number, total: number, meta: any, status: any, detail?: string) => void, +): Promise { + const document = await loadCsvDocument(path); + if (!document.ok) throw new Error(document.error.message); + + let doc = document.value; + const pending = doc.pending.map(toPendingEntry); + + return runPipeline(pending, { + ...opts, + commitSuccess: async ({ lineIndex }) => { + const skip = await markSkipped(doc, lineIndex); + if (skip.ok) { + doc = skip.value; + } else { + console.error( + ` ${symbols.warn} failed to mark line ${dim(`${lineIndex}`)} as skipped: ${dim(describe(skip.error))}`, + ); + } + return skip; + }, + }, onProgress); +} diff --git a/cli/formatter.ts b/cli/formatter.ts new file mode 100644 index 0000000..c835ec9 --- /dev/null +++ b/cli/formatter.ts @@ -0,0 +1,11 @@ +import { cyan, dim, gray, green, red } from "@std/fmt/colors"; + +export const symbols = { + retry: gray("\u21bb"), + warn: cyan("\u26a0"), + success: green("\u2714"), + forbid: red("\u2298"), + error: red("\u2716"), +} as const; + +export { dim }; diff --git a/cli/pipeline.ts b/cli/pipeline.ts index 601bdcc..f78a084 100644 --- a/cli/pipeline.ts +++ b/cli/pipeline.ts @@ -2,7 +2,9 @@ import { type Config } from "~/lib/config.ts"; import { countScrobblesInRange, scrobble, ScrobblePayload, ScrobbleResult } from "~/api/lastfm.ts"; import { AppError, describe } from "~/lib/errors.ts"; import { sleep, withRetryR } from "~/utils/retry.ts"; -import { Result } from "~/lib/result.ts"; +import { Ok, Result } from "~/lib/result.ts"; +import { symbols } from "~/cli/formatter.ts"; +import { dim, yellow } from "@std/fmt/colors"; const DAILY_SCROBBLE_LIMIT: number = 2880; @@ -26,7 +28,6 @@ export interface PipelineSummary { readonly accepted: number; readonly ignored: number; readonly failed: number; - readonly skipped: number; } /** @@ -103,25 +104,67 @@ export function isRetryable(error: unknown): boolean { return false; } +export function reportPipelineProgress( + summary: PipelineSummary, + dryRun: boolean, + current: number, + total: number, + meta: PipelineTrackMeta, + status: PipelineProgressStatus, + detail?: string, +) { + const prefix = dryRun ? " \u{1F6E0} π˜₯𝘳𝘺 𝘳𝘢𝘯 " : " "; + + const track = yellow(`"${meta.artist} - ${meta.title}"`); + const progress = prefix + dim(`[${current}/${total}]`); + + switch (status) { + case "ok": + console.log(`${progress} ${symbols.success} ${track}`); + break; + case "ignored": + console.warn(`${progress} ${symbols.forbid} Ignored by Last.fm: ${track}`); + break; + case "failed": + console.error( + `${progress} ${symbols.error} Failed: ${track}\n β†’ ${dim(detail ?? "(empty string)")}`, + ); + break; + } + + if (current !== total) return; + + if (dryRun) { + console.log("\nNo changes were made to the file."); + } else if (summary.failed === 0) { + console.log(`\nβœ“ All scrobbles were successfully submitted.`); + } else { + console.warn(`\n⚠ Finished with errors.`); + } +} + export async function runPipeline( pending: readonly PendingEntry[], opts: PipelineOptions, - onProgress: ( + $onProgress: ( + this: PipelineSummary, current: number, total: number, meta: PipelineTrackMeta, status: PipelineProgressStatus, detail?: string, - ) => void, + ) => void = function (current, total, meta, status, detail) { + reportPipelineProgress(this, opts.dryRun || false, current, total, meta, status, detail); + }, ): Promise { const summary = { total: pending.length, accepted: 0, ignored: 0, failed: 0, - skipped: 0, }; + const onProgress = $onProgress.bind(summary); const delay = opts.delayMs ?? 100; for (const [i, entry] of pending.entries()) { @@ -140,7 +183,9 @@ export async function runPipeline( baseDelayMs: 500, retryIf: isRetryable, onRetry: (attempt, delayMs) => { - console.error(` \u21BB retry ${attempt} for "${meta.title}" in ${delayMs}ms...`); + console.error( + ` ${symbols.retry} retry ${dim(`${attempt}`)} for ${yellow(meta.title)} in ${dim(`${delayMs}ms`)}...`, + ); }, }, ); @@ -148,7 +193,7 @@ export async function runPipeline( if (result.ignored) { summary.ignored += result.ignored; onProgress(i + 1, pending.length, meta, "ignored"); - } else { + } else if (result.accepted) { summary.accepted += result.accepted; onProgress(i + 1, pending.length, meta, "ok"); } @@ -156,7 +201,9 @@ export async function runPipeline( if (opts.commitSuccess) { const commitResult = await opts.commitSuccess(context); if (!commitResult.ok) { - console.error(` \u26a0 failed to persist state for "${meta.title}": ${describe(commitResult.error)}`); + console.error( + ` ${symbols.warn} failed to persist state for ${yellow(meta.title)}: ${dim(describe(commitResult.error))}`, + ); } } } catch (e) { diff --git a/cli/scrobbler-log-runner.ts b/cli/scrobbler-log-runner.ts new file mode 100644 index 0000000..cdbf788 --- /dev/null +++ b/cli/scrobbler-log-runner.ts @@ -0,0 +1,39 @@ +import { PendingEntry, PipelineOptions, PipelineSummary, runPipeline } from "~/cli/pipeline.ts"; +import { describe } from "~/lib/errors.ts"; +import { ScrobblerLogTrack } from "~/lib/format/scrobbler-log/mod.ts"; +import { deleteLog, readScrobblerLog } from "~/lib/format/scrobbler-log/io.ts"; + +type ScrobblerLogContext = Record; + +function toPendingEntry(track: ScrobblerLogTrack): PendingEntry { + return { + meta: { + artist: track.title, + album: track.album || undefined, + title: track.title, + timestamp: track.timestamp, + }, + context: {}, + }; +} + +export async function runScrobblerLogPipeline( + path: string, + opts: PipelineOptions, + onProgress: (current: number, total: number, meta: any, status: any, detail?: string) => void, +): Promise { + const log = await readScrobblerLog(path); + if (!log.ok) throw new Error(log.error.message); + + const pending = log.value.tracks.map(toPendingEntry); + const summary = await runPipeline(pending, { ...opts }, onProgress); + + if (summary.failed === 0 && !opts.dryRun) { + console.log(`\n \u2713 sync complete, removing .scrobbler.log...`); + await deleteLog(path); + } else if (summary.failed > 0) { + console.warn(`\n \u2640 ${summary.failed} tracks failed, log file preserved for next sync.`); + } + + return summary; +} diff --git a/commands/import.ts b/commands/import.ts new file mode 100644 index 0000000..09ddd5d --- /dev/null +++ b/commands/import.ts @@ -0,0 +1,5 @@ +export interface ImportOptions { +} + +export async function runImport(opts: ImportOptions): Promise { +} diff --git a/deno.json b/deno.json index 2a78d79..871d004 100644 --- a/deno.json +++ b/deno.json @@ -5,6 +5,7 @@ "imports": { "@std/cli": "jsr:@std/cli@^1.0.28", "@std/crypto": "jsr:@std/crypto@^1.0.5", + "@std/fmt": "jsr:@std/fmt@^1.0.9", "@std/path": "jsr:@std/path@^1.1.4", "~/": "./" }, diff --git a/deno.lock b/deno.lock index 215d38f..d1282bb 100644 --- a/deno.lock +++ b/deno.lock @@ -43,6 +43,7 @@ "dependencies": [ "jsr:@std/cli@^1.0.28", "jsr:@std/crypto@^1.0.5", + "jsr:@std/fmt@^1.0.9", "jsr:@std/path@^1.1.4" ] }