diff --git a/README.md b/README.md index 4d2055e..79309c1 100644 --- a/README.md +++ b/README.md @@ -295,7 +295,7 @@ Each scrobble becomes an `fm.teal.alpha.feed.play` record with: - **trackName**: The name of the track - **artists**: Array of artist objects (requires `artistName`, optional `artistMbId` for Last.fm) - **playedTime**: ISO 8601 timestamp of when you listened -- **submissionClientAgent**: Identifies this importer (`malachite/v0.7.0`) +- **submissionClientAgent**: Identifies this importer (`malachite/v0.7.1`) - **musicServiceBaseDomain**: Set to `last.fm` or `spotify.com` ### Optional Fields @@ -322,7 +322,7 @@ Each scrobble becomes an `fm.teal.alpha.feed.play` record with: "recordingMbId": "3a390ad3-fe56-45f2-a073-bebc45d6bde1", "playedTime": "2025-11-13T23:49:36Z", "originUrl": "https://www.last.fm/music/Cjbeards/_/Paint+My+Masterpiece", - "submissionClientAgent": "malachite/v0.7.0", + "submissionClientAgent": "malachite/v0.7.1", "musicServiceBaseDomain": "last.fm" } ``` @@ -340,7 +340,7 @@ Each scrobble becomes an `fm.teal.alpha.feed.play` record with: "releaseName": "Twenty", "playedTime": "2021-09-09T10:34:08Z", "originUrl": "https://open.spotify.com/track/3gZqDJkMZipOYCRjlHWgOV", - "submissionClientAgent": "malachite/v0.7.0", + "submissionClientAgent": "malachite/v0.7.1", "musicServiceBaseDomain": "spotify.com" } ``` diff --git a/package.json b/package.json index b622f6d..72bc671 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "malachite", - "version": "0.7.0", + "version": "0.7.1", "description": "Malachite - Import Last.fm and Spotify listening history to ATProto with intelligent deduplication and rate limiting", "type": "module", "main": "./dist/index.js", diff --git a/src/config.ts b/src/config.ts index 90a2cc3..b3ecc10 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,7 +23,7 @@ export const RECORD_TYPE = 'fm.teal.alpha.feed.play'; export function buildClientAgent(_debug = false) { // Always return just the version, regardless of debug mode // The debug parameter is kept for backwards compatibility but unused - return 'malachite/v0.7.0'; + return 'malachite/v0.7.1'; } // Default batch configuration - conservative for PDS safety diff --git a/src/lib/cli.ts b/src/lib/cli.ts index eb700fe..9872ea1 100644 --- a/src/lib/cli.ts +++ b/src/lib/cli.ts @@ -35,7 +35,7 @@ import { */ export function showHelp(): void { console.log(` -${'\x1b[1m'}Malachite v0.7.0${'\x1b[0m'} +${'\x1b[1m'}Malachite v0.7.1${'\x1b[0m'} ${'\x1b[1m'}USAGE:${'\x1b[0m'} pnpm start Run in interactive mode (prompts for all inputs) @@ -282,8 +282,23 @@ async function runInteractiveMode(): Promise { } if (!useSavedCreds) { - args.handle = await prompt('ATProto handle (e.g., alice.bsky.social): '); - args.password = await prompt('App password: ', true); + let handle = ''; + while (!handle) { + handle = await prompt('ATProto handle (e.g., alice.bsky.social): '); + if (!handle) { + console.log('⚠️ Handle is required. Please try again.'); + } + } + args.handle = handle; + + let password = ''; + while (!password) { + password = await prompt('App password: ', true); + if (!password) { + console.log('⚠️ Password is required. Please try again.'); + } + } + args.password = password; // Offer to save credentials const saveCredsAnswer = await confirm('\nSave credentials for future use? (encrypted, machine-specific)', false); @@ -299,12 +314,41 @@ async function runInteractiveMode(): Promise { // Get input files if (args.mode !== 'deduplicate') { if (args.mode === 'combined') { - args.input = await prompt('Path to Last.fm CSV file: '); - args['spotify-input'] = await prompt('Path to Spotify export (file or directory): '); + let input = ''; + while (!input) { + input = await prompt('Path to Last.fm CSV file: '); + if (!input) { + console.log('⚠️ Path is required. Please try again.'); + } + } + args.input = input; + + let spotifyInput = ''; + while (!spotifyInput) { + spotifyInput = await prompt('Path to Spotify export (file or directory): '); + if (!spotifyInput) { + console.log('⚠️ Path is required. Please try again.'); + } + } + args['spotify-input'] = spotifyInput; } else if (args.mode === 'spotify') { - args.input = await prompt('Path to Spotify export (file or directory): '); + let input = ''; + while (!input) { + input = await prompt('Path to Spotify export (file or directory): '); + if (!input) { + console.log('⚠️ Path is required. Please try again.'); + } + } + args.input = input; } else { - args.input = await prompt('Path to Last.fm CSV file: '); + let input = ''; + while (!input) { + input = await prompt('Path to Last.fm CSV file: '); + if (!input) { + console.log('⚠️ Path is required. Please try again.'); + } + } + args.input = input; } console.log(''); } @@ -338,13 +382,23 @@ export async function runCLI(): Promise { let args = parseCommandLineArgs(); // Check if running with no arguments (interactive mode) - const hasAnyArgs = Object.keys(args).some(key => { + // Modifier flags like --dry-run, --verbose, --yes, etc. don't count as "real" arguments + const modifierFlags = ['dry-run', 'verbose', 'quiet', 'yes', 'reverse', 'aggressive', 'fresh', 'dev']; + const hasSubstantiveArgs = Object.keys(args).some(key => { const value = args[key as keyof CommandLineArgs]; - return value !== undefined && value !== false && value !== 'lastfm'; // lastfm is default mode + // Skip undefined, false values, and default mode + if (value === undefined || value === false || (key === 'mode' && value === 'lastfm')) { + return false; + } + // Skip modifier flags + if (modifierFlags.includes(key)) { + return false; + } + return true; }); - if (!hasAnyArgs) { - // No arguments provided - run interactive mode + if (!hasSubstantiveArgs) { + // No substantive arguments provided - run interactive mode args = await runInteractiveMode(); } @@ -434,8 +488,16 @@ export async function runCLI(): Promise { } if (mode === 'deduplicate') { + // Try to load saved credentials if not provided if (!args.handle || !args.password) { - throw new Error('Deduplicate mode requires --handle and --password'); + const creds = loadCredentials(); + if (creds) { + args.handle = creds.handle; + args.password = creds.password; + log.info(`Using saved credentials for: ${creds.handle}`); + } else { + throw new Error('Deduplicate mode requires --handle and --password (or saved credentials)'); + } } log.section('Remove Duplicate Records'); agent = await login(args.handle, args.password, cfg.SLINGSHOT_RESOLVER) as AtpAgent; @@ -461,8 +523,16 @@ export async function runCLI(): Promise { return; } + // Try to load saved credentials if not provided if (!args.handle || !args.password) { - throw new Error('Missing required arguments: --handle and --password'); + const creds = loadCredentials(); + if (creds) { + args.handle = creds.handle; + args.password = creds.password; + log.info(`Using saved credentials for: ${creds.handle}`); + } else { + throw new Error('Missing required arguments: --handle and --password (or saved credentials)'); + } } log.debug('Authenticating...'); agent = await login(args.handle, args.password, cfg.SLINGSHOT_RESOLVER) as AtpAgent; diff --git a/src/lib/sync.ts b/src/lib/sync.ts index 556d7fb..a5ec668 100644 --- a/src/lib/sync.ts +++ b/src/lib/sync.ts @@ -147,13 +147,10 @@ export async function fetchExistingRecords( } } - // Show progress every 250 records or every request if less than 1000 total - const showProgress = totalFetched % 250 === 0 && totalFetched > 0; - if (showProgress || totalFetched < 1000) { - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - const rate = (totalFetched / (Date.now() - startTime) * 1000).toFixed(0); - log.progress(`Fetched ${totalFetched.toLocaleString()} records (${rate} rec/s, batch: ${batchSize}, ${elapsed}s)...`); - } + // Show progress on every batch + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + const rate = (totalFetched / (Date.now() - startTime) * 1000).toFixed(0); + log.progress(`Fetched ${totalFetched.toLocaleString()} records (${rate} rec/s, batch: ${batchSize}, ${elapsed}s)...`); } while (cursor); const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); @@ -257,13 +254,10 @@ export async function fetchAllRecords( } } - // Update spinner with progress every 250 records or every request if less than 1000 total - const showProgress = totalFetched % 250 === 0 && totalFetched > 0; - if (showProgress || totalFetched < 1000) { - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - const rate = (totalFetched / (Date.now() - startTime) * 1000).toFixed(0); - ui.updateSpinner(`Fetching records... ${totalFetched.toLocaleString()} found (${rate} rec/s, batch: ${batchSize}, ${elapsed}s)`); - } + // Update spinner with progress more frequently + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + const rate = (totalFetched / (Date.now() - startTime) * 1000).toFixed(0); + ui.updateSpinner(`Fetching records... ${totalFetched.toLocaleString()} found (${rate} rec/s, batch: ${batchSize}, ${elapsed}s)`); } while (cursor); const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); diff --git a/src/utils/input.ts b/src/utils/input.ts index 093b01a..37a41ca 100644 --- a/src/utils/input.ts +++ b/src/utils/input.ts @@ -1,6 +1,18 @@ import * as readline from 'readline'; import chalk from 'chalk'; +/** + * Strip surrounding quotes from a string (single or double quotes) + */ +function stripQuotes(str: string): string { + str = str.trim(); + if ((str.startsWith("'") && str.endsWith("'")) || + (str.startsWith('"') && str.endsWith('"'))) { + return str.slice(1, -1); + } + return str; +} + /** * Display a menu and get user selection */ @@ -113,7 +125,9 @@ export function prompt(question: string, hideInput = false): Promise { rl.question(question, (answer) => { rl.close(); - resolve(answer); + // Strip quotes from file paths + const cleaned = stripQuotes(answer); + resolve(cleaned); }); } });