summaryrefslogtreecommitdiff
path: root/config/mpv/scripts/subs2srsa
diff options
context:
space:
mode:
authornavewindre <boneyaard@gmail.com>2025-04-05 02:59:37 +0200
committernavewindre <boneyaard@gmail.com>2025-04-05 02:59:37 +0200
commitb24463f3d045783b8f4e72926054d53b908e150f (patch)
tree036f976e217128b9e4acf3854f72908c27dec17b /config/mpv/scripts/subs2srsa
parent398e41be4daf339bd55862520c528a7d93b83fb6 (diff)
a
Diffstat (limited to 'config/mpv/scripts/subs2srsa')
-rw-r--r--config/mpv/scripts/subs2srsa/.gitignore5
-rw-r--r--config/mpv/scripts/subs2srsa/LICENSE674
-rw-r--r--config/mpv/scripts/subs2srsa/Makefile36
-rw-r--r--config/mpv/scripts/subs2srsa/README.md478
-rw-r--r--config/mpv/scripts/subs2srsa/ankiconnect.lua241
-rw-r--r--config/mpv/scripts/subs2srsa/cfg_mgr.lua240
-rw-r--r--config/mpv/scripts/subs2srsa/encoder/codec_support.lua40
-rw-r--r--config/mpv/scripts/subs2srsa/encoder/encoder.lua729
-rwxr-xr-xconfig/mpv/scripts/subs2srsa/find_anki_col.sh5
-rw-r--r--config/mpv/scripts/subs2srsa/helpers.lua280
-rw-r--r--config/mpv/scripts/subs2srsa/howto/add_dialog.md9
-rw-r--r--config/mpv/scripts/subs2srsa/howto/create_card.md14
-rw-r--r--config/mpv/scripts/subs2srsa/howto/create_quick_card.md31
-rw-r--r--config/mpv/scripts/subs2srsa/howto/flatpak.md27
-rw-r--r--config/mpv/scripts/subs2srsa/howto/goldendict.md45
-rw-r--r--config/mpv/scripts/subs2srsa/howto/yomichan.md24
-rw-r--r--config/mpv/scripts/subs2srsa/main.lua1
-rw-r--r--config/mpv/scripts/subs2srsa/menu.lua77
-rw-r--r--config/mpv/scripts/subs2srsa/osd_styler.lua97
-rw-r--r--config/mpv/scripts/subs2srsa/platform/init.lua14
-rw-r--r--config/mpv/scripts/subs2srsa/platform/nix.lua49
-rw-r--r--config/mpv/scripts/subs2srsa/platform/win.lua72
-rw-r--r--config/mpv/scripts/subs2srsa/subs2srs.lua746
-rw-r--r--config/mpv/scripts/subs2srsa/subtitles/observer.lua344
-rw-r--r--config/mpv/scripts/subs2srsa/subtitles/secondary_sid.lua196
-rw-r--r--config/mpv/scripts/subs2srsa/subtitles/sub_list.lua74
-rw-r--r--config/mpv/scripts/subs2srsa/subtitles/subtitle.lua56
-rw-r--r--config/mpv/scripts/subs2srsa/test.lua32
-rw-r--r--config/mpv/scripts/subs2srsa/utils/base64.lua46
-rw-r--r--config/mpv/scripts/subs2srsa/utils/filename_factory.lua89
-rw-r--r--config/mpv/scripts/subs2srsa/utils/forvo.lua145
-rw-r--r--config/mpv/scripts/subs2srsa/utils/pause_timer.lua33
-rw-r--r--config/mpv/scripts/subs2srsa/utils/play_control.lua61
-rw-r--r--config/mpv/scripts/subs2srsa/utils/switch.lua38
-rw-r--r--config/mpv/scripts/subs2srsa/utils/timings.lua28
35 files changed, 5076 insertions, 0 deletions
diff --git a/config/mpv/scripts/subs2srsa/.gitignore b/config/mpv/scripts/subs2srsa/.gitignore
new file mode 100644
index 0000000..850b67c
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/.gitignore
@@ -0,0 +1,5 @@
+.idea
+.vscode
+/.github/RELEASE/*.zip
+/.github/RELEASE/*.html
+*TODO*
diff --git a/config/mpv/scripts/subs2srsa/LICENSE b/config/mpv/scripts/subs2srsa/LICENSE
new file mode 100644
index 0000000..f288702
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ <program> Copyright (C) <year> <name of author>
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<https://www.gnu.org/licenses/>.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+<https://www.gnu.org/licenses/why-not-lgpl.html>.
diff --git a/config/mpv/scripts/subs2srsa/Makefile b/config/mpv/scripts/subs2srsa/Makefile
new file mode 100644
index 0000000..fc346ce
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/Makefile
@@ -0,0 +1,36 @@
+PROJECT := mpvacious
+PREFIX ?= /etc/mpv/
+BRANCH ?= remotes/origin/master
+VERSION := $(shell git describe --tags $(BRANCH))
+RELEASE_DIR := .github/RELEASE
+ZIP := $(RELEASE_DIR)/$(PROJECT)_$(VERSION).zip
+DOCS := $(RELEASE_DIR)/README_$(VERSION).html
+MD2HTML = md2html --github --full-html
+
+.PHONY: all clean docs install uninstall
+
+all: $(ZIP)
+docs: $(DOCS)
+
+$(ZIP):
+ git archive \
+ --prefix=$(PROJECT)_$(VERSION)/ \
+ --format=zip \
+ -o $@ \
+ $(BRANCH) \
+
+$(DOCS):
+ git show "$(BRANCH):README.md" | $(MD2HTML) -o $@
+
+install:
+ find . -type f -iname '*.lua' | while read -r file; do \
+ install -Dm644 "$$file" "$(PREFIX)/scripts/$(PROJECT)/$$file"; \
+ done
+ install -Dm644 $(RELEASE_DIR)/subs2srs.conf "$(PREFIX)/script-opts/subs2srs.conf"
+
+uninstall:
+ rm -rf -- "$(PREFIX)/scripts/$(PROJECT)"
+ rm -- "$(PREFIX)/script-opts/subs2srs.conf"
+
+clean:
+ rm -- $(ZIP) $(DOCS)
diff --git a/config/mpv/scripts/subs2srsa/README.md b/config/mpv/scripts/subs2srsa/README.md
new file mode 100644
index 0000000..deefe3c
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/README.md
@@ -0,0 +1,478 @@
+<p align="center">
+<img src="https://user-images.githubusercontent.com/69171671/117440218-4ae26800-af23-11eb-87b4-1d9026fc953f.png"/>
+</p>
+
+# mpvacious
+
+[![AUR](https://img.shields.io/badge/AUR-install-blue.svg)](https://aur.archlinux.org/packages/mpv-mpvacious/)
+[![Chat](https://img.shields.io/badge/chat-join-green.svg)](https://tatsumoto-ren.github.io/blog/join-our-community.html)
+![GitHub](https://img.shields.io/github/license/Ajatt-Tools/mpvacious)
+[![Donate](https://img.shields.io/badge/support-developer-orange)](https://tatsumoto.neocities.org/blog/donating-to-tatsumoto.html)
+
+mpvacious is your semi-automatic subs2srs for mpv.
+It supports multiple workflows and allows you to quickly create Anki cards
+while watching your favorite TV show.
+**[Video demonstration](https://redirect.invidious.io/watch?v=vU85ramvyo4)**.
+
+## Requirements
+
+<table>
+<tr>
+ <th><a href="https://www.gnu.org/gnu/about-gnu.html">GNU/Linux</a></th>
+ <th><a href="https://www.gnu.org/proprietary/malware-microsoft.en.html">Windows 10+</a></th>
+ <th><a href="https://www.gnu.org/proprietary/malware-apple.en.html">macOS</a></th>
+ <th>Comments</th>
+</tr>
+<tr>
+ <td><a href="https://wiki.archlinux.org/index.php/Mpv">mpv</a></td>
+ <td><a href="https://sourceforge.net/projects/mpv-player-windows/files">mpv</a></td>
+ <td><a href="https://mpv.io/installation/">mpv</a></td>
+ <td>v0.32.0 or newer.</td>
+</tr>
+<tr>
+ <td><a href="https://wiki.archlinux.org/index.php/Anki">Anki</a></td>
+ <td colspan="2" align="center"><a href="https://apps.ankiweb.net/">Anki</a></td>
+ <td></td>
+</tr>
+<tr>
+ <td colspan="3" align="center"><a href="https://ankiweb.net/shared/info/2055492159">AnkiConnect</a></td>
+ <td>Install from AnkiWeb.</td>
+</tr>
+<tr>
+ <td><a href="https://www.archlinux.org/packages/core/x86_64/curl/">curl</a></td>
+ <td colspan="2" align="center"><a href="https://curl.haxx.se/">curl</a></td>
+ <td>Installed by default on all platforms except Windows 7.</td>
+</tr>
+<tr>
+ <td><a href="https://www.archlinux.org/packages/extra/x86_64/xclip/">xclip</a> or <a href="https://archlinux.org/packages/extra/x86_64/wl-clipboard">wl-copy</a></td>
+ <td></td>
+ <td>pbcopy</td>
+ <td>To copy subtitle text to clipboard.</td>
+</tr>
+</table>
+
+Install all dependencies at once (on [Arch-based](https://www.parabola.nu/)
+[distros](https://www.gnu.org/distros/free-distros.en.html)):
+
+```
+$ sudo pacman -Syu mpv anki curl xclip --needed
+```
+
+## Prerequisites
+
+* A guide on how to set up Anki can be found [on our site](https://tatsumoto.neocities.org/blog/setting-up-anki.html).
+* If you're on a [Windows](https://www.fsf.org/windows) or a [Windows-like](https://reactos.org/) machine,
+ a mpv build by `shinchiro` is recommended.
+* **macOS** users are advised to use [homebrew](https://brew.sh/) or manually add `mpv` to `PATH`.
+* Note that it is not recommended to use FlatPak or similar containers.
+ If you still want to, [read this](howto/flatpak.md).
+* Make sure that your build of mpv supports encoding of audio and images.
+ This shell command can be used to test it.
+
+ ```
+ $ mpv 'test_video.mkv' --loop-file=no --frames=1 -o='test_image.jpg'
+ ```
+
+ If the command fails, find a compatible build on the [mpv website](https://mpv.io/installation/)
+ or instead install FFmpeg and [enable FFmpeg support](#configuration) in `mpvacious`'s config file.
+* Most problems with adding audio or images to Anki cards can be fixed
+ by installing FFmpeg and enabling it settings.
+
+## Installation
+
+There are multiple ways you can install `mpvacious`.
+I recommend installing with `git` so that you can easily update on demand.
+
+`mpvacious` is a user-script for mpv,
+so it has to be installed in the directory `mpv` reads its user-scripts from.
+
+| OS | Location |
+|-----------|--------------------------------------------------|
+| GNU/Linux | `~/.config/mpv/scripts/` |
+| Windows | `C:/Users/Username/AppData/Roaming/mpv/scripts/` |
+
+Windows is not recommended,
+but we acknowledge that some people haven't switched to GNU/Linux yet.
+
+### Using git
+
+Clone the repo to the `scripts` directory.
+
+```
+mkdir -p ~/.config/mpv/scripts/
+git clone 'https://github.com/Ajatt-Tools/mpvacious.git' ~/.config/mpv/scripts/subs2srs
+```
+
+To update, run the following command.
+
+```
+cd ~/.config/mpv/scripts/subs2srs && git pull
+```
+
+### From the AUR
+
+`mpvacious` can be installed with the [mpv-mpvacious](https://aur.archlinux.org/packages/mpv-mpvacious/) package.
+
+### Manually
+
+This way is not recommended because it's easy to make a mistake during the process
+and end up with a broken install.
+
+Download
+[the repository](https://github.com/Ajatt-Tools/mpvacious/archive/refs/heads/master.zip)
+or
+[the latest release](https://github.com/Ajatt-Tools/mpvacious/releases)
+and extract the folder containing
+[subs2srs.lua](https://raw.githubusercontent.com/Ajatt-Tools/mpvacious/master/subs2srs.lua)
+to your [mpv scripts](https://github.com/mpv-player/mpv/wiki/User-Scripts) directory.
+
+<details>
+
+<summary>Expected directory tree</summary>
+
+```
+~/.config/mpv/scripts
+|-- other script 1
+|-- other script 2
+|-- subs2srs
+| |-- main.lua
+| |-- subs2srs.lua
+| `-- other files
+`-- other script 3
+```
+
+</details>
+
+<details>
+
+<summary>A note for mpv v0.32 and older</summary>
+
+Older versions of `mpv` don't know how to handle user-scripts in subdirectories.
+You need to tell mpv where to look for `mpvacious`.
+
+Open or create `~/.config/mpv/scripts/modules.lua` and add these lines:
+```
+local mpv_scripts_dir_path = os.getenv("HOME") .. "/.config/mpv/scripts/"
+package.path = package.path .. ';' .. os.getenv("HOME") .. '/.config/mpv/scripts/subs2srs/?.lua'
+function load(relative_path) dofile(mpv_scripts_dir_path .. relative_path) end
+load("subs2srs/subs2srs.lua")
+```
+
+</details>
+
+**Note:** in [Celluloid](https://www.archlinux.org/packages/community/x86_64/celluloid/)
+user scripts are installed in `/.config/celluloid/scripts/`.
+When following the instructions above, replace `.config/mpv` with `.config/celluloid`
+and optionally `subs2srs` with the name of the folder mpvacious is cloned into.
+
+## Configuration
+
+The config file should be created by the user, if needed.
+
+| OS | Config location |
+|--------------------|-------------------------------------------------------------------|
+| GNU/Linux | `~/.config/mpv/script-opts/subs2srs.conf` |
+| Windows | `C:/Users/Username/AppData/Roaming/mpv/script-opts/subs2srs.conf` |
+| Windows (portable) | `mpv.exeフォルダ/portable_config/script-opts/subs2srs.conf` |
+
+If a parameter is not specified
+in the config file, the default value will be used.
+mpv doesn't tolerate spaces before and after `=`.
+
+<p align="center">
+ <a href="https://github.com/Ajatt-Tools/mpvacious/blob/master/.github/RELEASE/subs2srs.conf">Example configuration file</a>
+</p>
+
+If the first field is empty, it will be set contain the string `[empty]`.
+Otherwise, Anki won't allow mpvacious to add new notes.
+This won't happen if the sentence field is first in the note type settings.
+
+**Tip**: Try [our official note type](https://ankiweb.net/shared/info/1557722832)
+if you don't want to configure note fields yourself.
+Alternatively, we have a collection of user-created note types, which you can browse
+[here](https://github.com/Ajatt-Tools/AnkiNoteTypes).
+
+If you are having problems playing media files on older mobile devices,
+set `audio_format` to `mp3` and/or `snapshot_format` to `jpg`.
+Otherwise, I recommend sticking with `opus` for audio,
+and `avif` or `webp` for images,
+as they greatly reduce the size of the generated files.
+
+If you still use AnkiMobile (the [proprietary](https://www.gnu.org/proprietary/) Anki app),
+set `opus_container` to `m4a` or `webm`. I'll allow iOS to play Opus files, while still maintaining
+compatibility with non-Apple devices. For really old iOS devices, set `opus_container` to
+[`caf`](https://en.wikipedia.org/wiki/Core_Audio_Format). CAF plays only on Anki Desktop,
+AnkiWeb in Safari and AnkiMobile, and is really not recommended. (Please note that
+[Lockdown Mode](https://support.apple.com/en-us/105120) completely disables Opus and AVIF support,
+though you may try to add an exception for AnkiMobile.)
+
+If no matter what mpvacious fails to create audio clips and/or snapshots,
+change `use_ffmpeg` to `yes`.
+By using ffmpeg instead of the encoder built in mpv you can work around most encoder issues.
+You need to have ffmpeg installed for this to work.
+
+### Key bindings
+
+The user may change some global key bindings, though this step is not necessary.
+See [Usage](#usage) for the explanation of what they do.
+
+| OS | Config location |
+|-----------|----------------------------------------------------|
+| GNU/Linux | `~/.config/mpv/input.conf` |
+| Windows | `C:/Users/Username/AppData/Roaming/mpv/input.conf` |
+
+Default bindings:
+
+```
+a script-binding mpvacious-menu-open
+
+Ctrl+g script-binding mpvacious-animated-snapshot-toggle
+
+Ctrl+n script-binding mpvacious-export-note
+
+Ctrl+m script-binding mpvacious-update-last-note
+Ctrl+M script-binding mpvacious-overwrite-last-note
+
+g script-binding mpvacious-quick-card-menu-open
+Alt+g script-binding mpvacious-quick-card-sel-menu-open
+
+Ctrl+c script-binding mpvacious-copy-primary-sub-to-clipboard
+Ctrl+C script-binding mpvacious-copy-secondary-sub-to-clipboard
+Ctrl+t script-binding mpvacious-autocopy-toggle
+
+H script-binding mpvacious-sub-seek-back
+L script-binding mpvacious-sub-seek-forward
+
+Alt+h script-binding mpvacious-sub-seek-back-pause
+Alt+l script-binding mpvacious-sub-seek-forward-pause
+
+Ctrl+h script-binding mpvacious-sub-rewind
+Ctrl+H script-binding mpvacious-sub-replay
+Ctrl+L script-binding mpvacious-sub-play-up-to-next
+
+Ctrl+v script-binding mpvacious-secondary-sid-toggle
+Ctrl+k script-binding mpvacious-secondary-sid-prev
+Ctrl+j script-binding mpvacious-secondary-sid-next
+```
+
+**Note:** A capital letter means that you need to press Shift in order to activate the corresponding binding.
+For example, <kbd>Ctrl+M</kbd> actually means <kbd>Ctrl+Shift+m</kbd>.
+mpv accepts both variants in `input.conf`.
+
+## Usage
+
+* [Create a card](howto/create_card.md)
+* [Quick card creation](howto/create_quick_card.md)
+* [Open the "Add" dialog](howto/add_dialog.md)
+* [Usage with Rikaitan](howto/yomichan.md)
+* [Usage with GoldenDict](howto/goldendict.md)
+
+### Global bindings
+
+**Menu:**
+
+* <kbd>a</kbd> - Open `advanced menu`.
+
+**Enable\Disable animation:**
+
+* <kbd>Ctrl+g</kbd> - If animation is enabled, animated snapshots will be generated instead of static images.
+ Animated snapshot are like GIFs (just in a different format)
+ and will capture the video from the start to the end times selected.
+
+**Make a card:**
+
+* <kbd>Ctrl+n</kbd> - Export a card with the currently visible subtitle line on the front.
+Use this when your subs are well-timed,
+and the target sentence doesn't span multiple subs.
+
+**Quick card creation:**
+
+* <kbd>g</kbd> - Quick card creation menu.
+* <kbd>Alt+g</kbd> - Quick card creation, card selection menu.
+
+**Update the last card:**
+
+* <kbd>Ctrl+m</kbd> - Append to the media fields of the newly added Anki card.
+* <kbd>Ctrl+Shift+m</kbd> - Overwrite media fields of the newly added Anki card.
+
+**Clipboard:**
+
+* <kbd>Ctrl+c</kbd> - Copy current subtitle string to the system clipboard.
+* <kbd>Ctrl+t</kbd> - Toggle automatic copying of subtitles to the clipboard.
+
+**Seeking:**
+
+* <kbd>Shift+h</kbd> and <kbd>Shift+l</kbd> - Seek to the previous or the next subtitle.
+* <kbd>Alt+h</kbd> and <kbd>Alt+l</kbd> - Seek to the previous, or the next subtitle, and pause.
+* <kbd>Ctrl+h</kbd> - Seek to the start of the currently visible subtitle. Use it if you missed something.
+* <kbd>Ctrl+Shift+h</kbd> - Replay current subtitle line, and pause.
+* <kbd>Ctrl+Shift+l</kbd> - Play until the end of the next subtitle, and pause. Useful for beginners who need
+ to look up words in each and every dialogue line.
+
+**Secondary subtitles:**
+
+* <kbd>Ctrl+v</kbd> - Toggle visibility.
+* <kbd>Ctrl+k</kbd> - Switch to the previous subtitle if it's not already selected.
+* <kbd>Ctrl+j</kbd> - Switch to the next subtitle if it's not already selected.
+
+### Menu options
+
+Advanced menu has the following options:
+
+* <kbd>f</kbd> - Increment number of cards to update. Only affects note updating, including quick card creation. The number of cards to update is reset to 1 upon updating a note.
+* <kbd>shift+f</kbd> - Decrement number of cards to update.
+
+* <kbd>c</kbd> - Interactive subtitle selection.
+ The range of the currently displayed subtitle line is selected. The selection then grows both ways based on the following displayed lines.
+ It does nothing if there are no subs on screen.
+
+* <kbd>shift+s</kbd> - Set the start time to the current sub. The selection then grows forward based on the following displayed lines.
+ The default selection spans across the range of the currently displayed subtitle line.
+* <kbd>shift+e</kbd> - Set the end time to the current sub. The selection then grows backward based on the following displayed lines.
+ The default selection spans across the range of the currently displayed subtitle line.
+
+Then seek with <kbd>Shift+h</kbd> and <kbd>Shift+l</kbd> to the previous/next line that you want to add.
+Press <kbd>n</kbd> to make the card.
+
+* <kbd>r</kbd> - Forget all previously saved timings and associated dialogs.
+
+* <kbd>z</kbd> and <kbd>Shift+z</kbd> - Adjust subtitle delay.
+
+If above fails, you have to manually set timings.
+* <kbd>s</kbd> - Set the start time. The selection then grows forward based on the following displayed lines.
+The default selection spans across the selected start point and the end of the subtitle line.
+* <kbd>e</kbd> - Set the end time. The selection then grows backward based on the following displayed lines.
+The default selection spans across the selected end point and the start of the subtitle line.
+
+Then, as earlier, press <kbd>n</kbd> to make the card.
+
+**Tip**: change playback speed by pressing <kbd>[</kbd> and <kbd>]</kbd>
+to precisely mark start and end of the phrase.
+
+### My subtitles are not in sync
+
+If subs are badly timed, first, you could try to re-time them.
+Read [Retiming subtitles](https://tatsumoto.neocities.org/blog/retiming-subtitles).
+Or shift timings using key bindings provided by mpv (usually <kbd>z</kbd> and <kbd>Shift+z</kbd>).
+
+### Example sentence card
+
+With the addon you can make cards like this in just a few seconds.
+
+![card-example](https://user-images.githubusercontent.com/69171671/92900057-e102d480-f40e-11ea-8cfc-b00848ca66ff.png)
+
+### Audio cards
+
+It is possible to make a card with just audio, and a picture
+when subtitles for the show you are watching aren't available, for example.
+mpv by default allows you to do a `1` second exact seek by pressing <kbd>Shift+LEFT</kbd> and <kbd>Shift+RIGHT</kbd>.
+Open the mpvacious menu by pressing <kbd>a</kbd>, seek to the position you need, and set the timings.
+Then press <kbd>g</kbd> to invoke the `Add Cards` dialog.
+Here's a [video demonstration](https://redirect.invidious.io/watch?v=BXhyckdHPGE).
+
+If the show is hard-subbed, you can use
+[transformers-ocr](https://tatsumoto.neocities.org/blog/mining-from-manga.html)
+to recognize and add text to the card.
+
+### Secondary subtitles
+
+If you want to add a translation to your cards, and you have the subtitles in that language,
+you can add them as secondary subtitles if you run `mpv` with `--secondary-sid=<sid>` parameter,
+`sid` being the track identifier for the subtitle.
+
+You also need to specify `secondary_field` in the [config file](#Configuration)
+if it is different from the default.
+
+If you want to load secondary subtitles **automatically**, don't modify the run parameters
+and instead set the desired languages in the config file (`secondary_sub_lang` option).
+
+Secondary subtitles will be visible when hovering over the top part of the `mpv` window.
+
+https://user-images.githubusercontent.com/69171671/188492261-909ba3e8-b82c-493f-88cf-0ec953dfcfe1.mp4
+
+By pressing <kbd>Ctrl</kbd>+<kbd>v</kbd> you can control secondary sid visibility without using the mouse.
+
+### Other tools
+
+If you don't like the default Rikaitan Search tool, try:
+
+* Clipboard Inserter browser add-on
+([chrome](https://chrome.google.com/webstore/detail/clipboard-inserter/deahejllghicakhplliloeheabddjajm))
+([firefox](https://addons.mozilla.org/ja/firefox/addon/clipboard-inserter/))
+* A html page ([1](https://pastebin.com/zDY6s3NK)) ([2](https://pastebin.com/hZ4sawL4))
+to paste the contents of your clipboard to
+
+You can use any html page as long as it has \<body\>\</body\> in it.
+
+### Additional mpv key bindings
+
+I recommend adding these lines to your [input.conf](#key-bindings) for smoother experience.
+```
+# vim-like seeking
+l seek 5
+h seek -5
+j seek -60
+k seek 60
+
+# Cycle between subtitle files
+K cycle sub
+J cycle sub down
+
+# Add/subtract 50 ms delay from subs
+Z add sub-delay +0.05
+z add sub-delay -0.05
+
+# Adjust timing to previous/next subtitle
+X sub-step 1
+x sub-step -1
+```
+
+## Profiles
+
+Mpvacious supports config profiles.
+To make use of them, create a new config file called `subs2srs_profiles.conf`
+in the same folder as your [subs2srs.conf](#Configuration).
+Inside the file, define available profile names (without `.conf`) and the name of the active profile:
+
+```
+profiles=subs2srs,english,german
+active=subs2srs
+```
+
+In the example above, I have three profiles.
+The first one is the default,
+the second one is for learning English,
+the third one is for learning German.
+
+Then in the same folder create config files for each of the defined profiles.
+For example, below is the contents of my `english.conf` file:
+
+```
+deck_name=English sentence mining
+model_name=General
+sentence_field=Question
+audio_field=Audio
+image_field=Extra
+```
+
+You don't have to redefine all settings in the new profile.
+Specify only the ones you want to be different from the default.
+
+To cycle profiles, open the advanced menu by pressing <kbd>a</kbd> and then press <kbd>p</kbd>.
+At any time you can see what profile is active in the menu's status bar.
+
+## Hacking
+
+If you want to modify this script
+or make an entirely new one from scratch,
+these links may help.
+
+* https://mpv.io/manual/master/#lua-scripting
+* https://github.com/mpv-player/mpv/blob/master/player/lua/defaults.lua
+* https://github.com/SenneH/mpv2anki
+* https://github.com/kelciour/mpv-scripts/blob/master/subs2srs.lua
+* https://pastebin.com/M2gBksHT
+* https://pastebin.com/NBudhMUk
+* https://pastebin.com/W5YV1A9q
+* https://github.com/ayuryshev/subs2srs
+* https://github.com/erjiang/subs2srs
diff --git a/config/mpv/scripts/subs2srsa/ankiconnect.lua b/config/mpv/scripts/subs2srsa/ankiconnect.lua
new file mode 100644
index 0000000..f9c87d8
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/ankiconnect.lua
@@ -0,0 +1,241 @@
+--[[
+Copyright: Ren Tatsumoto and contributors
+License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html
+
+AnkiConnect requests
+]]
+
+local utils = require('mp.utils')
+local msg = require('mp.msg')
+local h = require('helpers')
+local self = {}
+
+self.execute = function(request, completion_fn)
+ -- utils.format_json returns a string
+ -- On error, request_json will contain "null", not nil.
+ local request_json, error = utils.format_json(request)
+
+ if error ~= nil or request_json == "null" then
+ return completion_fn and completion_fn()
+ else
+ return self.platform.curl_request(self.config.ankiconnect_url, request_json, completion_fn)
+ end
+end
+
+self.parse_result = function(curl_output)
+ -- there are two values that we actually care about: result and error
+ -- but we need to crawl inside to get them.
+
+ if curl_output == nil then
+ return nil, "Failed to format json or no args passed"
+ end
+
+ if curl_output.status ~= 0 then
+ return nil, "Ankiconnect isn't running"
+ end
+
+ local stdout_json = utils.parse_json(curl_output.stdout)
+
+ if stdout_json == nil then
+ return nil, "Fatal error from Ankiconnect"
+ end
+
+ if stdout_json.error ~= nil then
+ return nil, tostring(stdout_json.error)
+ end
+
+ return stdout_json.result, nil
+end
+
+self.get_media_dir_path = function()
+ -- Ask AnkiConnect where to store media files.
+ -- If AnkiConnect isn't running, returns nil.
+
+ local ret = self.execute({
+ action = "getMediaDirPath",
+ version = 6,
+ })
+ local dir_path, error = self.parse_result(ret)
+ if not error then
+ return dir_path
+ else
+ msg.error(string.format("Couldn't retrieve path to collection.media folder: %s", error))
+ return nil
+ end
+end
+
+self.create_deck = function(deck_name)
+ local args = {
+ action = "changeDeck",
+ version = 6,
+ params = {
+ cards = {},
+ deck = deck_name
+ }
+ }
+ local result_notify = function(_, result, _)
+ local _, error = self.parse_result(result)
+ if not error then
+ msg.info(string.format("Deck %s: check completed.", deck_name))
+ else
+ msg.warn(string.format("Deck %s: check failed. Reason: %s.", deck_name, error))
+ end
+ end
+ self.execute(args, result_notify)
+end
+
+self.add_note = function(note_fields, tag, gui)
+ local action = gui and 'guiAddCards' or 'addNote'
+ local args = {
+ action = action,
+ version = 6,
+ params = {
+ note = {
+ deckName = self.config.deck_name,
+ modelName = self.config.model_name,
+ fields = note_fields,
+ options = {
+ allowDuplicate = self.config.allow_duplicates,
+ duplicateScope = "deck",
+ },
+ tags = h.is_empty(tag) and {} or { tag, },
+ }
+ }
+ }
+ local result_notify = function(_, result, _)
+ local note_id, error = self.parse_result(result)
+ if not error then
+ h.notify(string.format("Note added. ID = %s.", note_id))
+ else
+ h.notify(string.format("Error: %s.", error), "error", 2)
+ end
+ end
+ self.execute(args, result_notify)
+end
+
+self.get_last_note_ids = function(n_cards)
+ local ret = self.execute {
+ action = "findNotes",
+ version = 6,
+ params = {
+ query = "added:1" -- find all notes added today
+ }
+ }
+
+ local note_ids, _ = self.parse_result(ret)
+
+ if not h.is_empty(note_ids) then
+ return h.get_last_n_added_notes(note_ids, n_cards)
+ else
+ return {}
+ end
+end
+
+self.get_note_fields = function(note_id)
+ local ret = self.execute {
+ action = "notesInfo",
+ version = 6,
+ params = {
+ notes = { note_id }
+ }
+ }
+
+ local result, error = self.parse_result(ret)
+
+ if error == nil then
+ result = result[1].fields
+ for key, value in pairs(result) do
+ result[key] = value.value
+ end
+ return result
+ else
+ return nil
+ end
+end
+
+self.get_first_field = function(model_name)
+ local ret = self.execute {
+ action = "findModelsByName",
+ version = 6,
+ params = {
+ modelNames = { model_name }
+ }
+ }
+
+ local result, error = self.parse_result(ret)
+
+ if error == nil then
+ for _, field in pairs(result[1].flds) do
+ if field.ord == 0 then
+ return field.name
+ end
+ end
+ else
+ msg.error(string.format("Couldn't retrieve the first field's name of note type %s: %s", model_name, error))
+ return nil
+ end
+end
+
+self.gui_browse = function(query)
+ if not self.config.disable_gui_browse then
+ self.execute {
+ action = 'guiBrowse',
+ version = 6,
+ params = {
+ query = query
+ }
+ }
+ end
+end
+
+self.add_tag = function(note_id, tag)
+ if not h.is_empty(tag) then
+ self.execute {
+ action = 'addTags',
+ version = 6,
+ params = {
+ notes = { note_id },
+ tags = tag
+ }
+ }
+ end
+end
+
+self.append_media = function(note_id, fields, create_media_fn, tag)
+ -- AnkiConnect will fail to update the note if it's selected in the Anki Browser.
+ -- https://github.com/FooSoft/anki-connect/issues/82
+ -- Switch focus from the current note to avoid it.
+ self.gui_browse("nid:1") -- impossible nid
+
+ local args = {
+ action = "updateNoteFields",
+ version = 6,
+ params = {
+ note = {
+ id = note_id,
+ fields = fields,
+ }
+ }
+ }
+
+ local on_finish = function(_, result, _)
+ local _, error = self.parse_result(result)
+ if not error then
+ create_media_fn()
+ self.add_tag(note_id, tag)
+ self.gui_browse(string.format("nid:%s", note_id)) -- select the updated note in the card browser
+ h.notify(string.format("Note #%s updated.", note_id))
+ else
+ h.notify(string.format("Error: %s.", error), "error", 2)
+ end
+ end
+
+ self.execute(args, on_finish)
+end
+
+self.init = function(config, platform)
+ self.config = config
+ self.platform = platform
+end
+
+return self
diff --git a/config/mpv/scripts/subs2srsa/cfg_mgr.lua b/config/mpv/scripts/subs2srsa/cfg_mgr.lua
new file mode 100644
index 0000000..076c0d9
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/cfg_mgr.lua
@@ -0,0 +1,240 @@
+--[[
+Copyright: Ren Tatsumoto and contributors
+License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html
+
+Config management, validation, loading.
+]]
+
+local mp = require('mp')
+local mpopt = require('mp.options')
+local msg = require('mp.msg')
+local h = require('helpers')
+local utils = require('mp.utils')
+
+local min_side_px = 42
+local max_side_px = 640
+local default_height_px = 200
+
+-- This constant should be used in place of width and/or height in the config file.
+-- It tells the encoder to preserve aspect ratio when downscaling snapshots.
+-- The user almost always wants to set either width or height to this value.
+-- Note: If set to -1, encoding will fail with the "height/width not divisible by 2" error.
+local preserve_aspect_ratio = -2
+
+local self = {
+ config = nil,
+ profiles = nil,
+ initial_config = {}
+}
+
+local default_profile_filename = 'subs2srs'
+local profiles_filename = 'subs2srs_profiles'
+
+local function set_file_extension_for_opus()
+ -- Default to OGG, then change if an extension is supported.
+ -- https://en.wikipedia.org/wiki/Core_Audio_Format
+ self.config.audio_extension = '.ogg'
+ for _, extension in ipairs({ 'opus', 'm4a', 'webm', 'caf' }) do
+ if extension == self.config.opus_container then
+ self.config.audio_extension = '.' .. self.config.opus_container
+ break
+ end
+ end
+end
+
+local function set_audio_format()
+ if self.config.audio_format == 'opus' then
+ -- https://opus-codec.org/
+ self.config.audio_codec = 'libopus'
+ set_file_extension_for_opus()
+ else
+ self.config.audio_codec = 'libmp3lame'
+ self.config.audio_extension = '.mp3'
+ end
+end
+
+local function set_video_format()
+ if self.config.snapshot_format == 'avif' then
+ self.config.snapshot_extension = '.avif'
+ self.config.snapshot_codec = 'libaom-av1'
+ elseif self.config.snapshot_format == 'webp' then
+ self.config.snapshot_extension = '.webp'
+ self.config.snapshot_codec = 'libwebp'
+ else
+ self.config.snapshot_extension = '.jpg'
+ self.config.snapshot_codec = 'mjpeg'
+ end
+
+ -- Animated webp images can only have .webp extension.
+ -- The user has no choice on this. Same logic for avif.
+ if self.config.animated_snapshot_format == 'avif' then
+ self.config.animated_snapshot_extension = '.avif'
+ self.config.animated_snapshot_codec = 'libaom-av1'
+ else
+ self.config.animated_snapshot_extension = '.webp'
+ self.config.animated_snapshot_codec = 'libwebp'
+ end
+end
+
+local function ensure_in_range(dimension)
+ self.config[dimension] = self.config[dimension] < min_side_px and preserve_aspect_ratio or self.config[dimension]
+ self.config[dimension] = self.config[dimension] > max_side_px and max_side_px or self.config[dimension]
+end
+
+local function conditionally_set_defaults(width, height, quality)
+ if self.config[width] < 1 and self.config[height] < 1 then
+ self.config[width] = preserve_aspect_ratio
+ self.config[height] = default_height_px
+ end
+ if self.config[quality] < 0 or self.config[quality] > 100 then
+ self.config[quality] = 15
+ end
+end
+
+local function check_image_settings()
+ ensure_in_range('snapshot_width')
+ ensure_in_range('snapshot_height')
+ conditionally_set_defaults('snapshot_width', 'snapshot_height', 'snapshot_quality')
+end
+
+local function ensure_correct_fps()
+ if self.config.animated_snapshot_fps == nil or self.config.animated_snapshot_fps <= 0 or self.config.animated_snapshot_fps > 30 then
+ self.config.animated_snapshot_fps = 10
+ end
+end
+
+local function check_animated_snapshot_settings()
+ ensure_in_range('animated_snapshot_width')
+ ensure_in_range('animated_snapshot_height')
+ conditionally_set_defaults('animated_snapshot_width', 'animated_snapshot_height', 'animated_snapshot_quality')
+ ensure_correct_fps()
+end
+
+local function validate_config()
+ set_audio_format()
+ set_video_format()
+ check_image_settings()
+ check_animated_snapshot_settings()
+end
+
+local function remember_initial_config()
+ if h.is_empty(self.initial_config) then
+ for key, value in pairs(self.config) do
+ self.initial_config[key] = value
+ end
+ else
+ msg.fatal("Ignoring. Initial config has been read already.")
+ end
+end
+
+local function restore_initial_config()
+ for key, value in pairs(self.initial_config) do
+ self.config[key] = value
+ end
+end
+
+local function read_profile_list()
+ mpopt.read_options(self.profiles, profiles_filename)
+ msg.info("Read profile list. Defined profiles: " .. self.profiles.profiles)
+end
+
+local function read_profile(profile_name)
+ mpopt.read_options(self.config, profile_name)
+ msg.info("Read config file: " .. profile_name)
+end
+
+local function read_default_config()
+ read_profile(default_profile_filename)
+end
+
+local function reload_from_disk()
+ --- Loads default config file (subs2srs.conf), then overwrites it with current profile.
+ if not h.is_empty(self.config) and not h.is_empty(self.profiles) then
+ restore_initial_config()
+ read_default_config()
+ if self.profiles.active ~= default_profile_filename then
+ read_profile(self.profiles.active)
+ end
+ validate_config()
+ else
+ msg.fatal("Attempt to load config when init hasn't been done.")
+ end
+end
+
+local function next_profile()
+ local first, next, new
+ for profile in string.gmatch(self.profiles.profiles, '[^,]+') do
+ if not first then
+ first = profile
+ end
+ if profile == self.profiles.active then
+ next = true
+ elseif next then
+ next = false
+ new = profile
+ end
+ end
+ if next == true or not new then
+ new = first
+ end
+ self.profiles.active = new
+ reload_from_disk()
+end
+
+local function create_config_file()
+ local name = default_profile_filename
+ -- ~/.config/mpv/scripts/ and the mpvacious dir
+ local parent, child = utils.split_path(mp.get_script_directory())
+ -- ~/.config/mpv/ and "scripts"
+ parent, child = utils.split_path(parent:gsub("/$", ""))
+ -- ~/.config/mpv/script-opts/subs2srs.conf
+ local config_filepath = utils.join_path(utils.join_path(parent, "script-opts"), string.format('%s.conf', name))
+ local example_config_filepath = utils.join_path(mp.get_script_directory(), ".github/RELEASE/subs2srs.conf")
+
+ local file_info = utils.file_info(config_filepath)
+ if file_info and file_info.is_file then
+ print("config already exists")
+ return
+ end
+
+ local handle = io.open(example_config_filepath, 'r')
+ if handle == nil then
+ return
+ end
+
+ local content = handle:read("*a")
+ handle:close()
+
+ handle = io.open(config_filepath, 'w')
+ if handle == nil then
+ h.notify(string.format("Couldn't open %s.", config_filepath), "error", 4)
+ return
+ end
+
+ handle:write(string.format("# Written by %s on %s.\n", name, os.date()))
+ handle:write(content)
+ handle:close()
+ h.notify("Settings saved.", "info", 2)
+end
+
+local function init(config_table, profiles_table)
+ create_config_file()
+ self.config, self.profiles = config_table, profiles_table
+ -- 'subs2srs' is the main profile, it is always loaded. 'active profile' overrides it afterwards.
+ -- initial state is saved to another table to maintain consistency when cycling through incomplete profiles.
+ read_profile_list()
+ read_default_config()
+ remember_initial_config()
+ if self.profiles.active ~= default_profile_filename then
+ read_profile(self.profiles.active)
+ end
+ validate_config()
+end
+
+return {
+ reload_from_disk = reload_from_disk,
+ init = init,
+ next_profile = next_profile,
+ default_height_px = default_height_px,
+ preserve_aspect_ratio = preserve_aspect_ratio,
+}
diff --git a/config/mpv/scripts/subs2srsa/encoder/codec_support.lua b/config/mpv/scripts/subs2srsa/encoder/codec_support.lua
new file mode 100644
index 0000000..26cd91a
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/encoder/codec_support.lua
@@ -0,0 +1,40 @@
+--[[
+Copyright: Ajatt-Tools and contributors
+License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html
+
+Check what codecs are supported by mpv.
+If a desired codec is not supported, set the "use_ffmpeg" config option to "yes".
+]]
+
+local mp = require('mp')
+local h = require('helpers')
+
+local ovc_help = h.subprocess { 'mpv', '--ovc=help' }
+local oac_help = h.subprocess { 'mpv', '--oac=help' }
+
+local function is_audio_supported(codec)
+ return oac_help.status == 0 and oac_help.stdout:find('--oac=' .. codec, 1, true) ~= nil
+end
+
+local function is_image_supported(codec)
+ return ovc_help.status == 0 and ovc_help.stdout:find('--ovc=' .. codec, 1, true) ~= nil
+end
+
+local inspection_result = {
+ snapshot = {
+ ['libaom-av1'] = is_image_supported('libaom-av1'),
+ libwebp = is_image_supported('libwebp'),
+ mjpeg = is_image_supported('mjpeg'),
+ },
+ audio = {
+ libmp3lame = is_audio_supported('libmp3lame'),
+ libopus = is_audio_supported('libopus'),
+ },
+}
+for type, codecs in pairs(inspection_result) do
+ for codec, supported in pairs(codecs) do
+ mp.msg.info(string.format("mpv supports %s codec %s: %s", type, codec, tostring(supported)))
+ end
+end
+
+return inspection_result
diff --git a/config/mpv/scripts/subs2srsa/encoder/encoder.lua b/config/mpv/scripts/subs2srsa/encoder/encoder.lua
new file mode 100644
index 0000000..b05c3db
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/encoder/encoder.lua
@@ -0,0 +1,729 @@
+--[[
+Copyright: Ren Tatsumoto and contributors
+License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html
+
+Encoder creates audio clips and snapshots, both animated and static.
+]]
+
+local mp = require('mp')
+local utils = require('mp.utils')
+local h = require('helpers')
+local filename_factory = require('utils.filename_factory')
+local msg = require('mp.msg')
+
+--Contains the state of the module
+local self = {
+ snapshot = {},
+ audio = {},
+ config = nil,
+ store_fn = nil,
+ platform = nil,
+ encoder = nil,
+ output_dir_path = nil,
+}
+
+------------------------------------------------------------
+-- utility functions
+
+local function pad_timings(padding, start_time, end_time)
+ local video_duration = mp.get_property_number('duration')
+ start_time = start_time - padding
+ end_time = end_time + padding
+
+ if start_time < 0 then
+ start_time = 0
+ end
+
+ if end_time > video_duration then
+ end_time = video_duration
+ end
+
+ return start_time, end_time
+end
+
+local function alt_path_dirs()
+ return {
+ '/opt/homebrew/bin',
+ '/usr/local/bin',
+ utils.join_path(os.getenv("HOME") or "~", '.local/bin'),
+ }
+end
+
+local function find_exec(name)
+ local path, info
+ for _, alt_dir in pairs(alt_path_dirs()) do
+ path = utils.join_path(alt_dir, name)
+ info = utils.file_info(path)
+ if info and info.is_file then
+ return path
+ end
+ end
+ return name
+end
+
+local function toms(timestamp)
+ --- Trim timestamp down to milliseconds.
+ return string.format("%.3f", timestamp)
+end
+
+local function fit_quality_percentage_to_range(quality, worst_val, best_val)
+ local scaled = worst_val + (best_val - worst_val) * quality / 100
+ -- Round to the nearest integer that's better in quality.
+ if worst_val > best_val then
+ return math.floor(scaled)
+ end
+ return math.ceil(scaled)
+end
+
+local function quality_to_crf_avif(quality_value)
+ -- Quality is from 0 to 100. For avif images CRF is from 0 to 63 and reversed.
+ local worst_avif_crf = 63
+ local best_avif_crf = 0
+ return fit_quality_percentage_to_range(quality_value, worst_avif_crf, best_avif_crf)
+end
+
+local function quality_to_jpeg_qscale(quality_value)
+ local worst_jpeg_quality = 31
+ local best_jpeg_quality = 2
+ return fit_quality_percentage_to_range(quality_value, worst_jpeg_quality, best_jpeg_quality)
+end
+
+------------------------------------------------------------
+-- ffmpeg encoder
+
+local ffmpeg = {}
+
+ffmpeg.exec = find_exec("ffmpeg")
+
+ffmpeg.prepend = function(...)
+ return {
+ ffmpeg.exec, "-hide_banner", "-nostdin", "-y", "-loglevel", "quiet", "-sn",
+ ...,
+ }
+end
+
+local function make_scale_filter(algorithm, width, height)
+ -- algorithm is either "sinc" or "lanczos"
+ -- Static image scaling uses "sinc", which is the best downscaling algorithm: https://stackoverflow.com/a/6171860
+ -- Animated images use Lanczos, which is faster.
+ return string.format(
+ "scale='min(%d,iw)':'min(%d,ih)':flags=%s+accurate_rnd",
+ width, height, algorithm
+ )
+end
+
+local function static_scale_filter()
+ return make_scale_filter('sinc', self.config.snapshot_width, self.config.snapshot_height)
+end
+
+local function animated_scale_filter()
+ return make_scale_filter(
+ 'lanczos',
+ self.config.animated_snapshot_width,
+ self.config.animated_snapshot_height
+ )
+end
+
+ffmpeg.make_static_snapshot_args = function(source_path, output_path, timestamp)
+ local encoder_args
+ if self.config.snapshot_format == 'avif' then
+ encoder_args = {
+ '-c:v', 'libaom-av1',
+ -- cpu-used < 6 can take a lot of time to encode.
+ '-cpu-used', '6',
+ -- Avif quality can be controlled with crf.
+ '-crf', tostring(quality_to_crf_avif(self.config.snapshot_quality)),
+ '-still-picture', '1',
+ }
+ elseif self.config.snapshot_format == 'webp' then
+ encoder_args = {
+ '-c:v', 'libwebp',
+ '-compression_level', '6',
+ '-quality', tostring(self.config.snapshot_quality),
+ }
+ else
+ encoder_args = {
+ '-c:v', 'mjpeg',
+ '-q:v', tostring(quality_to_jpeg_qscale(self.config.snapshot_quality)),
+ }
+ end
+
+ local args = ffmpeg.prepend(
+ '-an',
+ '-ss', toms(timestamp),
+ '-i', source_path,
+ '-map_metadata', '-1',
+ '-vf', static_scale_filter(),
+ '-frames:v', '1',
+ h.unpack(encoder_args)
+ )
+ table.insert(args, output_path)
+ return args
+end
+
+ffmpeg.make_animated_snapshot_args = function(source_path, output_path, start_timestamp, end_timestamp)
+ local encoder_args
+ if self.config.animated_snapshot_format == 'avif' then
+ encoder_args = {
+ '-c:v', 'libaom-av1',
+ -- cpu-used < 6 can take a lot of time to encode.
+ '-cpu-used', '6',
+ -- Avif quality can be controlled with crf.
+ '-crf', tostring(quality_to_crf_avif(self.config.animated_snapshot_quality)),
+ }
+ else
+ -- Documentation: https://www.ffmpeg.org/ffmpeg-all.html#libwebp
+ encoder_args = {
+ '-c:v', 'libwebp',
+ '-compression_level', '6',
+ '-quality', tostring(self.config.animated_snapshot_quality),
+ }
+ end
+
+ local args = ffmpeg.prepend(
+ '-an',
+ '-ss', toms(start_timestamp),
+ '-to', toms(end_timestamp),
+ '-i', source_path,
+ '-map_metadata', '-1',
+ '-loop', '0',
+ '-vf', string.format(
+ 'fps=%d,%s',
+ self.config.animated_snapshot_fps,
+ animated_scale_filter()
+ ),
+ h.unpack(encoder_args)
+ )
+ table.insert(args, output_path)
+ return args
+end
+
+local function make_loudnorm_targets()
+ return string.format(
+ 'loudnorm=I=%s:LRA=%s:TP=%s:dual_mono=true',
+ self.config.loudnorm_target,
+ self.config.loudnorm_range,
+ self.config.loudnorm_peak
+ )
+end
+
+local function parse_loudnorm(loudnorm_targets, json_extractor, loudnorm_consumer)
+ local function warn()
+ msg.warn('Failed to measure loudnorm stats, falling back on dynamic loudnorm.')
+ end
+
+ return function(success, result)
+ local json
+ if success and result.status == 0 then
+ json = json_extractor(result.stdout, result.stderr)
+ end
+
+ if json == nil then
+ warn()
+ loudnorm_consumer(loudnorm_targets)
+ return
+ end
+
+ local loudnorm_args = { loudnorm_targets }
+ local function add_arg(name, val)
+ -- loudnorm sometimes fails to gather stats for extremely short inputs.
+ -- Simply omit the stat to fall back on dynamic loudnorm.
+ if val ~= '-inf' and val ~= 'inf' then
+ table.insert(loudnorm_args, string.format('%s=%s', name, val))
+ else
+ warn()
+ end
+ end
+
+ local stats = utils.parse_json(json)
+ add_arg('measured_I', stats.input_i)
+ add_arg('measured_LRA', stats.input_lra)
+ add_arg('measured_TP', stats.input_tp)
+ add_arg('measured_thresh', stats.input_thresh)
+ add_arg('offset', stats.target_offset)
+
+ loudnorm_consumer(table.concat(loudnorm_args, ':'))
+ end
+end
+
+local function add_filter(filters, filter)
+ if #filters == 0 then
+ filters = filter
+ else
+ filters = string.format('%s,%s', filters, filter)
+ end
+end
+
+local function separate_filters(filters, new_args, args)
+ -- Would've strongly preferred
+ -- if args[i] == '-af' or args[i] == '-filter:a' then
+ -- i = i + 1
+ -- add_filter(args[i])
+ -- but https://lua.org/manual/5.4/manual.html#3.3.5 says that
+ -- "You should not change the value of the control variable during the loop."
+ local expect_filter = false
+ for i = 1, #args do
+ if args[i] == '-af' or args[i] == '-filter:a' then
+ expect_filter = true
+ else
+ if expect_filter then
+ add_filter(filters, args[i])
+ else
+ table.insert(new_args, args[i])
+ end
+ expect_filter = false
+ end
+ end
+end
+
+ffmpeg.append_user_audio_args = function(args)
+ local new_args = {}
+ local filters = ''
+
+ separate_filters(filters, new_args, args)
+ if self.config.tie_volumes then
+ add_filter(filters, string.format("volume=%.1f", mp.get_property_native('volume') / 100.0))
+ end
+
+ local user_args = {}
+ for arg in string.gmatch(self.config.ffmpeg_audio_args, "%S+") do
+ table.insert(user_args, arg)
+ end
+ separate_filters(filters, new_args, user_args)
+
+ if #filters > 0 then
+ table.insert(new_args, '-af')
+ table.insert(new_args, filters)
+ end
+ return new_args
+end
+
+ffmpeg.make_audio_args = function(
+ source_path, output_path, start_timestamp, end_timestamp, args_consumer
+)
+ local audio_track = h.get_active_track('audio')
+ local audio_track_id = audio_track['ff-index']
+
+ if audio_track and audio_track.external == true then
+ source_path = audio_track['external-filename']
+ audio_track_id = 'a'
+ end
+
+ local function make_ffargs(...)
+ return ffmpeg.append_user_audio_args(
+ ffmpeg.prepend(
+ '-vn',
+ '-ss', toms(start_timestamp),
+ '-to', toms(end_timestamp),
+ '-i', source_path,
+ '-map_metadata', '-1',
+ '-map_chapters', '-1',
+ '-map', string.format("0:%s", tostring(audio_track_id)),
+ '-ac', '1',
+ ...
+ )
+ )
+ end
+
+ local function make_encoding_args(loudnorm_args)
+ local encoder_args
+ if self.config.audio_format == 'opus' then
+ encoder_args = {
+ '-c:a', 'libopus',
+ '-application', 'voip',
+ '-apply_phase_inv', '0', -- Improves mono audio.
+ }
+ if self.config.opus_container == 'm4a' then
+ table.insert(encoder_args, '-f')
+ table.insert(encoder_args, 'mp4')
+ end
+ else
+ -- https://wiki.hydrogenaud.io/index.php?title=LAME#Recommended_encoder_settings:
+ -- "For very low bitrates, up to 100kbps, ABR is most often the best solution."
+ encoder_args = {
+ '-c:a', 'libmp3lame',
+ '-compression_level', '0',
+ '-abr', '1',
+ }
+ end
+
+ encoder_args = { '-b:a', tostring(self.config.audio_bitrate), h.unpack(encoder_args) }
+ if loudnorm_args then
+ table.insert(encoder_args, '-af')
+ table.insert(encoder_args, loudnorm_args)
+ end
+ local args = make_ffargs(h.unpack(encoder_args))
+ table.insert(args, output_path)
+ args_consumer(args)
+ end
+
+ if not self.config.loudnorm then
+ make_encoding_args(nil)
+ return
+ end
+
+ local loudnorm_targets = make_loudnorm_targets()
+ local args = make_ffargs(
+ '-loglevel', 'info',
+ '-af', loudnorm_targets .. ':print_format=json'
+ )
+ table.insert(args, '-f')
+ table.insert(args, 'null')
+ table.insert(args, '-')
+ h.subprocess(
+ args,
+ parse_loudnorm(
+ loudnorm_targets,
+ function(stdout, stderr)
+ local start, stop, json = string.find(stderr, '%[Parsed_loudnorm_0.-({.-})')
+ return json
+ end,
+ make_encoding_args
+ )
+ )
+end
+
+------------------------------------------------------------
+-- mpv encoder
+
+local mpv = { }
+
+mpv.exec = find_exec("mpv")
+
+mpv.prepend_common_args = function(source_path, ...)
+ return {
+ mpv.exec,
+ source_path,
+ '--no-config',
+ '--loop-file=no',
+ '--keep-open=no',
+ '--no-sub',
+ '--no-ocopy-metadata',
+ ...,
+ }
+end
+
+mpv.make_static_snapshot_args = function(source_path, output_path, timestamp)
+ local encoder_args
+ if self.config.snapshot_format == 'avif' then
+ encoder_args = {
+ '--ovc=libaom-av1',
+ -- cpu-used < 6 can take a lot of time to encode.
+ '--ovcopts-add=cpu-used=6',
+ string.format('--ovcopts-add=crf=%d', quality_to_crf_avif(self.config.snapshot_quality)),
+ '--ovcopts-add=still-picture=1',
+ }
+ elseif self.config.snapshot_format == 'webp' then
+ encoder_args = {
+ '--ovc=libwebp',
+ '--ovcopts-add=compression_level=6',
+ string.format('--ovcopts-add=quality=%d', self.config.snapshot_quality),
+ }
+ else
+ encoder_args = {
+ '--ovc=mjpeg',
+ '--vf-add=scale=out_range=jpeg',
+ string.format(
+ '--ovcopts=global_quality=%d*QP2LAMBDA,flags=+qscale',
+ quality_to_jpeg_qscale(self.config.snapshot_quality)
+ ),
+ }
+ end
+
+ return mpv.prepend_common_args(
+ source_path,
+ '--audio=no',
+ '--frames=1',
+ '--start=' .. toms(timestamp),
+ string.format('--vf-add=lavfi=[%s]', static_scale_filter()),
+ '-o=' .. output_path,
+ h.unpack(encoder_args)
+ )
+end
+
+mpv.make_animated_snapshot_args = function(source_path, output_path, start_timestamp, end_timestamp)
+ local encoder_args
+ if self.config.animated_snapshot_format == 'avif' then
+ encoder_args = {
+ '--ovc=libaom-av1',
+ -- cpu-used < 6 can take a lot of time to encode.
+ '--ovcopts-add=cpu-used=6',
+ string.format('--ovcopts-add=crf=%d', quality_to_crf_avif(self.config.animated_snapshot_quality)),
+ }
+ else
+ encoder_args = {
+ '--ovc=libwebp',
+ '--ovcopts-add=compression_level=6',
+ string.format('--ovcopts-add=quality=%d', self.config.animated_snapshot_quality),
+ }
+ end
+
+ return mpv.prepend_common_args(
+ source_path,
+ '--audio=no',
+ '--start=' .. toms(start_timestamp),
+ '--end=' .. toms(end_timestamp),
+ '--ofopts-add=loop=0',
+ string.format('--vf-add=fps=%d', self.config.animated_snapshot_fps),
+ string.format('--vf-add=lavfi=[%s]', animated_scale_filter()),
+ '-o=' .. output_path,
+ h.unpack(encoder_args)
+ )
+end
+
+mpv.make_audio_args = function(source_path, output_path,
+ start_timestamp, end_timestamp, args_consumer)
+ local audio_track = h.get_active_track('audio')
+ local audio_track_id = mp.get_property("aid")
+
+ if audio_track and audio_track.external == true then
+ source_path = audio_track['external-filename']
+ audio_track_id = 'auto'
+ end
+
+ local function make_mpvargs(...)
+ local args = mpv.prepend_common_args(
+ source_path,
+ '--video=no',
+ '--aid=' .. audio_track_id,
+ '--audio-channels=mono',
+ '--start=' .. toms(start_timestamp),
+ '--end=' .. toms(end_timestamp),
+ string.format(
+ '--volume=%d',
+ self.config.tie_volumes and mp.get_property('volume') or 100
+ ),
+ ...
+ )
+ for arg in string.gmatch(self.config.mpv_audio_args, "%S+") do
+ table.insert(args, arg)
+ end
+ return args
+ end
+
+ local function make_encoding_args(loudnorm_args)
+ local encoder_args
+ if self.config.audio_format == 'opus' then
+ encoder_args = {
+ '--oac=libopus',
+ '--oacopts-add=application=voip',
+ '--oacopts-add=apply_phase_inv=0', -- Improves mono audio.
+ }
+ if self.config.opus_container == 'm4a' then
+ table.insert(encoder_args, '--of=mp4')
+ end
+ else
+ -- https://wiki.hydrogenaud.io/index.php?title=LAME#Recommended_encoder_settings:
+ -- "For very low bitrates, up to 100kbps, ABR is most often the best solution."
+ encoder_args = {
+ '--oac=libmp3lame',
+ '--oacopts-add=compression_level=0',
+ '--oacopts-add=abr=1',
+ }
+ end
+
+ local args = make_mpvargs(
+ '--oacopts-add=b=' .. self.config.audio_bitrate,
+ '-o=' .. output_path,
+ h.unpack(encoder_args)
+ )
+ if loudnorm_args then
+ table.insert(args, '--af-append=' .. loudnorm_args)
+ end
+ args_consumer(args)
+ end
+
+ if not self.config.loudnorm then
+ make_encoding_args(nil)
+ return
+ end
+
+ local loudnorm_targets = make_loudnorm_targets()
+ h.subprocess(
+ make_mpvargs(
+ '-v',
+ '--af-append=' .. loudnorm_targets .. ':print_format=json',
+ '--ao=null',
+ '--of=null'
+ ),
+ parse_loudnorm(
+ loudnorm_targets,
+ function(stdout, stderr)
+ local start, stop, json = string.find(stdout, '%[ffmpeg%] ({.-})')
+ if json then
+ json = string.gsub(json, '%[ffmpeg%]', '')
+ end
+ return json
+ end,
+ make_encoding_args
+ )
+ )
+end
+
+------------------------------------------------------------
+-- main interface
+
+local create_animated_snapshot = function(start_timestamp, end_timestamp, source_path, output_path, on_finish_fn)
+ -- Creates the animated snapshot and then calls on_finish_fn
+ local args = self.encoder.make_animated_snapshot_args(source_path, output_path, start_timestamp, end_timestamp)
+ h.subprocess(args, on_finish_fn)
+end
+
+local create_static_snapshot = function(timestamp, source_path, output_path, on_finish_fn)
+ -- Creates a static snapshot, in other words an image, and then calls on_finish_fn
+ if not self.config.screenshot then
+ local args = self.encoder.make_static_snapshot_args(source_path, output_path, timestamp)
+ h.subprocess(args, on_finish_fn)
+ else
+ local args = { 'screenshot-to-file', output_path, 'video', }
+ mp.command_native_async(args, on_finish_fn)
+ end
+
+end
+
+local report_creation_result = function(file_path)
+ return function(success, result)
+ -- result is nil on success for screenshot-to-file.
+ if success and (result == nil or result.status == 0) and h.file_exists(file_path) then
+ msg.info(string.format("Created file: %s", file_path))
+ return true
+ else
+ msg.error(string.format("Couldn't create file: %s", file_path))
+ return false
+ end
+ end
+end
+
+local create_snapshot = function(start_timestamp, end_timestamp, current_timestamp, filename)
+ if h.is_empty(self.output_dir_path) then
+ return msg.error("Output directory wasn't provided. Image file will not be created.")
+ end
+
+ -- Calls the proper function depending on whether or not the snapshot should be animated
+ if not h.is_empty(self.config.image_field) then
+ local source_path = mp.get_property("path")
+ local output_path = utils.join_path(self.output_dir_path, filename)
+
+ local on_finish = report_creation_result(output_path)
+ if self.config.animated_snapshot_enabled then
+ create_animated_snapshot(start_timestamp, end_timestamp, source_path, output_path, on_finish)
+ else
+ create_static_snapshot(current_timestamp, source_path, output_path, on_finish)
+ end
+ else
+ print("Snapshot will not be created.")
+ end
+end
+
+local background_play = function(file_path, on_finish)
+ return h.subprocess(
+ { mpv.exec, '--audio-display=no', '--force-window=no', '--keep-open=no', '--really-quiet', file_path },
+ on_finish
+ )
+end
+
+local create_audio = function(start_timestamp, end_timestamp, filename, padding)
+ if h.is_empty(self.output_dir_path) then
+ return msg.error("Output directory wasn't provided. Audio file will not be created.")
+ end
+
+ if not h.is_empty(self.config.audio_field) then
+ local source_path = mp.get_property("path")
+ local output_path = utils.join_path(self.output_dir_path, filename)
+
+ if padding > 0 then
+ start_timestamp, end_timestamp = pad_timings(padding, start_timestamp, end_timestamp)
+ end
+
+ local function start_encoding(args)
+ local on_finish = function(success, result)
+ local conversion_check = report_creation_result(output_path)
+ if conversion_check(success, result) and self.config.preview_audio then
+ background_play(output_path, function()
+ print("Played file: " .. output_path)
+ end)
+ end
+ end
+
+ h.subprocess(args, on_finish)
+ end
+
+ self.encoder.make_audio_args(
+ source_path, output_path, start_timestamp, end_timestamp, start_encoding
+ )
+ else
+ print("Audio will not be created.")
+ end
+end
+
+local make_snapshot_filename = function(start_time, end_time, timestamp)
+ -- Generate a filename for the snapshot, taking care of its extension and whether it's animated or static
+ if self.config.animated_snapshot_enabled then
+ return filename_factory.make_filename(start_time, end_time, self.config.animated_snapshot_extension)
+ else
+ return filename_factory.make_filename(timestamp, self.config.snapshot_extension)
+ end
+end
+
+local make_audio_filename = function(start_time, end_time)
+ -- Generates a filename for the audio
+ return filename_factory.make_filename(start_time, end_time, self.config.audio_extension)
+end
+
+local toggle_animation = function()
+ -- Toggles on and off animated snapshot generation at runtime. It is called whenever ctrl+g is pressed
+ self.config.animated_snapshot_enabled = not self.config.animated_snapshot_enabled
+ h.notify("Animation " .. (self.config.animated_snapshot_enabled and "enabled" or "disabled"), "info", 2)
+end
+
+local init = function(config)
+ -- Sets the module to its preconfigured status
+ self.config = config
+ self.encoder = config.use_ffmpeg and ffmpeg or mpv
+end
+
+local set_output_dir = function(dir_path)
+ -- Set directory where media files should be saved.
+ -- This function is called every time a card is created or updated.
+ self.output_dir_path = dir_path
+end
+
+local create_job = function(type, sub, audio_padding)
+ local filename, run_async, current_timestamp
+ if type == 'snapshot' and h.has_video_track() then
+ current_timestamp = mp.get_property_number("time-pos", 0)
+ filename = make_snapshot_filename(sub['start'], sub['end'], current_timestamp)
+ run_async = function()
+ create_snapshot(sub['start'], sub['end'], current_timestamp, filename)
+ end
+ elseif type == 'audioclip' and h.has_audio_track() then
+ filename = make_audio_filename(sub['start'], sub['end'])
+ run_async = function()
+ create_audio(sub['start'], sub['end'], filename, audio_padding)
+ end
+ else
+ run_async = function()
+ print(type .. " will not be created.")
+ end
+ end
+ return {
+ filename = filename,
+ run_async = run_async,
+ }
+end
+
+return {
+ init = init,
+ set_output_dir = set_output_dir,
+ snapshot = {
+ create_job = function(sub)
+ return create_job('snapshot', sub)
+ end,
+ toggle_animation = toggle_animation,
+ },
+ audio = {
+ create_job = function(sub, padding)
+ return create_job('audioclip', sub, padding)
+ end,
+ },
+}
diff --git a/config/mpv/scripts/subs2srsa/find_anki_col.sh b/config/mpv/scripts/subs2srsa/find_anki_col.sh
new file mode 100755
index 0000000..a98199b
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/find_anki_col.sh
@@ -0,0 +1,5 @@
+#!/bin/sh
+
+# Find full path to an opened Anki collection.
+
+readlink -f -- /proc/$(pgrep '^anki$')/fd/* | grep 'collection.anki2$'
diff --git a/config/mpv/scripts/subs2srsa/helpers.lua b/config/mpv/scripts/subs2srsa/helpers.lua
new file mode 100644
index 0000000..e8f911e
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/helpers.lua
@@ -0,0 +1,280 @@
+--[[
+Copyright: Ren Tatsumoto and contributors
+License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html
+
+Various helper functions.
+]]
+
+local mp = require('mp')
+local msg = require('mp.msg')
+local utils = require('mp.utils')
+local this = {}
+
+this.unpack = unpack and unpack or table.unpack
+
+this.remove_all_spaces = function(str)
+ return str:gsub('%s*', '')
+end
+
+this.table_get = function(table, key, default)
+ if table[key] == nil then
+ return default or 'nil'
+ else
+ return table[key]
+ end
+end
+
+this.max_num = function(table)
+ local max = table[1]
+ for _, value in ipairs(table) do
+ if value > max then
+ max = value
+ end
+ end
+ return max
+end
+
+this.get_last_n_added_notes = function(note_ids, n)
+ table.sort(note_ids)
+ return { this.unpack(note_ids, math.max(#note_ids - n + 1, 1), #note_ids) }
+end
+
+this.contains = function(table, element)
+ for _, value in pairs(table) do
+ if value == element then
+ return true
+ end
+ end
+ return false
+end
+
+this.minutes_ago = function(m)
+ return (os.time() - 60 * m) * 1000
+end
+
+this.is_wayland = function()
+ return os.getenv('WAYLAND_DISPLAY') ~= nil
+end
+
+this.is_win = function()
+ return mp.get_property('options/vo-mmcss-profile') ~= nil
+end
+
+this.is_mac = function()
+ return mp.get_property('options/macos-force-dedicated-gpu') ~= nil
+end
+
+local function map(tab, func)
+ local t = {}
+ for k, v in pairs(tab) do
+ t[k] = func(v)
+ end
+ return t
+end
+
+local function args_as_str(args)
+ return table.concat(map(args, function(str) return string.format("'%s'", str) end), " ")
+end
+
+this.subprocess = function(args, completion_fn, override_settings)
+ -- if `completion_fn` is passed, the command is ran asynchronously,
+ -- and upon completion, `completion_fn` is called to process the results.
+ msg.info("Executing: " .. args_as_str(args))
+ local command_native = type(completion_fn) == 'function' and mp.command_native_async or mp.command_native
+ local command_table = {
+ name = "subprocess",
+ playback_only = false,
+ capture_stdout = true,
+ capture_stderr = true,
+ args = args
+ }
+ if not this.is_empty(override_settings) then
+ for k,v in pairs(override_settings) do
+ command_table[k] = v
+ end
+ end
+ return command_native(command_table, completion_fn)
+end
+
+this.subprocess_detached = function(args, completion_fn)
+ local overwrite_settings = {
+ detach=true,
+ capture_stdout = false,
+ capture_stderr = false,
+ }
+ return this.subprocess(args, completion_fn, overwrite_settings)
+end
+
+this.is_empty = function(var)
+ return var == nil or var == '' or (type(var) == 'table' and next(var) == nil)
+end
+
+this.contains_non_latin_letters = function(str)
+ return str:match("[^%c%p%s%w—]")
+end
+
+this.capitalize_first_letter = function(string)
+ return string:gsub("^%l", string.upper)
+end
+
+this.remove_leading_trailing_spaces = function(str)
+ return str:gsub('^%s*(.-)%s*$', '%1')
+end
+
+this.remove_leading_trailing_dashes = function(str)
+ return str:gsub('^[%-_]*(.-)[%-_]*$', '%1')
+end
+
+this.remove_text_in_parentheses = function(str)
+ -- Remove text like (泣き声) or (ドアの開く音)
+ -- No deletion is performed if there's no text after the parentheses.
+ -- Note: the modifier `-´ matches zero or more occurrences.
+ -- However, instead of matching the longest sequence, it matches the shortest one.
+ return str:gsub('(%b())(.)', '%2'):gsub('((.-))(.)', '%2')
+end
+
+this.remove_newlines = function(str)
+ return str:gsub('[\n\r]+', ' ')
+end
+
+this.trim = function(str)
+ str = this.remove_leading_trailing_spaces(str)
+ str = this.remove_text_in_parentheses(str)
+ str = this.remove_newlines(str)
+ return str
+end
+
+this.escape_special_characters = (function()
+ local entities = {
+ ['&'] = '&amp;',
+ ['"'] = '&quot;',
+ ["'"] = '&apos;',
+ ['<'] = '&lt;',
+ ['>'] = '&gt;',
+ }
+ return function(s)
+ return s:gsub('[&"\'<>]', entities)
+ end
+end)()
+
+this.remove_extension = function(filename)
+ return filename:gsub('%.%w+$', '')
+end
+
+this.remove_special_characters = function(str)
+ return str:gsub('[%c%p%s]', ''):gsub(' ', '')
+end
+
+this.remove_text_in_brackets = function(str)
+ return str:gsub('%b[]', ''):gsub('【.-】', '')
+end
+
+this.remove_filename_text_in_parentheses = function(str)
+ return str:gsub('%b()', ''):gsub('(.-)', '')
+end
+
+this.remove_common_resolutions = function(str)
+ -- Also removes empty leftover parentheses and brackets.
+ return str:gsub("2160p", ""):gsub("1080p", ""):gsub("720p", ""):gsub("576p", ""):gsub("480p", ""):gsub("%(%)", ""):gsub("%[%]", "")
+end
+
+this.human_readable_time = function(seconds)
+ if type(seconds) ~= 'number' or seconds < 0 then
+ return 'empty'
+ end
+
+ local parts = {
+ h = math.floor(seconds / 3600),
+ m = math.floor(seconds / 60) % 60,
+ s = math.floor(seconds % 60),
+ ms = math.floor((seconds * 1000) % 1000),
+ }
+
+ local ret = string.format("%02dm%02ds%03dms", parts.m, parts.s, parts.ms)
+
+ if parts.h > 0 then
+ ret = string.format('%dh%s', parts.h, ret)
+ end
+
+ return ret
+end
+
+this.get_episode_number = function(filename)
+ -- Reverses the filename to start the search from the end as the media title might contain similar numbers.
+ local filename_reversed = filename:reverse()
+
+ local ep_num_patterns = {
+ "[%s_](%d?%d?%d)[pP]?[eE]", -- Starting with E or EP (case-insensitive). "Example Series S01E01 [94Z295D1]"
+ "^(%d?%d?%d)[pP]?[eE]", -- Starting with E or EP (case-insensitive) at the end of filename. "Example Series S01E01"
+ "%)(%d?%d?%d)%(", -- Surrounded by parentheses. "Example Series (12)"
+ "%](%d?%d?%d)%[", -- Surrounded by brackets. "Example Series [01]"
+ "%s(%d?%d?%d)%s", -- Surrounded by whitespace. "Example Series 124 [1080p 10-bit]"
+ "_(%d?%d?%d)_", -- Surrounded by underscores. "Example_Series_04_1080p"
+ "^(%d?%d?%d)[%s_]", -- Ending to the episode number. "Example Series 124"
+ "(%d?%d?%d)%-edosipE", -- Prepended by "Episode-". "Example Episode-165"
+ }
+
+ local s, e, episode_num
+ for _, pattern in pairs(ep_num_patterns) do
+ s, e, episode_num = string.find(filename_reversed, pattern)
+ if not this.is_empty(episode_num) then
+ return #filename - e, #filename - s, episode_num:reverse()
+ end
+ end
+end
+
+this.notify = function(message, level, duration)
+ level = level or 'info'
+ duration = duration or 1
+ msg[level](message)
+ mp.osd_message(message, duration)
+end
+
+this.get_active_track = function(track_type)
+ -- track_type == audio|sub
+ for _, track in pairs(mp.get_property_native('track-list')) do
+ if track.type == track_type and track.selected == true then
+ return track
+ end
+ end
+ return nil
+end
+
+this.has_video_track = function()
+ return mp.get_property_native('vid') ~= false
+end
+
+this.has_audio_track = function()
+ return mp.get_property_native('aid') ~= false
+end
+
+this.str_contains = function(s, pattern)
+ return not this.is_empty(s) and string.find(string.lower(s), string.lower(pattern)) ~= nil
+end
+
+this.filter = function(arr, func)
+ local filtered = {}
+ for _, elem in ipairs(arr) do
+ if func(elem) == true then
+ table.insert(filtered, elem)
+ end
+ end
+ return filtered
+end
+
+this.file_exists = function(filepath)
+ if not this.is_empty(filepath) then
+ local info = utils.file_info(filepath)
+ if info and info.is_file and info.size > 0 then
+ return true
+ end
+ end
+ return false
+end
+
+this.get_loaded_tracks = function(track_type)
+ --- Return all sub tracks, audio tracks, etc.
+ return this.filter(mp.get_property_native('track-list'), function(track) return track.type == track_type end)
+end
+
+return this
diff --git a/config/mpv/scripts/subs2srsa/howto/add_dialog.md b/config/mpv/scripts/subs2srsa/howto/add_dialog.md
new file mode 100644
index 0000000..22093c6
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/howto/add_dialog.md
@@ -0,0 +1,9 @@
+# Open the "Add" dialog
+
+1) Open a video in `mpv`.
+1) Press <kbd>a</kbd> to open advanced menu.
+1) Optionally, press <kbd>c</kbd> and select the desired subtitle lines with the interactive selection.
+1) Press <kbd>g</kbd> to open the Add dialog in Anki.
+1) Add dictionary definitions using software like GoldenDict, Qolibri, etc.
+
+After the card is created, you can find it by typing `added:1` in the Anki Browser.
diff --git a/config/mpv/scripts/subs2srsa/howto/create_card.md b/config/mpv/scripts/subs2srsa/howto/create_card.md
new file mode 100644
index 0000000..e92f77d
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/howto/create_card.md
@@ -0,0 +1,14 @@
+# Make a simple sentence card
+
+To make a card from the currently visible subtitle line, press <kbd>Ctrl+n</kbd>.
+
+To make a card from two or more subtitle lines:
+
+1) Press <kbd>a</kbd> to open advanced menu.
+2) Press <kbd>c</kbd> to start interactive selection.
+3) Seek to the previous/next subtitle with <kbd>Shift+h</kbd> and <kbd>Shift+l</kbd>.
+4) Press <kbd>n</kbd> to create a new card.
+
+After the card is created, you can find it by typing `added:1` in the Anki Browser.
+The card doesn't contain dictionary definitions.
+You need to add them yourself afterward, using software like GoldenDict, Qolibri, etc.
diff --git a/config/mpv/scripts/subs2srsa/howto/create_quick_card.md b/config/mpv/scripts/subs2srsa/howto/create_quick_card.md
new file mode 100644
index 0000000..619794a
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/howto/create_quick_card.md
@@ -0,0 +1,31 @@
+# Quick Card Creation
+
+The goal of the quick card creation menu is to streamline a specific flow for media with **well-timed subtitles**:
+
+1) Add a new note to Anki via
+ [Rikaitan](https://tatsumoto.neocities.org/blog/setting-up-yomichan.html)
+ or any other tool that works with AnkiConnect.
+2) Update the note via mpvacious.
+
+To update the most recently added card from the currently visible subtitle line, press <kbd>gg</kbd>.
+
+To make a card from two or more subtitle lines:
+
+1) Press <kbd>g</kbd> to open the quick card creation menu.
+2) Press any number <kbd>[2-9]</kbd>. This number corresponds to the number of lines to create the card from.
+
+Note: <kbd>g1</kbd> is also valid.
+However, <kbd>gg</kbd> is an additional bind to further streamline the most common scenario.
+
+For example,
+<kbd>g2</kbd> creates a card using 2 subtitle lines.
+
+Like the advanced menu, you can also update multiple cards:
+
+1) Press <kbd>Alt+g</kbd> to select the number of cards for quick card creation.
+2) Press any number <kbd>[2-9]</kbd>. This number corresponds to the number of cards to update.
+3) Press any number <kbd>[1-9]</kbd> again. This is the number of lines.
+
+Note: upon completing the note update, the selected number of cards resets back to the default of one.
+
+For example, <kbd>(Alt+g)22</kbd> would update the last 2 notes using 2 subtitle lines.
diff --git a/config/mpv/scripts/subs2srsa/howto/flatpak.md b/config/mpv/scripts/subs2srsa/howto/flatpak.md
new file mode 100644
index 0000000..46a5787
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/howto/flatpak.md
@@ -0,0 +1,27 @@
+# Flatpak notes
+
+We think it's best to never use Flatpak.
+Specifically, try not to use Flatpak to install `mpv` and `anki`.
+Install packages from the official repositories of your distro or from the AUR.
+
+Read the following notes if you still decide to use Flatpak.
+
+Make these changes in Flatseal:
+
+* Enable "Filesystem > All system files"
+ so it could see `wl-copy`.
+ Unfortunately, there's no option to provide only a specific system file.
+* Add `~/.var/app/net.ankiweb.Anki` to "Filesystem > Other Files"
+ so mpvacious could add encoded snapshots and audio to Anki.
+* Add `PATH=/home/USERNAME/.local/bin:/home/USERNAME/bin:/app/bin:/usr/bin:/run/host/usr/bin` to "Environment > Variables".
+ There's no option to add a path to `PATH` in Flatseal,
+ so I opened container,
+ saved it's PATH and added `/run/host/usr/bin`
+ so mpvacuous could access `wl-copy`.
+* Enable "Shared > Network".
+ It's enabled by default, but anyway.
+
+The mpv config root is `~/.var/app/io.mpv.Mpv/config/mpv`
+
+* `~/.var/app/io.mpv.Mpv/config/mpv/scripts`
+* `~/.var/app/io.mpv.Mpv/config/mpv/script-opts`
diff --git a/config/mpv/scripts/subs2srsa/howto/goldendict.md b/config/mpv/scripts/subs2srsa/howto/goldendict.md
new file mode 100644
index 0000000..7f355c6
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/howto/goldendict.md
@@ -0,0 +1,45 @@
+# Modifying cards added with GoldenDict
+
+You can add a card first using GoldenDict,
+and then append an audio clip and a picture to it.
+
+**Note:** the only version of GoldenDict that can create Anki cards with configurable fields is
+[xiaoyifang's goldendict](https://github.com/xiaoyifang/goldendict-ng).
+Read [Setting up GoldenDict](https://tatsumoto-ren.github.io/blog/setting-up-goldendict.html) and
+[How to connect with Anki](https://github.com/xiaoyifang/goldendict-ng/blob/staged/website/docs/topic_anki.md)
+if you are new to GoldenDict.
+
+To send subtitles from `mpv` directly to GoldenDict,
+append the following line to `subs2srs.conf`:
+
+```
+autoclip_method=goldendict
+```
+
+**Note:** If `goldendict` is not in the PATH,
+you have to [add it to the PATH](https://wiki.archlinux.org/title/Environment_variables#Per_user).
+
+1) Press <kbd>a</kbd> to open `advanced menu`.
+2) Press <kbd>t</kbd> to toggle the `autoclip` option.
+
+Now as subtitles appear on the screen,
+they will be immediately sent to GoldenDict instead of the system clipboard.
+
+1) Open GoldenDict.
+2) Play a video in `mpv`.
+3) When you find an unknown word,
+ select the definition text,
+ right-click and select "send word to anki" to make a card,
+ or press <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>N</kbd>.
+4) Go back to mpv and add an image and an audio clip
+ to the card you've just made by pressing <kbd>m</kbd> while the `advanced menu` is open.
+ Pressing <kbd>Shift+m</kbd> will overwrite any existing data in media fields.
+
+https://github.com/Ajatt-Tools/mpvacious/assets/69171671/0fc02d24-d320-4d2c-b7a9-cb478e9f0067
+
+Don't forget to set the right timings and join lines together
+if the sentence is split between multiple subs.
+To do it, enter interactive selection by pressing <kbd>c</kbd>
+and seek to the next or previous subtitle.
+
+To pair Mecab and GoldenDict, install [gd-tools](https://github.com/Ajatt-Tools/gd-tools).
diff --git a/config/mpv/scripts/subs2srsa/howto/yomichan.md b/config/mpv/scripts/subs2srsa/howto/yomichan.md
new file mode 100644
index 0000000..9ea16d6
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/howto/yomichan.md
@@ -0,0 +1,24 @@
+# Modifying cards added with Rikaitan
+
+You can add a card first using
+[Rikaitan](https://tatsumoto.neocities.org/blog/setting-up-yomichan.html),
+and then append an audio clip and a picture to it.
+
+1) Press <kbd>a</kbd> to open `advanced menu`.
+1) Press <kbd>t</kbd> to toggle the `autoclip` option.
+
+Now as subtitles appear on the screen, they will be immediately copied to the clipboard.
+You can use it in combination with clipboard monitor.
+
+1) Open [Rikaitan Search](https://tatsumoto.neocities.org/blog/what-is-yomichan-search)
+ by pressing <kbd>Alt+Insert</kbd> in your web browser.
+1) Play a video in `mpv`.
+1) When you find an unknown word, click the <kbd>+</kbd> button to make a card for it.
+4) Go back to mpv and add an image and an audio clip
+ to the card you've just made by pressing <kbd>m</kbd> while the `advanced menu` is open.
+ Pressing <kbd>Shift+m</kbd> will overwrite any existing data in media fields.
+
+Don't forget to set the right timings and join lines together
+if the sentence is split between multiple subs.
+To do it, enter interactive selection by pressing <kbd>c</kbd>
+and seek to the next or previous subtitle.
diff --git a/config/mpv/scripts/subs2srsa/main.lua b/config/mpv/scripts/subs2srsa/main.lua
new file mode 100644
index 0000000..6817586
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/main.lua
@@ -0,0 +1 @@
+require('subs2srs')
diff --git a/config/mpv/scripts/subs2srsa/menu.lua b/config/mpv/scripts/subs2srsa/menu.lua
new file mode 100644
index 0000000..4d34937
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/menu.lua
@@ -0,0 +1,77 @@
+--[[
+Copyright: Ren Tatsumoto and contributors
+License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html
+
+Menu for mpvacious
+]]
+
+local mp = require('mp')
+local msg = require('mp.msg')
+local h = require('helpers')
+
+local Menu = {
+ active = false,
+ keybindings = {},
+ overlay = mp.create_osd_overlay and mp.create_osd_overlay('ass-events'),
+}
+
+function Menu:new(o)
+ o = o or {}
+ setmetatable(o, self)
+ self.__index = self
+ return o
+end
+
+function Menu:with_update(params)
+ return function()
+ local status, error = pcall(h.unpack(params))
+ if not status then
+ msg['error'](error)
+ end
+ self:update()
+ end
+end
+
+function Menu:make_osd()
+ return nil
+end
+
+function Menu:update()
+ if self.active == false then return end
+ self.overlay.data = self:make_osd():get_text()
+ self.overlay:update()
+end
+
+function Menu:open()
+ if self.overlay == nil then
+ h.notify("OSD overlay is not supported in " .. mp.get_property("mpv-version"), "error", 5)
+ return
+ end
+
+ if self.active == true then
+ self:close()
+ return
+ end
+
+ for _, val in pairs(self.keybindings) do
+ mp.add_forced_key_binding(val.key, val.key, val.fn)
+ end
+
+ self.active = true
+ self:update()
+end
+
+function Menu:close()
+ if self.active == false then
+ return
+ end
+
+ for _, val in pairs(self.keybindings) do
+ mp.remove_key_binding(val.key)
+ end
+
+ self.overlay:remove()
+ self.active = false
+end
+
+return Menu
diff --git a/config/mpv/scripts/subs2srsa/osd_styler.lua b/config/mpv/scripts/subs2srsa/osd_styler.lua
new file mode 100644
index 0000000..3a865dc
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/osd_styler.lua
@@ -0,0 +1,97 @@
+--[[
+A helper class for styling OSD messages
+http://docs.aegisub.org/3.2/ASS_Tags/
+
+Copyright (C) 2021 Ren Tatsumoto
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+]]
+
+local OSD = {}
+OSD.__index = OSD
+
+function OSD:new()
+ return setmetatable({ messages = {} }, self)
+end
+
+function OSD:append(s)
+ table.insert(self.messages, tostring(s))
+ return self
+end
+
+function OSD:newline()
+ return self:append([[\N]])
+end
+
+function OSD:tab()
+ return self:append([[\h\h\h\h]])
+end
+
+function OSD:size(size)
+ return self:append('{\\fs'):append(size):append('}')
+end
+
+function OSD:font(name)
+ return self:append('{\\fn'):append(name):append('}')
+end
+
+function OSD:align(number)
+ return self:append('{\\an'):append(number):append('}')
+end
+
+function OSD:get_text()
+ return table.concat(self.messages)
+end
+
+function OSD:color(code)
+ return self:append('{\\1c&H')
+ :append(code:sub(5, 6))
+ :append(code:sub(3, 4))
+ :append(code:sub(1, 2))
+ :append('&}')
+end
+
+function OSD:text(text)
+ return self:append(text)
+end
+
+function OSD:new_layer()
+ return self:append('\n')
+end
+
+function OSD:bold(s)
+ return self:append('{\\b1}'):append(s):append('{\\b0}')
+end
+
+function OSD:italics(s)
+ return self:append('{\\i1}'):append(s):append('{\\i0}')
+end
+
+function OSD:submenu(text)
+ return self:color('ffe1d0'):bold(text):color('ffffff')
+end
+
+function OSD:item(text)
+ return self:color('fef6dd'):bold(text):color('ffffff')
+end
+
+function OSD:selected(text)
+ return self:color('48a868'):bold(text):color('ffffff')
+end
+
+function OSD:red(text)
+ return self:color('ff0000'):bold(text):color('ffffff')
+end
+
+return OSD
diff --git a/config/mpv/scripts/subs2srsa/platform/init.lua b/config/mpv/scripts/subs2srsa/platform/init.lua
new file mode 100644
index 0000000..825d8d4
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/platform/init.lua
@@ -0,0 +1,14 @@
+--[[
+Copyright: Ren Tatsumoto and contributors
+License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html
+
+Platform-specific functions.
+]]
+
+local h = require('helpers')
+
+if h.is_win() then
+ return require('platform.win')
+else
+ return require('platform.nix')
+end
diff --git a/config/mpv/scripts/subs2srsa/platform/nix.lua b/config/mpv/scripts/subs2srsa/platform/nix.lua
new file mode 100644
index 0000000..cbf6c85
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/platform/nix.lua
@@ -0,0 +1,49 @@
+--[[
+Copyright: Ren Tatsumoto and contributors
+License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html
+
+Platform-specific functions for *nix systems.
+]]
+
+local h = require('helpers')
+local self = { healthy = true, clip_util = "", clip_cmd = "", }
+
+if h.is_mac() then
+ self.clip_util = "pbcopy"
+ self.clip_cmd = "LANG=en_US.UTF-8 " .. self.clip_util
+elseif h.is_wayland() then
+ local function is_wl_copy_installed()
+ local handle = h.subprocess { 'wl-copy', '--version' }
+ return handle.status == 0 and handle.stdout:match("wl%-clipboard") ~= nil
+ end
+
+ self.clip_util = "wl-copy"
+ self.clip_cmd = self.clip_util
+ self.healthy = is_wl_copy_installed()
+else
+ local function is_xclip_installed()
+ local handle = h.subprocess { 'xclip', '-version' }
+ return handle.status == 0 and handle.stderr:match("xclip version") ~= nil
+ end
+
+ self.clip_util = "xclip"
+ self.clip_cmd = self.clip_util .. " -i -selection clipboard"
+ self.healthy = is_xclip_installed()
+end
+
+self.tmp_dir = function()
+ return os.getenv("TMPDIR") or '/tmp'
+end
+
+self.copy_to_clipboard = function(text)
+ local handle = io.popen(self.clip_cmd, 'w')
+ handle:write(text)
+ handle:close()
+end
+
+self.curl_request = function(url, request_json, completion_fn)
+ local args = { 'curl', '-s', url, '-X', 'POST', '-d', request_json }
+ return h.subprocess(args, completion_fn)
+end
+
+return self
diff --git a/config/mpv/scripts/subs2srsa/platform/win.lua b/config/mpv/scripts/subs2srsa/platform/win.lua
new file mode 100644
index 0000000..e40dd7a
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/platform/win.lua
@@ -0,0 +1,72 @@
+--[[
+Copyright: Ren Tatsumoto and contributors
+License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html
+
+Platform-specific functions for Windows.
+]]
+
+local mp = require('mp')
+local h = require('helpers')
+local utils = require('mp.utils')
+local base64 = require('utils.base64')
+local self = { windows = true, healthy = true, clip_util = "cmd", }
+local tmp_files = {}
+
+mp.register_event('shutdown', function()
+ for _, file in ipairs(tmp_files) do
+ os.remove(file)
+ end
+end)
+
+self.tmp_dir = function()
+ return os.getenv('TEMP')
+end
+
+self.copy_to_clipboard = function(text)
+ local args = {
+ "powershell", "-NoLogo", "-NoProfile", "-WindowStyle", "Hidden", "-Command",
+ string.format(
+ "Set-Clipboard ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('%s')))",
+ base64.enc(text)
+ )
+ }
+ return h.subprocess_detached(
+ args,
+ function()
+ end
+ )
+end
+
+self.gen_random_tmp_file_path = function()
+ return utils.join_path(self.tmp_dir(), string.format('curl_tmp_%d.txt', math.random(10 ^ 9)))
+end
+
+self.gen_unique_tmp_file_path = function()
+ local curl_tmpfile_path = self.gen_random_tmp_file_path()
+ while h.file_exists(curl_tmpfile_path) do
+ curl_tmpfile_path = self.gen_random_tmp_file_path()
+ end
+ return curl_tmpfile_path
+end
+
+self.curl_request = function(url, request_json, completion_fn)
+ local curl_tmpfile_path = self.gen_unique_tmp_file_path()
+ local handle = io.open(curl_tmpfile_path, "w")
+ handle:write(request_json)
+ handle:close()
+ table.insert(tmp_files, curl_tmpfile_path)
+ local args = {
+ 'curl',
+ '-s',
+ url,
+ '-H',
+ 'Content-Type: application/json; charset=UTF-8',
+ '-X',
+ 'POST',
+ '--data-binary',
+ table.concat { '@', curl_tmpfile_path }
+ }
+ return h.subprocess(args, completion_fn)
+end
+
+return self
diff --git a/config/mpv/scripts/subs2srsa/subs2srs.lua b/config/mpv/scripts/subs2srsa/subs2srs.lua
new file mode 100644
index 0000000..34c79a9
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/subs2srs.lua
@@ -0,0 +1,746 @@
+--[[
+Copyright (C) 2020-2022 Ren Tatsumoto and contributors
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+Requirements:
+* mpv >= 0.32.0
+* AnkiConnect
+* curl
+* xclip (when running X11)
+* wl-copy (when running Wayland)
+
+Usage:
+1. Change `config` according to your needs
+* Config path: ~/.config/mpv/script-opts/subs2srs.conf
+* Config file isn't created automatically.
+
+2. Open a video
+
+3. Use key bindings to manipulate the script
+* Open mpvacious menu - `a`
+* Create a note from the current subtitle line - `Ctrl + n`
+
+For complete usage guide, see <https://github.com/Ajatt-Tools/mpvacious/blob/master/README.md>
+]]
+
+local mp = require('mp')
+local OSD = require('osd_styler')
+local cfg_mgr = require('cfg_mgr')
+local encoder = require('encoder.encoder')
+local h = require('helpers')
+local Menu = require('menu')
+local ankiconnect = require('ankiconnect')
+local switch = require('utils.switch')
+local play_control = require('utils.play_control')
+local secondary_sid = require('subtitles.secondary_sid')
+local platform = require('platform.init')
+local forvo = require('utils.forvo')
+local subs_observer = require('subtitles.observer')
+local codec_support = require('encoder.codec_support')
+
+local menu, quick_menu, quick_menu_card
+local quick_creation_opts = {
+ _n_lines = nil,
+ _n_cards = 1,
+ set_cards = function(self, n)
+ self._n_cards = math.max(0, n)
+ end,
+ set_lines = function(self, n)
+ self._n_lines = math.max(0, n)
+ end,
+ get_cards = function(self)
+ return self._n_cards
+ end,
+ get_lines = function(self)
+ return self._n_lines
+ end,
+ increment_cards = function(self)
+ self:set_cards(self._n_cards + 1)
+ end,
+ decrement_cards = function(self)
+ self:set_cards(self._n_cards - 1)
+ end,
+ clear_options = function(self)
+ self._n_lines = nil
+ self._n_cards = 1
+ end
+}
+------------------------------------------------------------
+-- default config
+
+local config = {
+ -- The user should not modify anything below.
+
+ -- Common
+ nuke_spaces = false, -- remove all spaces from the primary subtitles on exported anki cards and when copying text to clipboard.
+ clipboard_trim_enabled = true, -- remove unnecessary characters from strings before copying to the clipboard
+ use_ffmpeg = false, -- if set to true, use ffmpeg to create audio clips and snapshots. by default use mpv.
+ reload_config_before_card_creation = true, -- for convenience, read config file from disk before a card is made.
+
+ -- Clipboard and external communication
+ autoclip = false, -- enable copying subs to the clipboard when mpv starts
+ autoclip_method = "clipboard", -- one of the methods
+ autoclip_custom_args = "", -- command to run when autoclip is triggered and autoclip_method and set to "custom_command".
+
+ -- Secondary subtitle
+ secondary_sub_auto_load = true, -- Automatically load secondary subtitle track when a video file is opened.
+ secondary_sub_lang = 'eng,en,rus,ru,jp,jpn,ja', -- Language of secondary subs that should be automatically loaded.
+ secondary_sub_area = 0.15, -- Hover area. Fraction of the window from the top.
+ secondary_sub_visibility = 'auto', -- One of: 'auto', 'never', 'always'. Controls secondary_sid visibility. Ctrl+V to cycle.
+
+ -- Snapshots
+ snapshot_format = "avif", -- avif, webp or jpg
+ snapshot_quality = 15, -- from 0=lowest to 100=highest
+ snapshot_width = cfg_mgr.preserve_aspect_ratio, -- a positive integer or -2 for auto
+ snapshot_height = cfg_mgr.default_height_px, -- same
+ screenshot = false, -- create a screenshot instead of a snapshot; see example config.
+
+ -- Animations
+ animated_snapshot_enabled = false, -- if enabled captures the selected segment of the video, instead of just a frame
+ animated_snapshot_format = "avif", -- avif or webp
+ animated_snapshot_fps = 10, -- positive integer between 0 and 30 (30 included)
+ animated_snapshot_width = cfg_mgr.preserve_aspect_ratio, -- positive integer or -2 to scale it maintaining ratio (height must not be -2 in that case)
+ animated_snapshot_height = cfg_mgr.default_height_px, -- positive integer or -2 to scale it maintaining ratio (width must not be -2 in that case)
+ animated_snapshot_quality = 5, -- positive integer between 0 and 100 (100 included)
+
+ -- Audio clips
+ audio_format = "opus", -- opus or mp3
+ opus_container = "ogg", -- ogg, opus, m4a, webm or caf
+ audio_bitrate = "18k", -- from 16k to 32k
+ audio_padding = 0.12, -- Set a pad to the dialog timings. 0.5 = audio is padded by .5 seconds. 0 = disable.
+ tie_volumes = false, -- if set to true, the volume of the outputted audio file depends on the volume of the player at the time of export
+ preview_audio = false, -- play created audio clips in background.
+
+ -- Menu
+ menu_font_name = "Noto Sans CJK JP",
+ menu_font_size = 25,
+ show_selected_text = true,
+
+ -- Make sure to remove loudnorm from ffmpeg_audio_args and mpv_audio_args before enabling.
+ loudnorm = false,
+ loudnorm_target = -16,
+ loudnorm_range = 11,
+ loudnorm_peak = -1.5,
+
+ -- Custom encoding args
+ -- Defaults are for backward compatibility, in case someone
+ -- updates mpvacious without updating their config.
+ -- Better to remove loudnorm from custom args and enable two-pass loudnorm.
+ -- Enabling loudnorm both through the separate switch and through custom args
+ -- can lead to unpredictable results.
+ ffmpeg_audio_args = '-af loudnorm=I=-16:TP=-1.5:LRA=11:dual_mono=true',
+ mpv_audio_args = '--af-append=loudnorm=I=-16:TP=-1.5:LRA=11:dual_mono=true',
+
+ -- Anki
+ create_deck = false, -- automatically create a deck for new cards
+ allow_duplicates = false, -- allow making notes with the same sentence field
+ deck_name = "Learning", -- name of the deck for new cards
+ model_name = "Japanese sentences", -- Tools -> Manage note types
+ sentence_field = "SentKanji",
+ secondary_field = "SentEng",
+ audio_field = "SentAudio",
+ audio_template = '[sound:%s]',
+ image_field = "Image",
+ image_template = '<img alt="snapshot" src="%s">',
+ append_media = true, -- True to append video media after existing data, false to insert media before
+ disable_gui_browse = false, -- Lets you disable anki browser manipulation by mpvacious.
+ ankiconnect_url = '127.0.0.1:8765',
+
+ -- Note tagging
+ -- The tag(s) added to new notes. Spaces separate multiple tags.
+ -- Change to "" to disable tagging completely.
+ -- The following substitutions are supported:
+ -- %n - the name of the video
+ -- %t - timestamp
+ -- %d - episode number (if none found, returns nothing)
+ -- %e - SUBS2SRS_TAGS environment variable
+ note_tag = "subs2srs %n",
+ tag_nuke_brackets = true, -- delete all text inside brackets before substituting filename into tag
+ tag_nuke_parentheses = false, -- delete all text inside parentheses before substituting filename into tag
+ tag_del_episode_num = true, -- delete the episode number if found
+ tag_del_after_episode_num = true, -- delete everything after the found episode number (does nothing if tag_del_episode_num is disabled)
+ tag_filename_lowercase = false, -- convert filename to lowercase for tagging.
+
+ -- Misc info
+ miscinfo_enable = true,
+ miscinfo_field = "Notes", -- misc notes and source information field
+ miscinfo_format = "%n EP%d (%t)", -- format string to use for the miscinfo_field, accepts note_tag-style format strings
+
+ -- Forvo support
+ use_forvo = "yes", -- 'yes', 'no', 'always'
+ vocab_field = "VocabKanji", -- target word field
+ vocab_audio_field = "VocabAudio", -- target word audio
+}
+
+-- Defines config profiles
+-- Each name references a file in ~/.config/mpv/script-opts/*.conf
+-- Profiles themselves are defined in ~/.config/mpv/script-opts/subs2srs_profiles.conf
+local profiles = {
+ profiles = "subs2srs,subs2srs_english",
+ active = "subs2srs",
+}
+
+------------------------------------------------------------
+-- utility functions
+local function _(params)
+ return function()
+ return pcall(h.unpack(params))
+ end
+end
+
+local function escape_for_osd(str)
+ str = h.trim(str)
+ str = str:gsub('[%[%]{}]', '')
+ return str
+end
+
+local function ensure_deck()
+ if config.create_deck == true then
+ ankiconnect.create_deck(config.deck_name)
+ end
+end
+
+local function load_next_profile()
+ cfg_mgr.next_profile()
+ ensure_deck()
+ h.notify("Loaded profile " .. profiles.active)
+end
+
+local function tag_format(filename)
+ filename = h.remove_extension(filename)
+ filename = h.remove_common_resolutions(filename)
+
+ local s, e, episode_num = h.get_episode_number(filename)
+
+ if config.tag_del_episode_num == true and not h.is_empty(s) then
+ if config.tag_del_after_episode_num == true then
+ -- Removing everything (e.g. episode name) after the episode number including itself.
+ filename = filename:sub(1, s)
+ else
+ -- Removing the first found instance of the episode number.
+ filename = filename:sub(1, s) .. filename:sub(e + 1, -1)
+ end
+ end
+
+ if config.tag_nuke_brackets == true then
+ filename = h.remove_text_in_brackets(filename)
+ end
+ if config.tag_nuke_parentheses == true then
+ filename = h.remove_filename_text_in_parentheses(filename)
+ end
+
+ if config.tag_filename_lowercase == true then
+ filename = filename:lower()
+ end
+
+ filename = h.remove_leading_trailing_spaces(filename)
+ filename = filename:gsub(" ", "_")
+ filename = filename:gsub("_%-_", "_") -- Replaces garbage _-_ substrings with a underscore
+ filename = h.remove_leading_trailing_dashes(filename)
+ return filename, episode_num or ''
+end
+
+local substitute_fmt = (function()
+ local function substitute_filename(tag, filename)
+ return tag:gsub("%%n", filename)
+ end
+
+ local function substitute_episode_number(tag, episode)
+ return tag:gsub("%%d", episode)
+ end
+
+ local function substitute_time_pos(tag)
+ local time_pos = h.human_readable_time(mp.get_property_number('time-pos'))
+ return tag:gsub("%%t", time_pos)
+ end
+
+ local function substitute_envvar(tag)
+ local env_tags = os.getenv('SUBS2SRS_TAGS') or ''
+ return tag:gsub("%%e", env_tags)
+ end
+
+ return function(tag)
+ if not h.is_empty(tag) then
+ local filename, episode = tag_format(mp.get_property("filename"))
+ tag = substitute_filename(tag, filename)
+ tag = substitute_episode_number(tag, episode)
+ tag = substitute_time_pos(tag)
+ tag = substitute_envvar(tag)
+ tag = h.remove_leading_trailing_spaces(tag)
+ end
+ return tag
+ end
+end)()
+
+local function prepare_for_exporting(sub_text)
+ if not h.is_empty(sub_text) then
+ sub_text = h.trim(sub_text)
+ sub_text = h.escape_special_characters(sub_text)
+ end
+ return sub_text
+end
+
+local function construct_note_fields(sub_text, secondary_text, snapshot_filename, audio_filename)
+ local ret = {
+ [config.sentence_field] = subs_observer.maybe_remove_all_spaces(prepare_for_exporting(sub_text)),
+ }
+ if not h.is_empty(config.secondary_field) then
+ ret[config.secondary_field] = prepare_for_exporting(secondary_text)
+ end
+ if not h.is_empty(config.image_field) and not h.is_empty(snapshot_filename) then
+ ret[config.image_field] = string.format(config.image_template, snapshot_filename)
+ end
+ if not h.is_empty(config.audio_field) and not h.is_empty(audio_filename) then
+ ret[config.audio_field] = string.format(config.audio_template, audio_filename)
+ end
+ if config.miscinfo_enable == true then
+ ret[config.miscinfo_field] = substitute_fmt(config.miscinfo_format)
+ end
+ return ret
+end
+
+local function join_media_fields(new_data, stored_data)
+ for _, field in pairs { config.audio_field, config.image_field, config.miscinfo_field } do
+ if not h.is_empty(field) then
+ new_data[field] = h.table_get(stored_data, field, "") .. h.table_get(new_data, field, "")
+ end
+ end
+ return new_data
+end
+
+local function update_sentence(new_data, stored_data)
+ -- adds support for TSCs
+ -- https://tatsumoto-ren.github.io/blog/discussing-various-card-templates.html#targeted-sentence-cards-or-mpvacious-cards
+ -- if the target word was marked by yomichan, this function makes sure that the highlighting doesn't get erased.
+
+ if h.is_empty(stored_data[config.sentence_field]) then
+ -- sentence field is empty. can't continue.
+ return new_data
+ elseif h.is_empty(new_data[config.sentence_field]) then
+ -- *new* sentence field is empty, but old one contains data. don't delete the existing sentence.
+ new_data[config.sentence_field] = stored_data[config.sentence_field]
+ return new_data
+ end
+
+ local _, opentag, target, closetag, _ = stored_data[config.sentence_field]:match('^(.-)(<[^>]+>)(.-)(</[^>]+>)(.-)$')
+ if target then
+ local prefix, _, suffix = new_data[config.sentence_field]:match(table.concat { '^(.-)(', target, ')(.-)$' })
+ if prefix and suffix then
+ new_data[config.sentence_field] = table.concat { prefix, opentag, target, closetag, suffix }
+ end
+ end
+ return new_data
+end
+
+local function audio_padding()
+ local video_duration = mp.get_property_number('duration')
+ if config.audio_padding == 0.0 or not video_duration then
+ return 0.0
+ end
+ if subs_observer.user_altered() then
+ return 0.0
+ end
+ return config.audio_padding
+end
+
+------------------------------------------------------------
+-- front for adding and updating notes
+
+local function maybe_reload_config()
+ if config.reload_config_before_card_creation then
+ cfg_mgr.reload_from_disk()
+ end
+end
+
+local function get_anki_media_dir_path()
+ return ankiconnect.get_media_dir_path()
+end
+
+local function export_to_anki(gui)
+ maybe_reload_config()
+ local sub = subs_observer.collect_from_current()
+
+ if not sub:is_valid() then
+ return h.notify("Nothing to export.", "warn", 1)
+ end
+
+ if not gui and h.is_empty(sub['text']) then
+ sub['text'] = string.format("mpvacious wasn't able to grab subtitles (%s)", os.time())
+ end
+
+ encoder.set_output_dir(get_anki_media_dir_path())
+ local snapshot = encoder.snapshot.create_job(sub)
+ local audio = encoder.audio.create_job(sub, audio_padding())
+
+ snapshot.run_async()
+ audio.run_async()
+
+ local first_field = ankiconnect.get_first_field(config.model_name)
+ local note_fields = construct_note_fields(sub['text'], sub['secondary'], snapshot.filename, audio.filename)
+
+ if not h.is_empty(first_field) and h.is_empty(note_fields[first_field]) then
+ note_fields[first_field] = "[empty]"
+ end
+
+ ankiconnect.add_note(note_fields, substitute_fmt(config.note_tag), gui)
+ subs_observer.clear()
+end
+
+local function update_last_note(overwrite)
+ maybe_reload_config()
+ local sub
+ local n_lines = quick_creation_opts:get_lines()
+ local n_cards = quick_creation_opts:get_cards()
+ if n_lines then
+ sub = subs_observer.collect_from_all_dialogues(n_lines)
+ else
+ sub = subs_observer.collect_from_current()
+ end
+ -- this now returns a table
+ local last_note_ids = ankiconnect.get_last_note_ids(n_cards)
+ n_cards = #last_note_ids
+
+ if not sub:is_valid() then
+ return h.notify("Nothing to export. Have you set the timings?", "warn", 2)
+ end
+
+ if h.is_empty(sub['text']) then
+ -- In this case, don't modify whatever existing text there is and just
+ -- modify the other fields we can. The user might be trying to add
+ -- audio to a card which they've manually transcribed (either the video
+ -- has no subtitles or it has image subtitles).
+ sub['text'] = nil
+ end
+
+ --first element is the earliest
+
+ if h.is_empty(last_note_ids) or last_note_ids[1] < h.minutes_ago(10) then
+ return h.notify("Couldn't find the target note.", "warn", 2)
+ end
+
+ local anki_media_dir = get_anki_media_dir_path()
+ encoder.set_output_dir(anki_media_dir)
+ local snapshot = encoder.snapshot.create_job(sub)
+ local audio = encoder.audio.create_job(sub, audio_padding())
+
+ local create_media = function()
+ snapshot.run_async()
+ audio.run_async()
+ end
+ for i = 1, n_cards do
+ local new_data = construct_note_fields(sub['text'], sub['secondary'], snapshot.filename, audio.filename)
+ local stored_data = ankiconnect.get_note_fields(last_note_ids[i])
+ if stored_data then
+ forvo.set_output_dir(anki_media_dir)
+ new_data = forvo.append(new_data, stored_data)
+ new_data = update_sentence(new_data, stored_data)
+ if not overwrite then
+ if config.append_media then
+ new_data = join_media_fields(new_data, stored_data)
+ else
+ new_data = join_media_fields(stored_data, new_data)
+ end
+ end
+ end
+
+ -- If the text is still empty, put some dummy text to let the user know why
+ -- there's no text in the sentence field.
+ if h.is_empty(new_data[config.sentence_field]) then
+ new_data[config.sentence_field] = string.format("mpvacious wasn't able to grab subtitles (%s)", os.time())
+ end
+
+ ankiconnect.append_media(last_note_ids[i], new_data, create_media, substitute_fmt(config.note_tag))
+ end
+ subs_observer.clear()
+ quick_creation_opts:clear_options()
+end
+
+------------------------------------------------------------
+-- main menu
+
+menu = Menu:new {
+ hints_state = switch.new { 'basic', 'menu', 'global', 'hidden', },
+}
+
+menu.keybindings = {
+ { key = 'S', fn = menu:with_update { subs_observer.set_manual_timing_to_sub, 'start' } },
+ { key = 'E', fn = menu:with_update { subs_observer.set_manual_timing_to_sub, 'end' } },
+ { key = 's', fn = menu:with_update { subs_observer.set_manual_timing, 'start' } },
+ { key = 'e', fn = menu:with_update { subs_observer.set_manual_timing, 'end' } },
+ { key = 'c', fn = menu:with_update { subs_observer.set_to_current_sub } },
+ { key = 'r', fn = menu:with_update { subs_observer.clear_and_notify } },
+ { key = 'g', fn = menu:with_update { export_to_anki, true } },
+ { key = 'n', fn = menu:with_update { export_to_anki, false } },
+ { key = 'm', fn = menu:with_update { update_last_note, false } },
+ { key = 'M', fn = menu:with_update { update_last_note, true } },
+ { key = 'f', fn = menu:with_update { function()
+ quick_creation_opts:increment_cards()
+ end } },
+ { key = 'F', fn = menu:with_update { function()
+ quick_creation_opts:decrement_cards()
+ end } },
+ { key = 't', fn = menu:with_update { subs_observer.toggle_autocopy } },
+ { key = 'T', fn = menu:with_update { subs_observer.next_autoclip_method } },
+ { key = 'i', fn = menu:with_update { menu.hints_state.bump } },
+ { key = 'p', fn = menu:with_update { load_next_profile } },
+ { key = 'ESC', fn = function()
+ menu:close()
+ end },
+ { key = 'q', fn = function()
+ menu:close()
+ end },
+}
+
+function menu:print_header(osd)
+ if self.hints_state.get() == 'hidden' then
+ return
+ end
+ osd:submenu('mpvacious options'):newline()
+ osd:item('Timings: '):text(h.human_readable_time(subs_observer.get_timing('start')))
+ osd:item(' to '):text(h.human_readable_time(subs_observer.get_timing('end'))):newline()
+ osd:item('Clipboard autocopy: '):text(subs_observer.autocopy_status_str()):newline()
+ osd:item('Active profile: '):text(profiles.active):newline()
+ osd:item('Deck: '):text(config.deck_name):newline()
+ osd:item('# cards: '):text(quick_creation_opts:get_cards()):newline()
+end
+
+function menu:print_bindings(osd)
+ if self.hints_state.get() == 'global' then
+ osd:submenu('Global bindings'):newline()
+ osd:tab():item('ctrl+c: '):text('Copy current subtitle to clipboard'):newline()
+ osd:tab():item('ctrl+h: '):text('Seek to the start of the line'):newline()
+ osd:tab():item('ctrl+g: '):text('Toggle animated snapshots'):newline()
+ osd:tab():item('ctrl+shift+h: '):text('Replay current subtitle'):newline()
+ osd:tab():item('shift+h/l: '):text('Seek to the previous/next subtitle'):newline()
+ osd:tab():item('alt+h/l: '):text('Seek to the previous/next subtitle and pause'):newline()
+ osd:italics("Press "):item('i'):italics(" to hide mpvacious options."):newline()
+ elseif self.hints_state.get() == 'menu' then
+ osd:submenu('Menu bindings'):newline()
+ osd:tab():item('c: '):text('Set timings to the current sub'):newline()
+ osd:tab():item('s: '):text('Set start time to current position'):newline()
+ osd:tab():item('e: '):text('Set end time to current position'):newline()
+ osd:tab():item('shift+s: '):text('Set start time to current subtitle'):newline()
+ osd:tab():item('shift+e: '):text('Set end time to current subtitle'):newline()
+ osd:tab():item('f: '):text('Increment # cards to update '):italics('(+shift to decrement)'):newline()
+ osd:tab():item('r: '):text('Reset timings'):newline()
+ osd:tab():item('n: '):text('Export note'):newline()
+ osd:tab():item('g: '):text('GUI export'):newline()
+ osd:tab():item('m: '):text('Update the last added note '):italics('(+shift to overwrite)'):newline()
+ osd:tab():item('t: '):text('Toggle clipboard autocopy'):newline()
+ osd:tab():item('T: '):text('Switch to the next clipboard method'):newline()
+ osd:tab():item('p: '):text('Switch to next profile'):newline()
+ osd:tab():item('ESC: '):text('Close'):newline()
+ osd:italics("Press "):item('i'):italics(" to show global bindings."):newline()
+ elseif self.hints_state.get() == 'hidden' then
+ -- Menu bindings are active but hidden
+ else
+ osd:italics("Press "):item('i'):italics(" to show menu bindings."):newline()
+ end
+end
+
+function menu:warn_formats(osd)
+ if config.use_ffmpeg then
+ return
+ end
+ for type, codecs in pairs(codec_support) do
+ for codec, supported in pairs(codecs) do
+ if not supported and config[type .. '_codec'] == codec then
+ osd:red('warning: '):newline()
+ osd:tab():text(string.format("your version of mpv does not support %s.", codec)):newline()
+ osd:tab():text(string.format("mpvacious won't be able to create %s files.", type)):newline()
+ end
+ end
+ end
+end
+
+function menu:warn_clipboard(osd)
+ if subs_observer.autocopy_current_method() == "clipboard" and platform.healthy == false then
+ osd:red('warning: '):text(string.format("%s is not installed.", platform.clip_util)):newline()
+ end
+end
+
+function menu:print_legend(osd)
+ osd:new_layer():size(config.menu_font_size):font(config.menu_font_name):align(4)
+ self:print_header(osd)
+ self:print_bindings(osd)
+ self:warn_formats(osd)
+ self:warn_clipboard(osd)
+end
+
+function menu:print_selection(osd)
+ if subs_observer.is_appending() and config.show_selected_text then
+ osd:new_layer():size(config.menu_font_size):font(config.menu_font_name):align(6)
+ osd:submenu("Primary text"):newline()
+ for _, s in ipairs(subs_observer.recorded_subs()) do
+ osd:text(escape_for_osd(s['text'])):newline()
+ end
+ if not h.is_empty(config.secondary_field) then
+ -- If the user wants to add secondary subs to Anki,
+ -- it's okay to print them on the screen.
+ osd:submenu("Secondary text"):newline()
+ for _, s in ipairs(subs_observer.recorded_secondary_subs()) do
+ osd:text(escape_for_osd(s['text'])):newline()
+ end
+ end
+ end
+end
+
+function menu:make_osd()
+ local osd = OSD:new()
+ self:print_legend(osd)
+ self:print_selection(osd)
+ return osd
+end
+
+------------------------------------------------------------
+--quick_menu line selection
+local choose_cards = function(i)
+ quick_creation_opts:set_cards(i)
+ quick_menu_card:close()
+ quick_menu:open()
+end
+local choose_lines = function(i)
+ quick_creation_opts:set_lines(i)
+ update_last_note(true)
+ quick_menu:close()
+end
+
+quick_menu = Menu:new()
+quick_menu.keybindings = {}
+for i = 1, 9 do
+ table.insert(quick_menu.keybindings, { key = tostring(i), fn = function()
+ choose_lines(i)
+ end })
+end
+table.insert(quick_menu.keybindings, { key = 'g', fn = function()
+ choose_lines(1)
+end })
+table.insert(quick_menu.keybindings, { key = 'ESC', fn = function()
+ quick_menu:close()
+end })
+table.insert(quick_menu.keybindings, { key = 'q', fn = function()
+ quick_menu:close()
+end })
+function quick_menu:print_header(osd)
+ osd:submenu('quick card creation: line selection'):newline()
+ osd:item('# lines: '):text('Enter 1-9'):newline()
+end
+function quick_menu:print_legend(osd)
+ osd:new_layer():size(config.menu_font_size):font(config.menu_font_name):align(4)
+ self:print_header(osd)
+ menu:warn_formats(osd)
+end
+function quick_menu:make_osd()
+ local osd = OSD:new()
+ self:print_legend(osd)
+ return osd
+end
+
+-- quick_menu card selection
+quick_menu_card = Menu:new()
+quick_menu_card.keybindings = {}
+for i = 1, 9 do
+ table.insert(quick_menu_card.keybindings, { key = tostring(i), fn = function()
+ choose_cards(i)
+ end })
+end
+table.insert(quick_menu_card.keybindings, { key = 'ESC', fn = function()
+ quick_menu_card:close()
+end })
+table.insert(quick_menu_card.keybindings, { key = 'q', fn = function()
+ quick_menu_card:close()
+end })
+function quick_menu_card:print_header(osd)
+ osd:submenu('quick card creation: card selection'):newline()
+ osd:item('# cards: '):text('Enter 1-9'):newline()
+end
+function quick_menu_card:print_legend(osd)
+ osd:new_layer():size(config.menu_font_size):font(config.menu_font_name):align(4)
+ self:print_header(osd)
+ menu:warn_formats(osd)
+end
+function quick_menu_card:make_osd()
+ local osd = OSD:new()
+ self:print_legend(osd)
+ return osd
+end
+
+------------------------------------------------------------
+-- main
+
+local main = (function()
+ local main_executed = false
+ return function()
+ if main_executed then
+ subs_observer.clear_all_dialogs()
+ return
+ else
+ main_executed = true
+ end
+
+ cfg_mgr.init(config, profiles)
+ ankiconnect.init(config, platform)
+ forvo.init(config, platform)
+ encoder.init(config)
+ secondary_sid.init(config)
+ ensure_deck()
+ subs_observer.init(menu, config)
+
+ -- Key bindings
+ mp.add_forced_key_binding("Ctrl+c", "mpvacious-copy-sub-to-clipboard", subs_observer.copy_current_primary_to_clipboard)
+ mp.add_key_binding("Ctrl+C", "mpvacious-copy-secondary-sub-to-clipboard", subs_observer.copy_current_secondary_to_clipboard)
+ mp.add_key_binding("Ctrl+t", "mpvacious-autocopy-toggle", subs_observer.toggle_autocopy)
+ mp.add_key_binding("Ctrl+g", "mpvacious-animated-snapshot-toggle", encoder.snapshot.toggle_animation)
+
+ -- Secondary subtitles
+ mp.add_key_binding("Ctrl+v", "mpvacious-secondary-sid-toggle", secondary_sid.change_visibility)
+ mp.add_key_binding("Ctrl+k", "mpvacious-secondary-sid-prev", secondary_sid.select_previous)
+ mp.add_key_binding("Ctrl+j", "mpvacious-secondary-sid-next", secondary_sid.select_next)
+
+ -- Open advanced menu
+ mp.add_key_binding("a", "mpvacious-menu-open", function()
+ menu:open()
+ end)
+
+ -- Add note
+ mp.add_forced_key_binding("Ctrl+n", "mpvacious-export-note", menu:with_update { export_to_anki, false })
+
+ -- Note updating
+ mp.add_key_binding("Ctrl+m", "mpvacious-update-last-note", menu:with_update { update_last_note, false })
+ mp.add_key_binding("Ctrl+M", "mpvacious-overwrite-last-note", menu:with_update { update_last_note, true })
+
+ mp.add_key_binding("g", "mpvacious-quick-card-menu-open", function()
+ quick_menu:open()
+ end)
+ mp.add_key_binding("Alt+g", "mpvacious-quick-card-sel-menu-open", function()
+ quick_menu_card:open()
+ end)
+
+ -- Vim-like seeking between subtitle lines
+ mp.add_key_binding("H", "mpvacious-sub-seek-back", _ { play_control.sub_seek, 'backward' })
+ mp.add_key_binding("L", "mpvacious-sub-seek-forward", _ { play_control.sub_seek, 'forward' })
+
+ mp.add_key_binding("Alt+h", "mpvacious-sub-seek-back-pause", _ { play_control.sub_seek, 'backward', true })
+ mp.add_key_binding("Alt+l", "mpvacious-sub-seek-forward-pause", _ { play_control.sub_seek, 'forward', true })
+
+ mp.add_key_binding("Ctrl+h", "mpvacious-sub-rewind", _ { play_control.sub_rewind })
+ mp.add_key_binding("Ctrl+H", "mpvacious-sub-replay", _ { play_control.play_till_sub_end })
+ mp.add_key_binding("Ctrl+L", "mpvacious-sub-play-up-to-next", _ { play_control.play_till_next_sub_end })
+
+ mp.msg.warn("Press 'a' to open the mpvacious menu.")
+ end
+end)()
+
+mp.register_event("file-loaded", main)
diff --git a/config/mpv/scripts/subs2srsa/subtitles/observer.lua b/config/mpv/scripts/subs2srsa/subtitles/observer.lua
new file mode 100644
index 0000000..d9284c5
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/subtitles/observer.lua
@@ -0,0 +1,344 @@
+--[[
+Copyright: Ren Tatsumoto and contributors
+License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html
+
+Observer waits for subtitles to appear on the screen and adds them to a list.
+]]
+
+local h = require('helpers')
+local timings = require('utils.timings')
+local sub_list = require('subtitles.sub_list')
+local Subtitle = require('subtitles.subtitle')
+local mp = require('mp')
+local platform = require('platform.init')
+local switch = require('utils.switch')
+
+local self = {}
+
+local dialogs = sub_list.new()
+local secondary_dialogs = sub_list.new()
+local all_dialogs = sub_list.new()
+local all_secondary_dialogs = sub_list.new()
+local user_timings = timings.new()
+
+local append_dialogue = false
+local autoclip_enabled = false
+local autoclip_method = {}
+
+
+------------------------------------------------------------
+-- private
+
+local function copy_primary_sub()
+ if autoclip_enabled then
+ autoclip_method.call()
+ end
+end
+
+local function append_primary_sub()
+ local current_sub = Subtitle:now()
+ all_dialogs.insert(current_sub)
+ if append_dialogue and dialogs.insert(current_sub) then
+ self.menu:update()
+ end
+end
+
+local function append_secondary_sub()
+ local current_secondary = Subtitle:now('secondary')
+ all_secondary_dialogs.insert(current_secondary)
+ if append_dialogue and secondary_dialogs.insert(Subtitle:now('secondary')) then
+ self.menu:update()
+ end
+end
+
+local function start_appending()
+ append_dialogue = true
+ append_primary_sub()
+ append_secondary_sub()
+end
+
+local function handle_primary_sub()
+ append_primary_sub()
+ copy_primary_sub()
+end
+
+local function handle_secondary_sub()
+ append_secondary_sub()
+end
+
+local function on_external_finish(success, result, error)
+ if success ~= true or error ~= nil then
+ h.notify("Command failed: " .. table.concat(result))
+ end
+end
+
+local function external_command_args(cur_lines)
+ local args = {}
+ for arg in string.gmatch(self.config.autoclip_custom_args, "%S+") do
+ if arg == '%MPV_PRIMARY%' then
+ arg = cur_lines.primary
+ elseif arg == '%MPV_SECONDARY%' then
+ arg = cur_lines.secondary
+ end
+ table.insert(args, arg)
+ end
+ return args
+end
+
+local function call_external_command(cur_lines)
+ if not h.is_empty(self.config.autoclip_custom_args) then
+ h.subprocess(external_command_args(cur_lines), on_external_finish)
+ end
+end
+
+local function current_subtitle_lines()
+ local primary = dialogs.get_text()
+
+ if h.is_empty(primary) then
+ primary = mp.get_property("sub-text")
+ end
+
+ if h.is_empty(primary) then
+ return nil
+ end
+
+ local secondary = secondary_dialogs.get_text()
+
+ if h.is_empty(secondary) then
+ secondary = mp.get_property("secondary-sub-text") or ""
+ end
+
+ return { primary = self.clipboard_prepare(primary), secondary = secondary }
+end
+
+local function ensure_goldendict_running()
+ --- Ensure that goldendict is running and is disowned by mpv.
+ --- Avoid goldendict getting killed when mpv exits.
+ if autoclip_enabled and self.autocopy_current_method() == "goldendict" then
+ os.execute("setsid -f goldendict")
+ end
+end
+
+------------------------------------------------------------
+-- autoclip methods
+
+autoclip_method = (function()
+ local methods = { 'clipboard', 'goldendict', 'custom_command', }
+ local current_method = switch.new(methods)
+
+ local function call()
+ local cur_lines = current_subtitle_lines()
+ if h.is_empty(cur_lines) then
+ return
+ end
+
+ if current_method.get() == 'clipboard' then
+ self.copy_to_clipboard("autocopy action", cur_lines.primary)
+ elseif current_method.get() == 'goldendict' then
+ h.subprocess_detached({ 'goldendict', cur_lines.primary }, on_external_finish)
+ elseif current_method.get() == 'custom_command' then
+ call_external_command(cur_lines)
+ end
+ end
+
+ return {
+ call = call,
+ get = current_method.get,
+ bump = current_method.bump,
+ set = current_method.set,
+ }
+end)()
+
+local function copy_subtitle(subtitle_id)
+ self.copy_to_clipboard("copy-on-demand", mp.get_property(subtitle_id))
+end
+
+------------------------------------------------------------
+-- public
+
+self.copy_to_clipboard = function(_, text)
+ if platform.healthy == false then
+ h.notify(platform.clip_util .. " is not installed.", "error", 5)
+ end
+ if not h.is_empty(text) then
+ platform.copy_to_clipboard(self.clipboard_prepare(text))
+ end
+end
+
+self.clipboard_prepare = function(text)
+ text = self.config.clipboard_trim_enabled and h.trim(text) or h.remove_newlines(text)
+ text = self.maybe_remove_all_spaces(text)
+ return text
+end
+
+self.maybe_remove_all_spaces = function(str)
+ if self.config.nuke_spaces == true and h.contains_non_latin_letters(str) then
+ return h.remove_all_spaces(str)
+ else
+ return str
+ end
+end
+
+self.copy_current_primary_to_clipboard = function()
+ copy_subtitle("sub-text")
+end
+
+self.copy_current_secondary_to_clipboard = function()
+ copy_subtitle("secondary-sub-text")
+end
+
+self.user_altered = function()
+ --- Return true if the user manually set at least start or end.
+ return user_timings.is_set('start') or user_timings.is_set('end')
+end
+
+self.get_timing = function(position)
+ if user_timings.is_set(position) then
+ return user_timings.get(position)
+ elseif not dialogs.is_empty() then
+ return dialogs.get_time(position)
+ end
+ return -1
+end
+
+self.collect_from_all_dialogues = function(n_lines)
+ local current_sub = Subtitle:now()
+ local current_secondary_sub = Subtitle:now('secondary')
+ all_dialogs.insert(current_sub)
+ all_secondary_dialogs.insert(current_secondary_sub)
+ if current_sub == nil then
+ return Subtitle:new() -- return a default empty new Subtitle to let consumer handle
+ end
+ local text, end_sub = all_dialogs.get_n_text(current_sub, n_lines)
+ local secondary_text, _
+ if current_secondary_sub == nil then
+ secondary_text = ''
+ else
+ secondary_text, _ = all_secondary_dialogs.get_n_text(current_secondary_sub, n_lines) -- we'll use main sub's timing
+ end
+ return Subtitle:new {
+ ['text'] = text,
+ ['secondary'] = secondary_text,
+ ['start'] = current_sub['start'],
+ ['end'] = end_sub['end'],
+ }
+end
+
+self.collect_from_current = function()
+ --- Return all recorded subtitle lines as one subtitle object.
+ --- The caller has to call subs_observer.clear() afterwards.
+ if dialogs.is_empty() then
+ dialogs.insert(Subtitle:now())
+ end
+ if secondary_dialogs.is_empty() then
+ secondary_dialogs.insert(Subtitle:now('secondary'))
+ end
+ return Subtitle:new {
+ ['text'] = dialogs.get_text(),
+ ['secondary'] = secondary_dialogs.get_text(),
+ ['start'] = self.get_timing('start'),
+ ['end'] = self.get_timing('end'),
+ }
+end
+
+self.set_manual_timing = function(position)
+ user_timings.set(position, mp.get_property_number('time-pos') - mp.get_property("audio-delay"))
+ h.notify(h.capitalize_first_letter(position) .. " time has been set.")
+ start_appending()
+end
+
+self.set_manual_timing_to_sub = function(position)
+ local sub = Subtitle:now()
+ if sub then
+ user_timings.set(position, sub[position] - mp.get_property("audio-delay"))
+ h.notify(h.capitalize_first_letter(position) .. " time has been set.")
+ start_appending()
+ else
+ h.notify("There's no visible subtitle.", "info", 2)
+ end
+end
+
+self.set_to_current_sub = function()
+ self.clear()
+ if Subtitle:now() then
+ start_appending()
+ h.notify("Timings have been set to the current sub.", "info", 2)
+ else
+ h.notify("There's no visible subtitle.", "info", 2)
+ end
+end
+
+self.clear = function()
+ append_dialogue = false
+ dialogs = sub_list.new()
+ secondary_dialogs = sub_list.new()
+ user_timings = timings.new()
+end
+
+self.clear_all_dialogs = function()
+ all_dialogs = sub_list.new()
+ all_secondary_dialogs = sub_list.new()
+end
+
+self.clear_and_notify = function()
+ --- Clear then notify the user.
+ --- Called by the OSD menu when the user presses a button to drop recorded subtitles.
+ self.clear()
+ h.notify("Timings have been reset.", "info", 2)
+end
+
+self.is_appending = function()
+ return append_dialogue
+end
+
+self.recorded_subs = function()
+ return dialogs.get_subs_list()
+end
+
+self.recorded_secondary_subs = function()
+ return secondary_dialogs.get_subs_list()
+end
+
+self.autocopy_status_str = function()
+ return string.format(
+ "%s (%s)",
+ (autoclip_enabled and 'enabled' or 'disabled'),
+ autoclip_method.get():gsub('_', ' ')
+ )
+end
+
+self.autocopy_current_method = function()
+ return autoclip_method.get()
+end
+
+local function notify_autocopy()
+ if autoclip_enabled then
+ copy_primary_sub()
+ end
+ h.notify(string.format("Clipboard autocopy has been %s.", self.autocopy_status_str()))
+end
+
+self.toggle_autocopy = function()
+ autoclip_enabled = not autoclip_enabled
+ notify_autocopy()
+end
+
+self.next_autoclip_method = function()
+ autoclip_method.bump()
+ notify_autocopy()
+end
+
+self.init = function(menu, config)
+ self.menu = menu
+ self.config = config
+
+ -- The autoclip state is copied as a local value
+ -- to prevent it from being reset when the user reloads the config file.
+ autoclip_enabled = self.config.autoclip
+ autoclip_method.set(self.config.autoclip_method)
+
+ mp.observe_property("sub-text", "string", handle_primary_sub)
+ mp.observe_property("secondary-sub-text", "string", handle_secondary_sub)
+end
+
+return self
diff --git a/config/mpv/scripts/subs2srsa/subtitles/secondary_sid.lua b/config/mpv/scripts/subs2srsa/subtitles/secondary_sid.lua
new file mode 100644
index 0000000..21576e8
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/subtitles/secondary_sid.lua
@@ -0,0 +1,196 @@
+--[[
+Copyright: Ren Tatsumoto and contributors
+License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html
+
+This module automatically finds and sets secondary sid if it's not already set.
+Secondary sid will be shown when mouse is moved to the top part of the mpv window.
+]]
+
+local mp = require('mp')
+local h = require('helpers')
+
+local self = {
+ visibility = 'auto',
+ visibility_states = { auto = true, never = true, always = true, },
+}
+
+local function is_accepted_language(sub_lang)
+ -- for missing keys compares nil to true
+ return self.accepted_languages[sub_lang] == true
+end
+
+local function is_selected_language(track, active_track)
+ return track.id == mp.get_property_native('sid') or (active_track and active_track.lang == track.lang)
+end
+
+local function is_full(track)
+ return h.str_contains(track.title, 'full')
+end
+
+local function is_garbage(track)
+ for _, keyword in pairs({ 'song', 'sign', 'caption', 'commentary' }) do
+ if h.str_contains(track.title, keyword) then
+ return true
+ end
+ end
+ return false
+end
+
+local function prioritize_full_subs(tracks_list)
+ return table.sort(tracks_list, function(first, second)
+ return (is_full(first) and not is_full(second)) or (is_garbage(second) and not is_garbage(first))
+ end)
+end
+
+local function find_best_secondary_sid()
+ local active_track = h.get_active_track('sub')
+ local sub_tracks = h.get_loaded_tracks('sub')
+ prioritize_full_subs(sub_tracks)
+ for _, track in ipairs(sub_tracks) do
+ if is_accepted_language(track.lang) and not is_selected_language(track, active_track) then
+ return track.id
+ end
+ end
+ return nil
+end
+
+local function window_height()
+ return mp.get_property_native('osd-dimensions/h')
+end
+
+local function get_accepted_sub_langs()
+ local languages = {}
+ for lang in self.config.secondary_sub_lang:gmatch('[a-zA-Z-]+') do
+ languages[lang] = true
+ end
+ return languages
+end
+
+local function on_mouse_move(_, state)
+ -- state = {x=int,y=int, hover=true|false, }
+ if mp.get_property_native('secondary-sid') and self.visibility == 'auto' and state ~= nil then
+ mp.set_property_bool(
+ 'secondary-sub-visibility',
+ state.hover and (state.y / window_height()) < self.config.secondary_sub_area
+ )
+ end
+end
+
+local function on_file_loaded()
+ -- If secondary sid is not already set, try to find and set it.
+ local secondary_sid = mp.get_property_native('secondary-sid')
+ if secondary_sid == false and self.config.secondary_sub_auto_load == true then
+ secondary_sid = find_best_secondary_sid()
+ if secondary_sid ~= nil then
+ mp.set_property_native('secondary-sid', secondary_sid)
+ end
+ end
+end
+
+local function update_visibility()
+ mp.set_property_bool('secondary-sub-visibility', self.visibility == 'always')
+end
+
+local function init(config)
+ self.config = config
+ self.visibility = config.secondary_sub_visibility
+ self.accepted_languages = get_accepted_sub_langs()
+ mp.register_event('file-loaded', on_file_loaded)
+ if config.secondary_sub_area > 0 then
+ mp.observe_property('mouse-pos', 'native', on_mouse_move)
+ end
+ update_visibility()
+end
+
+local function change_visibility()
+ while true do
+ self.visibility = next(self.visibility_states, self.visibility)
+ if self.visibility ~= nil then
+ break
+ end
+ end
+ update_visibility()
+ h.notify("Secondary sid visibility: " .. self.visibility)
+end
+
+local function compare_by_preference_then_id(track1, track2)
+ if is_accepted_language(track1.lang) and not is_accepted_language(track2.lang) then
+ return true
+ elseif not is_accepted_language(track1.lang) and is_accepted_language(track2.lang) then
+ return false
+ else
+ return (track1.id < track2.id)
+ end
+end
+
+local function split_before_after(previous_tracks, next_tracks, all_tracks, current_track_id)
+ -- works like take_while() and drop_while() combined
+ local prev = true
+ for _, track in ipairs(all_tracks) do
+ if prev == true and track.id == current_track_id then
+ prev = false
+ end
+ if track.id ~= current_track_id then
+ if prev then
+ table.insert(previous_tracks, track)
+ else
+ table.insert(next_tracks, track)
+ end
+ end
+ end
+end
+
+local function not_primary_sid(track)
+ return mp.get_property_native('sid') ~= track.id
+end
+
+local function find_new_secondary_sub(direction)
+ local subtitle_tracks = h.filter(h.get_loaded_tracks('sub'), not_primary_sid)
+ table.sort(subtitle_tracks, compare_by_preference_then_id)
+
+ local secondary_sid = mp.get_property_native('secondary-sid')
+ local new_secondary_sub = { id = false, title = "removed" }
+
+ if #subtitle_tracks > 0 then
+ if not secondary_sid then
+ new_secondary_sub = (direction == 'prev') and subtitle_tracks[#subtitle_tracks] or subtitle_tracks[1]
+ else
+ local previous_tracks = {}
+ local next_tracks = {}
+ split_before_after(previous_tracks, next_tracks, subtitle_tracks, secondary_sid)
+ if direction == 'prev' and #previous_tracks > 0 then
+ new_secondary_sub = previous_tracks[#previous_tracks]
+ elseif direction == 'next' and #next_tracks > 0 then
+ new_secondary_sub = next_tracks[1]
+ end
+ end
+ end
+ return new_secondary_sub
+end
+
+local function switch_secondary_sid(direction)
+ local new_secondary_sub = find_new_secondary_sub(direction)
+
+ mp.set_property_native('secondary-sid', new_secondary_sub.id)
+ if new_secondary_sub.id == false then
+ h.notify("Removed secondary sid.")
+ else
+ h.notify(string.format(
+ "Secondary #%d: %s (%s)",
+ new_secondary_sub.id,
+ new_secondary_sub.title or "No title",
+ new_secondary_sub.lang or "Unknown"
+ ))
+ end
+end
+
+return {
+ init = init,
+ change_visibility = change_visibility,
+ select_previous = function()
+ switch_secondary_sid('prev')
+ end,
+ select_next = function()
+ switch_secondary_sid('next')
+ end,
+}
diff --git a/config/mpv/scripts/subs2srsa/subtitles/sub_list.lua b/config/mpv/scripts/subs2srsa/subtitles/sub_list.lua
new file mode 100644
index 0000000..de7e1c2
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/subtitles/sub_list.lua
@@ -0,0 +1,74 @@
+--[[
+Copyright: Ren Tatsumoto and contributors
+License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html
+
+Subtitle list remembers selected subtitle lines.
+]]
+
+local h = require('helpers')
+
+local new_sub_list = function()
+ local subs_list = {}
+
+ local find_i = function(sub)
+ for i, v in ipairs(subs_list) do
+ if sub < v then
+ return i
+ end
+ end
+ return #subs_list + 1
+ end
+ local get_time = function(position)
+ local i = position == 'start' and 1 or #subs_list
+ return subs_list[i][position]
+ end
+ local get_text = function()
+ local speech = {}
+ for _, sub in ipairs(subs_list) do
+ table.insert(speech, sub['text'])
+ end
+ return table.concat(speech, ' ')
+ end
+ local get_n_text = function(sub, n_lines)
+ local speech = {}
+ local end_sub = sub
+ for _, v in ipairs(subs_list) do
+ if v['start'] - end_sub['end'] >= 20 then
+ break
+ end
+ if v >= sub and #speech < n_lines then
+ table.insert(speech, v['text'])
+ end_sub = v
+ end
+ end
+ return table.concat(speech, ' '), end_sub
+ end
+ local insert = function(sub)
+ if sub ~= nil and not h.contains(subs_list, sub) then
+ table.insert(subs_list, find_i(sub), sub)
+ return true
+ end
+ return false
+ end
+ local get_subs_list = function()
+ local copy = {}
+ for key, value in pairs(subs_list) do
+ copy[key] = value
+ end
+ return copy
+ end
+ return {
+ get_subs_list = get_subs_list,
+ get_time = get_time,
+ get_text = get_text,
+ get_n_text = get_n_text,
+ insert = insert,
+ is_empty = function()
+ return h.is_empty(subs_list)
+ end,
+ }
+end
+
+return {
+ new = new_sub_list,
+}
diff --git a/config/mpv/scripts/subs2srsa/subtitles/subtitle.lua b/config/mpv/scripts/subs2srsa/subtitles/subtitle.lua
new file mode 100644
index 0000000..4e8df41
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/subtitles/subtitle.lua
@@ -0,0 +1,56 @@
+--[[
+Copyright: Ren Tatsumoto and contributors
+License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html
+
+Subtitle class provides methods for storing and comparing subtitle lines.
+]]
+
+local mp = require('mp')
+
+local Subtitle = {
+ ['text'] = '',
+ ['secondary'] = '',
+ ['start'] = -1,
+ ['end'] = -1,
+}
+
+function Subtitle:new(o)
+ o = o or {}
+ setmetatable(o, self)
+ self.__index = self
+ return o
+end
+
+function Subtitle:now(secondary)
+ local prefix = secondary and "secondary-" or ""
+ local this = self:new {
+ ['text'] = mp.get_property(prefix .. "sub-text"),
+ ['start'] = mp.get_property_number(prefix .. "sub-start"),
+ ['end'] = mp.get_property_number(prefix .. "sub-end"),
+ }
+ if this:is_valid() then
+ return this:delay(mp.get_property_native("sub-delay") - mp.get_property_native("audio-delay"))
+ else
+ return nil
+ end
+end
+
+function Subtitle:delay(delay)
+ self['start'] = self['start'] + delay
+ self['end'] = self['end'] + delay
+ return self
+end
+
+function Subtitle:is_valid()
+ return self['start'] and self['end'] and self['start'] >= 0 and self['end'] > self['start']
+end
+
+Subtitle.__eq = function(lhs, rhs)
+ return lhs['text'] == rhs['text']
+end
+
+Subtitle.__lt = function(lhs, rhs)
+ return lhs['start'] < rhs['start']
+end
+
+return Subtitle
diff --git a/config/mpv/scripts/subs2srsa/test.lua b/config/mpv/scripts/subs2srsa/test.lua
new file mode 100644
index 0000000..d1291d2
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/test.lua
@@ -0,0 +1,32 @@
+local helpers = require('helpers')
+
+local function assert_equals(expected, actual)
+ if expected ~= actual then
+ error(string.format("TEST FAILED: Expected '%s', got '%s'", expected, actual))
+ end
+end
+
+local function test_get_episode_number()
+ local test_cases = {
+ { nil, "A Whisker Away.mkv" },
+ { nil, "[Placeholder] Gekijouban SHIROBAKO [Ma10p_1080p][x265_flac]" },
+ { "06", "[Placeholder] Sono Bisque Doll wa Koi wo Suru - 06 [54E495D0]" },
+ { "02", "(Hi10)_Kobayashi-san_Chi_no_Maid_Dragon_-_02_(BD_1080p)_(Placeholder)_(12C5D2B4)" },
+ { "01", "[Placeholder] Koi to Yobu ni wa Kimochi Warui - 01 (1080p) [D517C9F0]" },
+ { "01", "[Placeholder] Tsukimonogatari 01 [BD 1080p x264 10-bit FLAC] [5CD88145]" },
+ { "01", "[Placeholder] 86 - Eighty Six - 01 (1080p) [1B13598F]" },
+ { "00", "[Placeholder] Fate Stay Night - Unlimited Blade Works - 00 (BD 1080p Hi10 FLAC) [95590B7F]" },
+ { "01", "House, M.D. S01E01 Pilot - Everybody Lies (1080p x265 Placeholder)" },
+ { "165", "A Generic Episode-165" }
+ }
+
+ for _, case in pairs(test_cases) do
+ local _, _, episode_num = helpers.get_episode_number(case[2])
+ assert_equals(case[1], episode_num)
+ end
+end
+
+-- Runs tests
+test_get_episode_number()
+
+os.exit(print("Tests passed"))
diff --git a/config/mpv/scripts/subs2srsa/utils/base64.lua b/config/mpv/scripts/subs2srsa/utils/base64.lua
new file mode 100644
index 0000000..0fe2d06
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/utils/base64.lua
@@ -0,0 +1,46 @@
+--[[
+Copyright: Ren Tatsumoto and contributors
+License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html
+
+Encoding and decoding in base64
+]]
+
+-- http://lua-users.org/wiki/BaseSixtyFour
+
+-- character table string
+local b = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
+
+-- encoding
+local function enc(data)
+ return ((data:gsub('.', function(x)
+ local r,b='',x:byte()
+ for i=8,1,-1 do r=r..(b%2^i-b%2^(i-1)>0 and '1' or '0') end
+ return r;
+ end)..'0000'):gsub('%d%d%d?%d?%d?%d?', function(x)
+ if (#x < 6) then return '' end
+ local c=0
+ for i=1,6 do c=c+(x:sub(i,i)=='1' and 2^(6-i) or 0) end
+ return b:sub(c+1,c+1)
+ end)..({ '', '==', '=' })[#data%3+1])
+end
+
+-- decoding
+local function dec(data)
+ data = string.gsub(data, '[^'..b..'=]', '')
+ return (data:gsub('.', function(x)
+ if (x == '=') then return '' end
+ local r,f='',(b:find(x)-1)
+ for i=6,1,-1 do r=r..(f%2^i-f%2^(i-1)>0 and '1' or '0') end
+ return r;
+ end):gsub('%d%d%d?%d?%d?%d?%d?%d?', function(x)
+ if (#x ~= 8) then return '' end
+ local c=0
+ for i=1,8 do c=c+(x:sub(i,i)=='1' and 2^(8-i) or 0) end
+ return string.char(c)
+ end))
+end
+
+return {
+ enc = enc,
+ dec = dec,
+}
diff --git a/config/mpv/scripts/subs2srsa/utils/filename_factory.lua b/config/mpv/scripts/subs2srsa/utils/filename_factory.lua
new file mode 100644
index 0000000..a794fc7
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/utils/filename_factory.lua
@@ -0,0 +1,89 @@
+--[[
+Copyright: Ren Tatsumoto and contributors
+License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html
+
+Creates image and audio filenames compatible with Anki.
+]]
+
+local mp = require('mp')
+local h = require('helpers')
+
+local filename
+
+local anki_compatible_length = (function()
+ -- Anki forcibly mutilates all filenames longer than 119 bytes when you run `Tools->Check Media...`.
+ local allowed_bytes = 119
+ local timestamp_bytes = #'_99h99m99s999ms-99h99m99s999ms.webp'
+
+ return function(str, timestamp)
+ -- if timestamp provided, recalculate limit_bytes
+ local limit_bytes = allowed_bytes - (timestamp and #timestamp or timestamp_bytes)
+
+ if #str <= limit_bytes then
+ return str
+ end
+
+ local bytes_per_char = h.contains_non_latin_letters(str) and #'車' or #'z'
+ local limit_chars = math.floor(limit_bytes / bytes_per_char)
+
+ if limit_chars == limit_bytes then
+ return str:sub(1, limit_bytes)
+ end
+
+ local ret = h.subprocess {
+ 'awk',
+ '-v', string.format('str=%s', str),
+ '-v', string.format('limit=%d', limit_chars),
+ 'BEGIN{print substr(str, 1, limit); exit}'
+ }
+
+ if ret.status == 0 then
+ ret.stdout = h.remove_newlines(ret.stdout)
+ ret.stdout = h.remove_leading_trailing_spaces(ret.stdout)
+ return ret.stdout
+ else
+ return 'subs2srs_' .. os.time()
+ end
+ end
+end)()
+
+local make_media_filename = function()
+ filename = mp.get_property("filename") -- filename without path
+ filename = h.remove_extension(filename)
+ filename = h.remove_filename_text_in_parentheses(filename)
+ filename = h.remove_text_in_brackets(filename)
+ filename = h.remove_special_characters(filename)
+end
+
+local function timestamp_range(start_timestamp, end_timestamp, extension)
+ -- Generates a filename suffix of the form: _00h00m00s000ms-99h99m99s999ms.extension
+ -- Extension must already contain the dot.
+ return string.format(
+ '_%s_%s%s',
+ h.human_readable_time(start_timestamp),
+ h.human_readable_time(end_timestamp),
+ extension
+ )
+end
+
+local function timestamp_static(timestamp, extension)
+ -- Generates a filename suffix of the form: _00h00m00s000ms.extension
+ -- Extension must already contain the dot.
+ return string.format(
+ '_%s%s',
+ h.human_readable_time(timestamp),
+ extension
+ )
+end
+
+local make_filename = function(...)
+ local args = {...}
+ local timestamp = #args < 3 and timestamp_static(...) or timestamp_range(...)
+ return string.lower(anki_compatible_length(filename, timestamp) .. timestamp)
+end
+
+mp.register_event("file-loaded", make_media_filename)
+
+return {
+ make_filename = make_filename,
+}
diff --git a/config/mpv/scripts/subs2srsa/utils/forvo.lua b/config/mpv/scripts/subs2srsa/utils/forvo.lua
new file mode 100644
index 0000000..09bc596
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/utils/forvo.lua
@@ -0,0 +1,145 @@
+--[[
+Copyright: Ren Tatsumoto and contributors
+License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html
+
+Utils for downloading pronunciations from Forvo
+]]
+
+local utils = require('mp.utils')
+local msg = require('mp.msg')
+local h = require('helpers')
+local base64 = require('utils.base64')
+local self = {
+ output_dir_path = nil,
+}
+
+local function url_encode(url)
+ -- https://gist.github.com/liukun/f9ce7d6d14fa45fe9b924a3eed5c3d99
+ local char_to_hex = function(c)
+ return string.format("%%%02X", string.byte(c))
+ end
+ if url == nil then
+ return
+ end
+ url = url:gsub("\n", "\r\n")
+ url = url:gsub("([^%w _%%%-%.~])", char_to_hex)
+ url = url:gsub(" ", "+")
+ return url
+end
+
+local function reencode(source_path, dest_path)
+ local args = {
+ 'mpv',
+ source_path,
+ '--loop-file=no',
+ '--keep-open=no',
+ '--video=no',
+ '--no-ocopy-metadata',
+ '--no-sub',
+ '--audio-channels=mono',
+ '--oacopts-add=vbr=on',
+ '--oacopts-add=application=voip',
+ '--oacopts-add=compression_level=10',
+ '--af-append=silenceremove=1:0:-50dB',
+ table.concat { '--oac=', self.config.audio_codec },
+ table.concat { '--of=', self.config.audio_format },
+ table.concat { '--oacopts-add=b=', self.config.audio_bitrate },
+ table.concat { '-o=', dest_path }
+ }
+ return h.subprocess(args)
+end
+
+local function reencode_and_store(source_path, filename)
+ local reencoded_path = utils.join_path(self.output_dir_path, filename)
+ local result = reencode(source_path, reencoded_path)
+ return result.status == 0
+end
+
+local function curl_save(source_url, save_location)
+ local curl_args = { 'curl', source_url, '-s', '-L', '-o', save_location }
+ return h.subprocess(curl_args).status == 0
+end
+
+local function get_pronunciation_url(word)
+ local file_format = self.config.audio_extension:sub(2)
+ local forvo_page = h.subprocess { 'curl', '-s', string.format('https://forvo.com/search/%s/ja', url_encode(word)) }.stdout
+ local play_params = string.match(forvo_page, "Play%((.-)%);")
+
+ if play_params then
+ local iter = string.gmatch(play_params, "'(.-)'")
+ local formats = { mp3 = iter(), ogg = iter() }
+ return string.format('https://audio00.forvo.com/%s/%s', file_format, base64.dec(formats[file_format]))
+ end
+end
+
+local function make_forvo_filename(word)
+ return string.format('forvo_%s%s', self.platform.windows and os.time() or word, self.config.audio_extension)
+end
+
+local function get_forvo_pronunciation(word)
+ local audio_url = get_pronunciation_url(word)
+
+ if h.is_empty(audio_url) then
+ msg.warn(string.format("Seems like Forvo doesn't have audio for word %s.", word))
+ return
+ end
+
+ local filename = make_forvo_filename(word)
+ local tmp_filepath = utils.join_path(self.platform.tmp_dir(), filename)
+
+ local result
+ if curl_save(audio_url, tmp_filepath) and reencode_and_store(tmp_filepath, filename) then
+ result = string.format(self.config.audio_template, filename)
+ else
+ msg.warn(string.format("Couldn't download audio for word %s from Forvo.", word))
+ end
+
+ os.remove(tmp_filepath)
+ return result
+end
+
+local append = function(new_data, stored_data)
+ if self.config.use_forvo == 'no' then
+ -- forvo functionality was disabled in the config file
+ return new_data
+ end
+
+ if type(stored_data[self.config.vocab_audio_field]) ~= 'string' then
+ -- there is no field configured to store forvo pronunciation
+ return new_data
+ end
+
+ if h.is_empty(stored_data[self.config.vocab_field]) then
+ -- target word field is empty. can't continue.
+ return new_data
+ end
+
+ if self.config.use_forvo == 'always' or h.is_empty(stored_data[self.config.vocab_audio_field]) then
+ local forvo_pronunciation = get_forvo_pronunciation(stored_data[self.config.vocab_field])
+ if not h.is_empty(forvo_pronunciation) then
+ if self.config.vocab_audio_field == self.config.audio_field then
+ -- improperly configured fields. don't lose sentence audio
+ new_data[self.config.audio_field] = forvo_pronunciation .. new_data[self.config.audio_field]
+ else
+ new_data[self.config.vocab_audio_field] = forvo_pronunciation
+ end
+ end
+ end
+
+ return new_data
+end
+
+local set_output_dir = function(dir_path)
+ self.output_dir_path = dir_path
+end
+
+local function init(config, platform)
+ self.config = config
+ self.platform = platform
+end
+
+return {
+ append = append,
+ init = init,
+ set_output_dir = set_output_dir,
+}
diff --git a/config/mpv/scripts/subs2srsa/utils/pause_timer.lua b/config/mpv/scripts/subs2srsa/utils/pause_timer.lua
new file mode 100644
index 0000000..e37b0ea
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/utils/pause_timer.lua
@@ -0,0 +1,33 @@
+--[[
+Copyright: Ren Tatsumoto and contributors
+License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html
+
+Pause timer stops playback when reaching a set timing.
+]]
+
+local mp = require('mp')
+local stop_time = -1
+local check_stop
+
+local set_stop_time = function(time)
+ stop_time = time
+ mp.observe_property("time-pos", "number", check_stop)
+end
+
+local stop = function()
+ mp.unobserve_property(check_stop)
+ stop_time = -1
+end
+
+check_stop = function(_, time)
+ if time > stop_time then
+ stop()
+ mp.set_property("pause", "yes")
+ end
+end
+
+return {
+ set_stop_time = set_stop_time,
+ check_stop = check_stop,
+ stop = stop,
+}
diff --git a/config/mpv/scripts/subs2srsa/utils/play_control.lua b/config/mpv/scripts/subs2srsa/utils/play_control.lua
new file mode 100644
index 0000000..901377d
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/utils/play_control.lua
@@ -0,0 +1,61 @@
+--[[
+Copyright: Ren Tatsumoto and contributors
+License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html
+
+Provides additional methods for controlling playback.
+]]
+
+local mp = require('mp')
+local h = require('helpers')
+local pause_timer = require('utils.pause_timer')
+local Subtitle = require('subtitles.subtitle')
+
+local current_sub
+
+local function stop_at_the_end(sub)
+ pause_timer.set_stop_time(sub['end'] - 0.050)
+ h.notify("Playing till the end of the sub...", "info", 3)
+end
+
+local function play_till_sub_end()
+ local sub = Subtitle:now()
+ mp.commandv('seek', sub['start'], 'absolute')
+ mp.set_property("pause", "no")
+ stop_at_the_end(sub)
+end
+
+local function sub_seek(direction, pause)
+ mp.commandv("sub_seek", direction == 'backward' and '-1' or '1')
+ mp.commandv("seek", "0.015", "relative+exact")
+ if pause then
+ mp.set_property("pause", "yes")
+ end
+ pause_timer.stop()
+end
+
+local function sub_rewind()
+ mp.commandv('seek', Subtitle:now()['start'] + 0.015, 'absolute')
+ pause_timer.stop()
+end
+
+local function check_sub()
+ local sub = Subtitle:now()
+ if sub and sub ~= current_sub then
+ mp.unobserve_property(check_sub)
+ stop_at_the_end(sub)
+ end
+end
+
+local function play_till_next_sub_end()
+ current_sub = Subtitle:now()
+ mp.observe_property("sub-text", "string", check_sub)
+ mp.set_property("pause", "no")
+ h.notify("Waiting till next sub...", "info", 10)
+end
+
+return {
+ play_till_sub_end = play_till_sub_end,
+ play_till_next_sub_end = play_till_next_sub_end,
+ sub_seek = sub_seek,
+ sub_rewind = sub_rewind,
+}
diff --git a/config/mpv/scripts/subs2srsa/utils/switch.lua b/config/mpv/scripts/subs2srsa/utils/switch.lua
new file mode 100644
index 0000000..5dac1c6
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/utils/switch.lua
@@ -0,0 +1,38 @@
+--[[
+Copyright: Ren Tatsumoto and contributors
+License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html
+
+Switch cycles between values in a table.
+]]
+
+local make_switch = function(states)
+ local self = {
+ states = states,
+ current_state = 1
+ }
+ local bump = function()
+ self.current_state = self.current_state + 1
+ if self.current_state > #self.states then
+ self.current_state = 1
+ end
+ end
+ local get = function()
+ return self.states[self.current_state]
+ end
+ local set = function(new_state)
+ for idx, value in ipairs(self.states) do
+ if value == new_state then
+ self.current_state = idx
+ end
+ end
+ end
+ return {
+ bump = bump,
+ get = get,
+ set = set,
+ }
+end
+
+return {
+ new = make_switch
+}
diff --git a/config/mpv/scripts/subs2srsa/utils/timings.lua b/config/mpv/scripts/subs2srsa/utils/timings.lua
new file mode 100644
index 0000000..d2408d6
--- /dev/null
+++ b/config/mpv/scripts/subs2srsa/utils/timings.lua
@@ -0,0 +1,28 @@
+--[[
+Copyright: Ren Tatsumoto and contributors
+License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html
+
+Object that remembers manually set timings.
+]]
+
+local new_timings = function()
+ local self = { ['start'] = -1, ['end'] = -1, }
+ local is_set = function(position)
+ return self[position] >= 0
+ end
+ local set = function(position, time)
+ self[position] = time
+ end
+ local get = function(position)
+ return self[position]
+ end
+ return {
+ is_set = is_set,
+ set = set,
+ get = get,
+ }
+end
+
+return {
+ new = new_timings,
+}