Malachite is a tool to import your Last.fm and Spotify listening history to the AT Protocol network using the fm.teal.alpha.feed.play lexicon.
malachite scrobbles importer atproto music
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add PDS override option to bypass identity resolution in CLI and authentication

+50 -16
+13
README.md
··· 251 251 | `--batch-delay <ms>` | `-d` | Delay between batches in ms | `500` (min) | 252 252 | `--help` | | Show help message | - | 253 253 254 + ### PDS Override 255 + 256 + If you already know the base URL of your Personal Data Server (PDS) you can bypass the Slingshot identity resolver and provide it directly with the `--pds` flag. This is useful for private instances, testing, or when the resolver is unreliable. 257 + 258 + | Option | Description | 259 + |--------|-------------| 260 + | `--pds <url>` | PDS base URL to use for authentication and API calls (e.g. `https://pds.example.com`). When provided, Malachite will skip Slingshot lookup and use this URL directly. | 261 + 262 + Notes: 263 + - The `--pds` flag overrides the configured Slingshot resolver for identity lookup. If `--pds` is given, Malachite will attempt to authenticate directly against the supplied PDS using your handle/DID and app password. 264 + - Use the full base URL (including scheme), e.g. `https://pds.example.com`. 265 + - If authentication fails when using `--pds`, try removing the flag so Malachite can resolve your PDS automatically via Slingshot. 266 + 254 267 ### Legacy Flags (Backwards Compatible) 255 268 256 269 These old flags still work but are deprecated:
+29 -13
src/lib/auth.ts
··· 12 12 /** 13 13 * Resolves an AT Protocol identifier (handle or DID) to get PDS information 14 14 */ 15 - async function resolveIdentifier(identifier: string): Promise<ResolvedIdentity> { 15 + async function resolveIdentifier(identifier: string, resolverBase: string): Promise<ResolvedIdentity> { 16 16 ui.startSpinner(`Resolving identifier: ${identifier}`); 17 - 17 + 18 18 try { 19 19 const response = await fetch( 20 - `https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(identifier)}` 20 + `${resolverBase}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(identifier)}` 21 21 ); 22 - 22 + 23 23 if (!response.ok) { 24 24 throw new Error(`Failed to resolve identifier: ${response.status} ${response.statusText}`); 25 25 } 26 - 26 + 27 27 const data = await response.json() as ResolvedIdentity; 28 - 28 + 29 29 if (!data.did || !data.pds) { 30 30 throw new Error('Invalid response from identity resolver'); 31 31 } 32 - 32 + 33 33 ui.succeedSpinner(`Resolved to PDS: ${data.pds}`); 34 34 return data; 35 35 } catch (error) { ··· 44 44 export async function login( 45 45 identifier: string | undefined, 46 46 password: string | undefined, 47 - _resolverUrl?: string // Keep parameter for backwards compatibility but don't use it 47 + resolverOrPds?: string // If this contains the Slingshot resolver base, it will be used to resolve; otherwise treated as a PDS override URL 48 48 ): Promise<AtpAgent> { 49 49 ui.header('ATProto Login'); 50 50 ··· 64 64 console.log(''); 65 65 66 66 try { 67 - // Resolve the identifier to get PDS and other info 68 - const resolved = await resolveIdentifier(identifier); 69 - 67 + // If resolverOrPds is provided and does NOT look like the Slingshot resolver, 68 + // treat it as a PDS override and skip identity resolution. 69 + const isSlingshot = resolverOrPds?.includes('slingshot') ?? false; 70 + 71 + if (resolverOrPds && !isSlingshot) { 72 + ui.startSpinner(`Using provided PDS: ${resolverOrPds}`); 73 + const agent = new AtpAgent({ service: resolverOrPds }); 74 + await agent.login({ identifier: identifier!, password: password }); 75 + ui.succeedSpinner('Logged in successfully (PDS override)!'); 76 + ui.keyValue('DID', agent.session?.did || 'unknown'); 77 + ui.keyValue('Handle', agent.session?.handle || 'unknown'); 78 + console.log(''); 79 + return agent; 80 + } 81 + 82 + // Otherwise use the resolver (provided or default) to resolve identifier 83 + const resolverBase = resolverOrPds || 'https://slingshot.microcosm.blue'; 84 + const resolved = await resolveIdentifier(identifier, resolverBase); 85 + 70 86 // Initialize the agent with the resolved PDS URL 71 87 ui.startSpinner('Logging in...'); 72 88 const agent = new AtpAgent({ ··· 78 94 identifier: resolved.did, 79 95 password: password, 80 96 }); 81 - 97 + 82 98 ui.succeedSpinner('Logged in successfully!'); 83 99 ui.keyValue('DID', agent.session?.did || 'unknown'); 84 100 ui.keyValue('Handle', agent.session?.handle || 'unknown'); 85 101 console.log(''); 86 - 102 + 87 103 return agent; 88 104 } catch (error) { 89 105 const err = error as Error;
+6 -3
src/lib/cli.ts
··· 46 46 ${'\x1b[1m'}AUTHENTICATION:${'\x1b[0m'} 47 47 -h, --handle <handle> ATProto handle or DID (e.g., user.bsky.social) 48 48 -p, --password <password> ATProto app password 49 + --pds <url> PDS base URL to bypass identity resolution (optional) 49 50 50 51 ${'\x1b[1m'}INPUT:${'\x1b[0m'} 51 52 -i, --input <path> Path to Last.fm CSV or Spotify JSON export ··· 131 132 handle: { type: 'string', short: 'h' }, 132 133 password: { type: 'string', short: 'p' }, 133 134 input: { type: 'string', short: 'i' }, 135 + pds: { type: 'string' }, 134 136 'spotify-input': { type: 'string' }, 135 137 mode: { type: 'string', short: 'm' }, 136 138 'batch-size': { type: 'string', short: 'b' }, ··· 162 164 help: values.help, 163 165 handle: values.handle || values.identifier, 164 166 password: values.password, 167 + pds: values.pds, 165 168 input: values.input || values.file, 166 169 'spotify-input': values['spotify-input'] || values['spotify-file'], 167 170 'batch-size': values['batch-size'], ··· 493 496 } 494 497 log.section('Clear Cache'); 495 498 log.info('Authenticating to identify cache...'); 496 - agent = await login(args.handle, args.password, cfg.SLINGSHOT_RESOLVER) as AtpAgent; 499 + agent = await login(args.handle, args.password, args.pds ?? cfg.SLINGSHOT_RESOLVER) as AtpAgent; 497 500 const did = agent.session?.did; 498 501 if (!did) { 499 502 throw new Error('Failed to get DID from session'); ··· 531 534 } 532 535 } 533 536 log.section('Remove Duplicate Records'); 534 - agent = await login(args.handle, args.password, cfg.SLINGSHOT_RESOLVER) as AtpAgent; 537 + agent = await login(args.handle, args.password, args.pds ?? cfg.SLINGSHOT_RESOLVER) as AtpAgent; 535 538 const result = await removeDuplicates(agent, cfg, dryRun); 536 539 if (result.totalDuplicates === 0) { 537 540 return; ··· 566 569 } 567 570 } 568 571 log.debug('Authenticating...'); 569 - agent = await login(args.handle, args.password, cfg.SLINGSHOT_RESOLVER) as AtpAgent; 572 + agent = await login(args.handle, args.password, args.pds ?? cfg.SLINGSHOT_RESOLVER) as AtpAgent; 570 573 log.debug('Authentication successful'); 571 574 572 575 log.section('Loading Records');
+2
src/types.ts
··· 67 67 verbose?: boolean; 68 68 quiet?: boolean; 69 69 dev?: boolean; 70 + // Optional PDS override URL to skip identity resolution 71 + pds?: string; 70 72 71 73 // Legacy flags (for backwards compatibility) 72 74 file?: string;