Import your Last.fm and Spotify listening history to the AT Protocol network using the fm.teal.alpha.feed.play lexicon.
0
fork

Configure Feed

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

add files

Ewan 3f351ff1 98610350

+3424
+5
.gitignore
··· 1 + node_modules/ 2 + *.log 3 + .DS_Store 4 + .env 5 + *.csv
+661
LICENCE
··· 1 + GNU AFFERO GENERAL PUBLIC LICENSE 2 + Version 3, 19 November 2007 3 + 4 + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> 5 + Everyone is permitted to copy and distribute verbatim copies 6 + of this license document, but changing it is not allowed. 7 + 8 + Preamble 9 + 10 + The GNU Affero General Public License is a free, copyleft license for 11 + software and other kinds of works, specifically designed to ensure 12 + cooperation with the community in the case of network server software. 13 + 14 + The licenses for most software and other practical works are designed 15 + to take away your freedom to share and change the works. By contrast, 16 + our General Public Licenses are intended to guarantee your freedom to 17 + share and change all versions of a program--to make sure it remains free 18 + software for all its users. 19 + 20 + When we speak of free software, we are referring to freedom, not 21 + price. Our General Public Licenses are designed to make sure that you 22 + have the freedom to distribute copies of free software (and charge for 23 + them if you wish), that you receive source code or can get it if you 24 + want it, that you can change the software or use pieces of it in new 25 + free programs, and that you know you can do these things. 26 + 27 + Developers that use our General Public Licenses protect your rights 28 + with two steps: (1) assert copyright on the software, and (2) offer 29 + you this License which gives you legal permission to copy, distribute 30 + and/or modify the software. 31 + 32 + A secondary benefit of defending all users' freedom is that 33 + improvements made in alternate versions of the program, if they 34 + receive widespread use, become available for other developers to 35 + incorporate. Many developers of free software are heartened and 36 + encouraged by the resulting cooperation. However, in the case of 37 + software used on network servers, this result may fail to come about. 38 + The GNU General Public License permits making a modified version and 39 + letting the public access it on a server without ever releasing its 40 + source code to the public. 41 + 42 + The GNU Affero General Public License is designed specifically to 43 + ensure that, in such cases, the modified source code becomes available 44 + to the community. It requires the operator of a network server to 45 + provide the source code of the modified version running there to the 46 + users of that server. Therefore, public use of a modified version, on 47 + a publicly accessible server, gives the public access to the source 48 + code of the modified version. 49 + 50 + An older license, called the Affero General Public License and 51 + published by Affero, was designed to accomplish similar goals. This is 52 + a different license, not a version of the Affero GPL, but Affero has 53 + released a new version of the Affero GPL which permits relicensing under 54 + this license. 55 + 56 + The precise terms and conditions for copying, distribution and 57 + modification follow. 58 + 59 + TERMS AND CONDITIONS 60 + 61 + 0. Definitions. 62 + 63 + "This License" refers to version 3 of the GNU Affero General Public License. 64 + 65 + "Copyright" also means copyright-like laws that apply to other kinds of 66 + works, such as semiconductor masks. 67 + 68 + "The Program" refers to any copyrightable work licensed under this 69 + License. Each licensee is addressed as "you". "Licensees" and 70 + "recipients" may be individuals or organizations. 71 + 72 + To "modify" a work means to copy from or adapt all or part of the work 73 + in a fashion requiring copyright permission, other than the making of an 74 + exact copy. The resulting work is called a "modified version" of the 75 + earlier work or a work "based on" the earlier work. 76 + 77 + A "covered work" means either the unmodified Program or a work based 78 + on the Program. 79 + 80 + To "propagate" a work means to do anything with it that, without 81 + permission, would make you directly or secondarily liable for 82 + infringement under applicable copyright law, except executing it on a 83 + computer or modifying a private copy. Propagation includes copying, 84 + distribution (with or without modification), making available to the 85 + public, and in some countries other activities as well. 86 + 87 + To "convey" a work means any kind of propagation that enables other 88 + parties to make or receive copies. Mere interaction with a user through 89 + a computer network, with no transfer of a copy, is not conveying. 90 + 91 + An interactive user interface displays "Appropriate Legal Notices" 92 + to the extent that it includes a convenient and prominently visible 93 + feature that (1) displays an appropriate copyright notice, and (2) 94 + tells the user that there is no warranty for the work (except to the 95 + extent that warranties are provided), that licensees may convey the 96 + work under this License, and how to view a copy of this License. If 97 + the interface presents a list of user commands or options, such as a 98 + menu, a prominent item in the list meets this criterion. 99 + 100 + 1. Source Code. 101 + 102 + The "source code" for a work means the preferred form of the work 103 + for making modifications to it. "Object code" means any non-source 104 + form of a work. 105 + 106 + A "Standard Interface" means an interface that either is an official 107 + standard defined by a recognized standards body, or, in the case of 108 + interfaces specified for a particular programming language, one that 109 + is widely used among developers working in that language. 110 + 111 + The "System Libraries" of an executable work include anything, other 112 + than the work as a whole, that (a) is included in the normal form of 113 + packaging a Major Component, but which is not part of that Major 114 + Component, and (b) serves only to enable use of the work with that 115 + Major Component, or to implement a Standard Interface for which an 116 + implementation is available to the public in source code form. A 117 + "Major Component", in this context, means a major essential component 118 + (kernel, window system, and so on) of the specific operating system 119 + (if any) on which the executable work runs, or a compiler used to 120 + produce the work, or an object code interpreter used to run it. 121 + 122 + The "Corresponding Source" for a work in object code form means all 123 + the source code needed to generate, install, and (for an executable 124 + work) run the object code and to modify the work, including scripts to 125 + control those activities. However, it does not include the work's 126 + System Libraries, or general-purpose tools or generally available free 127 + programs which are used unmodified in performing those activities but 128 + which are not part of the work. For example, Corresponding Source 129 + includes interface definition files associated with source files for 130 + the work, and the source code for shared libraries and dynamically 131 + linked subprograms that the work is specifically designed to require, 132 + such as by intimate data communication or control flow between those 133 + subprograms and other parts of the work. 134 + 135 + The Corresponding Source need not include anything that users 136 + can regenerate automatically from other parts of the Corresponding 137 + Source. 138 + 139 + The Corresponding Source for a work in source code form is that 140 + same work. 141 + 142 + 2. Basic Permissions. 143 + 144 + All rights granted under this License are granted for the term of 145 + copyright on the Program, and are irrevocable provided the stated 146 + conditions are met. This License explicitly affirms your unlimited 147 + permission to run the unmodified Program. The output from running a 148 + covered work is covered by this License only if the output, given its 149 + content, constitutes a covered work. This License acknowledges your 150 + rights of fair use or other equivalent, as provided by copyright law. 151 + 152 + You may make, run and propagate covered works that you do not 153 + convey, without conditions so long as your license otherwise remains 154 + in force. You may convey covered works to others for the sole purpose 155 + of having them make modifications exclusively for you, or provide you 156 + with facilities for running those works, provided that you comply with 157 + the terms of this License in conveying all material for which you do 158 + not control copyright. Those thus making or running the covered works 159 + for you must do so exclusively on your behalf, under your direction 160 + and control, on terms that prohibit them from making any copies of 161 + your copyrighted material outside their relationship with you. 162 + 163 + Conveying under any other circumstances is permitted solely under 164 + the conditions stated below. Sublicensing is not allowed; section 10 165 + makes it unnecessary. 166 + 167 + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 + 169 + No covered work shall be deemed part of an effective technological 170 + measure under any applicable law fulfilling obligations under article 171 + 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 + similar laws prohibiting or restricting circumvention of such 173 + measures. 174 + 175 + When you convey a covered work, you waive any legal power to forbid 176 + circumvention of technological measures to the extent such circumvention 177 + is effected by exercising rights under this License with respect to 178 + the covered work, and you disclaim any intention to limit operation or 179 + modification of the work as a means of enforcing, against the work's 180 + users, your or third parties' legal rights to forbid circumvention of 181 + technological measures. 182 + 183 + 4. Conveying Verbatim Copies. 184 + 185 + You may convey verbatim copies of the Program's source code as you 186 + receive it, in any medium, provided that you conspicuously and 187 + appropriately publish on each copy an appropriate copyright notice; 188 + keep intact all notices stating that this License and any 189 + non-permissive terms added in accord with section 7 apply to the code; 190 + keep intact all notices of the absence of any warranty; and give all 191 + recipients a copy of this License along with the Program. 192 + 193 + You may charge any price or no price for each copy that you convey, 194 + and you may offer support or warranty protection for a fee. 195 + 196 + 5. Conveying Modified Source Versions. 197 + 198 + You may convey a work based on the Program, or the modifications to 199 + produce it from the Program, in the form of source code under the 200 + terms of section 4, provided that you also meet all of these conditions: 201 + 202 + a) The work must carry prominent notices stating that you modified 203 + it, and giving a relevant date. 204 + 205 + b) The work must carry prominent notices stating that it is 206 + released under this License and any conditions added under section 207 + 7. This requirement modifies the requirement in section 4 to 208 + "keep intact all notices". 209 + 210 + c) You must license the entire work, as a whole, under this 211 + License to anyone who comes into possession of a copy. This 212 + License will therefore apply, along with any applicable section 7 213 + additional terms, to the whole of the work, and all its parts, 214 + regardless of how they are packaged. This License gives no 215 + permission to license the work in any other way, but it does not 216 + invalidate such permission if you have separately received it. 217 + 218 + d) If the work has interactive user interfaces, each must display 219 + Appropriate Legal Notices; however, if the Program has interactive 220 + interfaces that do not display Appropriate Legal Notices, your 221 + work need not make them do so. 222 + 223 + A compilation of a covered work with other separate and independent 224 + works, which are not by their nature extensions of the covered work, 225 + and which are not combined with it such as to form a larger program, 226 + in or on a volume of a storage or distribution medium, is called an 227 + "aggregate" if the compilation and its resulting copyright are not 228 + used to limit the access or legal rights of the compilation's users 229 + beyond what the individual works permit. Inclusion of a covered work 230 + in an aggregate does not cause this License to apply to the other 231 + parts of the aggregate. 232 + 233 + 6. Conveying Non-Source Forms. 234 + 235 + You may convey a covered work in object code form under the terms 236 + of sections 4 and 5, provided that you also convey the 237 + machine-readable Corresponding Source under the terms of this License, 238 + in one of these ways: 239 + 240 + a) Convey the object code in, or embodied in, a physical product 241 + (including a physical distribution medium), accompanied by the 242 + Corresponding Source fixed on a durable physical medium 243 + customarily used for software interchange. 244 + 245 + b) Convey the object code in, or embodied in, a physical product 246 + (including a physical distribution medium), accompanied by a 247 + written offer, valid for at least three years and valid for as 248 + long as you offer spare parts or customer support for that product 249 + model, to give anyone who possesses the object code either (1) a 250 + copy of the Corresponding Source for all the software in the 251 + product that is covered by this License, on a durable physical 252 + medium customarily used for software interchange, for a price no 253 + more than your reasonable cost of physically performing this 254 + conveying of source, or (2) access to copy the 255 + Corresponding Source from a network server at no charge. 256 + 257 + c) Convey individual copies of the object code with a copy of the 258 + written offer to provide the Corresponding Source. This 259 + alternative is allowed only occasionally and noncommercially, and 260 + only if you received the object code with such an offer, in accord 261 + with subsection 6b. 262 + 263 + d) Convey the object code by offering access from a designated 264 + place (gratis or for a charge), and offer equivalent access to the 265 + Corresponding Source in the same way through the same place at no 266 + further charge. You need not require recipients to copy the 267 + Corresponding Source along with the object code. If the place to 268 + copy the object code is a network server, the Corresponding Source 269 + may be on a different server (operated by you or a third party) 270 + that supports equivalent copying facilities, provided you maintain 271 + clear directions next to the object code saying where to find the 272 + Corresponding Source. Regardless of what server hosts the 273 + Corresponding Source, you remain obligated to ensure that it is 274 + available for as long as needed to satisfy these requirements. 275 + 276 + e) Convey the object code using peer-to-peer transmission, provided 277 + you inform other peers where the object code and Corresponding 278 + Source of the work are being offered to the general public at no 279 + charge under subsection 6d. 280 + 281 + A separable portion of the object code, whose source code is excluded 282 + from the Corresponding Source as a System Library, need not be 283 + included in conveying the object code work. 284 + 285 + A "User Product" is either (1) a "consumer product", which means any 286 + tangible personal property which is normally used for personal, family, 287 + or household purposes, or (2) anything designed or sold for incorporation 288 + into a dwelling. In determining whether a product is a consumer product, 289 + doubtful cases shall be resolved in favor of coverage. For a particular 290 + product received by a particular user, "normally used" refers to a 291 + typical or common use of that class of product, regardless of the status 292 + of the particular user or of the way in which the particular user 293 + actually uses, or expects or is expected to use, the product. A product 294 + is a consumer product regardless of whether the product has substantial 295 + commercial, industrial or non-consumer uses, unless such uses represent 296 + the only significant mode of use of the product. 297 + 298 + "Installation Information" for a User Product means any methods, 299 + procedures, authorization keys, or other information required to install 300 + and execute modified versions of a covered work in that User Product from 301 + a modified version of its Corresponding Source. The information must 302 + suffice to ensure that the continued functioning of the modified object 303 + code is in no case prevented or interfered with solely because 304 + modification has been made. 305 + 306 + If you convey an object code work under this section in, or with, or 307 + specifically for use in, a User Product, and the conveying occurs as 308 + part of a transaction in which the right of possession and use of the 309 + User Product is transferred to the recipient in perpetuity or for a 310 + fixed term (regardless of how the transaction is characterized), the 311 + Corresponding Source conveyed under this section must be accompanied 312 + by the Installation Information. But this requirement does not apply 313 + if neither you nor any third party retains the ability to install 314 + modified object code on the User Product (for example, the work has 315 + been installed in ROM). 316 + 317 + The requirement to provide Installation Information does not include a 318 + requirement to continue to provide support service, warranty, or updates 319 + for a work that has been modified or installed by the recipient, or for 320 + the User Product in which it has been modified or installed. Access to a 321 + network may be denied when the modification itself materially and 322 + adversely affects the operation of the network or violates the rules and 323 + protocols for communication across the network. 324 + 325 + Corresponding Source conveyed, and Installation Information provided, 326 + in accord with this section must be in a format that is publicly 327 + documented (and with an implementation available to the public in 328 + source code form), and must require no special password or key for 329 + unpacking, reading or copying. 330 + 331 + 7. Additional Terms. 332 + 333 + "Additional permissions" are terms that supplement the terms of this 334 + License by making exceptions from one or more of its conditions. 335 + Additional permissions that are applicable to the entire Program shall 336 + be treated as though they were included in this License, to the extent 337 + that they are valid under applicable law. If additional permissions 338 + apply only to part of the Program, that part may be used separately 339 + under those permissions, but the entire Program remains governed by 340 + this License without regard to the additional permissions. 341 + 342 + When you convey a copy of a covered work, you may at your option 343 + remove any additional permissions from that copy, or from any part of 344 + it. (Additional permissions may be written to require their own 345 + removal in certain cases when you modify the work.) You may place 346 + additional permissions on material, added by you to a covered work, 347 + for which you have or can give appropriate copyright permission. 348 + 349 + Notwithstanding any other provision of this License, for material you 350 + add to a covered work, you may (if authorized by the copyright holders of 351 + that material) supplement the terms of this License with terms: 352 + 353 + a) Disclaiming warranty or limiting liability differently from the 354 + terms of sections 15 and 16 of this License; or 355 + 356 + b) Requiring preservation of specified reasonable legal notices or 357 + author attributions in that material or in the Appropriate Legal 358 + Notices displayed by works containing it; or 359 + 360 + c) Prohibiting misrepresentation of the origin of that material, or 361 + requiring that modified versions of such material be marked in 362 + reasonable ways as different from the original version; or 363 + 364 + d) Limiting the use for publicity purposes of names of licensors or 365 + authors of the material; or 366 + 367 + e) Declining to grant rights under trademark law for use of some 368 + trade names, trademarks, or service marks; or 369 + 370 + f) Requiring indemnification of licensors and authors of that 371 + material by anyone who conveys the material (or modified versions of 372 + it) with contractual assumptions of liability to the recipient, for 373 + any liability that these contractual assumptions directly impose on 374 + those licensors and authors. 375 + 376 + All other non-permissive additional terms are considered "further 377 + restrictions" within the meaning of section 10. If the Program as you 378 + received it, or any part of it, contains a notice stating that it is 379 + governed by this License along with a term that is a further 380 + restriction, you may remove that term. If a license document contains 381 + a further restriction but permits relicensing or conveying under this 382 + License, you may add to a covered work material governed by the terms 383 + of that license document, provided that the further restriction does 384 + not survive such relicensing or conveying. 385 + 386 + If you add terms to a covered work in accord with this section, you 387 + must place, in the relevant source files, a statement of the 388 + additional terms that apply to those files, or a notice indicating 389 + where to find the applicable terms. 390 + 391 + Additional terms, permissive or non-permissive, may be stated in the 392 + form of a separately written license, or stated as exceptions; 393 + the above requirements apply either way. 394 + 395 + 8. Termination. 396 + 397 + You may not propagate or modify a covered work except as expressly 398 + provided under this License. Any attempt otherwise to propagate or 399 + modify it is void, and will automatically terminate your rights under 400 + this License (including any patent licenses granted under the third 401 + paragraph of section 11). 402 + 403 + However, if you cease all violation of this License, then your 404 + license from a particular copyright holder is reinstated (a) 405 + provisionally, unless and until the copyright holder explicitly and 406 + finally terminates your license, and (b) permanently, if the copyright 407 + holder fails to notify you of the violation by some reasonable means 408 + prior to 60 days after the cessation. 409 + 410 + Moreover, your license from a particular copyright holder is 411 + reinstated permanently if the copyright holder notifies you of the 412 + violation by some reasonable means, this is the first time you have 413 + received notice of violation of this License (for any work) from that 414 + copyright holder, and you cure the violation prior to 30 days after 415 + your receipt of the notice. 416 + 417 + Termination of your rights under this section does not terminate the 418 + licenses of parties who have received copies or rights from you under 419 + this License. If your rights have been terminated and not permanently 420 + reinstated, you do not qualify to receive new licenses for the same 421 + material under section 10. 422 + 423 + 9. Acceptance Not Required for Having Copies. 424 + 425 + You are not required to accept this License in order to receive or 426 + run a copy of the Program. Ancillary propagation of a covered work 427 + occurring solely as a consequence of using peer-to-peer transmission 428 + to receive a copy likewise does not require acceptance. However, 429 + nothing other than this License grants you permission to propagate or 430 + modify any covered work. These actions infringe copyright if you do 431 + not accept this License. Therefore, by modifying or propagating a 432 + covered work, you indicate your acceptance of this License to do so. 433 + 434 + 10. Automatic Licensing of Downstream Recipients. 435 + 436 + Each time you convey a covered work, the recipient automatically 437 + receives a license from the original licensors, to run, modify and 438 + propagate that work, subject to this License. You are not responsible 439 + for enforcing compliance by third parties with this License. 440 + 441 + An "entity transaction" is a transaction transferring control of an 442 + organization, or substantially all assets of one, or subdividing an 443 + organization, or merging organizations. If propagation of a covered 444 + work results from an entity transaction, each party to that 445 + transaction who receives a copy of the work also receives whatever 446 + licenses to the work the party's predecessor in interest had or could 447 + give under the previous paragraph, plus a right to possession of the 448 + Corresponding Source of the work from the predecessor in interest, if 449 + the predecessor has it or can get it with reasonable efforts. 450 + 451 + You may not impose any further restrictions on the exercise of the 452 + rights granted or affirmed under this License. For example, you may 453 + not impose a license fee, royalty, or other charge for exercise of 454 + rights granted under this License, and you may not initiate litigation 455 + (including a cross-claim or counterclaim in a lawsuit) alleging that 456 + any patent claim is infringed by making, using, selling, offering for 457 + sale, or importing the Program or any portion of it. 458 + 459 + 11. Patents. 460 + 461 + A "contributor" is a copyright holder who authorizes use under this 462 + License of the Program or a work on which the Program is based. The 463 + work thus licensed is called the contributor's "contributor version". 464 + 465 + A contributor's "essential patent claims" are all patent claims 466 + owned or controlled by the contributor, whether already acquired or 467 + hereafter acquired, that would be infringed by some manner, permitted 468 + by this License, of making, using, or selling its contributor version, 469 + but do not include claims that would be infringed only as a 470 + consequence of further modification of the contributor version. For 471 + purposes of this definition, "control" includes the right to grant 472 + patent sublicenses in a manner consistent with the requirements of 473 + this License. 474 + 475 + Each contributor grants you a non-exclusive, worldwide, royalty-free 476 + patent license under the contributor's essential patent claims, to 477 + make, use, sell, offer for sale, import and otherwise run, modify and 478 + propagate the contents of its contributor version. 479 + 480 + In the following three paragraphs, a "patent license" is any express 481 + agreement or commitment, however denominated, not to enforce a patent 482 + (such as an express permission to practice a patent or covenant not to 483 + sue for patent infringement). To "grant" such a patent license to a 484 + party means to make such an agreement or commitment not to enforce a 485 + patent against the party. 486 + 487 + If you convey a covered work, knowingly relying on a patent license, 488 + and the Corresponding Source of the work is not available for anyone 489 + to copy, free of charge and under the terms of this License, through a 490 + publicly available network server or other readily accessible means, 491 + then you must either (1) cause the Corresponding Source to be so 492 + available, or (2) arrange to deprive yourself of the benefit of the 493 + patent license for this particular work, or (3) arrange, in a manner 494 + consistent with the requirements of this License, to extend the patent 495 + license to downstream recipients. "Knowingly relying" means you have 496 + actual knowledge that, but for the patent license, your conveying the 497 + covered work in a country, or your recipient's use of the covered work 498 + in a country, would infringe one or more identifiable patents in that 499 + country that you have reason to believe are valid. 500 + 501 + If, pursuant to or in connection with a single transaction or 502 + arrangement, you convey, or propagate by procuring conveyance of, a 503 + covered work, and grant a patent license to some of the parties 504 + receiving the covered work authorizing them to use, propagate, modify 505 + or convey a specific copy of the covered work, then the patent license 506 + you grant is automatically extended to all recipients of the covered 507 + work and works based on it. 508 + 509 + A patent license is "discriminatory" if it does not include within 510 + the scope of its coverage, prohibits the exercise of, or is 511 + conditioned on the non-exercise of one or more of the rights that are 512 + specifically granted under this License. You may not convey a covered 513 + work if you are a party to an arrangement with a third party that is 514 + in the business of distributing software, under which you make payment 515 + to the third party based on the extent of your activity of conveying 516 + the work, and under which the third party grants, to any of the 517 + parties who would receive the covered work from you, a discriminatory 518 + patent license (a) in connection with copies of the covered work 519 + conveyed by you (or copies made from those copies), or (b) primarily 520 + for and in connection with specific products or compilations that 521 + contain the covered work, unless you entered into that arrangement, 522 + or that patent license was granted, prior to 28 March 2007. 523 + 524 + Nothing in this License shall be construed as excluding or limiting 525 + any implied license or other defenses to infringement that may 526 + otherwise be available to you under applicable patent law. 527 + 528 + 12. No Surrender of Others' Freedom. 529 + 530 + If conditions are imposed on you (whether by court order, agreement or 531 + otherwise) that contradict the conditions of this License, they do not 532 + excuse you from the conditions of this License. If you cannot convey a 533 + covered work so as to satisfy simultaneously your obligations under this 534 + License and any other pertinent obligations, then as a consequence you may 535 + not convey it at all. For example, if you agree to terms that obligate you 536 + to collect a royalty for further conveying from those to whom you convey 537 + the Program, the only way you could satisfy both those terms and this 538 + License would be to refrain entirely from conveying the Program. 539 + 540 + 13. Remote Network Interaction; Use with the GNU General Public License. 541 + 542 + Notwithstanding any other provision of this License, if you modify the 543 + Program, your modified version must prominently offer all users 544 + interacting with it remotely through a computer network (if your version 545 + supports such interaction) an opportunity to receive the Corresponding 546 + Source of your version by providing access to the Corresponding Source 547 + from a network server at no charge, through some standard or customary 548 + means of facilitating copying of software. This Corresponding Source 549 + shall include the Corresponding Source for any work covered by version 3 550 + of the GNU General Public License that is incorporated pursuant to the 551 + following paragraph. 552 + 553 + Notwithstanding any other provision of this License, you have 554 + permission to link or combine any covered work with a work licensed 555 + under version 3 of the GNU General Public License into a single 556 + combined work, and to convey the resulting work. The terms of this 557 + License will continue to apply to the part which is the covered work, 558 + but the work with which it is combined will remain governed by version 559 + 3 of the GNU General Public License. 560 + 561 + 14. Revised Versions of this License. 562 + 563 + The Free Software Foundation may publish revised and/or new versions of 564 + the GNU Affero General Public License from time to time. Such new versions 565 + will be similar in spirit to the present version, but may differ in detail to 566 + address new problems or concerns. 567 + 568 + Each version is given a distinguishing version number. If the 569 + Program specifies that a certain numbered version of the GNU Affero General 570 + Public License "or any later version" applies to it, you have the 571 + option of following the terms and conditions either of that numbered 572 + version or of any later version published by the Free Software 573 + Foundation. If the Program does not specify a version number of the 574 + GNU Affero General Public License, you may choose any version ever published 575 + by the Free Software Foundation. 576 + 577 + If the Program specifies that a proxy can decide which future 578 + versions of the GNU Affero General Public License can be used, that proxy's 579 + public statement of acceptance of a version permanently authorizes you 580 + to choose that version for the Program. 581 + 582 + Later license versions may give you additional or different 583 + permissions. However, no additional obligations are imposed on any 584 + author or copyright holder as a result of your choosing to follow a 585 + later version. 586 + 587 + 15. Disclaimer of Warranty. 588 + 589 + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 + APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 + HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 + OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 + THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 + PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 + IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 + ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 + 598 + 16. Limitation of Liability. 599 + 600 + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 + WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 + THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 + GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 + USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 + DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 + PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 + EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 + SUCH DAMAGES. 609 + 610 + 17. Interpretation of Sections 15 and 16. 611 + 612 + If the disclaimer of warranty and limitation of liability provided 613 + above cannot be given local legal effect according to their terms, 614 + reviewing courts shall apply local law that most closely approximates 615 + an absolute waiver of all civil liability in connection with the 616 + Program, unless a warranty or assumption of liability accompanies a 617 + copy of the Program in return for a fee. 618 + 619 + END OF TERMS AND CONDITIONS 620 + 621 + How to Apply These Terms to Your New Programs 622 + 623 + If you develop a new program, and you want it to be of the greatest 624 + possible use to the public, the best way to achieve this is to make it 625 + free software which everyone can redistribute and change under these terms. 626 + 627 + To do so, attach the following notices to the program. It is safest 628 + to attach them to the start of each source file to most effectively 629 + state the exclusion of warranty; and each file should have at least 630 + the "copyright" line and a pointer to where the full notice is found. 631 + 632 + <one line to give the program's name and a brief idea of what it does.> 633 + Copyright (C) <year> <name of author> 634 + 635 + This program is free software: you can redistribute it and/or modify 636 + it under the terms of the GNU Affero General Public License as published by 637 + the Free Software Foundation, either version 3 of the License, or 638 + (at your option) any later version. 639 + 640 + This program is distributed in the hope that it will be useful, 641 + but WITHOUT ANY WARRANTY; without even the implied warranty of 642 + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 + GNU Affero General Public License for more details. 644 + 645 + You should have received a copy of the GNU Affero General Public License 646 + along with this program. If not, see <https://www.gnu.org/licenses/>. 647 + 648 + Also add information on how to contact you by electronic and paper mail. 649 + 650 + If your software can interact with users remotely through a computer 651 + network, you should also make sure that it provides a way for users to 652 + get its source. For example, if your program is a web application, its 653 + interface could display a "Source" link that leads users to an archive 654 + of the code. There are many ways you could offer source, and different 655 + solutions will be better for different programs; see section 13 for the 656 + specific requirements. 657 + 658 + You should also get your employer (if you work as a programmer) or school, 659 + if any, to sign a "copyright disclaimer" for the program, if necessary. 660 + For more information on this, and how to apply and follow the GNU AGPL, see 661 + <https://www.gnu.org/licenses/>.
+109
README.md
··· 1 + # Last.fm to ATProto Importer 2 + 3 + Import your Last.fm listening history to the AT Protocol network using the `fm.teal.alpha.feed.play` lexicon. 4 + 5 + ## Setup 6 + 7 + ```bash 8 + npm install 9 + ``` 10 + 11 + ## Usage 12 + 13 + ### Interactive Mode 14 + 15 + ```bash 16 + node importer.js 17 + ``` 18 + 19 + ### With Command Line Arguments 20 + 21 + **Full automation:** 22 + 23 + ```bash 24 + node importer.js -f lastfm.csv -i alice.bsky.social -p xxxx-xxxx-xxxx-xxxx -y 25 + ``` 26 + 27 + **Dry run (preview without publishing):** 28 + 29 + ```bash 30 + node importer.js -f lastfm.csv --dry-run 31 + ``` 32 + 33 + **Custom batch settings:** 34 + 35 + ```bash 36 + node importer.js -f lastfm.csv -i alice.bsky.social -b 20 -d 3000 37 + ``` 38 + 39 + ## Options 40 + 41 + - `-h, --help` - Show help message 42 + - `-f, --file <path>` - Path to Last.fm CSV export file 43 + - `-i, --identifier <id>` - ATProto handle or DID 44 + - `-p, --password <pass>` - ATProto app password 45 + - `-b, --batch-size <num>` - Records per batch (default: 10) 46 + - `-d, --batch-delay <ms>` - Delay between batches in ms (default: 2000) 47 + - `-y, --yes` - Skip confirmation prompt 48 + - `-n, --dry-run` - Preview records without publishing 49 + 50 + ## Getting Your Last.fm Data 51 + 52 + 1. Go to <https://lastfm.ghan.nl/export/> 53 + 2. Request your data export in CSV 54 + 3. Download the CSV file when ready 55 + 4. Use the CSV file path with this script 56 + 57 + ## Features 58 + 59 + - ✅ Resolves ATProto handles/DIDs using Slingshot 60 + - ✅ Connects to your personal PDS 61 + - ✅ Converts Last.fm scrobbles to `fm.teal.alpha.feed.play` records 62 + - ✅ Follows the official lexicon schema 63 + - ✅ Batch publishing with configurable rate limiting 64 + - ✅ Dry run mode for previewing 65 + - ✅ Progress tracking and error reporting 66 + - ✅ Preserves MusicBrainz IDs when available 67 + 68 + ## Record Format 69 + 70 + Each scrobble is converted according to the `fm.teal.alpha.feed.play` lexicon: 71 + 72 + ```json 73 + { 74 + "$type": "fm.teal.alpha.feed.play", 75 + "trackName": "Paint My Masterpiece", 76 + "artists": [ 77 + { 78 + "artistName": "Cjbeards", 79 + "artistMbId": "c8d4f4bf-1b82-4d4d-9d73-05909faaff89" 80 + } 81 + ], 82 + "releaseName": "Masquerade", 83 + "releaseMbId": "fdb2397b-78d5-4019-8fad-656d286e4d33", 84 + "recordingMbId": "3a390ad3-fe56-45f2-a073-bebc45d6bde1", 85 + "playedTime": "2025-11-13T23:49:36Z", 86 + "originUrl": "https://www.last.fm/music/Cjbeards/_/Paint+My+Masterpiece", 87 + "submissionClientAgent": "lastfm-importer/v1.0.0", 88 + "musicServiceBaseDomain": "last.fm" 89 + } 90 + ``` 91 + 92 + ### Required Fields 93 + 94 + - `trackName` - The name of the track 95 + - `artists` - Array of artist objects with `artistName` (required) and optional `artistMbId` 96 + 97 + ### Optional Fields 98 + 99 + - `releaseName` - Album name 100 + - `releaseMbId` - MusicBrainz release ID 101 + - `recordingMbId` - MusicBrainz recording ID 102 + - `playedTime` - ISO 8601 datetime 103 + - `originUrl` - Link to the track 104 + - `submissionClientAgent` - Client identifier 105 + - `musicServiceBaseDomain` - Service domain (e.g., "last.fm") 106 + 107 + ## Lexicon Reference 108 + 109 + This importer follows the lexicon defined in `/lexicons/fm.teal.alpha/feed/play.json`.
+126
STRUCTURE.md
··· 1 + # Last.fm to ATProto Importer - Modular Structure 2 + 3 + ## Project Structure 4 + 5 + ```plaintext 6 + lastfm-importer/ 7 + ├── src/ 8 + │ ├── index.js # Main entry point 9 + │ ├── config.js # Configuration constants 10 + │ ├── lib/ # Core library modules 11 + │ │ ├── auth.js # Authentication & login 12 + │ │ ├── cli.js # CLI argument parsing & help 13 + │ │ ├── csv.js # CSV parsing & conversion 14 + │ │ └── publisher.js # Record publishing logic 15 + │ └── utils/ # Utility functions 16 + │ ├── helpers.js # Helper functions (formatting, batch calculation) 17 + │ ├── input.js # User input & password masking 18 + │ └── killswitch.js # Graceful shutdown handling 19 + ├── importer.js # Wrapper for backwards compatibility 20 + └── importer.old.js # Original monolithic version (backup) 21 + ``` 22 + 23 + ## Module Responsibilities 24 + 25 + ### `/src/config.js` 26 + 27 + - Configuration constants 28 + - Batch size calculation parameters 29 + - API endpoints and client information 30 + 31 + ### `/src/lib/auth.js` 32 + 33 + - ATProto authentication 34 + - Identity resolution via Slingshot 35 + - Login error handling 36 + 37 + ### `/src/lib/cli.js` 38 + 39 + - Command-line argument parsing 40 + - Help text display 41 + - Input validation 42 + 43 + ### `/src/lib/csv.js` 44 + 45 + - CSV file parsing 46 + - Record conversion to ATProto format 47 + - Chronological sorting 48 + 49 + ### `/src/lib/publisher.js` 50 + 51 + - Batch publishing with rate limiting 52 + - Dry-run preview mode 53 + - Progress tracking and reporting 54 + - Killswitch integration 55 + 56 + ### `/src/utils/helpers.js` 57 + 58 + - Duration formatting 59 + - Optimal batch size calculation (logarithmic algorithm) 60 + - Generic utility functions 61 + 62 + ### `/src/utils/input.js` 63 + 64 + - Interactive prompts 65 + - Password masking with asterisks 66 + - Backspace support 67 + 68 + ### `/src/utils/killswitch.js` 69 + 70 + - SIGINT handler 71 + - Graceful shutdown state management 72 + - Force-quit on second Ctrl+C 73 + 74 + ## Benefits of Modular Structure 75 + 76 + 1. **Maintainability**: Each module has a single responsibility 77 + 2. **Testability**: Individual modules can be tested in isolation 78 + 3. **Reusability**: Modules can be imported and reused 79 + 4. **Readability**: Smaller files are easier to understand 80 + 5. **Collaboration**: Multiple developers can work on different modules 81 + 6. **Debugging**: Easier to locate and fix issues 82 + 83 + ## Usage 84 + 85 + The wrapper file (`importer.js`) maintains backwards compatibility: 86 + 87 + ```bash 88 + # Still works exactly as before 89 + node importer.js -f lastfm.csv -i handle.bsky.social 90 + 91 + # Or use the modular version directly 92 + node src/index.js -f lastfm.csv -i handle.bsky.social 93 + ``` 94 + 95 + ## Algorithm Details 96 + 97 + ### Batch Size Calculation 98 + 99 + Located in `/src/utils/helpers.js`: 100 + 101 + ```javascript 102 + batchSize = BASE + (log2(records/MIN) * SCALING_FACTOR) 103 + ``` 104 + 105 + - **Time Complexity**: O(n) - each record processed once 106 + - **Space Complexity**: O(b) where b is batch size 107 + - **Rate Limit Strategy**: Token bucket approach 108 + - **Adaptive**: Adjusts based on total records and delay settings 109 + 110 + ### Processing Order 111 + 112 + - Default: Chronological (oldest first) 113 + - Option: `--reverse-chronological` for newest first 114 + - Sorted by `playedTime` field 115 + 116 + ## Future Improvements 117 + 118 + With the modular structure, it's now easier to: 119 + 120 + - Add unit tests for each module 121 + - Implement different authentication methods 122 + - Support multiple export formats (JSON, XML) 123 + - Add progress persistence (resume interrupted imports) 124 + - Implement retry logic with exponential backoff 125 + - Add statistics and analytics 126 + - Create a web UI that imports these modules
+5
importer.js
··· 1 + #!/usr/bin/env node 2 + 3 + // Wrapper file for backwards compatibility 4 + // This imports and runs the modular version 5 + import './src/index.js';
+681
importer.old.js
··· 1 + #!/usr/bin/env node 2 + 3 + import { AtpAgent } from '@atproto/api'; 4 + import * as fs from 'fs'; 5 + import * as readline from 'readline'; 6 + import { parse } from 'csv-parse/sync'; 7 + import { parseArgs } from 'node:util'; 8 + 9 + // Configuration 10 + const DEFAULT_BATCH_SIZE = 10; // Default number of records to submit per batch 11 + const DEFAULT_BATCH_DELAY = 2000; // Default delay between batches in milliseconds 12 + const MIN_BATCH_DELAY = 1000; // Minimum safe delay to respect rate limits 13 + const RECORD_TYPE = 'fm.teal.alpha.feed.play'; 14 + const SLINGSHOT_RESOLVER = 'https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc'; 15 + 16 + // Global state for killswitch 17 + let importCancelled = false; 18 + let gracefulShutdown = false; 19 + 20 + /** 21 + * Setup killswitch handler for graceful shutdown 22 + */ 23 + function setupKillswitch() { 24 + process.on('SIGINT', () => { 25 + if (gracefulShutdown) { 26 + console.log('\n\n⚠️ Force quit detected. Exiting immediately...'); 27 + process.exit(1); 28 + } 29 + 30 + gracefulShutdown = true; 31 + importCancelled = true; 32 + console.log('\n\n🛑 Killswitch activated! Stopping after current batch...'); 33 + console.log(' Press Ctrl+C again to force quit immediately.\n'); 34 + }); 35 + } 36 + 37 + /** 38 + * Calculate optimal batch size based on total records and rate limits 39 + * Uses a logarithmic scaling approach to balance throughput with API safety 40 + * 41 + * Algorithm Analysis: 42 + * - Time Complexity: O(n) where n is total records (each record processed once) 43 + * - Space Complexity: O(1) for batch calculation, O(b) where b is batch size in memory 44 + * - Rate Limit Strategy: Token bucket approach with conservative limits 45 + * 46 + * The batch size grows logarithmically with input size to prevent overwhelming 47 + * the API while maximizing throughput. Formula: min(MAX, BASE * log2(n/MIN)) 48 + */ 49 + function calculateOptimalBatchSize(totalRecords, batchDelay = DEFAULT_BATCH_DELAY) { 50 + // Constants based on typical API rate limits and safety margins 51 + const MIN_RECORDS = 100; // Minimum records before scaling kicks in 52 + const BASE_BATCH_SIZE = 5; // Starting point for small datasets 53 + const MAX_BATCH_SIZE = 50; // Hard cap to prevent API overwhelming 54 + const SCALING_FACTOR = 1.5; // Growth rate modifier 55 + 56 + // For very small datasets, use minimal batches 57 + if (totalRecords <= 50) { 58 + return 3; 59 + } 60 + 61 + // For small to medium datasets, use conservative batching 62 + if (totalRecords <= MIN_RECORDS) { 63 + return BASE_BATCH_SIZE; 64 + } 65 + 66 + // Logarithmic scaling: batch size grows with log of total records 67 + // This ensures O(n) time complexity while respecting rate limits 68 + // Formula: BASE * (log2(n/MIN) * SCALING_FACTOR) 69 + const logScale = Math.log2(totalRecords / MIN_RECORDS); 70 + const calculatedSize = Math.floor(BASE_BATCH_SIZE + (logScale * SCALING_FACTOR)); 71 + 72 + // Apply maximum cap and ensure reasonable batch size 73 + let optimalSize = Math.min(calculatedSize, MAX_BATCH_SIZE); 74 + 75 + // Adjust based on batch delay to respect rate limits 76 + // Shorter delays should use smaller batches 77 + if (batchDelay < 1500 && optimalSize > 15) { 78 + optimalSize = Math.floor(optimalSize * 0.75); 79 + } 80 + 81 + // Ensure batch size is at least 3 for efficiency 82 + return Math.max(3, optimalSize); 83 + } 84 + 85 + /** 86 + * Parse command line arguments 87 + */ 88 + function parseCommandLineArgs() { 89 + const options = { 90 + help: { 91 + type: 'boolean', 92 + short: 'h', 93 + default: false, 94 + }, 95 + file: { 96 + type: 'string', 97 + short: 'f', 98 + }, 99 + identifier: { 100 + type: 'string', 101 + short: 'i', 102 + }, 103 + password: { 104 + type: 'string', 105 + short: 'p', 106 + }, 107 + 'batch-size': { 108 + type: 'string', 109 + short: 'b', 110 + }, 111 + 'batch-delay': { 112 + type: 'string', 113 + short: 'd', 114 + }, 115 + yes: { 116 + type: 'boolean', 117 + short: 'y', 118 + default: false, 119 + }, 120 + 'dry-run': { 121 + type: 'boolean', 122 + short: 'n', 123 + default: false, 124 + }, 125 + 'reverse-chronological': { 126 + type: 'boolean', 127 + short: 'r', 128 + default: false, 129 + }, 130 + }; 131 + 132 + try { 133 + const { values } = parseArgs({ options, allowPositionals: false }); 134 + return values; 135 + } catch (error) { 136 + console.error('Error parsing arguments:', error.message); 137 + showHelp(); 138 + process.exit(1); 139 + } 140 + } 141 + 142 + /** 143 + * Show help message 144 + */ 145 + function showHelp() { 146 + console.log(` 147 + Last.fm to ATProto Importer 148 + 149 + Usage: node importer.js [options] 150 + 151 + Options: 152 + -h, --help Show this help message 153 + -f, --file <path> Path to Last.fm CSV export file 154 + -i, --identifier <id> ATProto handle or DID 155 + -p, --password <pass> ATProto app password 156 + -b, --batch-size <num> Number of records per batch (auto-calculated if not set) 157 + -d, --batch-delay <ms> Delay between batches in ms (default: 2000, min: 1000) 158 + -y, --yes Skip confirmation prompt 159 + -n, --dry-run Preview records without publishing 160 + -r, --reverse-chronological Process newest first (default: oldest first) 161 + 162 + Examples: 163 + node importer.js -f lastfm.csv -i alice.bsky.social -p xxxx-xxxx-xxxx-xxxx 164 + node importer.js --file export.csv --identifier alice.bsky.social --yes 165 + node importer.js -f lastfm.csv --dry-run 166 + node importer.js (interactive mode - prompts for all values) 167 + 168 + Notes: 169 + - Batch size uses logarithmic scaling algorithm (O(n) complexity) for optimal throughput 170 + - Auto-calculated batch size considers both record count and delay settings 171 + - Records are processed in chronological order (oldest first) by default 172 + - Minimum batch delay of 1000ms enforced to respect rate limits 173 + - Rate limiting follows token bucket strategy for safe API usage 174 + `); 175 + } 176 + 177 + /** 178 + * Read user input from command line with proper password masking 179 + */ 180 + function prompt(question, hideInput = false) { 181 + return new Promise((resolve) => { 182 + if (hideInput) { 183 + // For password input, use a simpler approach 184 + const stdin = process.stdin; 185 + const wasRaw = stdin.isRaw; 186 + 187 + // Set raw mode to capture individual keystrokes 188 + if (stdin.isTTY) { 189 + stdin.setRawMode(true); 190 + } 191 + 192 + stdin.resume(); 193 + stdin.setEncoding('utf8'); 194 + 195 + process.stdout.write(question); 196 + 197 + let password = ''; 198 + const onData = (char) => { 199 + char = char.toString(); 200 + 201 + switch (char) { 202 + case '\n': 203 + case '\r': 204 + case '\u0004': // Ctrl-D 205 + stdin.removeListener('data', onData); 206 + if (stdin.isTTY) { 207 + stdin.setRawMode(wasRaw); 208 + } 209 + stdin.pause(); 210 + process.stdout.write('\n'); 211 + resolve(password); 212 + break; 213 + case '\u0003': // Ctrl-C 214 + process.exit(1); 215 + break; 216 + case '\u007f': // Backspace 217 + case '\b': // Backspace 218 + if (password.length > 0) { 219 + password = password.slice(0, -1); 220 + process.stdout.clearLine(0); 221 + process.stdout.cursorTo(0); 222 + process.stdout.write(question + '*'.repeat(password.length)); 223 + } 224 + break; 225 + default: 226 + password += char; 227 + process.stdout.write('*'); 228 + break; 229 + } 230 + }; 231 + 232 + stdin.on('data', onData); 233 + } else { 234 + const rl = readline.createInterface({ 235 + input: process.stdin, 236 + output: process.stdout, 237 + }); 238 + 239 + rl.question(question, (answer) => { 240 + rl.close(); 241 + resolve(answer); 242 + }); 243 + } 244 + }); 245 + } 246 + 247 + /** 248 + * Resolves an AT Protocol identifier (handle or DID) to get PDS information 249 + */ 250 + async function resolveIdentifier(identifier) { 251 + console.log(`Resolving identifier: ${identifier}`); 252 + 253 + const response = await fetch( 254 + `${SLINGSHOT_RESOLVER}?identifier=${encodeURIComponent(identifier)}` 255 + ); 256 + 257 + if (!response.ok) { 258 + throw new Error(`Failed to resolve identifier: ${response.status} ${response.statusText}`); 259 + } 260 + 261 + const data = await response.json(); 262 + 263 + if (!data.did || !data.pds) { 264 + throw new Error('Invalid response from identity resolver'); 265 + } 266 + 267 + console.log(`✓ Resolved to PDS: ${data.pds}`); 268 + return data; 269 + } 270 + 271 + /** 272 + * Login to ATProto using Slingshot resolver 273 + */ 274 + async function login(identifier, password) { 275 + console.log('\n=== ATProto Login ==='); 276 + 277 + // Prompt for missing credentials 278 + if (!identifier) { 279 + identifier = await prompt('Handle or DID: '); 280 + } else { 281 + console.log(`Handle or DID: ${identifier}`); 282 + } 283 + 284 + if (!password) { 285 + password = await prompt('App password: ', true); 286 + } else { 287 + console.log('App password: [hidden]'); 288 + } 289 + 290 + try { 291 + // Resolve the identifier to get PDS 292 + const resolved = await resolveIdentifier(identifier); 293 + 294 + // Create agent with resolved PDS 295 + const pdsAgent = new AtpAgent({ service: resolved.pds }); 296 + 297 + // Login using the resolved DID 298 + await pdsAgent.login({ 299 + identifier: resolved.did, 300 + password: password, 301 + }); 302 + 303 + console.log('✓ Logged in successfully!'); 304 + console.log(` DID: ${pdsAgent.session.did}`); 305 + console.log(` Handle: ${pdsAgent.session.handle}\n`); 306 + 307 + return pdsAgent; 308 + } catch (error) { 309 + console.error('✗ Login failed:', error.message); 310 + 311 + // Provide more specific error messages 312 + if (error.message.includes('Failed to resolve identifier')) { 313 + throw new Error('Handle not found. Please check your AT Protocol handle.'); 314 + } else if (error.message.includes('AuthFactorTokenRequired')) { 315 + throw new Error('Two-factor authentication required. Please use your app password.'); 316 + } else if (error.message.includes('InvalidCredentials')) { 317 + throw new Error('Invalid credentials. Please check your handle and app password.'); 318 + } 319 + 320 + throw error; 321 + } 322 + } 323 + 324 + /** 325 + * Parse Last.fm CSV export 326 + */ 327 + function parseLastFmCsv(filePath) { 328 + console.log(`Reading CSV file: ${filePath}`); 329 + const fileContent = fs.readFileSync(filePath, 'utf-8'); 330 + 331 + const records = parse(fileContent, { 332 + columns: true, 333 + skip_empty_lines: true, 334 + trim: true, 335 + }); 336 + 337 + console.log(`✓ Parsed ${records.length} scrobbles\n`); 338 + return records; 339 + } 340 + 341 + /** 342 + * Convert Last.fm CSV record to ATProto play record 343 + * Following the fm.teal.alpha.feed.play lexicon schema 344 + */ 345 + function convertToPlayRecord(csvRecord) { 346 + // Parse the timestamp (Unix timestamp in seconds) 347 + const timestamp = parseInt(csvRecord.uts); 348 + const playedTime = new Date(timestamp * 1000).toISOString(); 349 + 350 + // Build artists array according to lexicon 351 + const artists = []; 352 + if (csvRecord.artist) { 353 + const artistData = { 354 + artistName: csvRecord.artist, 355 + }; 356 + // Only add artistMbId if it exists and is not empty 357 + if (csvRecord.artist_mbid && csvRecord.artist_mbid.trim()) { 358 + artistData.artistMbId = csvRecord.artist_mbid; 359 + } 360 + artists.push(artistData); 361 + } 362 + 363 + // Build the play record with required fields 364 + const playRecord = { 365 + $type: RECORD_TYPE, 366 + trackName: csvRecord.track, 367 + artists, // Required field 368 + playedTime, 369 + submissionClientAgent: 'lastfm-importer/v0.0.1', 370 + musicServiceBaseDomain: 'last.fm', 371 + }; 372 + 373 + // Add optional fields only if present and not empty 374 + if (csvRecord.album && csvRecord.album.trim()) { 375 + playRecord.releaseName = csvRecord.album; 376 + } 377 + 378 + if (csvRecord.album_mbid && csvRecord.album_mbid.trim()) { 379 + playRecord.releaseMbId = csvRecord.album_mbid; 380 + } 381 + 382 + if (csvRecord.track_mbid && csvRecord.track_mbid.trim()) { 383 + playRecord.recordingMbId = csvRecord.track_mbid; 384 + } 385 + 386 + // Generate Last.fm URL 387 + const artistEncoded = encodeURIComponent(csvRecord.artist); 388 + const trackEncoded = encodeURIComponent(csvRecord.track); 389 + playRecord.originUrl = `https://www.last.fm/music/${artistEncoded}/_/${trackEncoded}`; 390 + 391 + return playRecord; 392 + } 393 + 394 + /** 395 + * Format duration in human-readable format 396 + */ 397 + function formatDuration(milliseconds) { 398 + const seconds = Math.floor(milliseconds / 1000); 399 + const minutes = Math.floor(seconds / 60); 400 + const hours = Math.floor(minutes / 60); 401 + 402 + if (hours > 0) { 403 + const mins = minutes % 60; 404 + return `${hours}h ${mins}m`; 405 + } else if (minutes > 0) { 406 + const secs = seconds % 60; 407 + return `${minutes}m ${secs}s`; 408 + } else { 409 + return `${seconds}s`; 410 + } 411 + } 412 + 413 + /** 414 + * Publish records in batches with rate limiting and killswitch support 415 + */ 416 + async function publishRecords(agent, records, batchSize, batchDelay, dryRun = false) { 417 + const totalRecords = records.length; 418 + let successCount = 0; 419 + let errorCount = 0; 420 + const startTime = Date.now(); 421 + 422 + if (dryRun) { 423 + console.log(`\n=== DRY RUN MODE ===`); 424 + console.log(`Would publish ${totalRecords} records in batches of ${batchSize}`); 425 + console.log(`Estimated time: ${formatDuration(Math.ceil(totalRecords / batchSize) * batchDelay)}\n`); 426 + 427 + // Show first 5 records as preview 428 + const previewCount = Math.min(5, totalRecords); 429 + console.log(`Preview of first ${previewCount} records (in processing order):\n`); 430 + 431 + for (let i = 0; i < previewCount; i++) { 432 + const record = records[i]; 433 + console.log(`${i + 1}. ${record.artists[0]?.artistName} - ${record.trackName}`); 434 + console.log(` Album: ${record.releaseName || 'N/A'}`); 435 + console.log(` Played: ${record.playedTime}`); 436 + console.log(` URL: ${record.originUrl}`); 437 + 438 + // Show MusicBrainz IDs if available 439 + const mbids = []; 440 + if (record.artists[0]?.artistMbId) mbids.push(`Artist: ${record.artists[0].artistMbId}`); 441 + if (record.recordingMbId) mbids.push(`Recording: ${record.recordingMbId}`); 442 + if (record.releaseMbId) mbids.push(`Release: ${record.releaseMbId}`); 443 + 444 + if (mbids.length > 0) { 445 + console.log(` MBIDs: ${mbids.join(', ')}`); 446 + } 447 + console.log(''); 448 + } 449 + 450 + if (totalRecords > previewCount) { 451 + console.log(`... and ${totalRecords - previewCount} more records\n`); 452 + } 453 + 454 + console.log('=== DRY RUN COMPLETE ==='); 455 + console.log('No records were actually published.'); 456 + console.log('Remove --dry-run flag to publish for real.\n'); 457 + 458 + return { successCount: totalRecords, errorCount: 0, cancelled: false }; 459 + } 460 + 461 + const totalBatches = Math.ceil(totalRecords / batchSize); 462 + const estimatedTime = formatDuration(totalBatches * batchDelay); 463 + 464 + console.log(`Publishing ${totalRecords} records in batches of ${batchSize}...`); 465 + console.log(`Total batches: ${totalBatches}`); 466 + console.log(`Estimated time: ${estimatedTime}`); 467 + console.log(`\n🚨 Press Ctrl+C to stop gracefully after current batch\n`); 468 + 469 + for (let i = 0; i < totalRecords; i += batchSize) { 470 + // Check killswitch before processing batch 471 + if (importCancelled) { 472 + console.log(`\n🛑 Import cancelled by user`); 473 + console.log(` Processed: ${successCount}/${totalRecords} records`); 474 + console.log(` Remaining: ${totalRecords - successCount} records\n`); 475 + return { successCount, errorCount, cancelled: true }; 476 + } 477 + 478 + const batch = records.slice(i, i + batchSize); 479 + const batchNum = Math.floor(i / batchSize) + 1; 480 + const progress = ((i / totalRecords) * 100).toFixed(1); 481 + 482 + console.log(`[${progress}%] Batch ${batchNum}/${totalBatches} (records ${i + 1}-${Math.min(i + batchSize, totalRecords)})`); 483 + 484 + // Process batch records 485 + const batchStartTime = Date.now(); 486 + for (const record of batch) { 487 + // Check killswitch during batch processing 488 + if (importCancelled) { 489 + console.log(` ⚠️ Stopping mid-batch...`); 490 + break; 491 + } 492 + 493 + try { 494 + await agent.com.atproto.repo.createRecord({ 495 + repo: agent.session.did, 496 + collection: RECORD_TYPE, 497 + record, 498 + }); 499 + successCount++; 500 + } catch (error) { 501 + errorCount++; 502 + console.error(` ✗ Failed: ${record.trackName} - ${error.message}`); 503 + } 504 + } 505 + 506 + const batchDuration = Date.now() - batchStartTime; 507 + const elapsed = formatDuration(Date.now() - startTime); 508 + const remaining = formatDuration(((totalRecords - i - batchSize) / batchSize) * batchDelay); 509 + 510 + console.log(` ✓ Complete in ${batchDuration}ms (${successCount} successful, ${errorCount} failed)`); 511 + 512 + // Only show time estimates if not cancelled 513 + if (!importCancelled) { 514 + console.log(` ⏱ Elapsed: ${elapsed} | Remaining: ~${remaining}\n`); 515 + } 516 + 517 + // Check again before waiting (in case cancelled during batch) 518 + if (importCancelled) { 519 + console.log(`\n🛑 Import cancelled by user`); 520 + console.log(` Processed: ${successCount}/${totalRecords} records`); 521 + console.log(` Remaining: ${totalRecords - successCount} records\n`); 522 + return { successCount, errorCount, cancelled: true }; 523 + } 524 + 525 + // Wait before next batch (except for last batch) 526 + if (i + batchSize < totalRecords) { 527 + await new Promise(resolve => setTimeout(resolve, batchDelay)); 528 + } 529 + } 530 + 531 + return { successCount, errorCount, cancelled: false }; 532 + } 533 + 534 + /** 535 + * Main execution 536 + */ 537 + async function main() { 538 + const args = parseCommandLineArgs(); 539 + 540 + // Show help if requested 541 + if (args.help) { 542 + showHelp(); 543 + process.exit(0); 544 + } 545 + 546 + // Setup killswitch (unless in dry-run mode) 547 + if (!args['dry-run']) { 548 + setupKillswitch(); 549 + } 550 + 551 + try { 552 + console.log('=== Last.fm to ATProto Importer ===\n'); 553 + 554 + // Get CSV file path 555 + let csvPath = args.file; 556 + if (!csvPath) { 557 + csvPath = await prompt('Enter path to Last.fm CSV export: '); 558 + } else { 559 + console.log(`CSV file: ${csvPath}`); 560 + } 561 + 562 + if (!fs.existsSync(csvPath)) { 563 + console.error('✗ File not found!'); 564 + process.exit(1); 565 + } 566 + 567 + // Parse CSV 568 + const csvRecords = parseLastFmCsv(csvPath); 569 + 570 + if (csvRecords.length === 0) { 571 + console.error('✗ No records found in CSV file!'); 572 + process.exit(1); 573 + } 574 + 575 + // Convert records 576 + console.log('Converting records to ATProto format...'); 577 + const playRecords = csvRecords.map(convertToPlayRecord); 578 + console.log('✓ Conversion complete\n'); 579 + 580 + // Sort records chronologically (oldest first) unless reverse flag is set 581 + const reverseChronological = args['reverse-chronological']; 582 + console.log(`Sorting records ${reverseChronological ? 'newest' : 'oldest'} first...`); 583 + 584 + playRecords.sort((a, b) => { 585 + const timeA = new Date(a.playedTime).getTime(); 586 + const timeB = new Date(b.playedTime).getTime(); 587 + return reverseChronological ? timeB - timeA : timeA - timeB; 588 + }); 589 + 590 + const firstPlay = new Date(playRecords[0].playedTime).toLocaleDateString(); 591 + const lastPlay = new Date(playRecords[playRecords.length - 1].playedTime).toLocaleDateString(); 592 + console.log(`✓ Sorted ${playRecords.length} records`); 593 + console.log(` First: ${firstPlay}`); 594 + console.log(` Last: ${lastPlay}\n`); 595 + 596 + // Validate and set batch delay with minimum enforcement first 597 + let batchDelay = args['batch-delay'] ? parseInt(args['batch-delay']) : DEFAULT_BATCH_DELAY; 598 + if (batchDelay < MIN_BATCH_DELAY) { 599 + console.log(`⚠️ Batch delay ${batchDelay}ms is below minimum safe limit.`); 600 + console.log(` Enforcing minimum delay of ${MIN_BATCH_DELAY}ms to respect rate limits.\n`); 601 + batchDelay = MIN_BATCH_DELAY; 602 + } 603 + 604 + // Calculate optimal batch size if not specified (considers batch delay) 605 + let batchSize = args['batch-size'] ? parseInt(args['batch-size']) : null; 606 + if (!batchSize) { 607 + batchSize = calculateOptimalBatchSize(playRecords.length, batchDelay); 608 + console.log(`Auto-calculated batch size: ${batchSize}`); 609 + console.log(` Algorithm: Logarithmic scaling with O(n) time complexity`); 610 + console.log(` Optimized for: ${playRecords.length} records at ${batchDelay}ms delay`); 611 + console.log(` Rate limit strategy: Token bucket with conservative limits\n`); 612 + } else { 613 + console.log(`Using specified batch size: ${batchSize}\n`); 614 + } 615 + 616 + // Check if dry run mode 617 + const isDryRun = args['dry-run']; 618 + 619 + if (isDryRun) { 620 + console.log('🔍 Running in DRY RUN mode - no authentication required\n'); 621 + 622 + // Show preview without publishing 623 + await publishRecords(null, playRecords, batchSize, batchDelay, true); 624 + process.exit(0); 625 + } 626 + 627 + // Login to ATProto (only if not dry run) 628 + const agent = await login(args.identifier, args.password); 629 + 630 + // Confirm before publishing (unless --yes flag is set) 631 + if (!args.yes) { 632 + const confirm = await prompt(`\nReady to publish ${playRecords.length} records. Continue? (yes/no): `); 633 + if (confirm.toLowerCase() !== 'yes' && confirm.toLowerCase() !== 'y') { 634 + console.log('Aborted.'); 635 + process.exit(0); 636 + } 637 + console.log(''); 638 + } else { 639 + console.log(`Auto-confirmed: Publishing ${playRecords.length} records...\n`); 640 + } 641 + 642 + // Publish records 643 + const startTime = Date.now(); 644 + const { successCount, errorCount, cancelled } = await publishRecords(agent, playRecords, batchSize, batchDelay, false); 645 + const totalTime = formatDuration(Date.now() - startTime); 646 + 647 + // Summary 648 + console.log('=== Import Complete ==='); 649 + if (cancelled) { 650 + console.log('Status: CANCELLED BY USER'); 651 + } else { 652 + console.log('Status: COMPLETED'); 653 + } 654 + console.log(`Total records: ${playRecords.length}`); 655 + console.log(`Successfully published: ${successCount}`); 656 + console.log(`Failed: ${errorCount}`); 657 + if (cancelled) { 658 + console.log(`Not processed: ${playRecords.length - successCount - errorCount}`); 659 + } 660 + console.log(`Total time: ${totalTime}`); 661 + 662 + if (successCount > 0) { 663 + const avgTime = (Date.now() - startTime) / successCount; 664 + console.log(`Average time per record: ${avgTime.toFixed(0)}ms`); 665 + } 666 + 667 + console.log('\n✓ Logged out'); 668 + 669 + // Exit with appropriate code 670 + process.exit(cancelled ? 130 : 0); 671 + 672 + } catch (error) { 673 + console.error('\n✗ Fatal error:', error.message); 674 + if (error.stack && process.env.DEBUG) { 675 + console.error('\nStack trace:', error.stack); 676 + } 677 + process.exit(1); 678 + } 679 + } 680 + 681 + main();
+84
lexicons/fm.teal.alpha/actor/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "fm.teal.alpha.actor.defs", 4 + "defs": { 5 + "profileView": { 6 + "type": "object", 7 + "properties": { 8 + "did": { 9 + "type": "string", 10 + "description": "The decentralized identifier of the actor" 11 + }, 12 + "displayName": { 13 + "type": "string" 14 + }, 15 + "description": { 16 + "type": "string", 17 + "description": "Free-form profile description text." 18 + }, 19 + "descriptionFacets": { 20 + "type": "array", 21 + "description": "Annotations of text in the profile description (mentions, URLs, hashtags, etc). May be changed to another (backwards compatible) lexicon.", 22 + "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } 23 + }, 24 + "featuredItem": { 25 + "type": "ref", 26 + "description": "The user's most recent item featured on their profile.", 27 + "ref": "fm.teal.alpha.actor.profile#featuredItem" 28 + }, 29 + "avatar": { 30 + "type": "string", 31 + "description": "IPLD of the avatar" 32 + }, 33 + "banner": { 34 + "type": "string", 35 + "description": "IPLD of the banner image" 36 + }, 37 + "status": { 38 + "type": "ref", 39 + "ref": "#statusView" 40 + }, 41 + "createdAt": { "type": "string", "format": "datetime" } 42 + } 43 + }, 44 + "miniProfileView": { 45 + "type": "object", 46 + "properties": { 47 + "did": { 48 + "type": "string", 49 + "description": "The decentralized identifier of the actor" 50 + }, 51 + "displayName": { 52 + "type": "string" 53 + }, 54 + "handle": { 55 + "type": "string" 56 + }, 57 + "avatar": { 58 + "type": "string", 59 + "description": "IPLD of the avatar" 60 + } 61 + } 62 + }, 63 + "statusView": { 64 + "type": "object", 65 + "description": "A declaration of the status of the actor.", 66 + "properties": { 67 + "time": { 68 + "type": "string", 69 + "format": "datetime", 70 + "description": "The unix timestamp of when the item was recorded" 71 + }, 72 + "expiry": { 73 + "type": "string", 74 + "format": "datetime", 75 + "description": "The unix timestamp of the expiry time of the item. If unavailable, default to 10 minutes past the start time." 76 + }, 77 + "item": { 78 + "type": "ref", 79 + "ref": "fm.teal.alpha.feed.defs#playView" 80 + } 81 + } 82 + } 83 + } 84 + }
+34
lexicons/fm.teal.alpha/actor/getProfile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "fm.teal.alpha.actor.getProfile", 4 + "description": "This lexicon is in a not officially released state. It is subject to change. | Retrieves a play given an author DID and record key.", 5 + "defs": { 6 + "main": { 7 + "type": "query", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["actor"], 11 + "properties": { 12 + "actor": { 13 + "type": "string", 14 + "format": "at-identifier", 15 + "description": "The author's DID" 16 + } 17 + } 18 + }, 19 + "output": { 20 + "encoding": "application/json", 21 + "schema": { 22 + "type": "object", 23 + "required": ["actor"], 24 + "properties": { 25 + "actor": { 26 + "type": "ref", 27 + "ref": "fm.teal.alpha.actor.defs#profileView" 28 + } 29 + } 30 + } 31 + } 32 + } 33 + } 34 + }
+40
lexicons/fm.teal.alpha/actor/getProfiles.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "fm.teal.alpha.actor.getProfiles", 4 + "description": "This lexicon is in a not officially released state. It is subject to change. | Retrieves the associated profile.", 5 + "defs": { 6 + "main": { 7 + "type": "query", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["actors"], 11 + "properties": { 12 + "actors": { 13 + "type": "array", 14 + "items": { 15 + "type": "string", 16 + "format": "at-identifier" 17 + }, 18 + "description": "Array of actor DIDs" 19 + } 20 + } 21 + }, 22 + "output": { 23 + "encoding": "application/json", 24 + "schema": { 25 + "type": "object", 26 + "required": ["actors"], 27 + "properties": { 28 + "actors": { 29 + "type": "array", 30 + "items": { 31 + "type": "ref", 32 + "ref": "fm.teal.alpha.actor.defs#miniProfileView" 33 + } 34 + } 35 + } 36 + } 37 + } 38 + } 39 + } 40 + }
+64
lexicons/fm.teal.alpha/actor/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "fm.teal.alpha.actor.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "This lexicon is in a not officially released state. It is subject to change. | A declaration of a teal.fm account profile.", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "properties": { 12 + "displayName": { 13 + "type": "string", 14 + "maxGraphemes": 64, 15 + "maxLength": 640 16 + }, 17 + "description": { 18 + "type": "string", 19 + "description": "Free-form profile description text.", 20 + "maxGraphemes": 256, 21 + "maxLength": 2560 22 + }, 23 + "descriptionFacets": { 24 + "type": "array", 25 + "description": "Annotations of text in the profile description (mentions, URLs, hashtags, etc).", 26 + "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } 27 + }, 28 + "featuredItem": { 29 + "type": "ref", 30 + "description": "The user's most recent item featured on their profile.", 31 + "ref": "#featuredItem" 32 + }, 33 + "avatar": { 34 + "type": "blob", 35 + "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'", 36 + "accept": ["image/png", "image/jpeg"], 37 + "maxSize": 1000000 38 + }, 39 + "banner": { 40 + "type": "blob", 41 + "description": "Larger horizontal image to display behind profile view.", 42 + "accept": ["image/png", "image/jpeg"], 43 + "maxSize": 1000000 44 + }, 45 + "createdAt": { "type": "string", "format": "datetime" } 46 + } 47 + } 48 + }, 49 + "featuredItem": { 50 + "type": "object", 51 + "required": ["mbid", "type"], 52 + "properties": { 53 + "mbid": { 54 + "type": "string", 55 + "description": "The Musicbrainz ID of the item" 56 + }, 57 + "type": { 58 + "type": "string", 59 + "description": "The type of the item. Must be a valid Musicbrainz type, e.g. album, track, recording, etc." 60 + } 61 + } 62 + } 63 + } 64 + }
+32
lexicons/fm.teal.alpha/actor/profileStatus.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "fm.teal.alpha.actor.profileStatus", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "This lexicon is in a not officially released state. It is subject to change. | A declaration of the profile status of the actor.", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "required": ["completedOnboarding"], 12 + "properties": { 13 + "completedOnboarding": { 14 + "type": "string", 15 + "description": "The onboarding completion status", 16 + "knownValues": ["none", "profileOnboarding", "playOnboarding", "complete"] 17 + }, 18 + "createdAt": { 19 + "type": "string", 20 + "format": "datetime", 21 + "description": "The timestamp when this status was created" 22 + }, 23 + "updatedAt": { 24 + "type": "string", 25 + "format": "datetime", 26 + "description": "The timestamp when this status was last updated" 27 + } 28 + } 29 + } 30 + } 31 + } 32 + }
+52
lexicons/fm.teal.alpha/actor/searchActors.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "fm.teal.alpha.actor.searchActors", 4 + "description": "This lexicon is in a not officially released state. It is subject to change. | Searches for actors based on profile contents.", 5 + "defs": { 6 + "main": { 7 + "type": "query", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["q"], 11 + "properties": { 12 + "q": { 13 + "type": "string", 14 + "description": "The search query", 15 + "maxGraphemes": 128, 16 + "maxLength": 640 17 + }, 18 + "limit": { 19 + "type": "integer", 20 + "description": "The maximum number of actors to return", 21 + "minimum": 1, 22 + "maximum": 25 23 + }, 24 + "cursor": { 25 + "type": "string", 26 + "description": "Cursor for pagination" 27 + } 28 + } 29 + }, 30 + "output": { 31 + "encoding": "application/json", 32 + "schema": { 33 + "type": "object", 34 + "required": ["actors"], 35 + "properties": { 36 + "actors": { 37 + "type": "array", 38 + "items": { 39 + "type": "ref", 40 + "ref": "fm.teal.alpha.actor.defs#miniProfileView" 41 + } 42 + }, 43 + "cursor": { 44 + "type": "string", 45 + "description": "Cursor for pagination" 46 + } 47 + } 48 + } 49 + } 50 + } 51 + } 52 + }
+31
lexicons/fm.teal.alpha/actor/status.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "fm.teal.alpha.actor.status", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "This lexicon is in a not officially released state. It is subject to change. | A declaration of the status of the actor. Only one can be shown at a time. If there are multiple, the latest record should be picked and earlier records should be deleted or tombstoned.", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "required": ["time", "item"], 12 + "properties": { 13 + "time": { 14 + "type": "string", 15 + "format": "datetime", 16 + "description": "The unix timestamp of when the item was recorded" 17 + }, 18 + "expiry": { 19 + "type": "string", 20 + "format": "datetime", 21 + "description": "The unix timestamp of the expiry time of the item. If unavailable, default to 10 minutes past the start time." 22 + }, 23 + "item": { 24 + "type": "ref", 25 + "ref": "fm.teal.alpha.feed.defs#playView" 26 + } 27 + } 28 + } 29 + } 30 + } 31 + }
+90
lexicons/fm.teal.alpha/feed/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "fm.teal.alpha.feed.defs", 4 + "description": "This lexicon is in a not officially released state. It is subject to change. | Misc. items related to feeds.", 5 + "defs": { 6 + "playView": { 7 + "type": "object", 8 + "required": ["trackName", "artists"], 9 + "properties": { 10 + "trackName": { 11 + "type": "string", 12 + "minLength": 1, 13 + "maxLength": 256, 14 + "maxGraphemes": 2560, 15 + "description": "The name of the track" 16 + }, 17 + "trackMbId": { 18 + "type": "string", 19 + "description": "The Musicbrainz ID of the track" 20 + }, 21 + "recordingMbId": { 22 + "type": "string", 23 + "description": "The Musicbrainz recording ID of the track" 24 + }, 25 + "duration": { 26 + "type": "integer", 27 + "description": "The length of the track in seconds" 28 + }, 29 + "artists": { 30 + "type": "array", 31 + "items": { 32 + "type": "ref", 33 + "ref": "#artist" 34 + }, 35 + "description": "Array of artists in order of original appearance." 36 + }, 37 + "releaseName": { 38 + "type": "string", 39 + "maxLength": 256, 40 + "maxGraphemes": 2560, 41 + "description": "The name of the release/album" 42 + }, 43 + "releaseMbId": { 44 + "type": "string", 45 + "description": "The Musicbrainz release ID" 46 + }, 47 + "isrc": { 48 + "type": "string", 49 + "description": "The ISRC code associated with the recording" 50 + }, 51 + "originUrl": { 52 + "type": "string", 53 + "description": "The URL associated with this track" 54 + }, 55 + "musicServiceBaseDomain": { 56 + "type": "string", 57 + "description": "The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if not provided." 58 + }, 59 + "submissionClientAgent": { 60 + "type": "string", 61 + "maxLength": 256, 62 + "maxGraphemes": 2560, 63 + "description": "A user-agent style string specifying the user agent. e.g. tealtracker/0.0.1b (Linux; Android 13; SM-A715F). Defaults to 'manual/unknown' if not provided." 64 + }, 65 + "playedTime": { 66 + "type": "string", 67 + "format": "datetime", 68 + "description": "The unix timestamp of when the track was played" 69 + } 70 + } 71 + }, 72 + "artist": { 73 + "type": "object", 74 + "required": ["artistName"], 75 + "properties": { 76 + "artistName": { 77 + "type": "string", 78 + "minLength": 1, 79 + "maxLength": 256, 80 + "maxGraphemes": 2560, 81 + "description": "The name of the artist" 82 + }, 83 + "artistMbId": { 84 + "type": "string", 85 + "description": "The Musicbrainz ID of the artist" 86 + } 87 + } 88 + } 89 + } 90 + }
+45
lexicons/fm.teal.alpha/feed/getActorFeed.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "fm.teal.alpha.feed.getActorFeed", 4 + "description": "This lexicon is in a not officially released state. It is subject to change. | Retrieves multiple plays from the index or via an author's DID.", 5 + "defs": { 6 + "main": { 7 + "type": "query", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["authorDID"], 11 + "properties": { 12 + "authorDID": { 13 + "type": "string", 14 + "format": "at-identifier", 15 + "description": "The author's DID for the play" 16 + }, 17 + "cursor": { 18 + "type": "string", 19 + "description": "The cursor to start the query from" 20 + }, 21 + "limit": { 22 + "type": "integer", 23 + "description": "The upper limit of tracks to get per request. Default is 20, max is 50." 24 + } 25 + } 26 + }, 27 + "output": { 28 + "encoding": "application/json", 29 + "schema": { 30 + "type": "object", 31 + "required": ["plays"], 32 + "properties": { 33 + "plays": { 34 + "type": "array", 35 + "items": { 36 + "type": "ref", 37 + "ref": "fm.teal.alpha.feed.defs#playView" 38 + } 39 + } 40 + } 41 + } 42 + } 43 + } 44 + } 45 + }
+38
lexicons/fm.teal.alpha/feed/getPlay.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "fm.teal.alpha.feed.getPlay", 4 + "description": "This lexicon is in a not officially released state. It is subject to change. | Retrieves a play given an author DID and record key.", 5 + "defs": { 6 + "main": { 7 + "type": "query", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["authorDID", "rkey"], 11 + "properties": { 12 + "authorDID": { 13 + "type": "string", 14 + "format": "at-identifier", 15 + "description": "The author's DID for the play" 16 + }, 17 + "rkey": { 18 + "type": "string", 19 + "description": "The record key of the play" 20 + } 21 + } 22 + }, 23 + "output": { 24 + "encoding": "application/json", 25 + "schema": { 26 + "type": "object", 27 + "required": ["play"], 28 + "properties": { 29 + "play": { 30 + "type": "ref", 31 + "ref": "fm.teal.alpha.feed.defs#playView" 32 + } 33 + } 34 + } 35 + } 36 + } 37 + } 38 + }
+107
lexicons/fm.teal.alpha/feed/play.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "fm.teal.alpha.feed.play", 4 + "description": "This lexicon is in a not officially released state. It is subject to change. | A declaration of a teal.fm play. Plays are submitted as a result of a user listening to a track. Plays should be marked as tracked when a user has listened to the entire track if it's under 2 minutes long, or half of the track's duration up to 4 minutes, whichever is longest.", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["trackName"], 12 + "properties": { 13 + "trackName": { 14 + "type": "string", 15 + "minLength": 1, 16 + "maxLength": 256, 17 + "maxGraphemes": 2560, 18 + "description": "The name of the track" 19 + }, 20 + "trackMbId": { 21 + "type": "string", 22 + 23 + "description": "The Musicbrainz ID of the track" 24 + }, 25 + "recordingMbId": { 26 + "type": "string", 27 + "description": "The Musicbrainz recording ID of the track" 28 + }, 29 + "duration": { 30 + "type": "integer", 31 + "description": "The length of the track in seconds" 32 + }, 33 + "artistNames": { 34 + "type": "array", 35 + "items": { 36 + "type": "string", 37 + "minLength": 1, 38 + "maxLength": 256, 39 + "maxGraphemes": 2560 40 + }, 41 + "description": "Array of artist names in order of original appearance. Prefer using 'artists'." 42 + }, 43 + "artistMbIds": { 44 + "type": "array", 45 + "items": { 46 + "type": "string" 47 + }, 48 + "description": "Array of Musicbrainz artist IDs. Prefer using 'artists'." 49 + }, 50 + "artists": { 51 + "type": "array", 52 + "items": { 53 + "type": "ref", 54 + "ref": "fm.teal.alpha.feed.defs#artist" 55 + }, 56 + "description": "Array of artists in order of original appearance." 57 + }, 58 + "releaseName": { 59 + "type": "string", 60 + "maxLength": 256, 61 + "maxGraphemes": 2560, 62 + "description": "The name of the release/album" 63 + }, 64 + "releaseMbId": { 65 + "type": "string", 66 + "description": "The Musicbrainz release ID" 67 + }, 68 + "isrc": { 69 + "type": "string", 70 + "description": "The ISRC code associated with the recording" 71 + }, 72 + "originUrl": { 73 + "type": "string", 74 + "description": "The URL associated with this track" 75 + }, 76 + "musicServiceBaseDomain": { 77 + "type": "string", 78 + "description": "The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if unavailable or not provided." 79 + }, 80 + "submissionClientAgent": { 81 + "type": "string", 82 + "maxLength": 256, 83 + "maxGraphemes": 2560, 84 + "description": "A metadata string specifying the user agent where the format is `<app-identifier>/<version> (<kernel/OS-base>; <platform/OS-version>; <device-model>)`. If string is provided, only `app-identifier` and `version` are required. `app-identifier` is recommended to be in reverse dns format. Defaults to 'manual/unknown' if unavailable or not provided." 85 + }, 86 + "playedTime": { 87 + "type": "string", 88 + "format": "datetime", 89 + "description": "The unix timestamp of when the track was played" 90 + }, 91 + "trackDiscriminant": { 92 + "type": "string", 93 + "maxLength": 128, 94 + "maxGraphemes": 1280, 95 + "description": "Distinguishing information for track variants (e.g. 'Acoustic Version', 'Live at Wembley', 'Radio Edit', 'Demo'). Used to differentiate between different versions of the same base track while maintaining grouping capabilities." 96 + }, 97 + "releaseDiscriminant": { 98 + "type": "string", 99 + "maxLength": 128, 100 + "maxGraphemes": 1280, 101 + "description": "Distinguishing information for release variants (e.g. 'Deluxe Edition', 'Remastered', '2023 Remaster', 'Special Edition'). Used to differentiate between different versions of the same base release while maintaining grouping capabilities." 102 + } 103 + } 104 + } 105 + } 106 + } 107 + }
+60
lexicons/fm.teal.alpha/stats/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "fm.teal.alpha.stats.defs", 4 + "defs": { 5 + "artistView": { 6 + "type": "object", 7 + "required": ["mbid", "name", "playCount"], 8 + "properties": { 9 + "mbid": { 10 + "type": "string", 11 + "description": "MusicBrainz artist ID" 12 + }, 13 + "name": { 14 + "type": "string", 15 + "description": "Artist name" 16 + }, 17 + "playCount": { 18 + "type": "integer", 19 + "description": "Total number of plays for this artist" 20 + } 21 + } 22 + }, 23 + "releaseView": { 24 + "type": "object", 25 + "required": ["mbid", "name", "playCount"], 26 + "properties": { 27 + "mbid": { 28 + "type": "string", 29 + "description": "MusicBrainz release ID" 30 + }, 31 + "name": { 32 + "type": "string", 33 + "description": "Release/album name" 34 + }, 35 + "playCount": { 36 + "type": "integer", 37 + "description": "Total number of plays for this release" 38 + } 39 + } 40 + }, 41 + "recordingView": { 42 + "type": "object", 43 + "required": ["mbid", "name", "playCount"], 44 + "properties": { 45 + "mbid": { 46 + "type": "string", 47 + "description": "MusicBrainz recording ID" 48 + }, 49 + "name": { 50 + "type": "string", 51 + "description": "Recording/track name" 52 + }, 53 + "playCount": { 54 + "type": "integer", 55 + "description": "Total number of plays for this recording" 56 + } 57 + } 58 + } 59 + } 60 + }
+38
lexicons/fm.teal.alpha/stats/getLatest.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "fm.teal.alpha.stats.getLatest", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get latest plays globally", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "limit": { 12 + "type": "integer", 13 + "minimum": 1, 14 + "maximum": 100, 15 + "default": 50, 16 + "description": "Number of latest plays to return" 17 + } 18 + } 19 + }, 20 + "output": { 21 + "encoding": "application/json", 22 + "schema": { 23 + "type": "object", 24 + "required": ["plays"], 25 + "properties": { 26 + "plays": { 27 + "type": "array", 28 + "items": { 29 + "type": "ref", 30 + "ref": "fm.teal.alpha.feed.defs#playView" 31 + } 32 + } 33 + } 34 + } 35 + } 36 + } 37 + } 38 + }
+52
lexicons/fm.teal.alpha/stats/getTopArtists.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "fm.teal.alpha.stats.getTopArtists", 4 + "description": "Get top artists by play count", 5 + "defs": { 6 + "main": { 7 + "type": "query", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "period": { 12 + "type": "string", 13 + "enum": ["all", "30days", "7days"], 14 + "default": "all", 15 + "description": "Time period for top artists" 16 + }, 17 + "limit": { 18 + "type": "integer", 19 + "minimum": 1, 20 + "maximum": 100, 21 + "default": 50, 22 + "description": "Number of artists to return" 23 + }, 24 + "cursor": { 25 + "type": "string", 26 + "description": "Pagination cursor" 27 + } 28 + } 29 + }, 30 + "output": { 31 + "encoding": "application/json", 32 + "schema": { 33 + "type": "object", 34 + "required": ["artists"], 35 + "properties": { 36 + "artists": { 37 + "type": "array", 38 + "items": { 39 + "type": "ref", 40 + "ref": "fm.teal.alpha.stats.defs#artistView" 41 + } 42 + }, 43 + "cursor": { 44 + "type": "string", 45 + "description": "Next page cursor" 46 + } 47 + } 48 + } 49 + } 50 + } 51 + } 52 + }
+52
lexicons/fm.teal.alpha/stats/getTopReleases.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "fm.teal.alpha.stats.getTopReleases", 4 + "description": "Get top releases/albums by play count", 5 + "defs": { 6 + "main": { 7 + "type": "query", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "period": { 12 + "type": "string", 13 + "enum": ["all", "30days", "7days"], 14 + "default": "all", 15 + "description": "Time period for top releases" 16 + }, 17 + "limit": { 18 + "type": "integer", 19 + "minimum": 1, 20 + "maximum": 100, 21 + "default": 50, 22 + "description": "Number of releases to return" 23 + }, 24 + "cursor": { 25 + "type": "string", 26 + "description": "Pagination cursor" 27 + } 28 + } 29 + }, 30 + "output": { 31 + "encoding": "application/json", 32 + "schema": { 33 + "type": "object", 34 + "required": ["releases"], 35 + "properties": { 36 + "releases": { 37 + "type": "array", 38 + "items": { 39 + "type": "ref", 40 + "ref": "fm.teal.alpha.stats.defs#releaseView" 41 + } 42 + }, 43 + "cursor": { 44 + "type": "string", 45 + "description": "Next page cursor" 46 + } 47 + } 48 + } 49 + } 50 + } 51 + } 52 + }
+58
lexicons/fm.teal.alpha/stats/getUserTopArtists.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "fm.teal.alpha.stats.getUserTopArtists", 4 + "description": "Get a user's top artists by play count", 5 + "defs": { 6 + "main": { 7 + "type": "query", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["actor"], 11 + "properties": { 12 + "actor": { 13 + "type": "string", 14 + "format": "at-identifier", 15 + "description": "The user's DID or handle" 16 + }, 17 + "period": { 18 + "type": "string", 19 + "enum": ["30days", "7days"], 20 + "default": "30days", 21 + "description": "Time period for top artists" 22 + }, 23 + "limit": { 24 + "type": "integer", 25 + "minimum": 1, 26 + "maximum": 100, 27 + "default": 50, 28 + "description": "Number of artists to return" 29 + }, 30 + "cursor": { 31 + "type": "string", 32 + "description": "Pagination cursor" 33 + } 34 + } 35 + }, 36 + "output": { 37 + "encoding": "application/json", 38 + "schema": { 39 + "type": "object", 40 + "required": ["artists"], 41 + "properties": { 42 + "artists": { 43 + "type": "array", 44 + "items": { 45 + "type": "ref", 46 + "ref": "fm.teal.alpha.stats.defs#artistView" 47 + } 48 + }, 49 + "cursor": { 50 + "type": "string", 51 + "description": "Next page cursor" 52 + } 53 + } 54 + } 55 + } 56 + } 57 + } 58 + }
+58
lexicons/fm.teal.alpha/stats/getUserTopReleases.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "fm.teal.alpha.stats.getUserTopReleases", 4 + "description": "Get a user's top releases/albums by play count", 5 + "defs": { 6 + "main": { 7 + "type": "query", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["actor"], 11 + "properties": { 12 + "actor": { 13 + "type": "string", 14 + "format": "at-identifier", 15 + "description": "The user's DID or handle" 16 + }, 17 + "period": { 18 + "type": "string", 19 + "enum": ["30days", "7days"], 20 + "default": "30days", 21 + "description": "Time period for top releases" 22 + }, 23 + "limit": { 24 + "type": "integer", 25 + "minimum": 1, 26 + "maximum": 100, 27 + "default": 50, 28 + "description": "Number of releases to return" 29 + }, 30 + "cursor": { 31 + "type": "string", 32 + "description": "Pagination cursor" 33 + } 34 + } 35 + }, 36 + "output": { 37 + "encoding": "application/json", 38 + "schema": { 39 + "type": "object", 40 + "required": ["releases"], 41 + "properties": { 42 + "releases": { 43 + "type": "array", 44 + "items": { 45 + "type": "ref", 46 + "ref": "fm.teal.alpha.stats.defs#releaseView" 47 + } 48 + }, 49 + "cursor": { 50 + "type": "string", 51 + "description": "Next page cursor" 52 + } 53 + } 54 + } 55 + } 56 + } 57 + } 58 + }
+137
package-lock.json
··· 1 + { 2 + "name": "lastfm-importer", 3 + "version": "1.0.0", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "lastfm-importer", 9 + "version": "1.0.0", 10 + "license": "MIT", 11 + "dependencies": { 12 + "@atproto/api": "^0.13.0", 13 + "csv-parse": "^5.5.0" 14 + } 15 + }, 16 + "node_modules/@atproto/api": { 17 + "version": "0.13.35", 18 + "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.13.35.tgz", 19 + "integrity": "sha512-vsEfBj0C333TLjDppvTdTE0IdKlXuljKSveAeI4PPx/l6eUKNnDTsYxvILtXUVzwUlTDmSRqy5O4Ryh78n1b7g==", 20 + "license": "MIT", 21 + "dependencies": { 22 + "@atproto/common-web": "^0.4.0", 23 + "@atproto/lexicon": "^0.4.6", 24 + "@atproto/syntax": "^0.3.2", 25 + "@atproto/xrpc": "^0.6.8", 26 + "await-lock": "^2.2.2", 27 + "multiformats": "^9.9.0", 28 + "tlds": "^1.234.0", 29 + "zod": "^3.23.8" 30 + } 31 + }, 32 + "node_modules/@atproto/common-web": { 33 + "version": "0.4.3", 34 + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.3.tgz", 35 + "integrity": "sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg==", 36 + "license": "MIT", 37 + "dependencies": { 38 + "graphemer": "^1.4.0", 39 + "multiformats": "^9.9.0", 40 + "uint8arrays": "3.0.0", 41 + "zod": "^3.23.8" 42 + } 43 + }, 44 + "node_modules/@atproto/lexicon": { 45 + "version": "0.4.14", 46 + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.14.tgz", 47 + "integrity": "sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ==", 48 + "license": "MIT", 49 + "dependencies": { 50 + "@atproto/common-web": "^0.4.2", 51 + "@atproto/syntax": "^0.4.0", 52 + "iso-datestring-validator": "^2.2.2", 53 + "multiformats": "^9.9.0", 54 + "zod": "^3.23.8" 55 + } 56 + }, 57 + "node_modules/@atproto/lexicon/node_modules/@atproto/syntax": { 58 + "version": "0.4.1", 59 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.1.tgz", 60 + "integrity": "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw==", 61 + "license": "MIT" 62 + }, 63 + "node_modules/@atproto/syntax": { 64 + "version": "0.3.4", 65 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.3.4.tgz", 66 + "integrity": "sha512-8CNmi5DipOLaVeSMPggMe7FCksVag0aO6XZy9WflbduTKM4dFZVCs4686UeMLfGRXX+X966XgwECHoLYrovMMg==", 67 + "license": "MIT" 68 + }, 69 + "node_modules/@atproto/xrpc": { 70 + "version": "0.6.12", 71 + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.6.12.tgz", 72 + "integrity": "sha512-Ut3iISNLujlmY9Gu8sNU+SPDJDvqlVzWddU8qUr0Yae5oD4SguaUFjjhireMGhQ3M5E0KljQgDbTmnBo1kIZ3w==", 73 + "license": "MIT", 74 + "dependencies": { 75 + "@atproto/lexicon": "^0.4.10", 76 + "zod": "^3.23.8" 77 + } 78 + }, 79 + "node_modules/await-lock": { 80 + "version": "2.2.2", 81 + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", 82 + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==", 83 + "license": "MIT" 84 + }, 85 + "node_modules/csv-parse": { 86 + "version": "5.6.0", 87 + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz", 88 + "integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==", 89 + "license": "MIT" 90 + }, 91 + "node_modules/graphemer": { 92 + "version": "1.4.0", 93 + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", 94 + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", 95 + "license": "MIT" 96 + }, 97 + "node_modules/iso-datestring-validator": { 98 + "version": "2.2.2", 99 + "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", 100 + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==", 101 + "license": "MIT" 102 + }, 103 + "node_modules/multiformats": { 104 + "version": "9.9.0", 105 + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 106 + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 107 + "license": "(Apache-2.0 AND MIT)" 108 + }, 109 + "node_modules/tlds": { 110 + "version": "1.261.0", 111 + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", 112 + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", 113 + "license": "MIT", 114 + "bin": { 115 + "tlds": "bin.js" 116 + } 117 + }, 118 + "node_modules/uint8arrays": { 119 + "version": "3.0.0", 120 + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", 121 + "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", 122 + "license": "MIT", 123 + "dependencies": { 124 + "multiformats": "^9.4.2" 125 + } 126 + }, 127 + "node_modules/zod": { 128 + "version": "3.25.76", 129 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 130 + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 131 + "license": "MIT", 132 + "funding": { 133 + "url": "https://github.com/sponsors/colinhacks" 134 + } 135 + } 136 + } 137 + }
+23
package.json
··· 1 + { 2 + "name": "lastfm-importer", 3 + "version": "1.0.0", 4 + "description": "Import Last.fm scrobbles to ATProto", 5 + "type": "module", 6 + "main": "importer.js", 7 + "scripts": { 8 + "start": "node importer.js", 9 + "dry-run": "node importer.js --dry-run" 10 + }, 11 + "keywords": [ 12 + "lastfm", 13 + "atproto", 14 + "bluesky", 15 + "import" 16 + ], 17 + "author": "", 18 + "license": "MIT", 19 + "dependencies": { 20 + "@atproto/api": "^0.13.0", 21 + "csv-parse": "^5.5.0" 22 + } 23 + }
+16
src/config.js
··· 1 + /** 2 + * Configuration constants for the Last.fm importer 3 + */ 4 + 5 + export const DEFAULT_BATCH_SIZE = 10; 6 + export const DEFAULT_BATCH_DELAY = 1500; 7 + export const MIN_BATCH_DELAY = 100; 8 + export const RECORD_TYPE = 'fm.teal.alpha.feed.play'; 9 + export const SLINGSHOT_RESOLVER = 'https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc'; 10 + export const CLIENT_AGENT = 'lastfm-importer/v0.0.1'; 11 + 12 + // Batch size calculation constants 13 + export const MIN_RECORDS_FOR_SCALING = 100; 14 + export const BASE_BATCH_SIZE = 5; 15 + export const MAX_BATCH_SIZE = 50; 16 + export const SCALING_FACTOR = 1.5;
+155
src/index.js
··· 1 + #!/usr/bin/env node 2 + 3 + import * as fs from 'fs'; 4 + import * as config from './config.js'; 5 + import { parseCommandLineArgs, showHelp } from './lib/cli.js'; 6 + import { login } from './lib/auth.js'; 7 + import { parseLastFmCsv, convertToPlayRecord, sortRecords } from './lib/csv.js'; 8 + import { publishRecords } from './lib/publisher.js'; 9 + import { prompt } from './utils/input.js'; 10 + import { formatDuration, calculateOptimalBatchSize } from './utils/helpers.js'; 11 + import { setupKillswitch } from './utils/killswitch.js'; 12 + 13 + /** 14 + * Main execution 15 + */ 16 + async function main() { 17 + const args = parseCommandLineArgs(); 18 + 19 + // Show help if requested 20 + if (args.help) { 21 + showHelp(); 22 + process.exit(0); 23 + } 24 + 25 + // Setup killswitch (unless in dry-run mode) 26 + if (!args['dry-run']) { 27 + setupKillswitch(); 28 + } 29 + 30 + try { 31 + console.log('=== Last.fm to ATProto Importer ===\n'); 32 + 33 + // Get CSV file path 34 + let csvPath = args.file; 35 + if (!csvPath) { 36 + csvPath = await prompt('Enter path to Last.fm CSV export: '); 37 + } else { 38 + console.log(`CSV file: ${csvPath}`); 39 + } 40 + 41 + if (!fs.existsSync(csvPath)) { 42 + console.error('✗ File not found!'); 43 + process.exit(1); 44 + } 45 + 46 + // Parse CSV 47 + const csvRecords = parseLastFmCsv(csvPath); 48 + 49 + if (csvRecords.length === 0) { 50 + console.error('✗ No records found in CSV file!'); 51 + process.exit(1); 52 + } 53 + 54 + // Convert records 55 + console.log('Converting records to ATProto format...'); 56 + const playRecords = csvRecords.map(record => convertToPlayRecord(record, config)); 57 + console.log('✓ Conversion complete\n'); 58 + 59 + // Sort records chronologically 60 + const reverseChronological = args['reverse-chronological']; 61 + sortRecords(playRecords, reverseChronological); 62 + 63 + // Validate and set batch delay 64 + let batchDelay = args['batch-delay'] ? parseInt(args['batch-delay']) : config.DEFAULT_BATCH_DELAY; 65 + if (batchDelay < config.MIN_BATCH_DELAY) { 66 + console.log(`⚠️ Batch delay ${batchDelay}ms is below minimum safe limit.`); 67 + console.log(` Enforcing minimum delay of ${config.MIN_BATCH_DELAY}ms to respect rate limits.\n`); 68 + batchDelay = config.MIN_BATCH_DELAY; 69 + } 70 + 71 + // Calculate optimal batch size 72 + let batchSize = args['batch-size'] ? parseInt(args['batch-size']) : null; 73 + if (!batchSize) { 74 + batchSize = calculateOptimalBatchSize(playRecords.length, batchDelay, config); 75 + console.log(`Auto-calculated batch size: ${batchSize}`); 76 + console.log(` Algorithm: Logarithmic scaling with O(n) time complexity`); 77 + console.log(` Optimized for: ${playRecords.length} records at ${batchDelay}ms delay`); 78 + console.log(` Rate limit strategy: Token bucket with conservative limits\n`); 79 + } else { 80 + console.log(`Using specified batch size: ${batchSize}\n`); 81 + } 82 + 83 + // Check if dry run mode 84 + const isDryRun = args['dry-run']; 85 + 86 + if (isDryRun) { 87 + console.log('🔍 Running in DRY RUN mode - no authentication required\n'); 88 + 89 + // Show preview without publishing 90 + await publishRecords(null, playRecords, batchSize, batchDelay, config, true); 91 + process.exit(0); 92 + } 93 + 94 + // Login to ATProto (only if not dry run) 95 + const agent = await login(args.identifier, args.password, config.SLINGSHOT_RESOLVER); 96 + 97 + // Confirm before publishing (unless --yes flag is set) 98 + if (!args.yes) { 99 + const confirm = await prompt(`\nReady to publish ${playRecords.length} records. Continue? (yes/no): `); 100 + if (confirm.toLowerCase() !== 'yes' && confirm.toLowerCase() !== 'y') { 101 + console.log('Aborted.'); 102 + process.exit(0); 103 + } 104 + console.log(''); 105 + } else { 106 + console.log(`Auto-confirmed: Publishing ${playRecords.length} records...\n`); 107 + } 108 + 109 + // Publish records 110 + const startTime = Date.now(); 111 + const { successCount, errorCount, cancelled } = await publishRecords( 112 + agent, 113 + playRecords, 114 + batchSize, 115 + batchDelay, 116 + config, 117 + false 118 + ); 119 + const totalTime = formatDuration(Date.now() - startTime); 120 + 121 + // Summary 122 + console.log('=== Import Complete ==='); 123 + if (cancelled) { 124 + console.log('Status: CANCELLED BY USER'); 125 + } else { 126 + console.log('Status: COMPLETED'); 127 + } 128 + console.log(`Total records: ${playRecords.length}`); 129 + console.log(`Successfully published: ${successCount}`); 130 + console.log(`Failed: ${errorCount}`); 131 + if (cancelled) { 132 + console.log(`Not processed: ${playRecords.length - successCount - errorCount}`); 133 + } 134 + console.log(`Total time: ${totalTime}`); 135 + 136 + if (successCount > 0) { 137 + const avgTime = (Date.now() - startTime) / successCount; 138 + console.log(`Average time per record: ${avgTime.toFixed(0)}ms`); 139 + } 140 + 141 + console.log('\n✓ Logged out'); 142 + 143 + // Exit with appropriate code 144 + process.exit(cancelled ? 130 : 0); 145 + 146 + } catch (error) { 147 + console.error('\n✗ Fatal error:', error.message); 148 + if (error.stack && process.env.DEBUG) { 149 + console.error('\nStack trace:', error.stack); 150 + } 151 + process.exit(1); 152 + } 153 + } 154 + 155 + main();
+79
src/lib/auth.js
··· 1 + import { AtpAgent } from '@atproto/api'; 2 + import { prompt } from '../utils/input.js'; 3 + 4 + /** 5 + * Resolves an AT Protocol identifier (handle or DID) to get PDS information 6 + */ 7 + async function resolveIdentifier(identifier, resolverUrl) { 8 + console.log(`Resolving identifier: ${identifier}`); 9 + 10 + const response = await fetch( 11 + `${resolverUrl}?identifier=${encodeURIComponent(identifier)}` 12 + ); 13 + 14 + if (!response.ok) { 15 + throw new Error(`Failed to resolve identifier: ${response.status} ${response.statusText}`); 16 + } 17 + 18 + const data = await response.json(); 19 + 20 + if (!data.did || !data.pds) { 21 + throw new Error('Invalid response from identity resolver'); 22 + } 23 + 24 + console.log(`✓ Resolved to PDS: ${data.pds}`); 25 + return data; 26 + } 27 + 28 + /** 29 + * Login to ATProto using Slingshot resolver 30 + */ 31 + export async function login(identifier, password, resolverUrl) { 32 + console.log('\n=== ATProto Login ==='); 33 + 34 + // Prompt for missing credentials 35 + if (!identifier) { 36 + identifier = await prompt('Handle or DID: '); 37 + } else { 38 + console.log(`Handle or DID: ${identifier}`); 39 + } 40 + 41 + if (!password) { 42 + password = await prompt('App password: ', true); 43 + } else { 44 + console.log('App password: [hidden]'); 45 + } 46 + 47 + try { 48 + // Resolve the identifier to get PDS 49 + const resolved = await resolveIdentifier(identifier, resolverUrl); 50 + 51 + // Create agent with resolved PDS 52 + const pdsAgent = new AtpAgent({ service: resolved.pds }); 53 + 54 + // Login using the resolved DID 55 + await pdsAgent.login({ 56 + identifier: resolved.did, 57 + password: password, 58 + }); 59 + 60 + console.log('✓ Logged in successfully!'); 61 + console.log(` DID: ${pdsAgent.session.did}`); 62 + console.log(` Handle: ${pdsAgent.session.handle}\n`); 63 + 64 + return pdsAgent; 65 + } catch (error) { 66 + console.error('✗ Login failed:', error.message); 67 + 68 + // Provide more specific error messages 69 + if (error.message.includes('Failed to resolve identifier')) { 70 + throw new Error('Handle not found. Please check your AT Protocol handle.'); 71 + } else if (error.message.includes('AuthFactorTokenRequired')) { 72 + throw new Error('Two-factor authentication required. Please use your app password.'); 73 + } else if (error.message.includes('InvalidCredentials')) { 74 + throw new Error('Invalid credentials. Please check your handle and app password.'); 75 + } 76 + 77 + throw error; 78 + } 79 + }
+93
src/lib/cli.js
··· 1 + import { parseArgs } from 'node:util'; 2 + 3 + /** 4 + * Parse command line arguments 5 + */ 6 + export function parseCommandLineArgs() { 7 + const options = { 8 + help: { 9 + type: 'boolean', 10 + short: 'h', 11 + default: false, 12 + }, 13 + file: { 14 + type: 'string', 15 + short: 'f', 16 + }, 17 + identifier: { 18 + type: 'string', 19 + short: 'i', 20 + }, 21 + password: { 22 + type: 'string', 23 + short: 'p', 24 + }, 25 + 'batch-size': { 26 + type: 'string', 27 + short: 'b', 28 + }, 29 + 'batch-delay': { 30 + type: 'string', 31 + short: 'd', 32 + }, 33 + yes: { 34 + type: 'boolean', 35 + short: 'y', 36 + default: false, 37 + }, 38 + 'dry-run': { 39 + type: 'boolean', 40 + short: 'n', 41 + default: false, 42 + }, 43 + 'reverse-chronological': { 44 + type: 'boolean', 45 + short: 'r', 46 + default: false, 47 + }, 48 + }; 49 + 50 + try { 51 + const { values } = parseArgs({ options, allowPositionals: false }); 52 + return values; 53 + } catch (error) { 54 + console.error('Error parsing arguments:', error.message); 55 + showHelp(); 56 + process.exit(1); 57 + } 58 + } 59 + 60 + /** 61 + * Show help message 62 + */ 63 + export function showHelp() { 64 + console.log(` 65 + Last.fm to ATProto Importer 66 + 67 + Usage: node importer.js [options] 68 + 69 + Options: 70 + -h, --help Show this help message 71 + -f, --file <path> Path to Last.fm CSV export file 72 + -i, --identifier <id> ATProto handle or DID 73 + -p, --password <pass> ATProto app password 74 + -b, --batch-size <num> Number of records per batch (auto-calculated if not set) 75 + -d, --batch-delay <ms> Delay between batches in ms (default: 2000, min: 1000) 76 + -y, --yes Skip confirmation prompt 77 + -n, --dry-run Preview records without publishing 78 + -r, --reverse-chronological Process newest first (default: oldest first) 79 + 80 + Examples: 81 + node importer.js -f lastfm.csv -i alice.bsky.social -p xxxx-xxxx-xxxx-xxxx 82 + node importer.js --file export.csv --identifier alice.bsky.social --yes 83 + node importer.js -f lastfm.csv --dry-run 84 + node importer.js (interactive mode - prompts for all values) 85 + 86 + Notes: 87 + - Batch size uses logarithmic scaling algorithm (O(n) complexity) for optimal throughput 88 + - Auto-calculated batch size considers both record count and delay settings 89 + - Records are processed in chronological order (oldest first) by default 90 + - Minimum batch delay of 1000ms enforced to respect rate limits 91 + - Rate limiting follows token bucket strategy for safe API usage 92 + `); 93 + }
+93
src/lib/csv.js
··· 1 + import * as fs from 'fs'; 2 + import { parse } from 'csv-parse/sync'; 3 + 4 + /** 5 + * Parse Last.fm CSV export 6 + */ 7 + export function parseLastFmCsv(filePath) { 8 + console.log(`Reading CSV file: ${filePath}`); 9 + const fileContent = fs.readFileSync(filePath, 'utf-8'); 10 + 11 + const records = parse(fileContent, { 12 + columns: true, 13 + skip_empty_lines: true, 14 + trim: true, 15 + }); 16 + 17 + console.log(`✓ Parsed ${records.length} scrobbles\n`); 18 + return records; 19 + } 20 + 21 + /** 22 + * Convert Last.fm CSV record to ATProto play record 23 + */ 24 + export function convertToPlayRecord(csvRecord, config) { 25 + const { RECORD_TYPE, CLIENT_AGENT } = config; 26 + 27 + // Parse the timestamp 28 + const timestamp = parseInt(csvRecord.uts); 29 + const playedTime = new Date(timestamp * 1000).toISOString(); 30 + 31 + // Build artists array 32 + const artists = []; 33 + if (csvRecord.artist) { 34 + const artistData = { 35 + artistName: csvRecord.artist, 36 + }; 37 + if (csvRecord.artist_mbid && csvRecord.artist_mbid.trim()) { 38 + artistData.artistMbId = csvRecord.artist_mbid; 39 + } 40 + artists.push(artistData); 41 + } 42 + 43 + // Build the play record 44 + const playRecord = { 45 + $type: RECORD_TYPE, 46 + trackName: csvRecord.track, 47 + artists, 48 + playedTime, 49 + submissionClientAgent: CLIENT_AGENT, 50 + musicServiceBaseDomain: 'last.fm', 51 + }; 52 + 53 + // Add optional fields 54 + if (csvRecord.album && csvRecord.album.trim()) { 55 + playRecord.releaseName = csvRecord.album; 56 + } 57 + 58 + if (csvRecord.album_mbid && csvRecord.album_mbid.trim()) { 59 + playRecord.releaseMbId = csvRecord.album_mbid; 60 + } 61 + 62 + if (csvRecord.track_mbid && csvRecord.track_mbid.trim()) { 63 + playRecord.recordingMbId = csvRecord.track_mbid; 64 + } 65 + 66 + // Generate Last.fm URL 67 + const artistEncoded = encodeURIComponent(csvRecord.artist); 68 + const trackEncoded = encodeURIComponent(csvRecord.track); 69 + playRecord.originUrl = `https://www.last.fm/music/${artistEncoded}/_/${trackEncoded}`; 70 + 71 + return playRecord; 72 + } 73 + 74 + /** 75 + * Sort records chronologically 76 + */ 77 + export function sortRecords(records, reverseChronological = false) { 78 + console.log(`Sorting records ${reverseChronological ? 'newest' : 'oldest'} first...`); 79 + 80 + records.sort((a, b) => { 81 + const timeA = new Date(a.playedTime).getTime(); 82 + const timeB = new Date(b.playedTime).getTime(); 83 + return reverseChronological ? timeB - timeA : timeA - timeB; 84 + }); 85 + 86 + const firstPlay = new Date(records[0].playedTime).toLocaleDateString(); 87 + const lastPlay = new Date(records[records.length - 1].playedTime).toLocaleDateString(); 88 + console.log(`✓ Sorted ${records.length} records`); 89 + console.log(` First: ${firstPlay}`); 90 + console.log(` Last: ${lastPlay}\n`); 91 + 92 + return records; 93 + }
+137
src/lib/publisher.js
··· 1 + import { formatDuration } from '../utils/helpers.js'; 2 + import { isImportCancelled } from '../utils/killswitch.js'; 3 + 4 + /** 5 + * Publish records in batches with rate limiting and killswitch support 6 + */ 7 + export async function publishRecords(agent, records, batchSize, batchDelay, config, dryRun = false) { 8 + const { RECORD_TYPE } = config; 9 + const totalRecords = records.length; 10 + let successCount = 0; 11 + let errorCount = 0; 12 + const startTime = Date.now(); 13 + 14 + if (dryRun) { 15 + return handleDryRun(records, batchSize, batchDelay); 16 + } 17 + 18 + const totalBatches = Math.ceil(totalRecords / batchSize); 19 + const estimatedTime = formatDuration(totalBatches * batchDelay); 20 + 21 + console.log(`Publishing ${totalRecords} records in batches of ${batchSize}...`); 22 + console.log(`Total batches: ${totalBatches}`); 23 + console.log(`Estimated time: ${estimatedTime}`); 24 + console.log(`\n🚨 Press Ctrl+C to stop gracefully after current batch\n`); 25 + 26 + for (let i = 0; i < totalRecords; i += batchSize) { 27 + // Check killswitch before processing batch 28 + if (isImportCancelled()) { 29 + return handleCancellation(successCount, errorCount, totalRecords); 30 + } 31 + 32 + const batch = records.slice(i, i + batchSize); 33 + const batchNum = Math.floor(i / batchSize) + 1; 34 + const progress = ((i / totalRecords) * 100).toFixed(1); 35 + 36 + console.log(`[${progress}%] Batch ${batchNum}/${totalBatches} (records ${i + 1}-${Math.min(i + batchSize, totalRecords)})`); 37 + 38 + // Process batch records 39 + const batchStartTime = Date.now(); 40 + for (const record of batch) { 41 + // Check killswitch during batch processing 42 + if (isImportCancelled()) { 43 + console.log(` ⚠️ Stopping mid-batch...`); 44 + break; 45 + } 46 + 47 + try { 48 + await agent.com.atproto.repo.createRecord({ 49 + repo: agent.session.did, 50 + collection: RECORD_TYPE, 51 + record, 52 + }); 53 + successCount++; 54 + } catch (error) { 55 + errorCount++; 56 + console.error(` ✗ Failed: ${record.trackName} - ${error.message}`); 57 + } 58 + } 59 + 60 + const batchDuration = Date.now() - batchStartTime; 61 + const elapsed = formatDuration(Date.now() - startTime); 62 + const remaining = formatDuration(((totalRecords - i - batchSize) / batchSize) * batchDelay); 63 + 64 + console.log(` ✓ Complete in ${batchDuration}ms (${successCount} successful, ${errorCount} failed)`); 65 + 66 + // Only show time estimates if not cancelled 67 + if (!isImportCancelled()) { 68 + console.log(` ⏱ Elapsed: ${elapsed} | Remaining: ~${remaining}\n`); 69 + } 70 + 71 + // Check again before waiting 72 + if (isImportCancelled()) { 73 + return handleCancellation(successCount, errorCount, totalRecords); 74 + } 75 + 76 + // Wait before next batch (except for last batch) 77 + if (i + batchSize < totalRecords) { 78 + await new Promise(resolve => setTimeout(resolve, batchDelay)); 79 + } 80 + } 81 + 82 + return { successCount, errorCount, cancelled: false }; 83 + } 84 + 85 + /** 86 + * Handle dry run mode 87 + */ 88 + function handleDryRun(records, batchSize, batchDelay) { 89 + const totalRecords = records.length; 90 + 91 + console.log(`\n=== DRY RUN MODE ===`); 92 + console.log(`Would publish ${totalRecords} records in batches of ${batchSize}`); 93 + console.log(`Estimated time: ${formatDuration(Math.ceil(totalRecords / batchSize) * batchDelay)}\n`); 94 + 95 + // Show first 5 records as preview 96 + const previewCount = Math.min(5, totalRecords); 97 + console.log(`Preview of first ${previewCount} records (in processing order):\n`); 98 + 99 + for (let i = 0; i < previewCount; i++) { 100 + const record = records[i]; 101 + console.log(`${i + 1}. ${record.artists[0]?.artistName} - ${record.trackName}`); 102 + console.log(` Album: ${record.releaseName || 'N/A'}`); 103 + console.log(` Played: ${record.playedTime}`); 104 + console.log(` URL: ${record.originUrl}`); 105 + 106 + // Show MusicBrainz IDs if available 107 + const mbids = []; 108 + if (record.artists[0]?.artistMbId) mbids.push(`Artist: ${record.artists[0].artistMbId}`); 109 + if (record.recordingMbId) mbids.push(`Recording: ${record.recordingMbId}`); 110 + if (record.releaseMbId) mbids.push(`Release: ${record.releaseMbId}`); 111 + 112 + if (mbids.length > 0) { 113 + console.log(` MBIDs: ${mbids.join(', ')}`); 114 + } 115 + console.log(''); 116 + } 117 + 118 + if (totalRecords > previewCount) { 119 + console.log(`... and ${totalRecords - previewCount} more records\n`); 120 + } 121 + 122 + console.log('=== DRY RUN COMPLETE ==='); 123 + console.log('No records were actually published.'); 124 + console.log('Remove --dry-run flag to publish for real.\n'); 125 + 126 + return { successCount: totalRecords, errorCount: 0, cancelled: false }; 127 + } 128 + 129 + /** 130 + * Handle cancellation 131 + */ 132 + function handleCancellation(successCount, errorCount, totalRecords) { 133 + console.log(`\n🛑 Import cancelled by user`); 134 + console.log(` Processed: ${successCount}/${totalRecords} records`); 135 + console.log(` Remaining: ${totalRecords - successCount} records\n`); 136 + return { successCount, errorCount, cancelled: true }; 137 + }
+63
src/utils/helpers.js
··· 1 + /** 2 + * Utility functions for the Last.fm importer 3 + */ 4 + 5 + /** 6 + * Format duration in human-readable format 7 + */ 8 + export function formatDuration(milliseconds) { 9 + const seconds = Math.floor(milliseconds / 1000); 10 + const minutes = Math.floor(seconds / 60); 11 + const hours = Math.floor(minutes / 60); 12 + 13 + if (hours > 0) { 14 + const mins = minutes % 60; 15 + return `${hours}h ${mins}m`; 16 + } else if (minutes > 0) { 17 + const secs = seconds % 60; 18 + return `${minutes}m ${secs}s`; 19 + } else { 20 + return `${seconds}s`; 21 + } 22 + } 23 + 24 + /** 25 + * Calculate optimal batch size based on total records and rate limits 26 + * Uses a logarithmic scaling approach to balance throughput with API safety 27 + */ 28 + export function calculateOptimalBatchSize(totalRecords, batchDelay, config) { 29 + const { 30 + MIN_RECORDS_FOR_SCALING, 31 + BASE_BATCH_SIZE, 32 + MAX_BATCH_SIZE, 33 + SCALING_FACTOR, 34 + DEFAULT_BATCH_DELAY 35 + } = config; 36 + 37 + const delay = batchDelay || DEFAULT_BATCH_DELAY; 38 + 39 + // For very small datasets, use minimal batches 40 + if (totalRecords <= 50) { 41 + return 3; 42 + } 43 + 44 + // For small to medium datasets, use conservative batching 45 + if (totalRecords <= MIN_RECORDS_FOR_SCALING) { 46 + return BASE_BATCH_SIZE; 47 + } 48 + 49 + // Logarithmic scaling 50 + const logScale = Math.log2(totalRecords / MIN_RECORDS_FOR_SCALING); 51 + const calculatedSize = Math.floor(BASE_BATCH_SIZE + (logScale * SCALING_FACTOR)); 52 + 53 + // Apply maximum cap 54 + let optimalSize = Math.min(calculatedSize, MAX_BATCH_SIZE); 55 + 56 + // Adjust based on batch delay 57 + if (delay < 1500 && optimalSize > 15) { 58 + optimalSize = Math.floor(optimalSize * 0.75); 59 + } 60 + 61 + // Ensure batch size is at least 3 62 + return Math.max(3, optimalSize); 63 + }
+71
src/utils/input.js
··· 1 + import * as readline from 'readline'; 2 + 3 + /** 4 + * Read user input from command line with proper password masking 5 + */ 6 + export function prompt(question, hideInput = false) { 7 + return new Promise((resolve) => { 8 + if (hideInput) { 9 + // For password input, use raw mode 10 + const stdin = process.stdin; 11 + const wasRaw = stdin.isRaw; 12 + 13 + // Set raw mode to capture individual keystrokes 14 + if (stdin.isTTY) { 15 + stdin.setRawMode(true); 16 + } 17 + 18 + stdin.resume(); 19 + stdin.setEncoding('utf8'); 20 + 21 + process.stdout.write(question); 22 + 23 + let password = ''; 24 + const onData = (char) => { 25 + char = char.toString(); 26 + 27 + switch (char) { 28 + case '\n': 29 + case '\r': 30 + case '\u0004': // Ctrl-D 31 + stdin.removeListener('data', onData); 32 + if (stdin.isTTY) { 33 + stdin.setRawMode(wasRaw); 34 + } 35 + stdin.pause(); 36 + process.stdout.write('\n'); 37 + resolve(password); 38 + break; 39 + case '\u0003': // Ctrl-C 40 + process.exit(1); 41 + break; 42 + case '\u007f': // Backspace 43 + case '\b': // Backspace 44 + if (password.length > 0) { 45 + password = password.slice(0, -1); 46 + process.stdout.clearLine(0); 47 + process.stdout.cursorTo(0); 48 + process.stdout.write(question + '*'.repeat(password.length)); 49 + } 50 + break; 51 + default: 52 + password += char; 53 + process.stdout.write('*'); 54 + break; 55 + } 56 + }; 57 + 58 + stdin.on('data', onData); 59 + } else { 60 + const rl = readline.createInterface({ 61 + input: process.stdin, 62 + output: process.stdout, 63 + }); 64 + 65 + rl.question(question, (answer) => { 66 + rl.close(); 67 + resolve(answer); 68 + }); 69 + } 70 + }); 71 + }
+35
src/utils/killswitch.js
··· 1 + // Global state for killswitch 2 + let importCancelled = false; 3 + let gracefulShutdown = false; 4 + 5 + /** 6 + * Setup killswitch handler for graceful shutdown 7 + */ 8 + export function setupKillswitch() { 9 + process.on('SIGINT', () => { 10 + if (gracefulShutdown) { 11 + console.log('\n\n⚠️ Force quit detected. Exiting immediately...'); 12 + process.exit(1); 13 + } 14 + 15 + gracefulShutdown = true; 16 + importCancelled = true; 17 + console.log('\n\n🛑 Killswitch activated! Stopping after current batch...'); 18 + console.log(' Press Ctrl+C again to force quit immediately.\n'); 19 + }); 20 + } 21 + 22 + /** 23 + * Check if import has been cancelled 24 + */ 25 + export function isImportCancelled() { 26 + return importCancelled; 27 + } 28 + 29 + /** 30 + * Reset killswitch state (useful for testing) 31 + */ 32 + export function resetKillswitch() { 33 + importCancelled = false; 34 + gracefulShutdown = false; 35 + }