diff --git a/lib/errors.ts b/lib/errors.ts index e6f1347..08c60ff 100644 --- a/lib/errors.ts +++ b/lib/errors.ts @@ -1,3 +1,5 @@ +import { ScrobblerLogError, ScrobblerLogErrorReason } from "~/lib/format/scrobbler-log/error.ts"; + export type AppError = | LastFmError | MusicBrainzError @@ -5,7 +7,8 @@ export type AppError = | CsvError | AuthError | RateLimitError - | NetworkError; + | NetworkError + | ScrobblerLogError; export type LastFmErrorCode = | 2 // unavailable service @@ -22,11 +25,11 @@ export type LastFmErrorCode = | 26 // suspended api key | 29; // rate limit exceeded -interface BaseError { +export interface BaseError { readonly kind: Kind; } -interface TaggedError extends BaseError { +export interface TaggedError extends BaseError { readonly tag: Code; } @@ -83,6 +86,12 @@ export const Errors = { rateLimit: (retryAfterMs?: number): RateLimitError => ({ kind: "rate_limit", retryAfterMs }), network: (message: string, status?: number): NetworkError => ({ kind: "network", message, tag: status }), + + scrobblerLog: (tag: ScrobblerLogErrorReason, message: string): ScrobblerLogError => ({ + kind: "scrobbler_log", + tag, + message, + }), } as const; export function describe(e: AppError): string { @@ -101,5 +110,7 @@ export function describe(e: AppError): string { return e.retryAfterMs ? `Rate limited. Retry after ${e.retryAfterMs}ms` : "Rate limited"; case "network": return e.tag ? `Network error ${e.tag}: ${e.message}` : `Network error: ${e.message}`; + case "scrobbler_log": + return `Scrobbler Log error (${e.tag}): ${e.message}`; } } diff --git a/lib/format/scrobbler-log/codec.ts b/lib/format/scrobbler-log/codec.ts new file mode 100644 index 0000000..11ae44f --- /dev/null +++ b/lib/format/scrobbler-log/codec.ts @@ -0,0 +1,141 @@ +import { Errors } from "~/lib/errors.ts"; +import { ClientIdentification, ScrobblerLogHeader, ScrobblerLogTrack } from "~/lib/format/scrobbler-log/mod.ts"; +import { Fail, Ok, Result } from "~/lib/result.ts"; + +type ParseError = ReturnType; + +const FIELD_COUNT = 8; +const TAB = "\t"; + +function createMusicBrainzId(value: string): Result { + const stripped = value.trim(); + const pattern = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + if (stripped.length && !pattern.test(stripped)) { + return Fail(Errors.scrobblerLog("invalid_field", "musicBrainz id must be a valid UUID")); + } + return Ok(stripped); +} + +function serializeClient(client: ClientIdentification): string { + const parts = [client.device, client.model, client.revision].filter(Boolean); + return parts.join(" "); +} + +export function serializeHeader(header: ScrobblerLogHeader): readonly string[] { + return [ + `#AUDIOSCROBBLER/${header.version}`, + `#TZ/${header.timezone.kind.toUpperCase()}`, + `#CLIENT/${serializeClient(header.client)}`, + ]; +} + +// the spec mandates: [...] "strip any tab characters from the data" +function sanitize(str: string): string { + return str.replace(/\t/g, " "); +} + +export function serializeTrack(track: ScrobblerLogTrack): string { + const fields: string[] = [ + sanitize(track.artist), + sanitize(track.album ?? ""), + sanitize(track.title), + track.trackIndex?.toString() ?? "", + track.duration.toString(), + track.rating, + track.timestamp.toString(), + sanitize(track.musicBrainzId ?? ""), + ]; + return fields.join(TAB); +} + +function parseHeaderLine(line: string): Result, ParseError> { + if (!line.startsWith("#")) { + return Fail(Errors.scrobblerLog("parse_failed", "header line must start with #")); + } + + const [directive, ...rest] = line.slice(1).split("/"); + const value = rest.join("/"); // sanity purposes + + switch (directive) { + case "AUDIOSCROBBLER": + if (value !== "1.1") { + return Fail(Errors.scrobblerLog("unsupported_version", `version ${value} is unsupported`)); + } + return Ok({ version: value as ScrobblerLogHeader["version"] }); + case "TZ": { + const tz = value.toLowerCase(); + if (tz === "utc" || tz === "unknown") return Ok({ timezone: { kind: tz } }); + return Fail(Errors.scrobblerLog("invalid_field", `invalid timezone: ${value}`)); + } + case "CLIENT": + return Ok({ client: { device: value, revision: "1.0" } }); + default: + // forward compatibility + return Ok({}); + } +} + +export function parseHeader(lines: string[]): Result { + let header: Partial = {}; + + for (const line of lines) { + if (!line.startsWith("#")) break; // headers end at first non-comment line + + const result = parseHeaderLine(line); + if (!result.ok) return result; + header = { ...header, ...result.value }; + } + + if (!header.version) return Fail(Errors.scrobblerLog("parse_failed", "missing version header")); + if (!header.timezone) return Fail(Errors.scrobblerLog("parse_failed", "missing timezone header")); + if (!header.client) return Fail(Errors.scrobblerLog("parse_failed", "missing client header")); + + return Ok(header as ScrobblerLogHeader); +} + +export function parseTrack(line: string): Result { + const fields = line.split(TAB); + if (fields.length !== FIELD_COUNT) { + return Fail(Errors.scrobblerLog("invalid_columns", `Expected ${FIELD_COUNT} fields, got ${fields.length}`)); + } + + const [artist, album, title, $trackIndex, rawDuration, rating, $timestamp, $mbId] = fields; + + if (!artist) { + return Fail(Errors.scrobblerLog("invalid_field", "artist must not be empty")); + } + if (!title) { + return Fail(Errors.scrobblerLog("invalid_field", "title must not be empty")); + } + + const duration = parseInt(rawDuration, 10); + if (isNaN(duration)) return Fail(Errors.scrobblerLog("invalid_field", "duration must be a number")); + + if (rating !== "L" && rating !== "S") { + return Fail(Errors.scrobblerLog("invalid_field", "rating must be either 'L' or 'S'")); + } + + const timestamp = typeof $timestamp === "string" ? parseInt($timestamp, 10) : $timestamp; + if (isNaN(timestamp) || timestamp < 0) { + return Fail(Errors.scrobblerLog("invalid_field", "timestamp must be a positive number")); + } + + const trackIndex = $trackIndex ? parseInt($trackIndex, 10) : undefined; + if (trackIndex !== undefined && isNaN(trackIndex)) { + return Fail(Errors.scrobblerLog("invalid_field", "track number must be a valid number")); + } + + const musicBrainzId = $mbId ? createMusicBrainzId($mbId) : Ok(undefined); + if (!musicBrainzId.ok) return musicBrainzId; + + return Ok({ + artist, + album, + title, + trackIndex, + duration, + rating, + timestamp, + musicBrainzId: musicBrainzId.value, + }); +} diff --git a/lib/format/scrobbler-log/error.ts b/lib/format/scrobbler-log/error.ts new file mode 100644 index 0000000..5f0fa22 --- /dev/null +++ b/lib/format/scrobbler-log/error.ts @@ -0,0 +1,14 @@ +import { TaggedError } from "~/lib/errors.ts"; + +export type ScrobblerLogErrorReason = + | "not_found" + | "read_failed" + | "write_failed" + | "parse_failed" + | "invalid_columns" + | "invalid_field" + | "unsupported_version"; + +export interface ScrobblerLogError extends TaggedError<"scrobbler_log", ScrobblerLogErrorReason> { + readonly message: string; +} diff --git a/lib/format/scrobbler-log/io.ts b/lib/format/scrobbler-log/io.ts new file mode 100644 index 0000000..ba78983 --- /dev/null +++ b/lib/format/scrobbler-log/io.ts @@ -0,0 +1,133 @@ +import { ScrobblerLogHeader, ScrobblerLogTrack } from "~/lib/format/scrobbler-log/mod.ts"; +import { parseHeader, parseTrack, serializeHeader, serializeTrack } from "~/lib/format/scrobbler-log/codec.ts"; +import { Errors } from "~/lib/errors.ts"; +import { Fail, Ok, Result } from "~/lib/result.ts"; +import { join } from "@std/path"; + +const FILENAME = ".scrobbler.log"; +const NEWLINE = "\n"; + +export interface ScrobblerLogPayload { + readonly header: ScrobblerLogHeader; + readonly tracks: readonly ScrobblerLogTrack[]; +} + +type IOError = ReturnType; + +/** + * determine if a header needs to be prepended to a log entry. + */ +function withHeaderIfNeeded( + path: string, + header: ScrobblerLogHeader, + line: string, + fileExists: (p: string) => boolean, +): string { + if (!fileExists(path)) { + const headerLines = serializeHeader(header).join(NEWLINE); + return `${headerLines}${NEWLINE}${line}`; + } + return line; +} + +export async function readScrobblerLog(input: string): Promise> { + let raw: string; + let path = input; + + try { + if ((await Deno.stat(path)).isDirectory) { + path = join(input, FILENAME); + } + raw = await Deno.readTextFile(path); + } catch (e) { + if (e instanceof Deno.errors.NotFound) { + return Fail(Errors.scrobblerLog("not_found", `log file not found at ${path}`)); + } + return Fail(Errors.scrobblerLog("read_failed", e instanceof Error ? e.message : String(e))); + } + + const lines = raw.split(NEWLINE).filter((line) => line.length); + + const header = parseHeader(lines); + if (!header.ok) return header; + + const headerLength = lines.filter((l) => l.startsWith("#")).length; + const body = lines.slice(headerLength); + + const tracks: ScrobblerLogTrack[] = []; + for (const meow of body) { + const track = parseTrack(meow); + if (!track.ok) continue; + tracks.push(track.value); + } + + return Ok({ header: header.value, tracks }); +} + +/** + * appends a single track to an existing log, or creates a new log if it doesn't exist. + */ +export async function appendTrack( + directory: string, + header: ScrobblerLogHeader, + track: ScrobblerLogTrack, +): Promise> { + const path = join(directory, FILENAME); + const line = serializeTrack(track) + NEWLINE; + + try { + const body = withHeaderIfNeeded(path, header, line, (p) => { + try { + Deno.statSync(p); + return true; + } catch (e) { + if (e instanceof Deno.errors.NotFound) return false; + throw e; // propagate actual i/o errors to the outer catch + } + }); + + await Deno.writeTextFile(path, body, { append: false }); + return Ok(undefined); + } catch (e) { + return Fail(Errors.scrobblerLog("write_failed", e instanceof Error ? e.message : String(e))); + } +} + +/** + * creates a new .scrobbler.log file, overwriting any existing one. + */ +export async function createLog( + directory: string, + $header: ScrobblerLogHeader, + $tracks: readonly ScrobblerLogTrack[] = [], +): Promise> { + const path = join(directory, FILENAME); + + const hader = serializeHeader($header).join(NEWLINE); + const tracks = $tracks.map(serializeTrack).join(NEWLINE); + + const body = [hader, tracks].filter(Boolean).join(NEWLINE) + NEWLINE; + + try { + await Deno.writeTextFile(path, body); + return Ok(undefined); + } catch (e) { + return Fail(Errors.scrobblerLog("write_failed", e instanceof Error ? e.message : String(e))); + } +} + +/** + * safely deletes the log file. done after successful synchronisation, in compliance with the spec + */ +export async function deleteLog(directory: string): Promise> { + const path = join(directory, FILENAME); + try { + await Deno.remove(path); + return Ok(undefined); + } catch (e) { + if (e instanceof Deno.errors.NotFound) { + return Ok(undefined); + } + return Fail(Errors.scrobblerLog("write_failed", e instanceof Error ? e.message : String(e))); + } +} diff --git a/lib/format/scrobbler-log/mod.ts b/lib/format/scrobbler-log/mod.ts new file mode 100644 index 0000000..eb9cc23 --- /dev/null +++ b/lib/format/scrobbler-log/mod.ts @@ -0,0 +1,54 @@ +// https://web.archive.org/web/20170107015006/http://www.audioscrobbler.net/wiki/Portable_Player_Logging + +/** + * Semantic version identifier for the scrobbler log format. + */ +export type ScrobblerLogVersion = "1.0" | "1.1"; + +/** + * Classify as "L" (scrobble) if the ratio of playback duration to total track duration is ≥ 50%; + * otherwise, classify as "S" (skip). + */ +export type TrackRating = "L" | "S"; + +/** + * If the device has a known time zone, it MUST normalize all recorded timestamps to UTC (e.g., #TZ/UTC). + * + * If the device has a valid clock but an unknown time zone, timestamps MUST be recorded as + * local (unqualified) time (e.g., #TZ/UNKNOWN). + */ +export type Timezone = + | { kind: "unknown" } + | { kind: "utc" }; + +/** + * Core domain representation of a scrobbled track. + */ +export interface ScrobblerLogTrack { + readonly artist: string; + readonly album?: string; + readonly title: string; + readonly trackIndex?: number; + readonly duration: number; + readonly rating: TrackRating; + readonly timestamp: number; + readonly musicBrainzId?: string; +} + +/** + * Strict client identification. Enforces format like "Rockbox h3xx 1.1" + */ +export interface ClientIdentification { + readonly device: string; + readonly model?: string; + readonly revision: string; +} + +/** + * The header configuration required to initialize a log file. + */ +export interface ScrobblerLogHeader { + readonly version: ScrobblerLogVersion; + readonly timezone: Timezone; + readonly client: ClientIdentification; +}