diff --git a/cli/bootstrap.ts b/cli/bootstrap.ts index 389ba2b..13836d5 100644 --- a/cli/bootstrap.ts +++ b/cli/bootstrap.ts @@ -1,6 +1,6 @@ import { type Config, loadConfig, updateConfig } from "~/lib/config.ts"; import { dim, log } from "~/cli/formatter.ts"; -import { AppError, describe } from "~/lib/errors.ts"; +import { AppError } from "~/lib/errors.ts"; import { Ok, Result } from "~/lib/result.ts"; import { ask } from "~/utils/prompt.ts"; import { italic } from "@std/fmt/colors"; diff --git a/cli/csv-runner.ts b/cli/csv-runner.ts index f4a0b6f..a27f703 100644 --- a/cli/csv-runner.ts +++ b/cli/csv-runner.ts @@ -1,5 +1,5 @@ import { CsvDocument } from "~/lib/format/csv/mod.ts"; -import { PendingEntry, PipelineOptions, PipelineSummary, runPipeline } from "~/cli/pipeline.ts"; +import { PendingEntry, PipelineOptions, PipelineSummary, resolveTimestamp, 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"; @@ -7,13 +7,13 @@ import { dim } from "@std/fmt/colors"; type CsvContext = { lineIndex: number }; -function toPendingEntry(entry: CsvDocument["pending"][number]): PendingEntry { +function toPendingEntry(entry: CsvDocument["pending"][number], index: number): PendingEntry { return { meta: { - artist: entry.track.title, + artist: entry.track.artist, album: entry.track.album || undefined, title: entry.track.title, - timestamp: Math.floor(new Date(entry.track.date).getTime() / 1000), + timestamp: resolveTimestamp(index, entry.track.date), }, context: { lineIndex: entry.lineIndex, @@ -24,7 +24,6 @@ function toPendingEntry(entry: CsvDocument["pending"][number]): PendingEntry, - 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); @@ -45,5 +44,5 @@ export async function runCsvPipeline( } return skip; }, - }, onProgress); + }); } diff --git a/cli/formatter.ts b/cli/formatter.ts index 0a86da1..c83855a 100644 --- a/cli/formatter.ts +++ b/cli/formatter.ts @@ -1,11 +1,11 @@ -import { cyan, dim, gray, green, red, yellow } from "@std/fmt/colors"; +import { brightRed, cyan, dim, gray, green, red, yellow } from "@std/fmt/colors"; export const symbols = { retry: gray("\u21bb"), warn: cyan("\u26a0"), success: green("\u2714"), forbid: red("\u2298"), - error: red("\u2716"), + error: brightRed("\u2716"), info: yellow("\u{1f6c8}"), } as const; diff --git a/cli/pipeline.ts b/cli/pipeline.ts index f78a084..15c8fa1 100644 --- a/cli/pipeline.ts +++ b/cli/pipeline.ts @@ -2,17 +2,17 @@ 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 { Ok, Result } from "~/lib/result.ts"; +import { Result } from "~/lib/result.ts"; import { symbols } from "~/cli/formatter.ts"; -import { dim, yellow } from "@std/fmt/colors"; +import { dim, italic, yellow } from "@std/fmt/colors"; -const DAILY_SCROBBLE_LIMIT: number = 2880; +export const DAILY_SCROBBLE_LIMIT: number = 2880; +export const TIMESTAMP_LIMIT = 1_200_960; // ~13.9 days export type PipelineTrackMeta = ScrobblePayload; export interface PipelineOptions { readonly config: Omit, "password">; - readonly sessionKey: string; readonly dryRun?: boolean; readonly delayMs?: number; @@ -113,17 +113,19 @@ export function reportPipelineProgress( status: PipelineProgressStatus, detail?: string, ) { - const prefix = dryRun ? " \u{1F6E0} π˜₯𝘳𝘺 𝘳𝘢𝘯 " : " "; + const prefix = dryRun ? ` \u{1F6E0} ${italic("dry run")} ` : " "; const track = yellow(`"${meta.artist} - ${meta.title}"`); const progress = prefix + dim(`[${current}/${total}]`); + const timestamp = new Date(meta.timestamp * 1000).toLocaleString(); + switch (status) { case "ok": - console.log(`${progress} ${symbols.success} ${track}`); + console.log(`${progress} ${symbols.success} ${track} ${dim("@ " + timestamp)}`); break; case "ignored": - console.warn(`${progress} ${symbols.forbid} Ignored by Last.fm: ${track}`); + console.warn(`${progress} ${symbols.forbid} Ignored by Last.fm: ${track} ${dim("@ " + timestamp)}`); break; case "failed": console.error( @@ -177,7 +179,7 @@ export async function runPipeline( try { const result: ScrobbleResult = await withRetryR( - () => scrobble(opts.config.apiKey, opts.config.secret, opts.sessionKey, meta), + () => scrobble(opts.config.apiKey, opts.config.secret, opts.config.sessionKey, meta), { maxAttempts: 4, baseDelayMs: 500, @@ -218,10 +220,37 @@ export async function runPipeline( } /** - * generate evenly-spaced timestamps for tracks that have no date. - * starts 13.9 days before now and steps 30s per track + * assigns a quasi-uniformly spaced timestamp to a track with a or date that's either older than 14 days + * or missing. + * + * tβ‚€ = (now βˆ’ 13.9 days), with fixed increment Ξ”t = 30 s per track. */ -export function generateTimestamps(count: number): number[] { - const begin = Math.floor(Date.now() / 1_000) - 1_200_960; - return Array.from({ length: count }, (_, i) => begin + (i + 1) * 30); +export function generateTimestamp(index: number, jitter = 10): number { + const t0 = Math.floor(Date.now() / 1_000) - TIMESTAMP_LIMIT; + + // uniform noise Ξ΅ ∈ [-jitter, +jitter] + const epsilon = Math.floor((Math.random() * 2 - 1) * jitter); + return t0 + index * 30 + epsilon; +} + +export function resolveTimestamp(index: number, input?: string | number): number { + let ts: number | undefined; + + if (typeof input === "number") { + ts = input > 1e9 ? input : undefined; + } else if (input) { + const parsed = Date.parse(input); + if (!isNaN(parsed)) { + ts = Math.floor(parsed / 1000); + } + } + + if (ts !== undefined) { + const now = Math.floor(Date.now() / 1000); + if (now - ts <= TIMESTAMP_LIMIT) { + return ts; + } + } + + return generateTimestamp(index); } diff --git a/cli/scrobbler-log-runner.ts b/cli/scrobbler-log-runner.ts index cdbf788..53f5683 100644 --- a/cli/scrobbler-log-runner.ts +++ b/cli/scrobbler-log-runner.ts @@ -1,17 +1,16 @@ -import { PendingEntry, PipelineOptions, PipelineSummary, runPipeline } from "~/cli/pipeline.ts"; -import { describe } from "~/lib/errors.ts"; +import { PendingEntry, PipelineOptions, PipelineSummary, resolveTimestamp, runPipeline } from "~/cli/pipeline.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 { +function toPendingEntry(track: ScrobblerLogTrack, index: number): PendingEntry { return { meta: { - artist: track.title, + artist: track.artist, album: track.album || undefined, title: track.title, - timestamp: track.timestamp, + timestamp: resolveTimestamp(index, track.timestamp), }, context: {}, }; @@ -20,13 +19,12 @@ function toPendingEntry(track: ScrobblerLogTrack): PendingEntry, - 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); + const summary = await runPipeline(pending, { ...opts }); if (summary.failed === 0 && !opts.dryRun) { console.log(`\n \u2713 sync complete, removing .scrobbler.log...`); diff --git a/deno.json b/deno.json index 871d004..60486c7 100644 --- a/deno.json +++ b/deno.json @@ -6,6 +6,7 @@ "@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/fs": "jsr:@std/fs@^1.0.23", "@std/path": "jsr:@std/path@^1.1.4", "~/": "./" }, diff --git a/deno.lock b/deno.lock index d1282bb..d9e7823 100644 --- a/deno.lock +++ b/deno.lock @@ -6,6 +6,7 @@ "jsr:@std/cli@^1.0.28": "1.0.28", "jsr:@std/crypto@^1.0.5": "1.0.5", "jsr:@std/fmt@^1.0.9": "1.0.9", + "jsr:@std/fs@^1.0.23": "1.0.23", "jsr:@std/internal@^1.0.12": "1.0.12", "jsr:@std/path@^1.1.4": "1.1.4" }, @@ -29,6 +30,13 @@ "@std/fmt@1.0.9": { "integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0" }, + "@std/fs@1.0.23": { + "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37", + "dependencies": [ + "jsr:@std/internal", + "jsr:@std/path" + ] + }, "@std/internal@1.0.12": { "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" }, @@ -44,6 +52,7 @@ "jsr:@std/cli@^1.0.28", "jsr:@std/crypto@^1.0.5", "jsr:@std/fmt@^1.0.9", + "jsr:@std/fs@^1.0.23", "jsr:@std/path@^1.1.4" ] } diff --git a/LICENSE b/license similarity index 100% rename from LICENSE rename to license