···11+ GNU AFFERO GENERAL PUBLIC LICENSE
22+ Version 3, 19 November 2007
33+44+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
55+ Everyone is permitted to copy and distribute verbatim copies
66+ of this license document, but changing it is not allowed.
77+88+ Preamble
99+1010+ The GNU Affero General Public License is a free, copyleft license for
1111+software and other kinds of works, specifically designed to ensure
1212+cooperation with the community in the case of network server software.
1313+1414+ The licenses for most software and other practical works are designed
1515+to take away your freedom to share and change the works. By contrast,
1616+our General Public Licenses are intended to guarantee your freedom to
1717+share and change all versions of a program--to make sure it remains free
1818+software for all its users.
1919+2020+ When we speak of free software, we are referring to freedom, not
2121+price. Our General Public Licenses are designed to make sure that you
2222+have the freedom to distribute copies of free software (and charge for
2323+them if you wish), that you receive source code or can get it if you
2424+want it, that you can change the software or use pieces of it in new
2525+free programs, and that you know you can do these things.
2626+2727+ Developers that use our General Public Licenses protect your rights
2828+with two steps: (1) assert copyright on the software, and (2) offer
2929+you this License which gives you legal permission to copy, distribute
3030+and/or modify the software.
3131+3232+ A secondary benefit of defending all users' freedom is that
3333+improvements made in alternate versions of the program, if they
3434+receive widespread use, become available for other developers to
3535+incorporate. Many developers of free software are heartened and
3636+encouraged by the resulting cooperation. However, in the case of
3737+software used on network servers, this result may fail to come about.
3838+The GNU General Public License permits making a modified version and
3939+letting the public access it on a server without ever releasing its
4040+source code to the public.
4141+4242+ The GNU Affero General Public License is designed specifically to
4343+ensure that, in such cases, the modified source code becomes available
4444+to the community. It requires the operator of a network server to
4545+provide the source code of the modified version running there to the
4646+users of that server. Therefore, public use of a modified version, on
4747+a publicly accessible server, gives the public access to the source
4848+code of the modified version.
4949+5050+ An older license, called the Affero General Public License and
5151+published by Affero, was designed to accomplish similar goals. This is
5252+a different license, not a version of the Affero GPL, but Affero has
5353+released a new version of the Affero GPL which permits relicensing under
5454+this license.
5555+5656+ The precise terms and conditions for copying, distribution and
5757+modification follow.
5858+5959+ TERMS AND CONDITIONS
6060+6161+ 0. Definitions.
6262+6363+ "This License" refers to version 3 of the GNU Affero General Public License.
6464+6565+ "Copyright" also means copyright-like laws that apply to other kinds of
6666+works, such as semiconductor masks.
6767+6868+ "The Program" refers to any copyrightable work licensed under this
6969+License. Each licensee is addressed as "you". "Licensees" and
7070+"recipients" may be individuals or organizations.
7171+7272+ To "modify" a work means to copy from or adapt all or part of the work
7373+in a fashion requiring copyright permission, other than the making of an
7474+exact copy. The resulting work is called a "modified version" of the
7575+earlier work or a work "based on" the earlier work.
7676+7777+ A "covered work" means either the unmodified Program or a work based
7878+on the Program.
7979+8080+ To "propagate" a work means to do anything with it that, without
8181+permission, would make you directly or secondarily liable for
8282+infringement under applicable copyright law, except executing it on a
8383+computer or modifying a private copy. Propagation includes copying,
8484+distribution (with or without modification), making available to the
8585+public, and in some countries other activities as well.
8686+8787+ To "convey" a work means any kind of propagation that enables other
8888+parties to make or receive copies. Mere interaction with a user through
8989+a computer network, with no transfer of a copy, is not conveying.
9090+9191+ An interactive user interface displays "Appropriate Legal Notices"
9292+to the extent that it includes a convenient and prominently visible
9393+feature that (1) displays an appropriate copyright notice, and (2)
9494+tells the user that there is no warranty for the work (except to the
9595+extent that warranties are provided), that licensees may convey the
9696+work under this License, and how to view a copy of this License. If
9797+the interface presents a list of user commands or options, such as a
9898+menu, a prominent item in the list meets this criterion.
9999+100100+ 1. Source Code.
101101+102102+ The "source code" for a work means the preferred form of the work
103103+for making modifications to it. "Object code" means any non-source
104104+form of a work.
105105+106106+ A "Standard Interface" means an interface that either is an official
107107+standard defined by a recognized standards body, or, in the case of
108108+interfaces specified for a particular programming language, one that
109109+is widely used among developers working in that language.
110110+111111+ The "System Libraries" of an executable work include anything, other
112112+than the work as a whole, that (a) is included in the normal form of
113113+packaging a Major Component, but which is not part of that Major
114114+Component, and (b) serves only to enable use of the work with that
115115+Major Component, or to implement a Standard Interface for which an
116116+implementation is available to the public in source code form. A
117117+"Major Component", in this context, means a major essential component
118118+(kernel, window system, and so on) of the specific operating system
119119+(if any) on which the executable work runs, or a compiler used to
120120+produce the work, or an object code interpreter used to run it.
121121+122122+ The "Corresponding Source" for a work in object code form means all
123123+the source code needed to generate, install, and (for an executable
124124+work) run the object code and to modify the work, including scripts to
125125+control those activities. However, it does not include the work's
126126+System Libraries, or general-purpose tools or generally available free
127127+programs which are used unmodified in performing those activities but
128128+which are not part of the work. For example, Corresponding Source
129129+includes interface definition files associated with source files for
130130+the work, and the source code for shared libraries and dynamically
131131+linked subprograms that the work is specifically designed to require,
132132+such as by intimate data communication or control flow between those
133133+subprograms and other parts of the work.
134134+135135+ The Corresponding Source need not include anything that users
136136+can regenerate automatically from other parts of the Corresponding
137137+Source.
138138+139139+ The Corresponding Source for a work in source code form is that
140140+same work.
141141+142142+ 2. Basic Permissions.
143143+144144+ All rights granted under this License are granted for the term of
145145+copyright on the Program, and are irrevocable provided the stated
146146+conditions are met. This License explicitly affirms your unlimited
147147+permission to run the unmodified Program. The output from running a
148148+covered work is covered by this License only if the output, given its
149149+content, constitutes a covered work. This License acknowledges your
150150+rights of fair use or other equivalent, as provided by copyright law.
151151+152152+ You may make, run and propagate covered works that you do not
153153+convey, without conditions so long as your license otherwise remains
154154+in force. You may convey covered works to others for the sole purpose
155155+of having them make modifications exclusively for you, or provide you
156156+with facilities for running those works, provided that you comply with
157157+the terms of this License in conveying all material for which you do
158158+not control copyright. Those thus making or running the covered works
159159+for you must do so exclusively on your behalf, under your direction
160160+and control, on terms that prohibit them from making any copies of
161161+your copyrighted material outside their relationship with you.
162162+163163+ Conveying under any other circumstances is permitted solely under
164164+the conditions stated below. Sublicensing is not allowed; section 10
165165+makes it unnecessary.
166166+167167+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
168168+169169+ No covered work shall be deemed part of an effective technological
170170+measure under any applicable law fulfilling obligations under article
171171+11 of the WIPO copyright treaty adopted on 20 December 1996, or
172172+similar laws prohibiting or restricting circumvention of such
173173+measures.
174174+175175+ When you convey a covered work, you waive any legal power to forbid
176176+circumvention of technological measures to the extent such circumvention
177177+is effected by exercising rights under this License with respect to
178178+the covered work, and you disclaim any intention to limit operation or
179179+modification of the work as a means of enforcing, against the work's
180180+users, your or third parties' legal rights to forbid circumvention of
181181+technological measures.
182182+183183+ 4. Conveying Verbatim Copies.
184184+185185+ You may convey verbatim copies of the Program's source code as you
186186+receive it, in any medium, provided that you conspicuously and
187187+appropriately publish on each copy an appropriate copyright notice;
188188+keep intact all notices stating that this License and any
189189+non-permissive terms added in accord with section 7 apply to the code;
190190+keep intact all notices of the absence of any warranty; and give all
191191+recipients a copy of this License along with the Program.
192192+193193+ You may charge any price or no price for each copy that you convey,
194194+and you may offer support or warranty protection for a fee.
195195+196196+ 5. Conveying Modified Source Versions.
197197+198198+ You may convey a work based on the Program, or the modifications to
199199+produce it from the Program, in the form of source code under the
200200+terms of section 4, provided that you also meet all of these conditions:
201201+202202+ a) The work must carry prominent notices stating that you modified
203203+ it, and giving a relevant date.
204204+205205+ b) The work must carry prominent notices stating that it is
206206+ released under this License and any conditions added under section
207207+ 7. This requirement modifies the requirement in section 4 to
208208+ "keep intact all notices".
209209+210210+ c) You must license the entire work, as a whole, under this
211211+ License to anyone who comes into possession of a copy. This
212212+ License will therefore apply, along with any applicable section 7
213213+ additional terms, to the whole of the work, and all its parts,
214214+ regardless of how they are packaged. This License gives no
215215+ permission to license the work in any other way, but it does not
216216+ invalidate such permission if you have separately received it.
217217+218218+ d) If the work has interactive user interfaces, each must display
219219+ Appropriate Legal Notices; however, if the Program has interactive
220220+ interfaces that do not display Appropriate Legal Notices, your
221221+ work need not make them do so.
222222+223223+ A compilation of a covered work with other separate and independent
224224+works, which are not by their nature extensions of the covered work,
225225+and which are not combined with it such as to form a larger program,
226226+in or on a volume of a storage or distribution medium, is called an
227227+"aggregate" if the compilation and its resulting copyright are not
228228+used to limit the access or legal rights of the compilation's users
229229+beyond what the individual works permit. Inclusion of a covered work
230230+in an aggregate does not cause this License to apply to the other
231231+parts of the aggregate.
232232+233233+ 6. Conveying Non-Source Forms.
234234+235235+ You may convey a covered work in object code form under the terms
236236+of sections 4 and 5, provided that you also convey the
237237+machine-readable Corresponding Source under the terms of this License,
238238+in one of these ways:
239239+240240+ a) Convey the object code in, or embodied in, a physical product
241241+ (including a physical distribution medium), accompanied by the
242242+ Corresponding Source fixed on a durable physical medium
243243+ customarily used for software interchange.
244244+245245+ b) Convey the object code in, or embodied in, a physical product
246246+ (including a physical distribution medium), accompanied by a
247247+ written offer, valid for at least three years and valid for as
248248+ long as you offer spare parts or customer support for that product
249249+ model, to give anyone who possesses the object code either (1) a
250250+ copy of the Corresponding Source for all the software in the
251251+ product that is covered by this License, on a durable physical
252252+ medium customarily used for software interchange, for a price no
253253+ more than your reasonable cost of physically performing this
254254+ conveying of source, or (2) access to copy the
255255+ Corresponding Source from a network server at no charge.
256256+257257+ c) Convey individual copies of the object code with a copy of the
258258+ written offer to provide the Corresponding Source. This
259259+ alternative is allowed only occasionally and noncommercially, and
260260+ only if you received the object code with such an offer, in accord
261261+ with subsection 6b.
262262+263263+ d) Convey the object code by offering access from a designated
264264+ place (gratis or for a charge), and offer equivalent access to the
265265+ Corresponding Source in the same way through the same place at no
266266+ further charge. You need not require recipients to copy the
267267+ Corresponding Source along with the object code. If the place to
268268+ copy the object code is a network server, the Corresponding Source
269269+ may be on a different server (operated by you or a third party)
270270+ that supports equivalent copying facilities, provided you maintain
271271+ clear directions next to the object code saying where to find the
272272+ Corresponding Source. Regardless of what server hosts the
273273+ Corresponding Source, you remain obligated to ensure that it is
274274+ available for as long as needed to satisfy these requirements.
275275+276276+ e) Convey the object code using peer-to-peer transmission, provided
277277+ you inform other peers where the object code and Corresponding
278278+ Source of the work are being offered to the general public at no
279279+ charge under subsection 6d.
280280+281281+ A separable portion of the object code, whose source code is excluded
282282+from the Corresponding Source as a System Library, need not be
283283+included in conveying the object code work.
284284+285285+ A "User Product" is either (1) a "consumer product", which means any
286286+tangible personal property which is normally used for personal, family,
287287+or household purposes, or (2) anything designed or sold for incorporation
288288+into a dwelling. In determining whether a product is a consumer product,
289289+doubtful cases shall be resolved in favor of coverage. For a particular
290290+product received by a particular user, "normally used" refers to a
291291+typical or common use of that class of product, regardless of the status
292292+of the particular user or of the way in which the particular user
293293+actually uses, or expects or is expected to use, the product. A product
294294+is a consumer product regardless of whether the product has substantial
295295+commercial, industrial or non-consumer uses, unless such uses represent
296296+the only significant mode of use of the product.
297297+298298+ "Installation Information" for a User Product means any methods,
299299+procedures, authorization keys, or other information required to install
300300+and execute modified versions of a covered work in that User Product from
301301+a modified version of its Corresponding Source. The information must
302302+suffice to ensure that the continued functioning of the modified object
303303+code is in no case prevented or interfered with solely because
304304+modification has been made.
305305+306306+ If you convey an object code work under this section in, or with, or
307307+specifically for use in, a User Product, and the conveying occurs as
308308+part of a transaction in which the right of possession and use of the
309309+User Product is transferred to the recipient in perpetuity or for a
310310+fixed term (regardless of how the transaction is characterized), the
311311+Corresponding Source conveyed under this section must be accompanied
312312+by the Installation Information. But this requirement does not apply
313313+if neither you nor any third party retains the ability to install
314314+modified object code on the User Product (for example, the work has
315315+been installed in ROM).
316316+317317+ The requirement to provide Installation Information does not include a
318318+requirement to continue to provide support service, warranty, or updates
319319+for a work that has been modified or installed by the recipient, or for
320320+the User Product in which it has been modified or installed. Access to a
321321+network may be denied when the modification itself materially and
322322+adversely affects the operation of the network or violates the rules and
323323+protocols for communication across the network.
324324+325325+ Corresponding Source conveyed, and Installation Information provided,
326326+in accord with this section must be in a format that is publicly
327327+documented (and with an implementation available to the public in
328328+source code form), and must require no special password or key for
329329+unpacking, reading or copying.
330330+331331+ 7. Additional Terms.
332332+333333+ "Additional permissions" are terms that supplement the terms of this
334334+License by making exceptions from one or more of its conditions.
335335+Additional permissions that are applicable to the entire Program shall
336336+be treated as though they were included in this License, to the extent
337337+that they are valid under applicable law. If additional permissions
338338+apply only to part of the Program, that part may be used separately
339339+under those permissions, but the entire Program remains governed by
340340+this License without regard to the additional permissions.
341341+342342+ When you convey a copy of a covered work, you may at your option
343343+remove any additional permissions from that copy, or from any part of
344344+it. (Additional permissions may be written to require their own
345345+removal in certain cases when you modify the work.) You may place
346346+additional permissions on material, added by you to a covered work,
347347+for which you have or can give appropriate copyright permission.
348348+349349+ Notwithstanding any other provision of this License, for material you
350350+add to a covered work, you may (if authorized by the copyright holders of
351351+that material) supplement the terms of this License with terms:
352352+353353+ a) Disclaiming warranty or limiting liability differently from the
354354+ terms of sections 15 and 16 of this License; or
355355+356356+ b) Requiring preservation of specified reasonable legal notices or
357357+ author attributions in that material or in the Appropriate Legal
358358+ Notices displayed by works containing it; or
359359+360360+ c) Prohibiting misrepresentation of the origin of that material, or
361361+ requiring that modified versions of such material be marked in
362362+ reasonable ways as different from the original version; or
363363+364364+ d) Limiting the use for publicity purposes of names of licensors or
365365+ authors of the material; or
366366+367367+ e) Declining to grant rights under trademark law for use of some
368368+ trade names, trademarks, or service marks; or
369369+370370+ f) Requiring indemnification of licensors and authors of that
371371+ material by anyone who conveys the material (or modified versions of
372372+ it) with contractual assumptions of liability to the recipient, for
373373+ any liability that these contractual assumptions directly impose on
374374+ those licensors and authors.
375375+376376+ All other non-permissive additional terms are considered "further
377377+restrictions" within the meaning of section 10. If the Program as you
378378+received it, or any part of it, contains a notice stating that it is
379379+governed by this License along with a term that is a further
380380+restriction, you may remove that term. If a license document contains
381381+a further restriction but permits relicensing or conveying under this
382382+License, you may add to a covered work material governed by the terms
383383+of that license document, provided that the further restriction does
384384+not survive such relicensing or conveying.
385385+386386+ If you add terms to a covered work in accord with this section, you
387387+must place, in the relevant source files, a statement of the
388388+additional terms that apply to those files, or a notice indicating
389389+where to find the applicable terms.
390390+391391+ Additional terms, permissive or non-permissive, may be stated in the
392392+form of a separately written license, or stated as exceptions;
393393+the above requirements apply either way.
394394+395395+ 8. Termination.
396396+397397+ You may not propagate or modify a covered work except as expressly
398398+provided under this License. Any attempt otherwise to propagate or
399399+modify it is void, and will automatically terminate your rights under
400400+this License (including any patent licenses granted under the third
401401+paragraph of section 11).
402402+403403+ However, if you cease all violation of this License, then your
404404+license from a particular copyright holder is reinstated (a)
405405+provisionally, unless and until the copyright holder explicitly and
406406+finally terminates your license, and (b) permanently, if the copyright
407407+holder fails to notify you of the violation by some reasonable means
408408+prior to 60 days after the cessation.
409409+410410+ Moreover, your license from a particular copyright holder is
411411+reinstated permanently if the copyright holder notifies you of the
412412+violation by some reasonable means, this is the first time you have
413413+received notice of violation of this License (for any work) from that
414414+copyright holder, and you cure the violation prior to 30 days after
415415+your receipt of the notice.
416416+417417+ Termination of your rights under this section does not terminate the
418418+licenses of parties who have received copies or rights from you under
419419+this License. If your rights have been terminated and not permanently
420420+reinstated, you do not qualify to receive new licenses for the same
421421+material under section 10.
422422+423423+ 9. Acceptance Not Required for Having Copies.
424424+425425+ You are not required to accept this License in order to receive or
426426+run a copy of the Program. Ancillary propagation of a covered work
427427+occurring solely as a consequence of using peer-to-peer transmission
428428+to receive a copy likewise does not require acceptance. However,
429429+nothing other than this License grants you permission to propagate or
430430+modify any covered work. These actions infringe copyright if you do
431431+not accept this License. Therefore, by modifying or propagating a
432432+covered work, you indicate your acceptance of this License to do so.
433433+434434+ 10. Automatic Licensing of Downstream Recipients.
435435+436436+ Each time you convey a covered work, the recipient automatically
437437+receives a license from the original licensors, to run, modify and
438438+propagate that work, subject to this License. You are not responsible
439439+for enforcing compliance by third parties with this License.
440440+441441+ An "entity transaction" is a transaction transferring control of an
442442+organization, or substantially all assets of one, or subdividing an
443443+organization, or merging organizations. If propagation of a covered
444444+work results from an entity transaction, each party to that
445445+transaction who receives a copy of the work also receives whatever
446446+licenses to the work the party's predecessor in interest had or could
447447+give under the previous paragraph, plus a right to possession of the
448448+Corresponding Source of the work from the predecessor in interest, if
449449+the predecessor has it or can get it with reasonable efforts.
450450+451451+ You may not impose any further restrictions on the exercise of the
452452+rights granted or affirmed under this License. For example, you may
453453+not impose a license fee, royalty, or other charge for exercise of
454454+rights granted under this License, and you may not initiate litigation
455455+(including a cross-claim or counterclaim in a lawsuit) alleging that
456456+any patent claim is infringed by making, using, selling, offering for
457457+sale, or importing the Program or any portion of it.
458458+459459+ 11. Patents.
460460+461461+ A "contributor" is a copyright holder who authorizes use under this
462462+License of the Program or a work on which the Program is based. The
463463+work thus licensed is called the contributor's "contributor version".
464464+465465+ A contributor's "essential patent claims" are all patent claims
466466+owned or controlled by the contributor, whether already acquired or
467467+hereafter acquired, that would be infringed by some manner, permitted
468468+by this License, of making, using, or selling its contributor version,
469469+but do not include claims that would be infringed only as a
470470+consequence of further modification of the contributor version. For
471471+purposes of this definition, "control" includes the right to grant
472472+patent sublicenses in a manner consistent with the requirements of
473473+this License.
474474+475475+ Each contributor grants you a non-exclusive, worldwide, royalty-free
476476+patent license under the contributor's essential patent claims, to
477477+make, use, sell, offer for sale, import and otherwise run, modify and
478478+propagate the contents of its contributor version.
479479+480480+ In the following three paragraphs, a "patent license" is any express
481481+agreement or commitment, however denominated, not to enforce a patent
482482+(such as an express permission to practice a patent or covenant not to
483483+sue for patent infringement). To "grant" such a patent license to a
484484+party means to make such an agreement or commitment not to enforce a
485485+patent against the party.
486486+487487+ If you convey a covered work, knowingly relying on a patent license,
488488+and the Corresponding Source of the work is not available for anyone
489489+to copy, free of charge and under the terms of this License, through a
490490+publicly available network server or other readily accessible means,
491491+then you must either (1) cause the Corresponding Source to be so
492492+available, or (2) arrange to deprive yourself of the benefit of the
493493+patent license for this particular work, or (3) arrange, in a manner
494494+consistent with the requirements of this License, to extend the patent
495495+license to downstream recipients. "Knowingly relying" means you have
496496+actual knowledge that, but for the patent license, your conveying the
497497+covered work in a country, or your recipient's use of the covered work
498498+in a country, would infringe one or more identifiable patents in that
499499+country that you have reason to believe are valid.
500500+501501+ If, pursuant to or in connection with a single transaction or
502502+arrangement, you convey, or propagate by procuring conveyance of, a
503503+covered work, and grant a patent license to some of the parties
504504+receiving the covered work authorizing them to use, propagate, modify
505505+or convey a specific copy of the covered work, then the patent license
506506+you grant is automatically extended to all recipients of the covered
507507+work and works based on it.
508508+509509+ A patent license is "discriminatory" if it does not include within
510510+the scope of its coverage, prohibits the exercise of, or is
511511+conditioned on the non-exercise of one or more of the rights that are
512512+specifically granted under this License. You may not convey a covered
513513+work if you are a party to an arrangement with a third party that is
514514+in the business of distributing software, under which you make payment
515515+to the third party based on the extent of your activity of conveying
516516+the work, and under which the third party grants, to any of the
517517+parties who would receive the covered work from you, a discriminatory
518518+patent license (a) in connection with copies of the covered work
519519+conveyed by you (or copies made from those copies), or (b) primarily
520520+for and in connection with specific products or compilations that
521521+contain the covered work, unless you entered into that arrangement,
522522+or that patent license was granted, prior to 28 March 2007.
523523+524524+ Nothing in this License shall be construed as excluding or limiting
525525+any implied license or other defenses to infringement that may
526526+otherwise be available to you under applicable patent law.
527527+528528+ 12. No Surrender of Others' Freedom.
529529+530530+ If conditions are imposed on you (whether by court order, agreement or
531531+otherwise) that contradict the conditions of this License, they do not
532532+excuse you from the conditions of this License. If you cannot convey a
533533+covered work so as to satisfy simultaneously your obligations under this
534534+License and any other pertinent obligations, then as a consequence you may
535535+not convey it at all. For example, if you agree to terms that obligate you
536536+to collect a royalty for further conveying from those to whom you convey
537537+the Program, the only way you could satisfy both those terms and this
538538+License would be to refrain entirely from conveying the Program.
539539+540540+ 13. Remote Network Interaction; Use with the GNU General Public License.
541541+542542+ Notwithstanding any other provision of this License, if you modify the
543543+Program, your modified version must prominently offer all users
544544+interacting with it remotely through a computer network (if your version
545545+supports such interaction) an opportunity to receive the Corresponding
546546+Source of your version by providing access to the Corresponding Source
547547+from a network server at no charge, through some standard or customary
548548+means of facilitating copying of software. This Corresponding Source
549549+shall include the Corresponding Source for any work covered by version 3
550550+of the GNU General Public License that is incorporated pursuant to the
551551+following paragraph.
552552+553553+ Notwithstanding any other provision of this License, you have
554554+permission to link or combine any covered work with a work licensed
555555+under version 3 of the GNU General Public License into a single
556556+combined work, and to convey the resulting work. The terms of this
557557+License will continue to apply to the part which is the covered work,
558558+but the work with which it is combined will remain governed by version
559559+3 of the GNU General Public License.
560560+561561+ 14. Revised Versions of this License.
562562+563563+ The Free Software Foundation may publish revised and/or new versions of
564564+the GNU Affero General Public License from time to time. Such new versions
565565+will be similar in spirit to the present version, but may differ in detail to
566566+address new problems or concerns.
567567+568568+ Each version is given a distinguishing version number. If the
569569+Program specifies that a certain numbered version of the GNU Affero General
570570+Public License "or any later version" applies to it, you have the
571571+option of following the terms and conditions either of that numbered
572572+version or of any later version published by the Free Software
573573+Foundation. If the Program does not specify a version number of the
574574+GNU Affero General Public License, you may choose any version ever published
575575+by the Free Software Foundation.
576576+577577+ If the Program specifies that a proxy can decide which future
578578+versions of the GNU Affero General Public License can be used, that proxy's
579579+public statement of acceptance of a version permanently authorizes you
580580+to choose that version for the Program.
581581+582582+ Later license versions may give you additional or different
583583+permissions. However, no additional obligations are imposed on any
584584+author or copyright holder as a result of your choosing to follow a
585585+later version.
586586+587587+ 15. Disclaimer of Warranty.
588588+589589+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
590590+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
591591+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
592592+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
593593+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
594594+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
595595+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
596596+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
597597+598598+ 16. Limitation of Liability.
599599+600600+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
601601+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
602602+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
603603+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
604604+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
605605+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
606606+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
607607+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
608608+SUCH DAMAGES.
609609+610610+ 17. Interpretation of Sections 15 and 16.
611611+612612+ If the disclaimer of warranty and limitation of liability provided
613613+above cannot be given local legal effect according to their terms,
614614+reviewing courts shall apply local law that most closely approximates
615615+an absolute waiver of all civil liability in connection with the
616616+Program, unless a warranty or assumption of liability accompanies a
617617+copy of the Program in return for a fee.
618618+619619+ END OF TERMS AND CONDITIONS
620620+621621+ How to Apply These Terms to Your New Programs
622622+623623+ If you develop a new program, and you want it to be of the greatest
624624+possible use to the public, the best way to achieve this is to make it
625625+free software which everyone can redistribute and change under these terms.
626626+627627+ To do so, attach the following notices to the program. It is safest
628628+to attach them to the start of each source file to most effectively
629629+state the exclusion of warranty; and each file should have at least
630630+the "copyright" line and a pointer to where the full notice is found.
631631+632632+ <one line to give the program's name and a brief idea of what it does.>
633633+ Copyright (C) <year> <name of author>
634634+635635+ This program is free software: you can redistribute it and/or modify
636636+ it under the terms of the GNU Affero General Public License as published by
637637+ the Free Software Foundation, either version 3 of the License, or
638638+ (at your option) any later version.
639639+640640+ This program is distributed in the hope that it will be useful,
641641+ but WITHOUT ANY WARRANTY; without even the implied warranty of
642642+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643643+ GNU Affero General Public License for more details.
644644+645645+ You should have received a copy of the GNU Affero General Public License
646646+ along with this program. If not, see <https://www.gnu.org/licenses/>.
647647+648648+Also add information on how to contact you by electronic and paper mail.
649649+650650+ If your software can interact with users remotely through a computer
651651+network, you should also make sure that it provides a way for users to
652652+get its source. For example, if your program is a web application, its
653653+interface could display a "Source" link that leads users to an archive
654654+of the code. There are many ways you could offer source, and different
655655+solutions will be better for different programs; see section 13 for the
656656+specific requirements.
657657+658658+ You should also get your employer (if you work as a programmer) or school,
659659+if any, to sign a "copyright disclaimer" for the program, if necessary.
660660+For more information on this, and how to apply and follow the GNU AGPL, see
661661+<https://www.gnu.org/licenses/>.
+109
README.md
···11+# Last.fm to ATProto Importer
22+33+Import your Last.fm listening history to the AT Protocol network using the `fm.teal.alpha.feed.play` lexicon.
44+55+## Setup
66+77+```bash
88+npm install
99+```
1010+1111+## Usage
1212+1313+### Interactive Mode
1414+1515+```bash
1616+node importer.js
1717+```
1818+1919+### With Command Line Arguments
2020+2121+**Full automation:**
2222+2323+```bash
2424+node importer.js -f lastfm.csv -i alice.bsky.social -p xxxx-xxxx-xxxx-xxxx -y
2525+```
2626+2727+**Dry run (preview without publishing):**
2828+2929+```bash
3030+node importer.js -f lastfm.csv --dry-run
3131+```
3232+3333+**Custom batch settings:**
3434+3535+```bash
3636+node importer.js -f lastfm.csv -i alice.bsky.social -b 20 -d 3000
3737+```
3838+3939+## Options
4040+4141+- `-h, --help` - Show help message
4242+- `-f, --file <path>` - Path to Last.fm CSV export file
4343+- `-i, --identifier <id>` - ATProto handle or DID
4444+- `-p, --password <pass>` - ATProto app password
4545+- `-b, --batch-size <num>` - Records per batch (default: 10)
4646+- `-d, --batch-delay <ms>` - Delay between batches in ms (default: 2000)
4747+- `-y, --yes` - Skip confirmation prompt
4848+- `-n, --dry-run` - Preview records without publishing
4949+5050+## Getting Your Last.fm Data
5151+5252+1. Go to <https://lastfm.ghan.nl/export/>
5353+2. Request your data export in CSV
5454+3. Download the CSV file when ready
5555+4. Use the CSV file path with this script
5656+5757+## Features
5858+5959+- ✅ Resolves ATProto handles/DIDs using Slingshot
6060+- ✅ Connects to your personal PDS
6161+- ✅ Converts Last.fm scrobbles to `fm.teal.alpha.feed.play` records
6262+- ✅ Follows the official lexicon schema
6363+- ✅ Batch publishing with configurable rate limiting
6464+- ✅ Dry run mode for previewing
6565+- ✅ Progress tracking and error reporting
6666+- ✅ Preserves MusicBrainz IDs when available
6767+6868+## Record Format
6969+7070+Each scrobble is converted according to the `fm.teal.alpha.feed.play` lexicon:
7171+7272+```json
7373+{
7474+ "$type": "fm.teal.alpha.feed.play",
7575+ "trackName": "Paint My Masterpiece",
7676+ "artists": [
7777+ {
7878+ "artistName": "Cjbeards",
7979+ "artistMbId": "c8d4f4bf-1b82-4d4d-9d73-05909faaff89"
8080+ }
8181+ ],
8282+ "releaseName": "Masquerade",
8383+ "releaseMbId": "fdb2397b-78d5-4019-8fad-656d286e4d33",
8484+ "recordingMbId": "3a390ad3-fe56-45f2-a073-bebc45d6bde1",
8585+ "playedTime": "2025-11-13T23:49:36Z",
8686+ "originUrl": "https://www.last.fm/music/Cjbeards/_/Paint+My+Masterpiece",
8787+ "submissionClientAgent": "lastfm-importer/v1.0.0",
8888+ "musicServiceBaseDomain": "last.fm"
8989+}
9090+```
9191+9292+### Required Fields
9393+9494+- `trackName` - The name of the track
9595+- `artists` - Array of artist objects with `artistName` (required) and optional `artistMbId`
9696+9797+### Optional Fields
9898+9999+- `releaseName` - Album name
100100+- `releaseMbId` - MusicBrainz release ID
101101+- `recordingMbId` - MusicBrainz recording ID
102102+- `playedTime` - ISO 8601 datetime
103103+- `originUrl` - Link to the track
104104+- `submissionClientAgent` - Client identifier
105105+- `musicServiceBaseDomain` - Service domain (e.g., "last.fm")
106106+107107+## Lexicon Reference
108108+109109+This importer follows the lexicon defined in `/lexicons/fm.teal.alpha/feed/play.json`.
+126
STRUCTURE.md
···11+# Last.fm to ATProto Importer - Modular Structure
22+33+## Project Structure
44+55+```plaintext
66+lastfm-importer/
77+├── src/
88+│ ├── index.js # Main entry point
99+│ ├── config.js # Configuration constants
1010+│ ├── lib/ # Core library modules
1111+│ │ ├── auth.js # Authentication & login
1212+│ │ ├── cli.js # CLI argument parsing & help
1313+│ │ ├── csv.js # CSV parsing & conversion
1414+│ │ └── publisher.js # Record publishing logic
1515+│ └── utils/ # Utility functions
1616+│ ├── helpers.js # Helper functions (formatting, batch calculation)
1717+│ ├── input.js # User input & password masking
1818+│ └── killswitch.js # Graceful shutdown handling
1919+├── importer.js # Wrapper for backwards compatibility
2020+└── importer.old.js # Original monolithic version (backup)
2121+```
2222+2323+## Module Responsibilities
2424+2525+### `/src/config.js`
2626+2727+- Configuration constants
2828+- Batch size calculation parameters
2929+- API endpoints and client information
3030+3131+### `/src/lib/auth.js`
3232+3333+- ATProto authentication
3434+- Identity resolution via Slingshot
3535+- Login error handling
3636+3737+### `/src/lib/cli.js`
3838+3939+- Command-line argument parsing
4040+- Help text display
4141+- Input validation
4242+4343+### `/src/lib/csv.js`
4444+4545+- CSV file parsing
4646+- Record conversion to ATProto format
4747+- Chronological sorting
4848+4949+### `/src/lib/publisher.js`
5050+5151+- Batch publishing with rate limiting
5252+- Dry-run preview mode
5353+- Progress tracking and reporting
5454+- Killswitch integration
5555+5656+### `/src/utils/helpers.js`
5757+5858+- Duration formatting
5959+- Optimal batch size calculation (logarithmic algorithm)
6060+- Generic utility functions
6161+6262+### `/src/utils/input.js`
6363+6464+- Interactive prompts
6565+- Password masking with asterisks
6666+- Backspace support
6767+6868+### `/src/utils/killswitch.js`
6969+7070+- SIGINT handler
7171+- Graceful shutdown state management
7272+- Force-quit on second Ctrl+C
7373+7474+## Benefits of Modular Structure
7575+7676+1. **Maintainability**: Each module has a single responsibility
7777+2. **Testability**: Individual modules can be tested in isolation
7878+3. **Reusability**: Modules can be imported and reused
7979+4. **Readability**: Smaller files are easier to understand
8080+5. **Collaboration**: Multiple developers can work on different modules
8181+6. **Debugging**: Easier to locate and fix issues
8282+8383+## Usage
8484+8585+The wrapper file (`importer.js`) maintains backwards compatibility:
8686+8787+```bash
8888+# Still works exactly as before
8989+node importer.js -f lastfm.csv -i handle.bsky.social
9090+9191+# Or use the modular version directly
9292+node src/index.js -f lastfm.csv -i handle.bsky.social
9393+```
9494+9595+## Algorithm Details
9696+9797+### Batch Size Calculation
9898+9999+Located in `/src/utils/helpers.js`:
100100+101101+```javascript
102102+batchSize = BASE + (log2(records/MIN) * SCALING_FACTOR)
103103+```
104104+105105+- **Time Complexity**: O(n) - each record processed once
106106+- **Space Complexity**: O(b) where b is batch size
107107+- **Rate Limit Strategy**: Token bucket approach
108108+- **Adaptive**: Adjusts based on total records and delay settings
109109+110110+### Processing Order
111111+112112+- Default: Chronological (oldest first)
113113+- Option: `--reverse-chronological` for newest first
114114+- Sorted by `playedTime` field
115115+116116+## Future Improvements
117117+118118+With the modular structure, it's now easier to:
119119+120120+- Add unit tests for each module
121121+- Implement different authentication methods
122122+- Support multiple export formats (JSON, XML)
123123+- Add progress persistence (resume interrupted imports)
124124+- Implement retry logic with exponential backoff
125125+- Add statistics and analytics
126126+- Create a web UI that imports these modules
+5
importer.js
···11+#!/usr/bin/env node
22+33+// Wrapper file for backwards compatibility
44+// This imports and runs the modular version
55+import './src/index.js';
+681
importer.old.js
···11+#!/usr/bin/env node
22+33+import { AtpAgent } from '@atproto/api';
44+import * as fs from 'fs';
55+import * as readline from 'readline';
66+import { parse } from 'csv-parse/sync';
77+import { parseArgs } from 'node:util';
88+99+// Configuration
1010+const DEFAULT_BATCH_SIZE = 10; // Default number of records to submit per batch
1111+const DEFAULT_BATCH_DELAY = 2000; // Default delay between batches in milliseconds
1212+const MIN_BATCH_DELAY = 1000; // Minimum safe delay to respect rate limits
1313+const RECORD_TYPE = 'fm.teal.alpha.feed.play';
1414+const SLINGSHOT_RESOLVER = 'https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc';
1515+1616+// Global state for killswitch
1717+let importCancelled = false;
1818+let gracefulShutdown = false;
1919+2020+/**
2121+ * Setup killswitch handler for graceful shutdown
2222+ */
2323+function setupKillswitch() {
2424+ process.on('SIGINT', () => {
2525+ if (gracefulShutdown) {
2626+ console.log('\n\n⚠️ Force quit detected. Exiting immediately...');
2727+ process.exit(1);
2828+ }
2929+3030+ gracefulShutdown = true;
3131+ importCancelled = true;
3232+ console.log('\n\n🛑 Killswitch activated! Stopping after current batch...');
3333+ console.log(' Press Ctrl+C again to force quit immediately.\n');
3434+ });
3535+}
3636+3737+/**
3838+ * Calculate optimal batch size based on total records and rate limits
3939+ * Uses a logarithmic scaling approach to balance throughput with API safety
4040+ *
4141+ * Algorithm Analysis:
4242+ * - Time Complexity: O(n) where n is total records (each record processed once)
4343+ * - Space Complexity: O(1) for batch calculation, O(b) where b is batch size in memory
4444+ * - Rate Limit Strategy: Token bucket approach with conservative limits
4545+ *
4646+ * The batch size grows logarithmically with input size to prevent overwhelming
4747+ * the API while maximizing throughput. Formula: min(MAX, BASE * log2(n/MIN))
4848+ */
4949+function calculateOptimalBatchSize(totalRecords, batchDelay = DEFAULT_BATCH_DELAY) {
5050+ // Constants based on typical API rate limits and safety margins
5151+ const MIN_RECORDS = 100; // Minimum records before scaling kicks in
5252+ const BASE_BATCH_SIZE = 5; // Starting point for small datasets
5353+ const MAX_BATCH_SIZE = 50; // Hard cap to prevent API overwhelming
5454+ const SCALING_FACTOR = 1.5; // Growth rate modifier
5555+5656+ // For very small datasets, use minimal batches
5757+ if (totalRecords <= 50) {
5858+ return 3;
5959+ }
6060+6161+ // For small to medium datasets, use conservative batching
6262+ if (totalRecords <= MIN_RECORDS) {
6363+ return BASE_BATCH_SIZE;
6464+ }
6565+6666+ // Logarithmic scaling: batch size grows with log of total records
6767+ // This ensures O(n) time complexity while respecting rate limits
6868+ // Formula: BASE * (log2(n/MIN) * SCALING_FACTOR)
6969+ const logScale = Math.log2(totalRecords / MIN_RECORDS);
7070+ const calculatedSize = Math.floor(BASE_BATCH_SIZE + (logScale * SCALING_FACTOR));
7171+7272+ // Apply maximum cap and ensure reasonable batch size
7373+ let optimalSize = Math.min(calculatedSize, MAX_BATCH_SIZE);
7474+7575+ // Adjust based on batch delay to respect rate limits
7676+ // Shorter delays should use smaller batches
7777+ if (batchDelay < 1500 && optimalSize > 15) {
7878+ optimalSize = Math.floor(optimalSize * 0.75);
7979+ }
8080+8181+ // Ensure batch size is at least 3 for efficiency
8282+ return Math.max(3, optimalSize);
8383+}
8484+8585+/**
8686+ * Parse command line arguments
8787+ */
8888+function parseCommandLineArgs() {
8989+ const options = {
9090+ help: {
9191+ type: 'boolean',
9292+ short: 'h',
9393+ default: false,
9494+ },
9595+ file: {
9696+ type: 'string',
9797+ short: 'f',
9898+ },
9999+ identifier: {
100100+ type: 'string',
101101+ short: 'i',
102102+ },
103103+ password: {
104104+ type: 'string',
105105+ short: 'p',
106106+ },
107107+ 'batch-size': {
108108+ type: 'string',
109109+ short: 'b',
110110+ },
111111+ 'batch-delay': {
112112+ type: 'string',
113113+ short: 'd',
114114+ },
115115+ yes: {
116116+ type: 'boolean',
117117+ short: 'y',
118118+ default: false,
119119+ },
120120+ 'dry-run': {
121121+ type: 'boolean',
122122+ short: 'n',
123123+ default: false,
124124+ },
125125+ 'reverse-chronological': {
126126+ type: 'boolean',
127127+ short: 'r',
128128+ default: false,
129129+ },
130130+ };
131131+132132+ try {
133133+ const { values } = parseArgs({ options, allowPositionals: false });
134134+ return values;
135135+ } catch (error) {
136136+ console.error('Error parsing arguments:', error.message);
137137+ showHelp();
138138+ process.exit(1);
139139+ }
140140+}
141141+142142+/**
143143+ * Show help message
144144+ */
145145+function showHelp() {
146146+ console.log(`
147147+Last.fm to ATProto Importer
148148+149149+Usage: node importer.js [options]
150150+151151+Options:
152152+ -h, --help Show this help message
153153+ -f, --file <path> Path to Last.fm CSV export file
154154+ -i, --identifier <id> ATProto handle or DID
155155+ -p, --password <pass> ATProto app password
156156+ -b, --batch-size <num> Number of records per batch (auto-calculated if not set)
157157+ -d, --batch-delay <ms> Delay between batches in ms (default: 2000, min: 1000)
158158+ -y, --yes Skip confirmation prompt
159159+ -n, --dry-run Preview records without publishing
160160+ -r, --reverse-chronological Process newest first (default: oldest first)
161161+162162+Examples:
163163+ node importer.js -f lastfm.csv -i alice.bsky.social -p xxxx-xxxx-xxxx-xxxx
164164+ node importer.js --file export.csv --identifier alice.bsky.social --yes
165165+ node importer.js -f lastfm.csv --dry-run
166166+ node importer.js (interactive mode - prompts for all values)
167167+168168+Notes:
169169+ - Batch size uses logarithmic scaling algorithm (O(n) complexity) for optimal throughput
170170+ - Auto-calculated batch size considers both record count and delay settings
171171+ - Records are processed in chronological order (oldest first) by default
172172+ - Minimum batch delay of 1000ms enforced to respect rate limits
173173+ - Rate limiting follows token bucket strategy for safe API usage
174174+`);
175175+}
176176+177177+/**
178178+ * Read user input from command line with proper password masking
179179+ */
180180+function prompt(question, hideInput = false) {
181181+ return new Promise((resolve) => {
182182+ if (hideInput) {
183183+ // For password input, use a simpler approach
184184+ const stdin = process.stdin;
185185+ const wasRaw = stdin.isRaw;
186186+187187+ // Set raw mode to capture individual keystrokes
188188+ if (stdin.isTTY) {
189189+ stdin.setRawMode(true);
190190+ }
191191+192192+ stdin.resume();
193193+ stdin.setEncoding('utf8');
194194+195195+ process.stdout.write(question);
196196+197197+ let password = '';
198198+ const onData = (char) => {
199199+ char = char.toString();
200200+201201+ switch (char) {
202202+ case '\n':
203203+ case '\r':
204204+ case '\u0004': // Ctrl-D
205205+ stdin.removeListener('data', onData);
206206+ if (stdin.isTTY) {
207207+ stdin.setRawMode(wasRaw);
208208+ }
209209+ stdin.pause();
210210+ process.stdout.write('\n');
211211+ resolve(password);
212212+ break;
213213+ case '\u0003': // Ctrl-C
214214+ process.exit(1);
215215+ break;
216216+ case '\u007f': // Backspace
217217+ case '\b': // Backspace
218218+ if (password.length > 0) {
219219+ password = password.slice(0, -1);
220220+ process.stdout.clearLine(0);
221221+ process.stdout.cursorTo(0);
222222+ process.stdout.write(question + '*'.repeat(password.length));
223223+ }
224224+ break;
225225+ default:
226226+ password += char;
227227+ process.stdout.write('*');
228228+ break;
229229+ }
230230+ };
231231+232232+ stdin.on('data', onData);
233233+ } else {
234234+ const rl = readline.createInterface({
235235+ input: process.stdin,
236236+ output: process.stdout,
237237+ });
238238+239239+ rl.question(question, (answer) => {
240240+ rl.close();
241241+ resolve(answer);
242242+ });
243243+ }
244244+ });
245245+}
246246+247247+/**
248248+ * Resolves an AT Protocol identifier (handle or DID) to get PDS information
249249+ */
250250+async function resolveIdentifier(identifier) {
251251+ console.log(`Resolving identifier: ${identifier}`);
252252+253253+ const response = await fetch(
254254+ `${SLINGSHOT_RESOLVER}?identifier=${encodeURIComponent(identifier)}`
255255+ );
256256+257257+ if (!response.ok) {
258258+ throw new Error(`Failed to resolve identifier: ${response.status} ${response.statusText}`);
259259+ }
260260+261261+ const data = await response.json();
262262+263263+ if (!data.did || !data.pds) {
264264+ throw new Error('Invalid response from identity resolver');
265265+ }
266266+267267+ console.log(`✓ Resolved to PDS: ${data.pds}`);
268268+ return data;
269269+}
270270+271271+/**
272272+ * Login to ATProto using Slingshot resolver
273273+ */
274274+async function login(identifier, password) {
275275+ console.log('\n=== ATProto Login ===');
276276+277277+ // Prompt for missing credentials
278278+ if (!identifier) {
279279+ identifier = await prompt('Handle or DID: ');
280280+ } else {
281281+ console.log(`Handle or DID: ${identifier}`);
282282+ }
283283+284284+ if (!password) {
285285+ password = await prompt('App password: ', true);
286286+ } else {
287287+ console.log('App password: [hidden]');
288288+ }
289289+290290+ try {
291291+ // Resolve the identifier to get PDS
292292+ const resolved = await resolveIdentifier(identifier);
293293+294294+ // Create agent with resolved PDS
295295+ const pdsAgent = new AtpAgent({ service: resolved.pds });
296296+297297+ // Login using the resolved DID
298298+ await pdsAgent.login({
299299+ identifier: resolved.did,
300300+ password: password,
301301+ });
302302+303303+ console.log('✓ Logged in successfully!');
304304+ console.log(` DID: ${pdsAgent.session.did}`);
305305+ console.log(` Handle: ${pdsAgent.session.handle}\n`);
306306+307307+ return pdsAgent;
308308+ } catch (error) {
309309+ console.error('✗ Login failed:', error.message);
310310+311311+ // Provide more specific error messages
312312+ if (error.message.includes('Failed to resolve identifier')) {
313313+ throw new Error('Handle not found. Please check your AT Protocol handle.');
314314+ } else if (error.message.includes('AuthFactorTokenRequired')) {
315315+ throw new Error('Two-factor authentication required. Please use your app password.');
316316+ } else if (error.message.includes('InvalidCredentials')) {
317317+ throw new Error('Invalid credentials. Please check your handle and app password.');
318318+ }
319319+320320+ throw error;
321321+ }
322322+}
323323+324324+/**
325325+ * Parse Last.fm CSV export
326326+ */
327327+function parseLastFmCsv(filePath) {
328328+ console.log(`Reading CSV file: ${filePath}`);
329329+ const fileContent = fs.readFileSync(filePath, 'utf-8');
330330+331331+ const records = parse(fileContent, {
332332+ columns: true,
333333+ skip_empty_lines: true,
334334+ trim: true,
335335+ });
336336+337337+ console.log(`✓ Parsed ${records.length} scrobbles\n`);
338338+ return records;
339339+}
340340+341341+/**
342342+ * Convert Last.fm CSV record to ATProto play record
343343+ * Following the fm.teal.alpha.feed.play lexicon schema
344344+ */
345345+function convertToPlayRecord(csvRecord) {
346346+ // Parse the timestamp (Unix timestamp in seconds)
347347+ const timestamp = parseInt(csvRecord.uts);
348348+ const playedTime = new Date(timestamp * 1000).toISOString();
349349+350350+ // Build artists array according to lexicon
351351+ const artists = [];
352352+ if (csvRecord.artist) {
353353+ const artistData = {
354354+ artistName: csvRecord.artist,
355355+ };
356356+ // Only add artistMbId if it exists and is not empty
357357+ if (csvRecord.artist_mbid && csvRecord.artist_mbid.trim()) {
358358+ artistData.artistMbId = csvRecord.artist_mbid;
359359+ }
360360+ artists.push(artistData);
361361+ }
362362+363363+ // Build the play record with required fields
364364+ const playRecord = {
365365+ $type: RECORD_TYPE,
366366+ trackName: csvRecord.track,
367367+ artists, // Required field
368368+ playedTime,
369369+ submissionClientAgent: 'lastfm-importer/v0.0.1',
370370+ musicServiceBaseDomain: 'last.fm',
371371+ };
372372+373373+ // Add optional fields only if present and not empty
374374+ if (csvRecord.album && csvRecord.album.trim()) {
375375+ playRecord.releaseName = csvRecord.album;
376376+ }
377377+378378+ if (csvRecord.album_mbid && csvRecord.album_mbid.trim()) {
379379+ playRecord.releaseMbId = csvRecord.album_mbid;
380380+ }
381381+382382+ if (csvRecord.track_mbid && csvRecord.track_mbid.trim()) {
383383+ playRecord.recordingMbId = csvRecord.track_mbid;
384384+ }
385385+386386+ // Generate Last.fm URL
387387+ const artistEncoded = encodeURIComponent(csvRecord.artist);
388388+ const trackEncoded = encodeURIComponent(csvRecord.track);
389389+ playRecord.originUrl = `https://www.last.fm/music/${artistEncoded}/_/${trackEncoded}`;
390390+391391+ return playRecord;
392392+}
393393+394394+/**
395395+ * Format duration in human-readable format
396396+ */
397397+function formatDuration(milliseconds) {
398398+ const seconds = Math.floor(milliseconds / 1000);
399399+ const minutes = Math.floor(seconds / 60);
400400+ const hours = Math.floor(minutes / 60);
401401+402402+ if (hours > 0) {
403403+ const mins = minutes % 60;
404404+ return `${hours}h ${mins}m`;
405405+ } else if (minutes > 0) {
406406+ const secs = seconds % 60;
407407+ return `${minutes}m ${secs}s`;
408408+ } else {
409409+ return `${seconds}s`;
410410+ }
411411+}
412412+413413+/**
414414+ * Publish records in batches with rate limiting and killswitch support
415415+ */
416416+async function publishRecords(agent, records, batchSize, batchDelay, dryRun = false) {
417417+ const totalRecords = records.length;
418418+ let successCount = 0;
419419+ let errorCount = 0;
420420+ const startTime = Date.now();
421421+422422+ if (dryRun) {
423423+ console.log(`\n=== DRY RUN MODE ===`);
424424+ console.log(`Would publish ${totalRecords} records in batches of ${batchSize}`);
425425+ console.log(`Estimated time: ${formatDuration(Math.ceil(totalRecords / batchSize) * batchDelay)}\n`);
426426+427427+ // Show first 5 records as preview
428428+ const previewCount = Math.min(5, totalRecords);
429429+ console.log(`Preview of first ${previewCount} records (in processing order):\n`);
430430+431431+ for (let i = 0; i < previewCount; i++) {
432432+ const record = records[i];
433433+ console.log(`${i + 1}. ${record.artists[0]?.artistName} - ${record.trackName}`);
434434+ console.log(` Album: ${record.releaseName || 'N/A'}`);
435435+ console.log(` Played: ${record.playedTime}`);
436436+ console.log(` URL: ${record.originUrl}`);
437437+438438+ // Show MusicBrainz IDs if available
439439+ const mbids = [];
440440+ if (record.artists[0]?.artistMbId) mbids.push(`Artist: ${record.artists[0].artistMbId}`);
441441+ if (record.recordingMbId) mbids.push(`Recording: ${record.recordingMbId}`);
442442+ if (record.releaseMbId) mbids.push(`Release: ${record.releaseMbId}`);
443443+444444+ if (mbids.length > 0) {
445445+ console.log(` MBIDs: ${mbids.join(', ')}`);
446446+ }
447447+ console.log('');
448448+ }
449449+450450+ if (totalRecords > previewCount) {
451451+ console.log(`... and ${totalRecords - previewCount} more records\n`);
452452+ }
453453+454454+ console.log('=== DRY RUN COMPLETE ===');
455455+ console.log('No records were actually published.');
456456+ console.log('Remove --dry-run flag to publish for real.\n');
457457+458458+ return { successCount: totalRecords, errorCount: 0, cancelled: false };
459459+ }
460460+461461+ const totalBatches = Math.ceil(totalRecords / batchSize);
462462+ const estimatedTime = formatDuration(totalBatches * batchDelay);
463463+464464+ console.log(`Publishing ${totalRecords} records in batches of ${batchSize}...`);
465465+ console.log(`Total batches: ${totalBatches}`);
466466+ console.log(`Estimated time: ${estimatedTime}`);
467467+ console.log(`\n🚨 Press Ctrl+C to stop gracefully after current batch\n`);
468468+469469+ for (let i = 0; i < totalRecords; i += batchSize) {
470470+ // Check killswitch before processing batch
471471+ if (importCancelled) {
472472+ console.log(`\n🛑 Import cancelled by user`);
473473+ console.log(` Processed: ${successCount}/${totalRecords} records`);
474474+ console.log(` Remaining: ${totalRecords - successCount} records\n`);
475475+ return { successCount, errorCount, cancelled: true };
476476+ }
477477+478478+ const batch = records.slice(i, i + batchSize);
479479+ const batchNum = Math.floor(i / batchSize) + 1;
480480+ const progress = ((i / totalRecords) * 100).toFixed(1);
481481+482482+ console.log(`[${progress}%] Batch ${batchNum}/${totalBatches} (records ${i + 1}-${Math.min(i + batchSize, totalRecords)})`);
483483+484484+ // Process batch records
485485+ const batchStartTime = Date.now();
486486+ for (const record of batch) {
487487+ // Check killswitch during batch processing
488488+ if (importCancelled) {
489489+ console.log(` ⚠️ Stopping mid-batch...`);
490490+ break;
491491+ }
492492+493493+ try {
494494+ await agent.com.atproto.repo.createRecord({
495495+ repo: agent.session.did,
496496+ collection: RECORD_TYPE,
497497+ record,
498498+ });
499499+ successCount++;
500500+ } catch (error) {
501501+ errorCount++;
502502+ console.error(` ✗ Failed: ${record.trackName} - ${error.message}`);
503503+ }
504504+ }
505505+506506+ const batchDuration = Date.now() - batchStartTime;
507507+ const elapsed = formatDuration(Date.now() - startTime);
508508+ const remaining = formatDuration(((totalRecords - i - batchSize) / batchSize) * batchDelay);
509509+510510+ console.log(` ✓ Complete in ${batchDuration}ms (${successCount} successful, ${errorCount} failed)`);
511511+512512+ // Only show time estimates if not cancelled
513513+ if (!importCancelled) {
514514+ console.log(` ⏱ Elapsed: ${elapsed} | Remaining: ~${remaining}\n`);
515515+ }
516516+517517+ // Check again before waiting (in case cancelled during batch)
518518+ if (importCancelled) {
519519+ console.log(`\n🛑 Import cancelled by user`);
520520+ console.log(` Processed: ${successCount}/${totalRecords} records`);
521521+ console.log(` Remaining: ${totalRecords - successCount} records\n`);
522522+ return { successCount, errorCount, cancelled: true };
523523+ }
524524+525525+ // Wait before next batch (except for last batch)
526526+ if (i + batchSize < totalRecords) {
527527+ await new Promise(resolve => setTimeout(resolve, batchDelay));
528528+ }
529529+ }
530530+531531+ return { successCount, errorCount, cancelled: false };
532532+}
533533+534534+/**
535535+ * Main execution
536536+ */
537537+async function main() {
538538+ const args = parseCommandLineArgs();
539539+540540+ // Show help if requested
541541+ if (args.help) {
542542+ showHelp();
543543+ process.exit(0);
544544+ }
545545+546546+ // Setup killswitch (unless in dry-run mode)
547547+ if (!args['dry-run']) {
548548+ setupKillswitch();
549549+ }
550550+551551+ try {
552552+ console.log('=== Last.fm to ATProto Importer ===\n');
553553+554554+ // Get CSV file path
555555+ let csvPath = args.file;
556556+ if (!csvPath) {
557557+ csvPath = await prompt('Enter path to Last.fm CSV export: ');
558558+ } else {
559559+ console.log(`CSV file: ${csvPath}`);
560560+ }
561561+562562+ if (!fs.existsSync(csvPath)) {
563563+ console.error('✗ File not found!');
564564+ process.exit(1);
565565+ }
566566+567567+ // Parse CSV
568568+ const csvRecords = parseLastFmCsv(csvPath);
569569+570570+ if (csvRecords.length === 0) {
571571+ console.error('✗ No records found in CSV file!');
572572+ process.exit(1);
573573+ }
574574+575575+ // Convert records
576576+ console.log('Converting records to ATProto format...');
577577+ const playRecords = csvRecords.map(convertToPlayRecord);
578578+ console.log('✓ Conversion complete\n');
579579+580580+ // Sort records chronologically (oldest first) unless reverse flag is set
581581+ const reverseChronological = args['reverse-chronological'];
582582+ console.log(`Sorting records ${reverseChronological ? 'newest' : 'oldest'} first...`);
583583+584584+ playRecords.sort((a, b) => {
585585+ const timeA = new Date(a.playedTime).getTime();
586586+ const timeB = new Date(b.playedTime).getTime();
587587+ return reverseChronological ? timeB - timeA : timeA - timeB;
588588+ });
589589+590590+ const firstPlay = new Date(playRecords[0].playedTime).toLocaleDateString();
591591+ const lastPlay = new Date(playRecords[playRecords.length - 1].playedTime).toLocaleDateString();
592592+ console.log(`✓ Sorted ${playRecords.length} records`);
593593+ console.log(` First: ${firstPlay}`);
594594+ console.log(` Last: ${lastPlay}\n`);
595595+596596+ // Validate and set batch delay with minimum enforcement first
597597+ let batchDelay = args['batch-delay'] ? parseInt(args['batch-delay']) : DEFAULT_BATCH_DELAY;
598598+ if (batchDelay < MIN_BATCH_DELAY) {
599599+ console.log(`⚠️ Batch delay ${batchDelay}ms is below minimum safe limit.`);
600600+ console.log(` Enforcing minimum delay of ${MIN_BATCH_DELAY}ms to respect rate limits.\n`);
601601+ batchDelay = MIN_BATCH_DELAY;
602602+ }
603603+604604+ // Calculate optimal batch size if not specified (considers batch delay)
605605+ let batchSize = args['batch-size'] ? parseInt(args['batch-size']) : null;
606606+ if (!batchSize) {
607607+ batchSize = calculateOptimalBatchSize(playRecords.length, batchDelay);
608608+ console.log(`Auto-calculated batch size: ${batchSize}`);
609609+ console.log(` Algorithm: Logarithmic scaling with O(n) time complexity`);
610610+ console.log(` Optimized for: ${playRecords.length} records at ${batchDelay}ms delay`);
611611+ console.log(` Rate limit strategy: Token bucket with conservative limits\n`);
612612+ } else {
613613+ console.log(`Using specified batch size: ${batchSize}\n`);
614614+ }
615615+616616+ // Check if dry run mode
617617+ const isDryRun = args['dry-run'];
618618+619619+ if (isDryRun) {
620620+ console.log('🔍 Running in DRY RUN mode - no authentication required\n');
621621+622622+ // Show preview without publishing
623623+ await publishRecords(null, playRecords, batchSize, batchDelay, true);
624624+ process.exit(0);
625625+ }
626626+627627+ // Login to ATProto (only if not dry run)
628628+ const agent = await login(args.identifier, args.password);
629629+630630+ // Confirm before publishing (unless --yes flag is set)
631631+ if (!args.yes) {
632632+ const confirm = await prompt(`\nReady to publish ${playRecords.length} records. Continue? (yes/no): `);
633633+ if (confirm.toLowerCase() !== 'yes' && confirm.toLowerCase() !== 'y') {
634634+ console.log('Aborted.');
635635+ process.exit(0);
636636+ }
637637+ console.log('');
638638+ } else {
639639+ console.log(`Auto-confirmed: Publishing ${playRecords.length} records...\n`);
640640+ }
641641+642642+ // Publish records
643643+ const startTime = Date.now();
644644+ const { successCount, errorCount, cancelled } = await publishRecords(agent, playRecords, batchSize, batchDelay, false);
645645+ const totalTime = formatDuration(Date.now() - startTime);
646646+647647+ // Summary
648648+ console.log('=== Import Complete ===');
649649+ if (cancelled) {
650650+ console.log('Status: CANCELLED BY USER');
651651+ } else {
652652+ console.log('Status: COMPLETED');
653653+ }
654654+ console.log(`Total records: ${playRecords.length}`);
655655+ console.log(`Successfully published: ${successCount}`);
656656+ console.log(`Failed: ${errorCount}`);
657657+ if (cancelled) {
658658+ console.log(`Not processed: ${playRecords.length - successCount - errorCount}`);
659659+ }
660660+ console.log(`Total time: ${totalTime}`);
661661+662662+ if (successCount > 0) {
663663+ const avgTime = (Date.now() - startTime) / successCount;
664664+ console.log(`Average time per record: ${avgTime.toFixed(0)}ms`);
665665+ }
666666+667667+ console.log('\n✓ Logged out');
668668+669669+ // Exit with appropriate code
670670+ process.exit(cancelled ? 130 : 0);
671671+672672+ } catch (error) {
673673+ console.error('\n✗ Fatal error:', error.message);
674674+ if (error.stack && process.env.DEBUG) {
675675+ console.error('\nStack trace:', error.stack);
676676+ }
677677+ process.exit(1);
678678+ }
679679+}
680680+681681+main();
+84
lexicons/fm.teal.alpha/actor/defs.json
···11+{
22+ "lexicon": 1,
33+ "id": "fm.teal.alpha.actor.defs",
44+ "defs": {
55+ "profileView": {
66+ "type": "object",
77+ "properties": {
88+ "did": {
99+ "type": "string",
1010+ "description": "The decentralized identifier of the actor"
1111+ },
1212+ "displayName": {
1313+ "type": "string"
1414+ },
1515+ "description": {
1616+ "type": "string",
1717+ "description": "Free-form profile description text."
1818+ },
1919+ "descriptionFacets": {
2020+ "type": "array",
2121+ "description": "Annotations of text in the profile description (mentions, URLs, hashtags, etc). May be changed to another (backwards compatible) lexicon.",
2222+ "items": { "type": "ref", "ref": "app.bsky.richtext.facet" }
2323+ },
2424+ "featuredItem": {
2525+ "type": "ref",
2626+ "description": "The user's most recent item featured on their profile.",
2727+ "ref": "fm.teal.alpha.actor.profile#featuredItem"
2828+ },
2929+ "avatar": {
3030+ "type": "string",
3131+ "description": "IPLD of the avatar"
3232+ },
3333+ "banner": {
3434+ "type": "string",
3535+ "description": "IPLD of the banner image"
3636+ },
3737+ "status": {
3838+ "type": "ref",
3939+ "ref": "#statusView"
4040+ },
4141+ "createdAt": { "type": "string", "format": "datetime" }
4242+ }
4343+ },
4444+ "miniProfileView": {
4545+ "type": "object",
4646+ "properties": {
4747+ "did": {
4848+ "type": "string",
4949+ "description": "The decentralized identifier of the actor"
5050+ },
5151+ "displayName": {
5252+ "type": "string"
5353+ },
5454+ "handle": {
5555+ "type": "string"
5656+ },
5757+ "avatar": {
5858+ "type": "string",
5959+ "description": "IPLD of the avatar"
6060+ }
6161+ }
6262+ },
6363+ "statusView": {
6464+ "type": "object",
6565+ "description": "A declaration of the status of the actor.",
6666+ "properties": {
6767+ "time": {
6868+ "type": "string",
6969+ "format": "datetime",
7070+ "description": "The unix timestamp of when the item was recorded"
7171+ },
7272+ "expiry": {
7373+ "type": "string",
7474+ "format": "datetime",
7575+ "description": "The unix timestamp of the expiry time of the item. If unavailable, default to 10 minutes past the start time."
7676+ },
7777+ "item": {
7878+ "type": "ref",
7979+ "ref": "fm.teal.alpha.feed.defs#playView"
8080+ }
8181+ }
8282+ }
8383+ }
8484+}
+34
lexicons/fm.teal.alpha/actor/getProfile.json
···11+{
22+ "lexicon": 1,
33+ "id": "fm.teal.alpha.actor.getProfile",
44+ "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.",
55+ "defs": {
66+ "main": {
77+ "type": "query",
88+ "parameters": {
99+ "type": "params",
1010+ "required": ["actor"],
1111+ "properties": {
1212+ "actor": {
1313+ "type": "string",
1414+ "format": "at-identifier",
1515+ "description": "The author's DID"
1616+ }
1717+ }
1818+ },
1919+ "output": {
2020+ "encoding": "application/json",
2121+ "schema": {
2222+ "type": "object",
2323+ "required": ["actor"],
2424+ "properties": {
2525+ "actor": {
2626+ "type": "ref",
2727+ "ref": "fm.teal.alpha.actor.defs#profileView"
2828+ }
2929+ }
3030+ }
3131+ }
3232+ }
3333+ }
3434+}
···11+{
22+ "lexicon": 1,
33+ "id": "fm.teal.alpha.actor.profile",
44+ "defs": {
55+ "main": {
66+ "type": "record",
77+ "description": "This lexicon is in a not officially released state. It is subject to change. | A declaration of a teal.fm account profile.",
88+ "key": "literal:self",
99+ "record": {
1010+ "type": "object",
1111+ "properties": {
1212+ "displayName": {
1313+ "type": "string",
1414+ "maxGraphemes": 64,
1515+ "maxLength": 640
1616+ },
1717+ "description": {
1818+ "type": "string",
1919+ "description": "Free-form profile description text.",
2020+ "maxGraphemes": 256,
2121+ "maxLength": 2560
2222+ },
2323+ "descriptionFacets": {
2424+ "type": "array",
2525+ "description": "Annotations of text in the profile description (mentions, URLs, hashtags, etc).",
2626+ "items": { "type": "ref", "ref": "app.bsky.richtext.facet" }
2727+ },
2828+ "featuredItem": {
2929+ "type": "ref",
3030+ "description": "The user's most recent item featured on their profile.",
3131+ "ref": "#featuredItem"
3232+ },
3333+ "avatar": {
3434+ "type": "blob",
3535+ "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'",
3636+ "accept": ["image/png", "image/jpeg"],
3737+ "maxSize": 1000000
3838+ },
3939+ "banner": {
4040+ "type": "blob",
4141+ "description": "Larger horizontal image to display behind profile view.",
4242+ "accept": ["image/png", "image/jpeg"],
4343+ "maxSize": 1000000
4444+ },
4545+ "createdAt": { "type": "string", "format": "datetime" }
4646+ }
4747+ }
4848+ },
4949+ "featuredItem": {
5050+ "type": "object",
5151+ "required": ["mbid", "type"],
5252+ "properties": {
5353+ "mbid": {
5454+ "type": "string",
5555+ "description": "The Musicbrainz ID of the item"
5656+ },
5757+ "type": {
5858+ "type": "string",
5959+ "description": "The type of the item. Must be a valid Musicbrainz type, e.g. album, track, recording, etc."
6060+ }
6161+ }
6262+ }
6363+ }
6464+}
+32
lexicons/fm.teal.alpha/actor/profileStatus.json
···11+{
22+ "lexicon": 1,
33+ "id": "fm.teal.alpha.actor.profileStatus",
44+ "defs": {
55+ "main": {
66+ "type": "record",
77+ "description": "This lexicon is in a not officially released state. It is subject to change. | A declaration of the profile status of the actor.",
88+ "key": "literal:self",
99+ "record": {
1010+ "type": "object",
1111+ "required": ["completedOnboarding"],
1212+ "properties": {
1313+ "completedOnboarding": {
1414+ "type": "string",
1515+ "description": "The onboarding completion status",
1616+ "knownValues": ["none", "profileOnboarding", "playOnboarding", "complete"]
1717+ },
1818+ "createdAt": {
1919+ "type": "string",
2020+ "format": "datetime",
2121+ "description": "The timestamp when this status was created"
2222+ },
2323+ "updatedAt": {
2424+ "type": "string",
2525+ "format": "datetime",
2626+ "description": "The timestamp when this status was last updated"
2727+ }
2828+ }
2929+ }
3030+ }
3131+ }
3232+}
+52
lexicons/fm.teal.alpha/actor/searchActors.json
···11+{
22+ "lexicon": 1,
33+ "id": "fm.teal.alpha.actor.searchActors",
44+ "description": "This lexicon is in a not officially released state. It is subject to change. | Searches for actors based on profile contents.",
55+ "defs": {
66+ "main": {
77+ "type": "query",
88+ "parameters": {
99+ "type": "params",
1010+ "required": ["q"],
1111+ "properties": {
1212+ "q": {
1313+ "type": "string",
1414+ "description": "The search query",
1515+ "maxGraphemes": 128,
1616+ "maxLength": 640
1717+ },
1818+ "limit": {
1919+ "type": "integer",
2020+ "description": "The maximum number of actors to return",
2121+ "minimum": 1,
2222+ "maximum": 25
2323+ },
2424+ "cursor": {
2525+ "type": "string",
2626+ "description": "Cursor for pagination"
2727+ }
2828+ }
2929+ },
3030+ "output": {
3131+ "encoding": "application/json",
3232+ "schema": {
3333+ "type": "object",
3434+ "required": ["actors"],
3535+ "properties": {
3636+ "actors": {
3737+ "type": "array",
3838+ "items": {
3939+ "type": "ref",
4040+ "ref": "fm.teal.alpha.actor.defs#miniProfileView"
4141+ }
4242+ },
4343+ "cursor": {
4444+ "type": "string",
4545+ "description": "Cursor for pagination"
4646+ }
4747+ }
4848+ }
4949+ }
5050+ }
5151+ }
5252+}
+31
lexicons/fm.teal.alpha/actor/status.json
···11+{
22+ "lexicon": 1,
33+ "id": "fm.teal.alpha.actor.status",
44+ "defs": {
55+ "main": {
66+ "type": "record",
77+ "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.",
88+ "key": "literal:self",
99+ "record": {
1010+ "type": "object",
1111+ "required": ["time", "item"],
1212+ "properties": {
1313+ "time": {
1414+ "type": "string",
1515+ "format": "datetime",
1616+ "description": "The unix timestamp of when the item was recorded"
1717+ },
1818+ "expiry": {
1919+ "type": "string",
2020+ "format": "datetime",
2121+ "description": "The unix timestamp of the expiry time of the item. If unavailable, default to 10 minutes past the start time."
2222+ },
2323+ "item": {
2424+ "type": "ref",
2525+ "ref": "fm.teal.alpha.feed.defs#playView"
2626+ }
2727+ }
2828+ }
2929+ }
3030+ }
3131+}
+90
lexicons/fm.teal.alpha/feed/defs.json
···11+{
22+ "lexicon": 1,
33+ "id": "fm.teal.alpha.feed.defs",
44+ "description": "This lexicon is in a not officially released state. It is subject to change. | Misc. items related to feeds.",
55+ "defs": {
66+ "playView": {
77+ "type": "object",
88+ "required": ["trackName", "artists"],
99+ "properties": {
1010+ "trackName": {
1111+ "type": "string",
1212+ "minLength": 1,
1313+ "maxLength": 256,
1414+ "maxGraphemes": 2560,
1515+ "description": "The name of the track"
1616+ },
1717+ "trackMbId": {
1818+ "type": "string",
1919+ "description": "The Musicbrainz ID of the track"
2020+ },
2121+ "recordingMbId": {
2222+ "type": "string",
2323+ "description": "The Musicbrainz recording ID of the track"
2424+ },
2525+ "duration": {
2626+ "type": "integer",
2727+ "description": "The length of the track in seconds"
2828+ },
2929+ "artists": {
3030+ "type": "array",
3131+ "items": {
3232+ "type": "ref",
3333+ "ref": "#artist"
3434+ },
3535+ "description": "Array of artists in order of original appearance."
3636+ },
3737+ "releaseName": {
3838+ "type": "string",
3939+ "maxLength": 256,
4040+ "maxGraphemes": 2560,
4141+ "description": "The name of the release/album"
4242+ },
4343+ "releaseMbId": {
4444+ "type": "string",
4545+ "description": "The Musicbrainz release ID"
4646+ },
4747+ "isrc": {
4848+ "type": "string",
4949+ "description": "The ISRC code associated with the recording"
5050+ },
5151+ "originUrl": {
5252+ "type": "string",
5353+ "description": "The URL associated with this track"
5454+ },
5555+ "musicServiceBaseDomain": {
5656+ "type": "string",
5757+ "description": "The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if not provided."
5858+ },
5959+ "submissionClientAgent": {
6060+ "type": "string",
6161+ "maxLength": 256,
6262+ "maxGraphemes": 2560,
6363+ "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."
6464+ },
6565+ "playedTime": {
6666+ "type": "string",
6767+ "format": "datetime",
6868+ "description": "The unix timestamp of when the track was played"
6969+ }
7070+ }
7171+ },
7272+ "artist": {
7373+ "type": "object",
7474+ "required": ["artistName"],
7575+ "properties": {
7676+ "artistName": {
7777+ "type": "string",
7878+ "minLength": 1,
7979+ "maxLength": 256,
8080+ "maxGraphemes": 2560,
8181+ "description": "The name of the artist"
8282+ },
8383+ "artistMbId": {
8484+ "type": "string",
8585+ "description": "The Musicbrainz ID of the artist"
8686+ }
8787+ }
8888+ }
8989+ }
9090+}
+45
lexicons/fm.teal.alpha/feed/getActorFeed.json
···11+{
22+ "lexicon": 1,
33+ "id": "fm.teal.alpha.feed.getActorFeed",
44+ "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.",
55+ "defs": {
66+ "main": {
77+ "type": "query",
88+ "parameters": {
99+ "type": "params",
1010+ "required": ["authorDID"],
1111+ "properties": {
1212+ "authorDID": {
1313+ "type": "string",
1414+ "format": "at-identifier",
1515+ "description": "The author's DID for the play"
1616+ },
1717+ "cursor": {
1818+ "type": "string",
1919+ "description": "The cursor to start the query from"
2020+ },
2121+ "limit": {
2222+ "type": "integer",
2323+ "description": "The upper limit of tracks to get per request. Default is 20, max is 50."
2424+ }
2525+ }
2626+ },
2727+ "output": {
2828+ "encoding": "application/json",
2929+ "schema": {
3030+ "type": "object",
3131+ "required": ["plays"],
3232+ "properties": {
3333+ "plays": {
3434+ "type": "array",
3535+ "items": {
3636+ "type": "ref",
3737+ "ref": "fm.teal.alpha.feed.defs#playView"
3838+ }
3939+ }
4040+ }
4141+ }
4242+ }
4343+ }
4444+ }
4545+}
+38
lexicons/fm.teal.alpha/feed/getPlay.json
···11+{
22+ "lexicon": 1,
33+ "id": "fm.teal.alpha.feed.getPlay",
44+ "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.",
55+ "defs": {
66+ "main": {
77+ "type": "query",
88+ "parameters": {
99+ "type": "params",
1010+ "required": ["authorDID", "rkey"],
1111+ "properties": {
1212+ "authorDID": {
1313+ "type": "string",
1414+ "format": "at-identifier",
1515+ "description": "The author's DID for the play"
1616+ },
1717+ "rkey": {
1818+ "type": "string",
1919+ "description": "The record key of the play"
2020+ }
2121+ }
2222+ },
2323+ "output": {
2424+ "encoding": "application/json",
2525+ "schema": {
2626+ "type": "object",
2727+ "required": ["play"],
2828+ "properties": {
2929+ "play": {
3030+ "type": "ref",
3131+ "ref": "fm.teal.alpha.feed.defs#playView"
3232+ }
3333+ }
3434+ }
3535+ }
3636+ }
3737+ }
3838+}
+107
lexicons/fm.teal.alpha/feed/play.json
···11+{
22+ "lexicon": 1,
33+ "id": "fm.teal.alpha.feed.play",
44+ "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.",
55+ "defs": {
66+ "main": {
77+ "type": "record",
88+ "key": "tid",
99+ "record": {
1010+ "type": "object",
1111+ "required": ["trackName"],
1212+ "properties": {
1313+ "trackName": {
1414+ "type": "string",
1515+ "minLength": 1,
1616+ "maxLength": 256,
1717+ "maxGraphemes": 2560,
1818+ "description": "The name of the track"
1919+ },
2020+ "trackMbId": {
2121+ "type": "string",
2222+2323+ "description": "The Musicbrainz ID of the track"
2424+ },
2525+ "recordingMbId": {
2626+ "type": "string",
2727+ "description": "The Musicbrainz recording ID of the track"
2828+ },
2929+ "duration": {
3030+ "type": "integer",
3131+ "description": "The length of the track in seconds"
3232+ },
3333+ "artistNames": {
3434+ "type": "array",
3535+ "items": {
3636+ "type": "string",
3737+ "minLength": 1,
3838+ "maxLength": 256,
3939+ "maxGraphemes": 2560
4040+ },
4141+ "description": "Array of artist names in order of original appearance. Prefer using 'artists'."
4242+ },
4343+ "artistMbIds": {
4444+ "type": "array",
4545+ "items": {
4646+ "type": "string"
4747+ },
4848+ "description": "Array of Musicbrainz artist IDs. Prefer using 'artists'."
4949+ },
5050+ "artists": {
5151+ "type": "array",
5252+ "items": {
5353+ "type": "ref",
5454+ "ref": "fm.teal.alpha.feed.defs#artist"
5555+ },
5656+ "description": "Array of artists in order of original appearance."
5757+ },
5858+ "releaseName": {
5959+ "type": "string",
6060+ "maxLength": 256,
6161+ "maxGraphemes": 2560,
6262+ "description": "The name of the release/album"
6363+ },
6464+ "releaseMbId": {
6565+ "type": "string",
6666+ "description": "The Musicbrainz release ID"
6767+ },
6868+ "isrc": {
6969+ "type": "string",
7070+ "description": "The ISRC code associated with the recording"
7171+ },
7272+ "originUrl": {
7373+ "type": "string",
7474+ "description": "The URL associated with this track"
7575+ },
7676+ "musicServiceBaseDomain": {
7777+ "type": "string",
7878+ "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."
7979+ },
8080+ "submissionClientAgent": {
8181+ "type": "string",
8282+ "maxLength": 256,
8383+ "maxGraphemes": 2560,
8484+ "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."
8585+ },
8686+ "playedTime": {
8787+ "type": "string",
8888+ "format": "datetime",
8989+ "description": "The unix timestamp of when the track was played"
9090+ },
9191+ "trackDiscriminant": {
9292+ "type": "string",
9393+ "maxLength": 128,
9494+ "maxGraphemes": 1280,
9595+ "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."
9696+ },
9797+ "releaseDiscriminant": {
9898+ "type": "string",
9999+ "maxLength": 128,
100100+ "maxGraphemes": 1280,
101101+ "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."
102102+ }
103103+ }
104104+ }
105105+ }
106106+ }
107107+}