mirror of
https://github.com/Apologieze/Benri.git
synced 2026-01-18 17:17:21 +01:00
Added curd files
This commit is contained in:
674
curd/LICENSE
Normal file
674
curd/LICENSE
Normal file
@@ -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>.
|
||||
26
curd/PKGBUILD
Normal file
26
curd/PKGBUILD
Normal file
@@ -0,0 +1,26 @@
|
||||
# Maintainer: Wraient <rushikeshwastaken@gmail.com>
|
||||
pkgname='curd'
|
||||
pkgver=1.0.3
|
||||
pkgrel=2
|
||||
pkgdesc="Watch anime in CLI with AniList Tracking, Discord RPC, Intro/Outro/Filler/Recap Skipping, etc."
|
||||
arch=('x86_64')
|
||||
url="https://github.com/Wraient/curd"
|
||||
license=('GPL')
|
||||
depends=('mpv' 'rofi' 'ueberzugpp')
|
||||
source=("$pkgname::https://github.com/Wraient/curd/releases/latest/download/curd")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
package() {
|
||||
# Install the precompiled binary into /usr/bin
|
||||
install -Dm755 "$srcdir/curd" "$pkgdir/usr/bin/curd"
|
||||
}
|
||||
|
||||
pkgver() {
|
||||
local ver
|
||||
ver=$(curl -s --fail "https://api.github.com/repos/Wraient/curd/releases/latest" | jq -r '.tag_name' | sed 's/^v//')
|
||||
if [ -n "$ver" ]; then
|
||||
echo "$ver"
|
||||
else
|
||||
echo "$pkgver"
|
||||
fi
|
||||
}
|
||||
219
curd/README.md
Normal file
219
curd/README.md
Normal file
@@ -0,0 +1,219 @@
|
||||
|
||||
# Curd
|
||||
|
||||
A cli application to stream anime with [Anilist](https://anilist.co/) integration and Discord RPC written in golang.
|
||||
Works on Windows and Linux
|
||||
|
||||
## Join the discord server
|
||||
|
||||
https://discord.gg/cNaNVEE3B6
|
||||
|
||||
## Demo Video
|
||||
Normal mode:
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/376e7580-b1af-40ee-82c3-154191f75b79
|
||||
|
||||
Rofi with Image preview
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/cbf799bc-9fdd-4402-ab61-b4e31f1e264d
|
||||
|
||||
|
||||
## Features
|
||||
- Stream anime online
|
||||
- Update anime in Anilist after completion
|
||||
- Skip anime Intro and Outro
|
||||
- Skip Filler and Recap episodes
|
||||
- Discord RPC about the anime
|
||||
- Rofi support
|
||||
- Image preview in rofi
|
||||
- Local anime history to continue from where you left off last time
|
||||
- Save mpv speed for next episode
|
||||
- Configurable through config file
|
||||
|
||||
|
||||
## Installing and Setup
|
||||
> **Note**: `Curd` requires `mpv`, `rofi`, and `ueberzugpp` for Rofi support and image preview. These are included in the installation instructions below for each distribution.
|
||||
|
||||
### Linux
|
||||
<details>
|
||||
<summary>Arch Linux / Manjaro (AUR-based systems)</summary>
|
||||
|
||||
Using Yay:
|
||||
|
||||
```bash
|
||||
yay -Sy curd
|
||||
```
|
||||
|
||||
or using Paru:
|
||||
|
||||
```bash
|
||||
paru -Sy curd
|
||||
```
|
||||
|
||||
Or, to manually clone and install:
|
||||
|
||||
```bash
|
||||
git clone https://aur.archlinux.org/curd.git
|
||||
cd curd
|
||||
makepkg -si
|
||||
sudo pacman -S rofi ueberzugpp
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Debian / Ubuntu (and derivatives)</summary>
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install mpv curl rofi ueberzugpp
|
||||
curl -Lo curd https://github.com/Wraient/curd/releases/latest/download/curd
|
||||
chmod +x curd
|
||||
sudo mv curd /usr/bin/
|
||||
curd
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Fedora Installation</summary>
|
||||
|
||||
```bash
|
||||
sudo dnf update
|
||||
sudo dnf install mpv curl rofi ueberzugpp
|
||||
curl -Lo curd https://github.com/Wraient/curd/releases/latest/download/curd
|
||||
chmod +x curd
|
||||
sudo mv curd /usr/bin/
|
||||
curd
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>openSUSE Installation</summary>
|
||||
|
||||
```bash
|
||||
sudo zypper refresh
|
||||
sudo zypper install mpv curl rofi ueberzugpp
|
||||
curl -Lo curd https://github.com/Wraient/curd/releases/latest/download/curd
|
||||
chmod +x curd
|
||||
sudo mv curd /usr/bin/
|
||||
curd
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Generic Installation</summary>
|
||||
|
||||
```bash
|
||||
# Install mpv, curl, rofi, and ueberzugpp (required for image preview)
|
||||
curl -Lo curd https://github.com/Wraient/curd/releases/latest/download/curd
|
||||
chmod +x curd
|
||||
sudo mv curd /usr/bin/
|
||||
curd
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Uninstallation</summary>
|
||||
|
||||
```bash
|
||||
sudo rm /usr/bin/curd
|
||||
```
|
||||
|
||||
For AUR-based distributions:
|
||||
|
||||
```bash
|
||||
yay -R curd
|
||||
```
|
||||
</details>
|
||||
|
||||
### [Windows Installer](https://github.com/Wraient/curd/releases/latest/download/CurdInstaller.exe)
|
||||
|
||||
## Usage
|
||||
|
||||
Run `curd` with the following options:
|
||||
|
||||
```bash
|
||||
curd [options]
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
> **Note**:
|
||||
> - To use rofi you need rofi and ueberzug installed.
|
||||
> - Rofi .rasi files are at default `~/.local/share/curd/`
|
||||
> - You can edit them as you like.
|
||||
> - If there are no rasi files with specific names, they would be downloaded from this repo.
|
||||
|
||||
| Flag | Description | Default |
|
||||
|---------------------------|-------------------------------------------------------------------------|---------------|
|
||||
| `-c` | Continue the last episode | - |
|
||||
| `-change-token` | Change your authentication token | - |
|
||||
| `-dub` | Watch the dubbed version of the anime | - |
|
||||
| `-sub` | Watch the subbed version of the anime | - |
|
||||
| `-new` | Add a new anime to your list | - |
|
||||
| `-e` | Edit the configuration file | - |
|
||||
| `-skip-op` | Automatically skip the opening section of each episode | `true` |
|
||||
| `-skip-ed` | Automatically skip the ending section of each episode | `true` |
|
||||
| `-skip-filler` | Automatically skip filler episodes | `true` |
|
||||
| `-skip-recap` | Automatically skip recap sections | `true` |
|
||||
| `-discord-presence` | Enable or disable Discord presence | `true` |
|
||||
| `-image-preview` | Show an image preview of the anime | - |
|
||||
| `-no-image-preview` | Disable image preview | - |
|
||||
| `-next-episode-prompt` | Prompt for the next episode after completing one | - |
|
||||
| `-rofi` | Open anime selection in the rofi interface | - |
|
||||
| `-no-rofi` | Disable rofi interface | - |
|
||||
| `-percentage-to-mark-complete` | Set the percentage watched to mark an episode as complete | `85` |
|
||||
| `-player` | Specify the player to use for playback | `"mpv"` |
|
||||
| `-save-mpv-speed` | Save the current MPV speed setting for future sessions | `true` |
|
||||
| `-score-on-completion` | Prompt to score the episode on completion | `true` |
|
||||
| `-storage-path` | Path to the storage directory | `"$HOME/.local/share/curd"` |
|
||||
| `-subs-lang` | Set the language for subtitles | `"english"` |
|
||||
| `-u` | Update the script | - |
|
||||
|
||||
### Examples
|
||||
|
||||
- **Continue the Last Episode**:
|
||||
```bash
|
||||
curd -c
|
||||
```
|
||||
|
||||
- **Add a New Anime**:
|
||||
```bash
|
||||
curd -percentage-to-mark-complete=90
|
||||
```
|
||||
|
||||
- **Play with Rofi and Image Preview**:
|
||||
```bash
|
||||
curd -rofi -image-preview
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
All configurations are stored in a file you can edit with the `-e` option.
|
||||
|
||||
```bash
|
||||
curd -e
|
||||
```
|
||||
|
||||
Script is made in a way that you use it for one session of watching.
|
||||
|
||||
You can quit it anytime and the resume time would be saved in the history file
|
||||
|
||||
more settings can be found at config file.
|
||||
config file is located at ```~/.config/curd/curd.conf```
|
||||
|
||||
## Dependencies
|
||||
- mpv - Video player (vlc support might be added later)
|
||||
- rofi - Selection menu
|
||||
- ueberzug - Display images in rofi
|
||||
|
||||
## API Used
|
||||
- [Anilist API](https://anilist.gitbook.io/anilist-apiv2-docs) - Update user data and download user data
|
||||
- [AniSkip API](https://api.aniskip.com/api-docs) - Get anime intro and outro timings
|
||||
- [AllAnime Content](https://allanime.to/) - Fetch anime url
|
||||
- [Jikan](https://jikan.moe/) - Get filler episode number
|
||||
|
||||
## Credits
|
||||
- [ani-cli](https://github.com/pystardust/ani-cli) - Code for fetching anime url
|
||||
- [jerry](https://github.com/justchokingaround/jerry) - For the inspiration
|
||||
1
curd/VERSION.txt
Normal file
1
curd/VERSION.txt
Normal file
@@ -0,0 +1 @@
|
||||
1.0.4
|
||||
522
curd/cmd/curd/main.go
Normal file
522
curd/cmd/curd/main.go
Normal file
@@ -0,0 +1,522 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/wraient/curd/internal"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
discordClientId := "1287457464148820089"
|
||||
|
||||
// Setup
|
||||
internal.ClearScreen()
|
||||
defer internal.RestoreScreen()
|
||||
|
||||
var anime internal.Anime
|
||||
var user internal.User
|
||||
|
||||
var homeDir string
|
||||
if runtime.GOOS == "windows" {
|
||||
homeDir = os.Getenv("USERPROFILE")
|
||||
} else {
|
||||
homeDir = os.Getenv("HOME")
|
||||
}
|
||||
|
||||
configFilePath := filepath.Join(homeDir, ".config", "curd", "curd.conf")
|
||||
|
||||
// load curd userCurdConfig
|
||||
userCurdConfig, err := internal.LoadConfig(configFilePath)
|
||||
if err != nil {
|
||||
fmt.Println("Error loading config:", err)
|
||||
return
|
||||
}
|
||||
internal.SetGlobalConfig(&userCurdConfig)
|
||||
|
||||
logFile := filepath.Join(os.ExpandEnv(userCurdConfig.StoragePath), "debug.log")
|
||||
internal.ClearLogFile(logFile)
|
||||
|
||||
// Flags configured here cause userconfig needs to be changed.
|
||||
flag.StringVar(&userCurdConfig.Player, "player", userCurdConfig.Player, "Player to use for playback (Only mpv supported currently)")
|
||||
flag.StringVar(&userCurdConfig.StoragePath, "storage-path", userCurdConfig.StoragePath, "Path to the storage directory")
|
||||
flag.StringVar(&userCurdConfig.SubsLanguage, "subs-lang", userCurdConfig.SubsLanguage, "Subtitles language")
|
||||
flag.IntVar(&userCurdConfig.PercentageToMarkComplete, "percentage-to-mark-complete", userCurdConfig.PercentageToMarkComplete, "Percentage to mark episode as complete")
|
||||
|
||||
// Boolean flags that accept true/false
|
||||
flag.BoolVar(&userCurdConfig.NextEpisodePrompt, "next-episode-prompt", userCurdConfig.NextEpisodePrompt, "Prompt for the next episode (true/false)")
|
||||
flag.BoolVar(&userCurdConfig.SkipOp, "skip-op", userCurdConfig.SkipOp, "Skip opening (true/false)")
|
||||
flag.BoolVar(&userCurdConfig.SkipEd, "skip-ed", userCurdConfig.SkipEd, "Skip ending (true/false)")
|
||||
flag.BoolVar(&userCurdConfig.SkipFiller, "skip-filler", userCurdConfig.SkipFiller, "Skip filler episodes (true/false)")
|
||||
flag.BoolVar(&userCurdConfig.SkipRecap, "skip-recap", userCurdConfig.SkipRecap, "Skip recap (true/false)")
|
||||
flag.BoolVar(&userCurdConfig.ScoreOnCompletion, "score-on-completion", userCurdConfig.ScoreOnCompletion, "Score on episode completion (true/false)")
|
||||
flag.BoolVar(&userCurdConfig.SaveMpvSpeed, "save-mpv-speed", userCurdConfig.SaveMpvSpeed, "Save MPV speed setting (true/false)")
|
||||
flag.BoolVar(&userCurdConfig.DiscordPresence, "discord-presence", userCurdConfig.DiscordPresence, "Enable Discord presence (true/false)")
|
||||
continueLast := flag.Bool("c", false, "Continue last episode")
|
||||
addNewAnime := flag.Bool("new", false, "Add new anime")
|
||||
rofiSelection := flag.Bool("rofi", false, "Open selection in rofi")
|
||||
noRofi := flag.Bool("no-rofi", false, "No rofi")
|
||||
imagePreview := flag.Bool("image-preview", false, "Show image preview")
|
||||
noImagePreview := flag.Bool("no-image-preview", false, "No image preview")
|
||||
changeToken := flag.Bool("change-token", false, "Change token")
|
||||
currentCategory := flag.Bool("current", false, "Current category")
|
||||
updateScript := flag.Bool("u", false, "Update the script")
|
||||
editConfig := flag.Bool("e", false, "Edit config")
|
||||
subFlag := flag.Bool("sub", false, "Watch sub version")
|
||||
dubFlag := flag.Bool("dub", false, "Watch dub version")
|
||||
|
||||
// Custom help/usage function
|
||||
flag.Usage = func() {
|
||||
internal.RestoreScreen()
|
||||
fmt.Fprintf(os.Stderr, "Curd is a CLI tool to manage anime playback with advanced features like skipping intro, outro, filler, recap, tracking progress, and integrating with Discord.\n")
|
||||
fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0])
|
||||
flag.PrintDefaults() // This prints the default flag information
|
||||
}
|
||||
|
||||
flag.Parse()
|
||||
|
||||
anime.Ep.ContinueLast = *continueLast
|
||||
|
||||
if *updateScript {
|
||||
repo := "wraient/curd"
|
||||
fileName := "curd"
|
||||
|
||||
if err := internal.UpdateCurd(repo, fileName); err != nil {
|
||||
internal.CurdOut(fmt.Sprintf("Error updating executable: %v\n", err))
|
||||
internal.ExitCurd(err)
|
||||
} else {
|
||||
internal.CurdOut("Program Updated!")
|
||||
internal.ExitCurd(nil)
|
||||
}
|
||||
}
|
||||
|
||||
if *changeToken {
|
||||
internal.ChangeToken(&userCurdConfig, &user)
|
||||
return
|
||||
}
|
||||
|
||||
if *currentCategory {
|
||||
userCurdConfig.CurrentCategory = true
|
||||
}
|
||||
|
||||
if *rofiSelection {
|
||||
userCurdConfig.RofiSelection = true
|
||||
}
|
||||
|
||||
if *noRofi || runtime.GOOS == "windows" {
|
||||
userCurdConfig.RofiSelection = false
|
||||
}
|
||||
|
||||
if *imagePreview {
|
||||
userCurdConfig.ImagePreview = true
|
||||
}
|
||||
|
||||
if *noImagePreview || runtime.GOOS == "windows" {
|
||||
userCurdConfig.ImagePreview = false
|
||||
}
|
||||
|
||||
if *editConfig {
|
||||
internal.EditConfig(configFilePath)
|
||||
return
|
||||
}
|
||||
|
||||
// Set SubOrDub based on the flags
|
||||
if *subFlag {
|
||||
userCurdConfig.SubOrDub = "sub"
|
||||
} else if *dubFlag {
|
||||
userCurdConfig.SubOrDub = "dub"
|
||||
}
|
||||
|
||||
// Get the token from the token file
|
||||
user.Token, err = internal.GetTokenFromFile(filepath.Join(os.ExpandEnv(userCurdConfig.StoragePath), "token"))
|
||||
if err != nil {
|
||||
internal.Log("Error reading token", logFile)
|
||||
}
|
||||
if user.Token == "" {
|
||||
internal.ChangeToken(&userCurdConfig, &user)
|
||||
}
|
||||
|
||||
if userCurdConfig.RofiSelection {
|
||||
// Define a slice of file names to check and download
|
||||
filesToCheck := []string{
|
||||
"selectanimepreview.rasi",
|
||||
"selectanime.rasi",
|
||||
"userinput.rasi",
|
||||
}
|
||||
|
||||
// Call the function to check and download files
|
||||
err := internal.CheckAndDownloadFiles(os.ExpandEnv(userCurdConfig.StoragePath), filesToCheck)
|
||||
if err != nil {
|
||||
internal.Log(fmt.Sprintf("Error checking and downloading files: %v\n", err), logFile)
|
||||
internal.CurdOut(fmt.Sprintf("Error checking and downloading files: %v\n", err))
|
||||
internal.ExitCurd(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Load animes in database
|
||||
databaseFile := filepath.Join(os.ExpandEnv(userCurdConfig.StoragePath), "curd_history.txt")
|
||||
databaseAnimes := internal.LocalGetAllAnime(databaseFile)
|
||||
|
||||
if *addNewAnime {
|
||||
internal.AddNewAnime(&userCurdConfig, &anime, &user, &databaseAnimes, logFile)
|
||||
// internal.ExitCurd(fmt.Errorf("Added new anime!"))
|
||||
}
|
||||
|
||||
internal.SetupCurd(&userCurdConfig, &anime, &user, &databaseAnimes, logFile)
|
||||
|
||||
temp_anime, err := internal.FindAnimeByAnilistID(user.AnimeList, strconv.Itoa(anime.AnilistId))
|
||||
if err != nil {
|
||||
internal.Log("Error finding anime by Anilist ID: "+err.Error(), logFile)
|
||||
}
|
||||
|
||||
if anime.TotalEpisodes == temp_anime.Progress {
|
||||
internal.Log(temp_anime.Progress, logFile)
|
||||
internal.Log(anime.TotalEpisodes, logFile)
|
||||
internal.Log(user.AnimeList, logFile)
|
||||
internal.Log("Rewatching anime: "+internal.GetAnimeName(anime), logFile)
|
||||
anime.Rewatching = true
|
||||
}
|
||||
|
||||
anime.Ep.Player.Speed = 1.0
|
||||
|
||||
// Main loop
|
||||
for {
|
||||
|
||||
internal.Log(anime, logFile)
|
||||
|
||||
// Create a channel to signal when to exit the skip loop
|
||||
var wg sync.WaitGroup
|
||||
skipLoopDone := make(chan struct{})
|
||||
|
||||
// Get MalId and CoverImage (only if discord presence is enabled)
|
||||
if userCurdConfig.DiscordPresence {
|
||||
anime.MalId, anime.CoverImage, err = internal.GetAnimeIDAndImage(anime.AnilistId)
|
||||
if err != nil {
|
||||
internal.Log("Error getting anime ID and image: "+err.Error(), logFile)
|
||||
}
|
||||
err = internal.DiscordPresence(discordClientId, anime, false)
|
||||
if err != nil {
|
||||
internal.Log("Error setting Discord presence: "+err.Error(), logFile)
|
||||
}
|
||||
} else {
|
||||
anime.MalId, err = internal.GetAnimeMalID(anime.AnilistId)
|
||||
if err != nil {
|
||||
internal.Log("Error getting anime MAL ID: "+err.Error(), logFile)
|
||||
}
|
||||
}
|
||||
|
||||
// Start curd
|
||||
for {
|
||||
// Check if current episode is filler/recap
|
||||
err = internal.GetEpisodeData(anime.MalId, anime.Ep.Number, &anime)
|
||||
if err != nil {
|
||||
internal.Log("Error getting episode data, assuming non-filler: "+err.Error(), logFile)
|
||||
break // Break the loop and continue with playback
|
||||
}
|
||||
|
||||
// If not filler/recap (or skip is disabled), break and continue with playback
|
||||
if !((anime.Ep.IsFiller && userCurdConfig.SkipFiller) || (anime.Ep.IsRecap && userCurdConfig.SkipRecap)) {
|
||||
if anime.Ep.LastWasSkipped {
|
||||
go internal.UpdateAnimeProgress(user.Token, anime.AnilistId, anime.Ep.Number-1)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// If it is filler/recap, log it and move to next episode
|
||||
if anime.Ep.IsFiller {
|
||||
internal.CurdOut(fmt.Sprint("Filler episode, skipping: ", anime.Ep.Number))
|
||||
} else {
|
||||
internal.CurdOut(fmt.Sprint("Recap episode, skipping: ", anime.Ep.Number))
|
||||
}
|
||||
|
||||
anime.Ep.Number++
|
||||
anime.Ep.LastWasSkipped = true
|
||||
anime.Ep.Started = false
|
||||
internal.LocalUpdateAnime(databaseFile, anime.AnilistId, anime.AllanimeId, anime.Ep.Number, 0, 0, internal.GetAnimeName(anime))
|
||||
|
||||
// Check if we've reached the end of the series
|
||||
if anime.Ep.Number > anime.TotalEpisodes {
|
||||
internal.CurdOut("Reached end of series")
|
||||
internal.ExitCurd(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Now start playback for the non-filler episode
|
||||
anime.Ep.Player.SocketPath = internal.StartCurd(&userCurdConfig, &anime, logFile)
|
||||
internal.Log(fmt.Sprint("Playback starting time: ", anime.Ep.Player.PlaybackTime), logFile)
|
||||
internal.Log(anime.Ep.Player.SocketPath, logFile)
|
||||
|
||||
wg.Add(1)
|
||||
// Get episode data
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err = internal.GetEpisodeData(anime.MalId, anime.Ep.Number, &anime)
|
||||
if err != nil {
|
||||
internal.Log("Error getting episode data: "+err.Error(), logFile)
|
||||
} else {
|
||||
internal.Log(anime, logFile)
|
||||
|
||||
// if filler episode or recap episode and skip is enabled
|
||||
if (anime.Ep.IsFiller && userCurdConfig.SkipFiller) || (anime.Ep.IsRecap && userCurdConfig.SkipRecap) {
|
||||
if anime.Ep.IsFiller && userCurdConfig.SkipFiller {
|
||||
internal.CurdOut(fmt.Sprint("Filler Episode, starting next episode: ", anime.Ep.Number+1))
|
||||
internal.Log("Filler episode detected", logFile)
|
||||
} else if anime.Ep.IsRecap && userCurdConfig.SkipRecap {
|
||||
internal.CurdOut(fmt.Sprint("Recap Episode, starting next episode: ", anime.Ep.Number+1))
|
||||
internal.Log("Recap episode detected", logFile)
|
||||
}
|
||||
anime.Ep.Number++
|
||||
anime.Ep.Started = false
|
||||
anime.Ep.IsCompleted = true
|
||||
internal.Log("Skipping filler episode, starting next.", logFile)
|
||||
internal.LocalUpdateAnime(databaseFile, anime.AnilistId, anime.AllanimeId, anime.Ep.Number, anime.Ep.Player.PlaybackTime, internal.ConvertSecondsToMinutes(anime.Ep.Duration), internal.GetAnimeName(anime))
|
||||
// Send command to close MPV
|
||||
_, err := internal.MPVSendCommand(anime.Ep.Player.SocketPath, []interface{}{"quit"})
|
||||
if err != nil {
|
||||
internal.Log("Error closing MPV: "+err.Error(), logFile)
|
||||
}
|
||||
// Exit the skip loop
|
||||
close(skipLoopDone)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Add(1)
|
||||
// Thread to update Discord presence
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if userCurdConfig.DiscordPresence {
|
||||
for {
|
||||
select {
|
||||
case <-skipLoopDone:
|
||||
return
|
||||
default:
|
||||
isPaused, err := internal.MPVSendCommand(anime.Ep.Player.SocketPath, []interface{}{"get_property", "pause"})
|
||||
if err != nil {
|
||||
internal.Log("Error getting pause status: "+err.Error(), logFile)
|
||||
}
|
||||
if isPaused == nil {
|
||||
isPaused = true
|
||||
} else {
|
||||
isPaused = isPaused.(bool)
|
||||
}
|
||||
err = internal.DiscordPresence(discordClientId, anime, isPaused.(bool))
|
||||
if err != nil {
|
||||
// internal.Log("Error setting Discord presence: "+err.Error(), logFile)
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Get skip times Parallel
|
||||
go func() {
|
||||
err = internal.GetAndParseAniSkipData(anime.MalId, anime.Ep.Number, 1, &anime)
|
||||
if err != nil {
|
||||
internal.Log("Error getting and parsing AniSkip data: "+err.Error(), logFile)
|
||||
}
|
||||
internal.Log(anime.Ep.SkipTimes, logFile)
|
||||
}()
|
||||
|
||||
// Get video duration
|
||||
go func() {
|
||||
for {
|
||||
if anime.Ep.Started {
|
||||
if anime.Ep.Duration == 0 {
|
||||
// Get video duration
|
||||
durationPos, err := internal.MPVSendCommand(anime.Ep.Player.SocketPath, []interface{}{"get_property", "duration"})
|
||||
if err != nil {
|
||||
internal.Log("Error getting video duration: "+err.Error(), logFile)
|
||||
} else if durationPos != nil {
|
||||
if duration, ok := durationPos.(float64); ok {
|
||||
anime.Ep.Duration = int(duration + 0.5) // Round to nearest integer
|
||||
internal.Log(fmt.Sprintf("Video duration: %d seconds", anime.Ep.Duration), logFile)
|
||||
} else {
|
||||
internal.Log("Error: duration is not a float64", logFile)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Add(1)
|
||||
// Thread to update playback time in database
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-skipLoopDone:
|
||||
return
|
||||
default:
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// Get current playback time
|
||||
// internal.Log("Getting playback time "+anime.Ep.Player.SocketPath, logFile)
|
||||
timePos, err := internal.MPVSendCommand(anime.Ep.Player.SocketPath, []interface{}{"get_property", "time-pos"})
|
||||
if err != nil {
|
||||
internal.Log("Error getting playback time: "+err.Error(), logFile)
|
||||
|
||||
// Check if the error is due to invalid JSON
|
||||
// User closed the video
|
||||
if anime.Ep.Started {
|
||||
percentageWatched := internal.PercentageWatched(anime.Ep.Player.PlaybackTime, anime.Ep.Duration)
|
||||
// Episode is completed
|
||||
internal.Log(fmt.Sprint(percentageWatched), logFile)
|
||||
internal.Log(fmt.Sprint(anime.Ep.Player.Speed), logFile)
|
||||
internal.Log(fmt.Sprint(anime.Ep.Player.PlaybackTime), logFile)
|
||||
internal.Log(fmt.Sprint(anime.Ep.Duration), logFile)
|
||||
internal.Log(fmt.Sprint(userCurdConfig.PercentageToMarkComplete), logFile)
|
||||
if int(percentageWatched) >= userCurdConfig.PercentageToMarkComplete {
|
||||
anime.Ep.Number++
|
||||
anime.Ep.Started = false
|
||||
internal.Log("Completed episode, starting next.", logFile)
|
||||
anime.Ep.IsCompleted = true
|
||||
// Exit the skip loop
|
||||
close(skipLoopDone)
|
||||
} else if fmt.Sprintf("%v", err) == "invalid character '{' after top-level value" { // Episode is not completed
|
||||
internal.Log("Received invalid JSON response, continuing...", logFile)
|
||||
} else {
|
||||
internal.Log("Episode is not completed, exiting", logFile)
|
||||
internal.ExitCurd(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert timePos to integer
|
||||
if timePos != nil {
|
||||
if !anime.Ep.Started {
|
||||
anime.Ep.Started = true
|
||||
// Set the playback speed
|
||||
if userCurdConfig.SaveMpvSpeed {
|
||||
speedCmd := []interface{}{"set_property", "speed", anime.Ep.Player.Speed}
|
||||
_, err := internal.MPVSendCommand(anime.Ep.Player.SocketPath, speedCmd)
|
||||
if err != nil {
|
||||
internal.Log("Error setting playback speed: "+err.Error(), logFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If resume is true, seek to the playback time
|
||||
if anime.Ep.Resume {
|
||||
internal.SeekMPV(anime.Ep.Player.SocketPath, anime.Ep.Player.PlaybackTime)
|
||||
anime.Ep.Resume = false
|
||||
}
|
||||
|
||||
animePosition, ok := timePos.(float64)
|
||||
if !ok {
|
||||
internal.Log("Error: timePos is not a float64", logFile)
|
||||
continue
|
||||
}
|
||||
|
||||
anime.Ep.Player.PlaybackTime = int(animePosition + 0.5) // Round to nearest integer
|
||||
// Update Local Database
|
||||
err = internal.LocalUpdateAnime(databaseFile, anime.AnilistId, anime.AllanimeId, anime.Ep.Number, anime.Ep.Player.PlaybackTime, internal.ConvertSecondsToMinutes(anime.Ep.Duration), internal.GetAnimeName(anime))
|
||||
if err != nil {
|
||||
internal.Log("Error updating local database: "+err.Error(), logFile)
|
||||
} else {
|
||||
// internal.Log(fmt.Sprintf("Updated database: AnilistId=%d, AllanimeId=%s, EpNumber=%d, PlaybackTime=%d",
|
||||
// anime.AnilistId, anime.AllanimeId, anime.Ep.Number, anime.Ep.Player.PlaybackTime), logFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Skip OP and ED and Save MPV Speed
|
||||
skipLoop:
|
||||
for {
|
||||
select {
|
||||
case <-skipLoopDone:
|
||||
// Exit signal received, break out of the skipLoop
|
||||
break skipLoop
|
||||
default:
|
||||
if userCurdConfig.SkipOp {
|
||||
if anime.Ep.Player.PlaybackTime > anime.Ep.SkipTimes.Op.Start && anime.Ep.Player.PlaybackTime < anime.Ep.SkipTimes.Op.Start+2 && anime.Ep.SkipTimes.Op.Start != anime.Ep.SkipTimes.Op.End {
|
||||
internal.SeekMPV(anime.Ep.Player.SocketPath, anime.Ep.SkipTimes.Op.End)
|
||||
}
|
||||
}
|
||||
if userCurdConfig.SkipEd {
|
||||
if anime.Ep.Player.PlaybackTime > anime.Ep.SkipTimes.Ed.Start && anime.Ep.Player.PlaybackTime < anime.Ep.SkipTimes.Ed.Start+2 && anime.Ep.SkipTimes.Ed.Start != anime.Ep.SkipTimes.Ed.End {
|
||||
internal.SeekMPV(anime.Ep.Player.SocketPath, anime.Ep.SkipTimes.Ed.End)
|
||||
}
|
||||
}
|
||||
_, err := internal.MPVSendCommand(anime.Ep.Player.SocketPath, []interface{}{"get_property", "time-pos"})
|
||||
if err == nil && anime.Ep.Started {
|
||||
anime.Ep.Player.Speed, err = internal.GetMPVPlaybackSpeed(anime.Ep.Player.SocketPath)
|
||||
if err != nil {
|
||||
internal.Log("Failed to get mpv speed "+err.Error(), logFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(1 * time.Second) // Wait before checking again
|
||||
}
|
||||
|
||||
// Wait for all goroutines to finish before starting the next iteration
|
||||
wg.Wait()
|
||||
|
||||
// Reset the WaitGroup for the next loop
|
||||
wg = sync.WaitGroup{}
|
||||
|
||||
if anime.Ep.IsCompleted && !anime.Rewatching {
|
||||
// Update progress for both regular episodes and skipped fillers
|
||||
go func() {
|
||||
err = internal.UpdateAnimeProgress(user.Token, anime.AnilistId, anime.Ep.Number-1)
|
||||
if err != nil {
|
||||
internal.Log("Error updating Anilist progress: "+err.Error(), logFile)
|
||||
}
|
||||
}()
|
||||
|
||||
anime.Ep.IsCompleted = false
|
||||
// internal.CurdOut(anime.Ep.Number, anime.TotalEpisodes, &userCurdConfig)
|
||||
if anime.Ep.Number-1 == anime.TotalEpisodes && userCurdConfig.ScoreOnCompletion {
|
||||
anime.Ep.Number = anime.Ep.Number - 1
|
||||
internal.CurdOut("Completed anime.")
|
||||
err = internal.RateAnime(user.Token, anime.AnilistId)
|
||||
if err != nil {
|
||||
internal.Log("Error rating anime: "+err.Error(), logFile)
|
||||
internal.CurdOut("Error rating anime: " + err.Error())
|
||||
}
|
||||
internal.LocalDeleteAnime(databaseFile, anime.AnilistId, anime.AllanimeId)
|
||||
internal.ExitCurd(nil)
|
||||
}
|
||||
}
|
||||
if anime.Rewatching && anime.Ep.IsCompleted && anime.Ep.Number-1 == anime.TotalEpisodes {
|
||||
anime.Ep.Number = anime.Ep.Number - 1
|
||||
internal.CurdOut("Completed anime. (Rewatching so no scoring)")
|
||||
internal.LocalDeleteAnime(databaseFile, anime.AnilistId, anime.AllanimeId)
|
||||
internal.ExitCurd(nil)
|
||||
}
|
||||
|
||||
if userCurdConfig.NextEpisodePrompt {
|
||||
options := map[string]string{
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
}
|
||||
|
||||
selectedOption, err := internal.DynamicSelect(options, false)
|
||||
if err != nil {
|
||||
internal.ExitCurd(err)
|
||||
}
|
||||
|
||||
if selectedOption.Key == "no" || selectedOption.Key == "-1" {
|
||||
internal.ExitCurd(nil)
|
||||
}
|
||||
// If yes or any other case, continue with the next episode
|
||||
}
|
||||
|
||||
internal.CurdOut(fmt.Sprint("Starting next episode: ", anime.Ep.Number))
|
||||
anime.Ep.Started = false
|
||||
|
||||
}
|
||||
}
|
||||
34
curd/go.mod
Normal file
34
curd/go.mod
Normal file
@@ -0,0 +1,34 @@
|
||||
module github.com/wraient/curd
|
||||
|
||||
go 1.23.2
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/charmbracelet/bubbletea v1.1.1
|
||||
github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4
|
||||
github.com/hugolgst/rich-go v0.0.0-20240715122152-74618cc1ace2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/lipgloss v0.13.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.2.3 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.15.2 // indirect
|
||||
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect
|
||||
golang.org/x/sync v0.8.0 // indirect
|
||||
golang.org/x/sys v0.24.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||
)
|
||||
53
curd/go.sum
Normal file
53
curd/go.sum
Normal file
@@ -0,0 +1,53 @@
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbletea v1.1.1 h1:KJ2/DnmpfqFtDNVTvYZ6zpPFL9iRCRr0qqKOCvppbPY=
|
||||
github.com/charmbracelet/bubbletea v1.1.1/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4=
|
||||
github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw=
|
||||
github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY=
|
||||
github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY=
|
||||
github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
|
||||
github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
|
||||
github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4 h1:ygs9POGDQpQGLJPlq4+0LBUmMBNox1N4JSpw+OETcvI=
|
||||
github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4/go.mod h1:0W7dI87PvXJ1Sjs0QPvWXKcQmNERY77e8l7GFhZB/s4=
|
||||
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE=
|
||||
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/hugolgst/rich-go v0.0.0-20240715122152-74618cc1ace2 h1:9qOViOQGFIP5ar+2NorfAIsfuADEKXtklySC0zNnYf4=
|
||||
github.com/hugolgst/rich-go v0.0.0-20240715122152-74618cc1ace2/go.mod h1:nGaW7CGfNZnhtiFxMpc4OZdqIexGXjUlBnlmpZmjEKA=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
|
||||
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
|
||||
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ=
|
||||
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk=
|
||||
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
|
||||
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU=
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c=
|
||||
841
curd/internal/anilist.go
Normal file
841
curd/internal/anilist.go
Normal file
@@ -0,0 +1,841 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FindKeyByValue searches for a key associated with a given value in a map[string]string
|
||||
func FindKeyByValue(m map[string]string, value string) (string, error) {
|
||||
for key, val := range m {
|
||||
if val == value {
|
||||
return key, nil // Return the key and true if the value is found
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no key with value %v", value) // Return empty string and false if the value is not found
|
||||
}
|
||||
|
||||
// GetAnimeMap takes an AnimeList and returns a map with media.id as key and media.title.english as value.
|
||||
func GetAnimeMap(animeList AnimeList) map[string]string {
|
||||
animeMap := make(map[string]string)
|
||||
userCurdConfig := GetGlobalConfig()
|
||||
|
||||
// Helper function to populate the map from a slice of entries
|
||||
populateMap := func(entries []Entry) {
|
||||
for _, entry := range entries {
|
||||
// Only include entries with a non-empty English title
|
||||
|
||||
if entry.Media.Title.English != "" && userCurdConfig.AnimeNameLanguage == "english" {
|
||||
animeMap[strconv.Itoa(entry.Media.ID)] = entry.Media.Title.English
|
||||
} else {
|
||||
animeMap[strconv.Itoa(entry.Media.ID)] = entry.Media.Title.Romaji
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Populate the map for each category
|
||||
populateMap(animeList.Watching)
|
||||
populateMap(animeList.Completed)
|
||||
populateMap(animeList.Paused)
|
||||
populateMap(animeList.Dropped)
|
||||
populateMap(animeList.Planning)
|
||||
|
||||
return animeMap
|
||||
}
|
||||
|
||||
// GetAnimeMapPreview takes an AnimeList and returns a map with media.id as key and media.title.english as value.
|
||||
func GetAnimeMapPreview(animeList AnimeList) map[string]RofiSelectPreview {
|
||||
userCurdConfig := GetGlobalConfig()
|
||||
animeMap := make(map[string]RofiSelectPreview)
|
||||
|
||||
// Helper function to populate the map from a slice of entries
|
||||
populateMap := func(entries []Entry) {
|
||||
for _, entry := range entries {
|
||||
// Only include entries with a non-empty English title
|
||||
Log(fmt.Sprintf("AnimeNameLanguage: ", userCurdConfig.AnimeNameLanguage), logFile)
|
||||
if entry.Media.Title.English != "" && userCurdConfig.AnimeNameLanguage == "english" {
|
||||
animeMap[strconv.Itoa(entry.Media.ID)] = RofiSelectPreview{
|
||||
Title: entry.Media.Title.English,
|
||||
CoverImage: entry.CoverImage,
|
||||
}
|
||||
} else {
|
||||
animeMap[strconv.Itoa(entry.Media.ID)] = RofiSelectPreview{
|
||||
Title: entry.Media.Title.Romaji,
|
||||
CoverImage: entry.CoverImage,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Populate the map for each category
|
||||
populateMap(animeList.Watching)
|
||||
populateMap(animeList.Completed)
|
||||
populateMap(animeList.Paused)
|
||||
populateMap(animeList.Dropped)
|
||||
populateMap(animeList.Planning)
|
||||
|
||||
return animeMap
|
||||
}
|
||||
|
||||
// SearchAnimeAnilist sends the query to AniList and returns a map of title to ID
|
||||
func SearchAnimeAnilistPreview(query, token string) (map[string]RofiSelectPreview, error) {
|
||||
url := "https://graphql.anilist.co"
|
||||
|
||||
queryString := `
|
||||
query ($search: String) {
|
||||
Page(page: 1, perPage: 10) {
|
||||
media(search: $search, type: ANIME) {
|
||||
id
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
coverImage {
|
||||
large
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
variables := map[string]string{"search": query}
|
||||
requestBody, err := json.Marshal(map[string]interface{}{
|
||||
"query": queryString,
|
||||
"variables": variables,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create new request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to make request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("failed to search for anime. Status Code: %d, Response: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
var responseData map[string]ResponseData
|
||||
err = json.Unmarshal(body, &responseData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
animeList := responseData["data"].Page.Media
|
||||
animeDict := make(map[string]RofiSelectPreview)
|
||||
|
||||
// Map titles and cover images to their IDs
|
||||
for _, anime := range animeList {
|
||||
idStr := strconv.Itoa(anime.ID)
|
||||
title := anime.Title.English
|
||||
if title == "" {
|
||||
title = anime.Title.Romaji
|
||||
}
|
||||
animeDict[idStr] = RofiSelectPreview{
|
||||
Title: title,
|
||||
CoverImage: anime.CoverImage.Large,
|
||||
}
|
||||
}
|
||||
|
||||
return animeDict, nil
|
||||
}
|
||||
|
||||
// SearchAnimeAnilist sends the query to AniList and returns a map of title to ID
|
||||
func SearchAnimeAnilist(query, token string) (map[string]string, error) {
|
||||
url := "https://graphql.anilist.co"
|
||||
|
||||
queryString := `
|
||||
query ($search: String) {
|
||||
Page(page: 1, perPage: 10) {
|
||||
media(search: $search, type: ANIME) {
|
||||
id
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
variables := map[string]string{"search": query}
|
||||
requestBody, err := json.Marshal(map[string]interface{}{
|
||||
"query": queryString,
|
||||
"variables": variables,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create new request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to make request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("failed to search for anime. Status Code: %d, Response: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
var responseData map[string]ResponseData
|
||||
err = json.Unmarshal(body, &responseData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
animeList := responseData["data"].Page.Media
|
||||
animeDict := make(map[string]string)
|
||||
|
||||
// Map titles to their IDs as strings
|
||||
for _, anime := range animeList {
|
||||
idStr := strconv.Itoa(anime.ID) // Convert ID to string
|
||||
if anime.Title.English != "" {
|
||||
animeDict[idStr] = anime.Title.English
|
||||
} else {
|
||||
animeDict[idStr] = anime.Title.Romaji
|
||||
}
|
||||
}
|
||||
|
||||
return animeDict, nil
|
||||
}
|
||||
|
||||
// Function to get AniList user ID and username
|
||||
func GetAnilistUserID(token string) (int, string, error) {
|
||||
url := "https://graphql.anilist.co"
|
||||
query := `
|
||||
query {
|
||||
Viewer {
|
||||
id
|
||||
name
|
||||
}
|
||||
}`
|
||||
|
||||
headers := map[string]string{
|
||||
"Authorization": "Bearer " + token,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
response, err := makePostRequest(url, query, nil, headers)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
data := response["data"].(map[string]interface{})["Viewer"].(map[string]interface{})
|
||||
userID := int(data["id"].(float64))
|
||||
userName := data["name"].(string)
|
||||
|
||||
return userID, userName, nil
|
||||
}
|
||||
|
||||
// Function to add an anime to the watching list
|
||||
func AddAnimeToWatchingList(animeID int, token string) error {
|
||||
url := "https://graphql.anilist.co"
|
||||
mutation := `
|
||||
mutation ($mediaId: Int) {
|
||||
SaveMediaListEntry (mediaId: $mediaId, status: CURRENT) {
|
||||
id
|
||||
status
|
||||
}
|
||||
}`
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"mediaId": animeID,
|
||||
}
|
||||
|
||||
headers := map[string]string{
|
||||
"Authorization": "Bearer " + token,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
_, err := makePostRequest(url, mutation, variables, headers)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add anime: %w", err)
|
||||
}
|
||||
|
||||
CurdOut(fmt.Sprintf("Anime with ID %d has been added to your watching list.", animeID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Function to get MAL ID using AniList media ID
|
||||
func GetAnimeMalID(anilistMediaID int) (int, error) {
|
||||
url := "https://graphql.anilist.co"
|
||||
query := `
|
||||
query ($id: Int) {
|
||||
Media(id: $id) {
|
||||
idMal
|
||||
}
|
||||
}`
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"id": anilistMediaID,
|
||||
}
|
||||
|
||||
response, err := makePostRequest(url, query, variables, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
malID := int(response["data"].(map[string]interface{})["Media"].(map[string]interface{})["idMal"].(float64))
|
||||
return malID, nil
|
||||
}
|
||||
|
||||
// This function retrieves the MAL ID and cover image URL for an anime from AniList
|
||||
func GetAnimeIDAndImage(anilistMediaID int) (int, string, error) {
|
||||
url := "https://graphql.anilist.co"
|
||||
query := `
|
||||
query ($id: Int) {
|
||||
Media(id: $id) {
|
||||
coverImage {
|
||||
large
|
||||
}
|
||||
idMal
|
||||
}
|
||||
}`
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"id": anilistMediaID,
|
||||
}
|
||||
|
||||
response, err := makePostRequest(url, query, variables, nil)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
data := response["data"].(map[string]interface{})["Media"].(map[string]interface{})
|
||||
malID := int(data["idMal"].(float64))
|
||||
imageURL := data["coverImage"].(map[string]interface{})["large"].(string)
|
||||
|
||||
return malID, imageURL, nil
|
||||
}
|
||||
|
||||
// Function to get user data from AniList
|
||||
func GetUserData(token string, userID int) (map[string]interface{}, error) {
|
||||
query := fmt.Sprintf(`
|
||||
{
|
||||
MediaListCollection(userId: %d, type: ANIME) {
|
||||
lists {
|
||||
entries {
|
||||
media {
|
||||
id
|
||||
episodes
|
||||
duration
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
}
|
||||
status
|
||||
score
|
||||
progress
|
||||
}
|
||||
}
|
||||
}
|
||||
}`, userID)
|
||||
|
||||
headers := map[string]string{
|
||||
"Authorization": "Bearer " + token,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
response, err := makePostRequest("https://graphql.anilist.co", query, nil, headers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func GetUserDataPreview(token string, userID int) (map[string]interface{}, error) {
|
||||
query := fmt.Sprintf(`
|
||||
{
|
||||
MediaListCollection(userId: %d, type: ANIME) {
|
||||
lists {
|
||||
entries {
|
||||
media {
|
||||
id
|
||||
episodes
|
||||
duration
|
||||
coverImage {
|
||||
large
|
||||
}
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
}
|
||||
status
|
||||
score
|
||||
progress
|
||||
}
|
||||
}
|
||||
}
|
||||
}`, userID)
|
||||
|
||||
headers := map[string]string{
|
||||
"Authorization": "Bearer " + token,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
response, err := makePostRequest("https://graphql.anilist.co", query, nil, headers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// Function to load a JSON file
|
||||
func LoadJSONFile(filePath string) (map[string]interface{}, error) {
|
||||
data, err := os.ReadFile(filepath.Clean(filePath))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
var jsonData map[string]interface{}
|
||||
err = json.Unmarshal(data, &jsonData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JSON: %w", err)
|
||||
}
|
||||
|
||||
return jsonData, nil
|
||||
}
|
||||
|
||||
// Function to search for an anime by title in user data
|
||||
func SearchAnimeByTitle(jsonData map[string]interface{}, searchTitle string) []map[string]interface{} {
|
||||
results := []map[string]interface{}{}
|
||||
|
||||
lists := jsonData["data"].(map[string]interface{})["MediaListCollection"].(map[string]interface{})["lists"].([]interface{})
|
||||
for _, list := range lists {
|
||||
entries := list.(map[string]interface{})["entries"].([]interface{})
|
||||
for _, entry := range entries {
|
||||
media := entry.(map[string]interface{})["media"].(map[string]interface{})
|
||||
romajiTitle := media["title"].(map[string]interface{})["romaji"].(string)
|
||||
englishTitle := media["title"].(map[string]interface{})["english"].(string)
|
||||
episodes := int(media["episodes"].(float64))
|
||||
duration := int(media["duration"].(float64))
|
||||
|
||||
if strings.Contains(strings.ToLower(romajiTitle), strings.ToLower(searchTitle)) || strings.Contains(strings.ToLower(englishTitle), strings.ToLower(searchTitle)) {
|
||||
result := map[string]interface{}{
|
||||
"id": media["id"],
|
||||
"progress": entry.(map[string]interface{})["progress"],
|
||||
"romaji_title": romajiTitle,
|
||||
"english_title": englishTitle,
|
||||
"episodes": episodes,
|
||||
"duration": duration,
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// Function to update anime progress
|
||||
func UpdateAnimeProgress(token string, mediaID, progress int) error {
|
||||
url := "https://graphql.anilist.co"
|
||||
query := `
|
||||
mutation($mediaId: Int, $progress: Int) {
|
||||
SaveMediaListEntry(mediaId: $mediaId, progress: $progress) {
|
||||
id
|
||||
progress
|
||||
}
|
||||
}`
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"mediaId": mediaID,
|
||||
"progress": progress,
|
||||
}
|
||||
|
||||
headers := map[string]string{
|
||||
"Authorization": "Bearer " + token,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
_, err := makePostRequest(url, query, variables, headers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
CurdOut(fmt.Sprint("Anime progress updated! Latest watched episode: ", progress))
|
||||
return nil
|
||||
}
|
||||
|
||||
func UpdateAnimeStatus(token string, mediaID int, status string) error {
|
||||
url := "https://graphql.anilist.co"
|
||||
query := `
|
||||
mutation($mediaId: Int, $status: MediaListStatus) {
|
||||
SaveMediaListEntry(mediaId: $mediaId, status: $status) {
|
||||
id
|
||||
status
|
||||
}
|
||||
}`
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"mediaId": mediaID,
|
||||
"status": status,
|
||||
}
|
||||
|
||||
headers := map[string]string{
|
||||
"Authorization": "Bearer " + token,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
_, err := makePostRequest(url, query, variables, headers)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update anime status: %w", err)
|
||||
}
|
||||
|
||||
statusMap := map[string]string{
|
||||
"CURRENT": "Currently Watching",
|
||||
"COMPLETED": "Completed",
|
||||
"PAUSED": "On Hold",
|
||||
"DROPPED": "Dropped",
|
||||
"PLANNING": "Plan to Watch",
|
||||
}
|
||||
|
||||
CurdOut(fmt.Sprintf("Anime status updated to: %s", statusMap[status]))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Function to rate an anime on AniList
|
||||
func RateAnime(token string, mediaID int) error {
|
||||
var score float64
|
||||
var err error
|
||||
|
||||
userCurdConfig := GetGlobalConfig()
|
||||
if userCurdConfig == nil {
|
||||
return fmt.Errorf("failed to get curd config")
|
||||
}
|
||||
|
||||
if userCurdConfig.RofiSelection {
|
||||
userInput, err := GetUserInputFromRofi("Enter a score for the anime (0-10)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
score, err = strconv.ParseFloat(userInput, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Rate this anime: ")
|
||||
fmt.Scanln(&score)
|
||||
}
|
||||
|
||||
url := "https://graphql.anilist.co"
|
||||
query := `
|
||||
mutation($mediaId: Int, $score: Float) {
|
||||
SaveMediaListEntry(mediaId: $mediaId, score: $score) {
|
||||
id
|
||||
mediaId
|
||||
score
|
||||
}
|
||||
}`
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"mediaId": mediaID,
|
||||
"score": score,
|
||||
}
|
||||
|
||||
headers := map[string]string{
|
||||
"Authorization": "Bearer " + token,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
_, err = makePostRequest(url, query, variables, headers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
CurdOut(fmt.Sprintf("Successfully rated anime (mediaId: %d) with score: %.2f", mediaID, score))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper function to make POST requests
|
||||
func makePostRequest(url, query string, variables map[string]interface{}, headers map[string]string) (map[string]interface{}, error) {
|
||||
requestBody, err := json.Marshal(map[string]interface{}{
|
||||
"query": query,
|
||||
"variables": variables,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json") // <-- Important!
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed with status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var responseData map[string]interface{}
|
||||
// Unmarshal the response into a map
|
||||
err = json.Unmarshal(body, &responseData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
return responseData, nil
|
||||
}
|
||||
|
||||
func ParseAnimeList(input map[string]interface{}) AnimeList {
|
||||
var animeList AnimeList
|
||||
userCurdConfig := GetGlobalConfig()
|
||||
|
||||
toInt := func(value interface{}) int {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return v
|
||||
case float64:
|
||||
return int(v) // You could also use int(math.Round(v)) to round
|
||||
default:
|
||||
return 0 // Default value for unexpected types
|
||||
}
|
||||
}
|
||||
|
||||
safeString := func(value interface{}) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Attempt to assert the value as a string
|
||||
if str, ok := value.(string); ok {
|
||||
return str
|
||||
}
|
||||
|
||||
// If it's not a string, return an empty string or handle it as needed
|
||||
return ""
|
||||
}
|
||||
|
||||
// Access the list entries in the input map
|
||||
if input["data"] == nil {
|
||||
Log("Anilist request failed", logFile)
|
||||
CurdOut("Anilist request failed")
|
||||
ExitCurd(fmt.Errorf("Anilist request failed"))
|
||||
return animeList
|
||||
}
|
||||
data := input["data"].(map[string]interface{})
|
||||
mediaList := data["MediaListCollection"].(map[string]interface{})["lists"].([]interface{})
|
||||
|
||||
for _, list := range mediaList {
|
||||
entries := list.(map[string]interface{})["entries"].([]interface{})
|
||||
|
||||
for _, entry := range entries {
|
||||
entryData := entry.(map[string]interface{})
|
||||
media := entryData["media"].(map[string]interface{})
|
||||
animeEntry := Entry{
|
||||
Media: Media{
|
||||
Duration: toInt(media["duration"]),
|
||||
Episodes: toInt(media["episodes"]),
|
||||
ID: toInt(media["id"]),
|
||||
Title: AnimeTitle{
|
||||
English: safeString(media["title"].(map[string]interface{})["english"]),
|
||||
Romaji: safeString(media["title"].(map[string]interface{})["romaji"]),
|
||||
Japanese: safeString(media["title"].(map[string]interface{})["native"]),
|
||||
},
|
||||
},
|
||||
Progress: toInt(entryData["progress"]),
|
||||
Score: entryData["score"].(float64),
|
||||
Status: safeString(entryData["status"]), // Ensure status is fetched safely
|
||||
}
|
||||
|
||||
if userCurdConfig.RofiSelection && userCurdConfig.ImagePreview {
|
||||
animeEntry.CoverImage = safeString(media["coverImage"].(map[string]interface{})["large"])
|
||||
}
|
||||
|
||||
// Append entries based on their status
|
||||
switch animeEntry.Status {
|
||||
case "CURRENT":
|
||||
animeList.Watching = append(animeList.Watching, animeEntry)
|
||||
case "COMPLETED":
|
||||
animeList.Completed = append(animeList.Completed, animeEntry) // Fix: Ensure Completed list is used
|
||||
case "PAUSED":
|
||||
animeList.Paused = append(animeList.Paused, animeEntry) // Fix: Append to Paused list
|
||||
case "DROPPED":
|
||||
animeList.Dropped = append(animeList.Dropped, animeEntry) // Fix: Append to Dropped list
|
||||
case "PLANNING":
|
||||
animeList.Planning = append(animeList.Planning, animeEntry) // Fix: Append to Planning list
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return animeList
|
||||
}
|
||||
|
||||
// FindAnimeByID searches for an anime by its ID in the AnimeList
|
||||
func FindAnimeByAnilistID(list AnimeList, idStr string) (*Entry, error) {
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid ID format: %s", idStr)
|
||||
}
|
||||
|
||||
// Define a slice of pointers to hold categories
|
||||
categories := [][]Entry{
|
||||
list.Watching,
|
||||
list.Completed,
|
||||
list.Paused,
|
||||
list.Dropped,
|
||||
list.Planning,
|
||||
}
|
||||
|
||||
// Iterate through each category
|
||||
for _, category := range categories {
|
||||
for _, entry := range category {
|
||||
if entry.Media.ID == id {
|
||||
return &entry, nil // Return a pointer to the found entry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("anime with ID %d not found", id) // Return an error if not found
|
||||
}
|
||||
|
||||
// FindAnimeByAnilistIDInAnimes searches for an anime by its AniList ID in a slice of Anime
|
||||
func FindAnimeByAnilistIDInAnimes(animes []Anime, anilistID int) (*Anime, error) {
|
||||
for i := range animes {
|
||||
if animes[i].AnilistId == anilistID {
|
||||
return &animes[i], nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("anime with ID %d not found", anilistID)
|
||||
}
|
||||
|
||||
// GetAnimeDataByID retrieves detailed anime data from AniList using the anime's ID and user token
|
||||
func GetAnimeDataByID(anilistID int, token string) (Anime, error) {
|
||||
query := `
|
||||
query ($id: Int) {
|
||||
Media (id: $id, type: ANIME) {
|
||||
id
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
episodes
|
||||
duration
|
||||
status
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"id": anilistID,
|
||||
}
|
||||
|
||||
jsonValue, _ := json.Marshal(map[string]interface{}{
|
||||
"query": query,
|
||||
"variables": variables,
|
||||
})
|
||||
|
||||
req, err := http.NewRequest("POST", "https://graphql.anilist.co", bytes.NewBuffer(jsonValue))
|
||||
if err != nil {
|
||||
return Anime{}, fmt.Errorf("error creating request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return Anime{}, fmt.Errorf("error sending request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return Anime{}, fmt.Errorf("error reading response: %v", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Data struct {
|
||||
Media struct {
|
||||
ID int `json:"id"`
|
||||
Title AnimeTitle `json:"title"`
|
||||
Episodes int `json:"episodes"`
|
||||
Duration int `json:"duration"`
|
||||
Status string `json:"status"`
|
||||
CoverImage struct {
|
||||
Large string `json:"large"`
|
||||
} `json:"coverImage"`
|
||||
Genres []string `json:"genres"`
|
||||
AverageScore int `json:"averageScore"`
|
||||
} `json:"Media"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return Anime{}, fmt.Errorf("error unmarshaling JSON: %v", err)
|
||||
}
|
||||
|
||||
anime := Anime{
|
||||
AnilistId: result.Data.Media.ID,
|
||||
Title: result.Data.Media.Title,
|
||||
TotalEpisodes: result.Data.Media.Episodes,
|
||||
CoverImage: result.Data.Media.CoverImage.Large,
|
||||
}
|
||||
|
||||
return anime, nil
|
||||
}
|
||||
161
curd/internal/anime_list.go
Normal file
161
curd/internal/anime_list.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
// "strings"
|
||||
)
|
||||
|
||||
type anime struct {
|
||||
ID string `json:"_id"`
|
||||
Name string `json:"name"`
|
||||
EnglishName string `json:"englishName"`
|
||||
AvailableEpisodes interface{} `json:"availableEpisodes"`
|
||||
}
|
||||
|
||||
type response struct {
|
||||
Data struct {
|
||||
Shows struct {
|
||||
Edges []anime `json:"edges"`
|
||||
} `json:"shows"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// func main() {
|
||||
// // Get environment variables
|
||||
// mode := "sub"
|
||||
|
||||
// // Query for the anime (from a file in this example)
|
||||
// query := "one piece"
|
||||
|
||||
// // Search anime
|
||||
// animeList, err := SearchAnime(string(query), mode)
|
||||
// if err != nil {
|
||||
|
||||
// }
|
||||
// fmt.Println(animeList)
|
||||
// }
|
||||
|
||||
func SearchAnime(query, mode string) (map[string]string, error) {
|
||||
userCurdConfig := GetGlobalConfig()
|
||||
var logFile string
|
||||
if userCurdConfig == nil {
|
||||
logFile = os.ExpandEnv("$HOME/.local/share/curd/debug.log")
|
||||
} else {
|
||||
logFile = filepath.Join(os.ExpandEnv(userCurdConfig.StoragePath), "debug.log")
|
||||
}
|
||||
const (
|
||||
agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0"
|
||||
allanimeRef = "https://allanime.to"
|
||||
allanimeBase = "allanime.day"
|
||||
allanimeAPI = "https://api." + allanimeBase + "/api"
|
||||
)
|
||||
|
||||
// Prepare the anime list
|
||||
animeList := make(map[string]string)
|
||||
|
||||
searchGql := `query($search: SearchInput, $limit: Int, $page: Int, $translationType: VaildTranslationTypeEnumType, $countryOrigin: VaildCountryOriginEnumType) {
|
||||
shows(search: $search, limit: $limit, page: $page, translationType: $translationType, countryOrigin: $countryOrigin) {
|
||||
edges {
|
||||
_id
|
||||
name
|
||||
englishName
|
||||
availableEpisodes
|
||||
__typename
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
// Prepare the GraphQL variables
|
||||
variables := map[string]interface{}{
|
||||
"search": map[string]interface{}{
|
||||
"allowAdult": false,
|
||||
"allowUnknown": false,
|
||||
"query": query,
|
||||
},
|
||||
"limit": 40,
|
||||
"page": 1,
|
||||
"translationType": mode,
|
||||
"countryOrigin": "ALL",
|
||||
}
|
||||
|
||||
// Marshal the variables to JSON
|
||||
variablesJSON, err := json.Marshal(variables)
|
||||
if err != nil {
|
||||
Log(fmt.Sprintf("Error encoding variables to JSON: %v", err), logFile)
|
||||
return animeList, err
|
||||
}
|
||||
|
||||
// Build the request URL
|
||||
url := fmt.Sprintf("%s?variables=%s&query=%s", allanimeAPI, url.QueryEscape(string(variablesJSON)), url.QueryEscape(searchGql))
|
||||
|
||||
// Make the HTTP request
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
Log(fmt.Sprintf("Error creating HTTP request: %v", err), logFile)
|
||||
return animeList, err
|
||||
}
|
||||
req.Header.Set("User-Agent", agent)
|
||||
req.Header.Set("Referer", allanimeRef)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
Log(fmt.Sprintf("Error making HTTP request: %v", err), logFile)
|
||||
return animeList, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read the response body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
Log(fmt.Sprintf("Error reading response body: %v", err), logFile)
|
||||
return animeList, err
|
||||
}
|
||||
|
||||
// Debug: Log the response status and first part of the body
|
||||
Log(fmt.Sprintf("Response Status: %s", resp.Status), logFile)
|
||||
Log(fmt.Sprintf("Response Body (first 500 chars): %s", string(body[:min(len(body), 500)])), logFile)
|
||||
|
||||
// Parse the JSON response
|
||||
var response response
|
||||
err = json.Unmarshal(body, &response)
|
||||
if err != nil {
|
||||
Log(fmt.Sprintf("Error parsing JSON for query '%s': %v\nBody: %s", query, err, string(body)), logFile)
|
||||
return animeList, err
|
||||
}
|
||||
|
||||
for _, anime := range response.Data.Shows.Edges {
|
||||
var episodesStr string
|
||||
if episodes, ok := anime.AvailableEpisodes.(map[string]interface{}); ok {
|
||||
if subEpisodes, ok := episodes["sub"].(float64); ok {
|
||||
episodesStr = fmt.Sprintf("%d", int(subEpisodes))
|
||||
} else {
|
||||
Log(subEpisodes, logFile)
|
||||
episodesStr = "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Use English name if available and configured, otherwise use default name
|
||||
displayName := anime.Name
|
||||
if anime.EnglishName != "" && userCurdConfig.AnimeNameLanguage == "english" {
|
||||
displayName = anime.EnglishName
|
||||
}
|
||||
|
||||
animeList[anime.ID] = fmt.Sprintf("%s (%s episodes)", displayName, episodesStr)
|
||||
}
|
||||
return animeList, nil
|
||||
}
|
||||
|
||||
// Helper function
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
103
curd/internal/aniskip.go
Normal file
103
curd/internal/aniskip.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// skipTimesResponse struct to hold the response from the AniSkip API
|
||||
type skipTimesResponse struct {
|
||||
Found bool `json:"found"`
|
||||
Results []skipResult `json:"results"`
|
||||
}
|
||||
|
||||
// skipResult struct to hold individual skip result data
|
||||
type skipResult struct {
|
||||
Interval skipInterval `json:"interval"`
|
||||
}
|
||||
|
||||
// skipInterval struct to hold the start and end times for skip intervals
|
||||
type skipInterval struct {
|
||||
StartTime float64 `json:"start_time"`
|
||||
EndTime float64 `json:"end_time"`
|
||||
}
|
||||
|
||||
// GetAniSkipData fetches skip times data for a given anime ID and episode
|
||||
func GetAniSkipData(animeMalId int, episode int) (string, error) {
|
||||
baseURL := "https://api.aniskip.com/v1/skip-times"
|
||||
url := fmt.Sprintf("%s/%d/%d?types=op&types=ed", baseURL, animeMalId, episode)
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
Log(fmt.Sprintf("error fetching data from AniSkip API: %w", err), logFile)
|
||||
return "", fmt.Errorf("error fetching data from AniSkip API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
Log(fmt.Sprintf("failed with status %d", resp.StatusCode), logFile)
|
||||
return "", fmt.Errorf("failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
Log(fmt.Sprintf("failed to read response body: %w", err), logFile)
|
||||
return "", fmt.Errorf("failed to read response body %w", err)
|
||||
}
|
||||
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
// RoundTime rounds a time value to the specified precision
|
||||
func RoundTime(timeValue float64, precision int) float64 {
|
||||
multiplier := math.Pow(10, float64(precision))
|
||||
return math.Floor(timeValue*multiplier+0.5) / multiplier
|
||||
}
|
||||
|
||||
// ParseAniSkipResponse parses the response text from the AniSkip API and updates the Anime struct
|
||||
func ParseAniSkipResponse(responseText string, anime *Anime, timePrecision int) error {
|
||||
if responseText == "" {
|
||||
return fmt.Errorf("response text is empty")
|
||||
}
|
||||
|
||||
var data skipTimesResponse
|
||||
err := json.Unmarshal([]byte(responseText), &data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error unmarshalling response: %w", err)
|
||||
}
|
||||
|
||||
if !data.Found {
|
||||
return fmt.Errorf("no skip times found")
|
||||
}
|
||||
|
||||
// Populate skip times for the anime's episode
|
||||
if len(data.Results) > 0 {
|
||||
op := data.Results[0].Interval
|
||||
anime.Ep.SkipTimes.Op = Skip{
|
||||
Start: int(RoundTime(op.StartTime, timePrecision)),
|
||||
End: int(RoundTime(op.EndTime, timePrecision)),
|
||||
}
|
||||
}
|
||||
|
||||
if len(data.Results) > 1 {
|
||||
ed := data.Results[len(data.Results)-1].Interval
|
||||
anime.Ep.SkipTimes.Ed = Skip{
|
||||
Start: int(RoundTime(ed.StartTime, timePrecision)),
|
||||
End: int(RoundTime(ed.EndTime, timePrecision)),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAndParseAniSkipData fetches and parses skip times for a given anime ID and episode
|
||||
func GetAndParseAniSkipData(animeMalId int, episode int, timePrecision int, anime *Anime) error {
|
||||
responseText, err := GetAniSkipData(animeMalId, episode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ParseAniSkipResponse(responseText, anime, timePrecision)
|
||||
}
|
||||
271
curd/internal/config.go
Normal file
271
curd/internal/config.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CurdConfig struct with field names that match the config keys
|
||||
type CurdConfig struct {
|
||||
Player string `config:"Player"`
|
||||
SubsLanguage string `config:"SubsLanguage"`
|
||||
SubOrDub string `config:"SubOrDub"`
|
||||
StoragePath string `config:"StoragePath"`
|
||||
AnimeNameLanguage string `config:"AnimeNameLanguage"`
|
||||
PercentageToMarkComplete int `config:"PercentageToMarkComplete"`
|
||||
NextEpisodePrompt bool `config:"NextEpisodePrompt"`
|
||||
SkipOp bool `config:"SkipOp"`
|
||||
SkipEd bool `config:"SkipEd"`
|
||||
SkipFiller bool `config:"SkipFiller"`
|
||||
ImagePreview bool `config:"ImagePreview"`
|
||||
SkipRecap bool `config:"SkipRecap"`
|
||||
RofiSelection bool `config:"RofiSelection"`
|
||||
CurrentCategory bool `config:"CurrentCategory"`
|
||||
ScoreOnCompletion bool `config:"ScoreOnCompletion"`
|
||||
SaveMpvSpeed bool `config:"SaveMpvSpeed"`
|
||||
DiscordPresence bool `config:"DiscordPresence"`
|
||||
}
|
||||
|
||||
// Default configuration values as a map
|
||||
func defaultConfigMap() map[string]string {
|
||||
return map[string]string{
|
||||
"Player": "mpv",
|
||||
"StoragePath": "$HOME/.local/share/curd",
|
||||
"AnimeNameLanguage": "english",
|
||||
"SubsLanguage": "english",
|
||||
"SubOrDub": "sub",
|
||||
"PercentageToMarkComplete": "85",
|
||||
"NextEpisodePrompt": "false",
|
||||
"SkipOp": "true",
|
||||
"SkipEd": "true",
|
||||
"SkipFiller": "true",
|
||||
"SkipRecap": "true",
|
||||
"RofiSelection": "false",
|
||||
"ImagePreview": "false",
|
||||
"ScoreOnCompletion": "true",
|
||||
"SaveMpvSpeed": "true",
|
||||
"DiscordPresence": "true",
|
||||
}
|
||||
}
|
||||
|
||||
var globalConfig *CurdConfig
|
||||
|
||||
func SetGlobalConfig(config *CurdConfig) {
|
||||
globalConfig = config
|
||||
}
|
||||
|
||||
func GetGlobalConfig() *CurdConfig {
|
||||
return globalConfig
|
||||
}
|
||||
|
||||
// LoadConfig reads or creates the config file, adds missing fields, and returns the populated CurdConfig struct
|
||||
func LoadConfig(configPath string) (CurdConfig, error) {
|
||||
configPath = os.ExpandEnv(configPath) // Substitute environment variables like $HOME
|
||||
|
||||
// Check if config file exists
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
// Create the config file with default values if it doesn't exist
|
||||
CurdOut("Config file not found. Creating default config...")
|
||||
if err := createDefaultConfig(configPath); err != nil {
|
||||
return CurdConfig{}, fmt.Errorf("error creating default config file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Load the config from file
|
||||
configMap, err := loadConfigFromFile(configPath)
|
||||
if err != nil {
|
||||
return CurdConfig{}, fmt.Errorf("error loading config file: %v", err)
|
||||
}
|
||||
|
||||
// Add missing fields to the config map
|
||||
updated := false
|
||||
defaultConfigMap := defaultConfigMap()
|
||||
for key, defaultValue := range defaultConfigMap {
|
||||
if _, exists := configMap[key]; !exists {
|
||||
configMap[key] = defaultValue
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
|
||||
// Write updated config back to file if there were any missing fields
|
||||
if updated {
|
||||
if err := saveConfigToFile(configPath, configMap); err != nil {
|
||||
return CurdConfig{}, fmt.Errorf("error saving updated config file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Populate the CurdConfig struct from the config map
|
||||
config := populateConfig(configMap)
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// Create a config file with default values in key=value format
|
||||
// Ensure the directory exists before creating the file
|
||||
func createDefaultConfig(path string) error {
|
||||
defaultConfig := defaultConfigMap()
|
||||
|
||||
// Ensure the directory exists
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("error creating directory: %v", err)
|
||||
}
|
||||
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer := bufio.NewWriter(file)
|
||||
for key, value := range defaultConfig {
|
||||
line := fmt.Sprintf("%s=%s\n", key, value)
|
||||
if _, err := writer.WriteString(line); err != nil {
|
||||
return fmt.Errorf("error writing to file: %v", err)
|
||||
}
|
||||
}
|
||||
if err := writer.Flush(); err != nil {
|
||||
return fmt.Errorf("error flushing writer: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ChangeToken(config *CurdConfig, user *User) {
|
||||
var err error
|
||||
tokenPath := filepath.Join(os.ExpandEnv(config.StoragePath), "token")
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
// Create a temporary file for the token
|
||||
tempFile, err := os.CreateTemp("", "curd-token-*.txt")
|
||||
if err != nil {
|
||||
Log("Error creating temp file: "+err.Error(), logFile)
|
||||
ExitCurd(err)
|
||||
}
|
||||
tempPath := tempFile.Name()
|
||||
tempFile.Close()
|
||||
|
||||
// Write instructions to the temp file
|
||||
instructions := "Please generate a token from https://anilist.co/api/v2/oauth/authorize?client_id=20686&response_type=token\n" +
|
||||
"Replace this text with your token and save the file.\n"
|
||||
if err := os.WriteFile(tempPath, []byte(instructions), 0644); err != nil {
|
||||
Log("Error writing instructions: "+err.Error(), logFile)
|
||||
ExitCurd(err)
|
||||
}
|
||||
|
||||
// Open notepad with the temp file
|
||||
cmd := exec.Command("notepad.exe", tempPath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
Log("Error opening notepad: "+err.Error(), logFile)
|
||||
ExitCurd(err)
|
||||
}
|
||||
|
||||
// Read the token from the file
|
||||
content, err := os.ReadFile(tempPath)
|
||||
if err != nil {
|
||||
Log("Error reading token: "+err.Error(), logFile)
|
||||
ExitCurd(err)
|
||||
}
|
||||
|
||||
// Clean up the temp file
|
||||
os.Remove(tempPath)
|
||||
|
||||
// Extract token (remove instructions and whitespace)
|
||||
user.Token = strings.TrimSpace(string(content))
|
||||
} else if config.RofiSelection {
|
||||
user.Token, err = GetTokenFromRofi()
|
||||
} else {
|
||||
fmt.Println("Please generate a token from https://anilist.co/api/v2/oauth/authorize?client_id=20686&response_type=token")
|
||||
fmt.Scanln(&user.Token)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
Log("Error getting user input: "+err.Error(), logFile)
|
||||
ExitCurd(err)
|
||||
}
|
||||
WriteTokenToFile(user.Token, tokenPath)
|
||||
}
|
||||
|
||||
// Load config file from disk into a map (key=value format)
|
||||
func loadConfigFromFile(path string) (map[string]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
configMap := make(map[string]string)
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue // Skip empty lines and comments
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
configMap[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return configMap, nil
|
||||
}
|
||||
|
||||
// Save updated config map to file in key=value format
|
||||
func saveConfigToFile(path string, configMap map[string]string) error {
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer := bufio.NewWriter(file)
|
||||
for key, value := range configMap {
|
||||
line := fmt.Sprintf("%s=%s\n", key, value)
|
||||
if _, err := writer.WriteString(line); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return writer.Flush()
|
||||
}
|
||||
|
||||
// Populate the CurdConfig struct from a map
|
||||
func populateConfig(configMap map[string]string) CurdConfig {
|
||||
config := CurdConfig{}
|
||||
configValue := reflect.ValueOf(&config).Elem()
|
||||
|
||||
for i := 0; i < configValue.NumField(); i++ {
|
||||
field := configValue.Type().Field(i)
|
||||
tag := field.Tag.Get("config")
|
||||
|
||||
if value, exists := configMap[tag]; exists {
|
||||
fieldValue := configValue.FieldByName(field.Name)
|
||||
|
||||
if fieldValue.CanSet() {
|
||||
switch fieldValue.Kind() {
|
||||
case reflect.String:
|
||||
fieldValue.SetString(value)
|
||||
case reflect.Int:
|
||||
intVal, _ := strconv.Atoi(value)
|
||||
fieldValue.SetInt(int64(intVal))
|
||||
case reflect.Bool:
|
||||
boolVal, _ := strconv.ParseBool(value)
|
||||
fieldValue.SetBool(boolVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
1054
curd/internal/curd.go
Normal file
1054
curd/internal/curd.go
Normal file
File diff suppressed because it is too large
Load Diff
65
curd/internal/discord.go
Normal file
65
curd/internal/discord.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/hugolgst/rich-go/client"
|
||||
)
|
||||
|
||||
func DiscordPresence(clientId string, anime Anime, IsPaused bool) error {
|
||||
err := client.Login(clientId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var state string
|
||||
if IsPaused {
|
||||
state = fmt.Sprintf("\nEpisode %d - %s (Paused)",
|
||||
anime.Ep.Number,
|
||||
FormatTime(anime.Ep.Player.PlaybackTime),
|
||||
)
|
||||
} else {
|
||||
state = fmt.Sprintf("\nEpisode %d - %s / %s",
|
||||
anime.Ep.Number,
|
||||
FormatTime(anime.Ep.Player.PlaybackTime),
|
||||
FormatTime(anime.Ep.Duration),
|
||||
)
|
||||
}
|
||||
|
||||
err = client.SetActivity(client.Activity{
|
||||
Details: fmt.Sprintf("%s", GetAnimeName(anime)), // Large text
|
||||
LargeImage: anime.CoverImage,
|
||||
LargeText: GetAnimeName(anime), // Would display while hovering over the large image
|
||||
State: state,
|
||||
//SmallImage: anime.SmallCoverImage, // Image for the bottom left corner
|
||||
//SmallText: fmt.Sprintf("Episode: %s", anime.Ep.Title.English), // Text when hovering over the small image
|
||||
Buttons: []*client.Button{
|
||||
&client.Button{
|
||||
Label: "View on AniList", // Button label
|
||||
Url: fmt.Sprintf("https://anilist.co/anime/%d", anime.AnilistId), // Button link
|
||||
},
|
||||
&client.Button{
|
||||
Label: "View on MAL", // Button label
|
||||
Url: fmt.Sprintf("https://myanimelist.net/anime/%d", anime.MalId), // Button link
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func FormatTime(seconds int) string {
|
||||
hours := seconds / 3600
|
||||
minutes := (seconds % 3600) / 60
|
||||
remainingSeconds := seconds % 60
|
||||
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%d:%02d:%02d", hours, minutes, remainingSeconds)
|
||||
}
|
||||
return fmt.Sprintf("%d:%02d", minutes, remainingSeconds)
|
||||
}
|
||||
|
||||
func ConvertSecondsToMinutes(seconds int) int {
|
||||
return seconds / 60
|
||||
}
|
||||
106
curd/internal/episode_list.go
Normal file
106
curd/internal/episode_list.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type episodesResponse struct {
|
||||
Data struct {
|
||||
Show struct {
|
||||
ID string `json:"_id"`
|
||||
AvailableEpisodesDetail map[string]interface{} `json:"availableEpisodesDetail"`
|
||||
} `json:"show"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// func main() {
|
||||
// // Get environment variables
|
||||
// // Read the ID from the file
|
||||
// id := "ReooPAxPMsHM4KPMY"
|
||||
|
||||
// // Fetch episodes list
|
||||
// episodeList := episodesList(string(id), "sub")
|
||||
|
||||
// // Write the episode list to a file
|
||||
// fmt.Println(episodeList)
|
||||
// }
|
||||
|
||||
// episodesList performs the API call and fetches the episodes list
|
||||
func EpisodesList(showID, mode string) ([]string, error) {
|
||||
const (
|
||||
agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0"
|
||||
allanimeRef = "https://allanime.to"
|
||||
allanimeBase = "allanime.day"
|
||||
allanimeAPI = "https://api." + allanimeBase + "/api"
|
||||
)
|
||||
|
||||
episodesListGql := `query ($showId: String!) { show( _id: $showId ) { _id availableEpisodesDetail }}`
|
||||
|
||||
// Build the request URL
|
||||
url := fmt.Sprintf("%s?variables={\"showId\":\"%s\"}&query=%s", allanimeAPI, showID, episodesListGql)
|
||||
episodes := []string{}
|
||||
|
||||
// Make the HTTP request
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
Log(fmt.Sprint("Error creating HTTP request:", err), logFile)
|
||||
return episodes, err
|
||||
}
|
||||
req.Header.Set("User-Agent", agent)
|
||||
req.Header.Set("Referer", allanimeRef)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
Log(fmt.Sprint("Error making HTTP request:", err), logFile)
|
||||
return episodes, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
Log(fmt.Sprint("Error reading response body:", err), logFile)
|
||||
return episodes, err
|
||||
}
|
||||
|
||||
// Parse the JSON response
|
||||
var response episodesResponse
|
||||
err = json.Unmarshal(body, &response)
|
||||
if err != nil {
|
||||
Log(fmt.Sprint("Error parsing JSON:", err), logFile)
|
||||
return episodes, err
|
||||
}
|
||||
|
||||
// Extract and sort the episodes
|
||||
episodes = extractEpisodes(response.Data.Show.AvailableEpisodesDetail, mode)
|
||||
return episodes, nil
|
||||
}
|
||||
|
||||
// extractEpisodes extracts the episodes list from the availableEpisodesDetail field
|
||||
func extractEpisodes(availableEpisodesDetail map[string]interface{}, mode string) []string {
|
||||
var episodes []float64
|
||||
|
||||
// Check if the mode (e.g., "sub") exists in the map
|
||||
if eps, ok := availableEpisodesDetail[mode].([]interface{}); ok {
|
||||
for _, ep := range eps {
|
||||
if epNum, err := strconv.ParseFloat(fmt.Sprintf("%v", ep), 64); err == nil {
|
||||
episodes = append(episodes, epNum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort episodes numerically
|
||||
sort.Float64s(episodes)
|
||||
|
||||
// Convert to string and return
|
||||
var episodesStr []string
|
||||
for _, ep := range episodes {
|
||||
episodesStr = append(episodesStr, fmt.Sprintf("%v", ep))
|
||||
}
|
||||
return episodesStr
|
||||
}
|
||||
178
curd/internal/episode_url.go
Normal file
178
curd/internal/episode_url.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type allanimeResponse struct {
|
||||
Data struct {
|
||||
Episode struct {
|
||||
SourceUrls []struct {
|
||||
SourceUrl string `json:"sourceUrl"`
|
||||
} `json:"sourceUrls"`
|
||||
} `json:"episode"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func decodeProviderID(encoded string) string {
|
||||
// Split the string into pairs of characters (.. equivalent of 'sed s/../&\n/g')
|
||||
re := regexp.MustCompile("..")
|
||||
pairs := re.FindAllString(encoded, -1)
|
||||
|
||||
// Mapping for the replacements
|
||||
replacements := map[string]string{
|
||||
"01": "9", "08": "0", "05": "=", "0a": "2", "0b": "3", "0c": "4", "07": "?",
|
||||
"00": "8", "5c": "d", "0f": "7", "5e": "f", "17": "/", "54": "l", "09": "1",
|
||||
"48": "p", "4f": "w", "0e": "6", "5b": "c", "5d": "e", "0d": "5", "53": "k",
|
||||
"1e": "&", "5a": "b", "59": "a", "4a": "r", "4c": "t", "4e": "v", "57": "o",
|
||||
"51": "i",
|
||||
}
|
||||
|
||||
// Perform the replacement equivalent to sed 's/^../.../'
|
||||
for i, pair := range pairs {
|
||||
if val, exists := replacements[pair]; exists {
|
||||
pairs[i] = val
|
||||
}
|
||||
}
|
||||
|
||||
// Join the modified pairs back into a single string
|
||||
result := strings.Join(pairs, "")
|
||||
|
||||
// Replace "/clock" with "/clock.json" equivalent of sed "s/\/clock/\/clock\.json/"
|
||||
result = strings.ReplaceAll(result, "/clock", "/clock.json")
|
||||
|
||||
// Print the final result
|
||||
return result
|
||||
}
|
||||
|
||||
func extractLinks(provider_id string) map[string]interface{} {
|
||||
allanime_base := "https://allanime.day"
|
||||
url := allanime_base + provider_id
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
var videoData map[string]interface{}
|
||||
if err != nil {
|
||||
Log(fmt.Sprint("Error creating request:", err), logFile)
|
||||
return videoData
|
||||
}
|
||||
|
||||
// Add the headers
|
||||
req.Header.Set("Referer", "https://allanime.to")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0")
|
||||
|
||||
// Send the request
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
Log(fmt.Sprint("Error sending request:", err), logFile)
|
||||
return videoData
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read the response body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
Log(fmt.Sprint("Error reading response:", err), logFile)
|
||||
return videoData
|
||||
}
|
||||
|
||||
// Parse the JSON response
|
||||
err = json.Unmarshal(body, &videoData)
|
||||
if err != nil {
|
||||
Log(fmt.Sprint("Error parsing JSON:", err), logFile)
|
||||
return videoData
|
||||
}
|
||||
|
||||
// Process the data as needed
|
||||
return videoData
|
||||
}
|
||||
|
||||
// Get anime episode url respective to given config
|
||||
// If the link is found, it returns a list of links. Otherwise, it returns an error.
|
||||
//
|
||||
// Parameters:
|
||||
// - config: Configuration of the anime search.
|
||||
// - id: Allanime id of the anime to search for.
|
||||
// - epNo: Anime episode number to get links for.
|
||||
//
|
||||
// Returns:
|
||||
// - []string: a list of links for specified episode.
|
||||
// - error: an error if the episode is not found or if there is an issue during the search.
|
||||
func GetEpisodeURL(config CurdConfig, id string, epNo int) ([]string, error) {
|
||||
query := `query($showId:String!,$translationType:VaildTranslationTypeEnumType!,$episodeString:String!){episode(showId:$showId,translationType:$translationType,episodeString:$episodeString){episodeString sourceUrls}}`
|
||||
|
||||
variables := map[string]string{
|
||||
"showId": id,
|
||||
"translationType": config.SubOrDub,
|
||||
"episodeString": fmt.Sprintf("%d", epNo),
|
||||
}
|
||||
|
||||
variablesJSON, err := json.Marshal(variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
values := url.Values{}
|
||||
values.Set("query", query)
|
||||
values.Set("variables", string(variablesJSON))
|
||||
|
||||
reqURL := fmt.Sprintf("%s/api?%s", "https://api.allanime.day", values.Encode())
|
||||
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0")
|
||||
req.Header.Set("Referer", "https://allanime.to")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
responseStr := string(body)
|
||||
|
||||
// Unmarshal the JSON data into the struct
|
||||
var response allanimeResponse
|
||||
err = json.Unmarshal([]byte(responseStr), &response)
|
||||
if err != nil {
|
||||
Log(fmt.Sprint("Error parsing JSON: ", err), logFile)
|
||||
}
|
||||
|
||||
var allinks []string // This will be returned
|
||||
|
||||
// Iterate through the SourceUrls and print each URL
|
||||
for _, url := range response.Data.Episode.SourceUrls {
|
||||
if len(url.SourceUrl) > 2 && unicode.IsDigit(rune(url.SourceUrl[2])) { // Source Url 3rd letter is a number (it stars as --32f23k31jk)
|
||||
decodedProviderID := decodeProviderID(url.SourceUrl[2:]) // Decode the source url to get the provider id
|
||||
extractedLinks := extractLinks(decodedProviderID) // Extract the links using provider id
|
||||
if linksInterface, ok := extractedLinks["links"].([]interface{}); ok {
|
||||
for _, linkInterface := range linksInterface {
|
||||
if linkMap, ok := linkInterface.(map[string]interface{}); ok {
|
||||
if link, ok := linkMap["link"].(string); ok {
|
||||
allinks = append(allinks, link) // Add all extracted links into allinks
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log("Links field is not of the expected type []interface{}", logFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allinks, nil
|
||||
}
|
||||
106
curd/internal/jikan.go
Normal file
106
curd/internal/jikan.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// GetEpisodeData fetches episode data for a given anime ID and episode number
|
||||
func GetEpisodeData(animeID int, episodeNo int, anime *Anime) error {
|
||||
url := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/episodes/%d", animeID, episodeNo)
|
||||
|
||||
// Use the helper function for making the GET request
|
||||
response, err := makeGetRequest(url, nil)
|
||||
if err != nil {
|
||||
Log(fmt.Sprintf("Warning: Jikan API error: %v - continuing without filler data", err), logFile)
|
||||
// Set default values when API fails
|
||||
anime.Ep.IsFiller = false
|
||||
anime.Ep.IsRecap = false
|
||||
return nil // Return nil to allow the application to continue
|
||||
}
|
||||
|
||||
Log(response, logFile)
|
||||
|
||||
// Check if the 'data' field exists and is valid
|
||||
data, ok := response["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
Log("Warning: Invalid Jikan API response - continuing without filler data", logFile)
|
||||
// Set default values when response is invalid
|
||||
anime.Ep.IsFiller = false
|
||||
anime.Ep.IsRecap = false
|
||||
return nil // Return nil to allow the application to continue
|
||||
}
|
||||
// Helper function to safely get string value
|
||||
getStringValue := func(field string) string {
|
||||
if value, ok := data[field].(string); ok {
|
||||
return value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Helper function to safely get int value
|
||||
getIntValue := func(field string) int {
|
||||
if value, ok := data[field].(float64); ok {
|
||||
return int(value)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Helper function to safely get bool value
|
||||
getBoolValue := func(field string) bool {
|
||||
if value, ok := data[field].(bool); ok {
|
||||
return value
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Safely assign values to the Anime struct
|
||||
anime.Ep.Title.Romaji = getStringValue("title_romanji")
|
||||
anime.Ep.Title.English = getStringValue("title")
|
||||
anime.Ep.Title.Japanese = getStringValue("title_japanese")
|
||||
anime.Ep.Aired = getStringValue("aired")
|
||||
anime.Ep.Duration = getIntValue("duration")
|
||||
anime.Ep.IsFiller = getBoolValue("filler")
|
||||
anime.Ep.IsRecap = getBoolValue("recap")
|
||||
anime.Ep.Synopsis = getStringValue("synopsis")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper function to make GET requests
|
||||
func makeGetRequest(url string, headers map[string]string) (map[string]interface{}, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create GET request: %w", err)
|
||||
}
|
||||
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send GET request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed with status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var responseData map[string]interface{}
|
||||
err = json.Unmarshal(body, &responseData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
return responseData, nil
|
||||
}
|
||||
48
curd/internal/links.go
Normal file
48
curd/internal/links.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package internal
|
||||
|
||||
import "strings"
|
||||
|
||||
// LinkPriorities defines the order of priority for link domains
|
||||
var LinkPriorities = []string{
|
||||
"sharepoint.com",
|
||||
"wixmp.com",
|
||||
"dropbox.com",
|
||||
"wetransfer.com",
|
||||
"gogoanime.com",
|
||||
// Add more domains in order of priority
|
||||
}
|
||||
|
||||
// PrioritizeLink takes an array of links and returns a single link based on priority
|
||||
func PrioritizeLink(links []string) string {
|
||||
if len(links) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Create a map for quick lookup of priorities
|
||||
priorityMap := make(map[string]int)
|
||||
for i, p := range LinkPriorities {
|
||||
priorityMap[p] = len(LinkPriorities) - i // Higher index means higher priority
|
||||
}
|
||||
|
||||
highestPriority := -1
|
||||
var bestLink string
|
||||
|
||||
for _, link := range links {
|
||||
for domain, priority := range priorityMap {
|
||||
if strings.Contains(link, domain) {
|
||||
if priority > highestPriority {
|
||||
highestPriority = priority
|
||||
bestLink = link
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no priority link found, return the first link
|
||||
if bestLink == "" {
|
||||
return links[0]
|
||||
}
|
||||
|
||||
return bestLink
|
||||
}
|
||||
415
curd/internal/localTracking.go
Normal file
415
curd/internal/localTracking.go
Normal file
@@ -0,0 +1,415 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Function to add an anime entry
|
||||
func LocalAddAnime(databaseFile string, anilistID int, allanimeID string, watchingEpisode int, watchingTime int, animeDuration int, animeName string) {
|
||||
file, err := os.OpenFile(databaseFile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
CurdOut(fmt.Sprintf("Error opening file: %v", err))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer := csv.NewWriter(file)
|
||||
defer writer.Flush()
|
||||
|
||||
err = writer.Write([]string{
|
||||
strconv.Itoa(anilistID),
|
||||
allanimeID,
|
||||
strconv.Itoa(watchingEpisode),
|
||||
strconv.Itoa(watchingTime),
|
||||
strconv.Itoa(animeDuration),
|
||||
animeName,
|
||||
})
|
||||
if err != nil {
|
||||
CurdOut(fmt.Sprintf("Error writing to file: %v", err))
|
||||
} else {
|
||||
CurdOut("Written to file")
|
||||
}
|
||||
}
|
||||
|
||||
// Function to delete an anime entry by Anilist ID and Allanime ID
|
||||
func LocalDeleteAnime(databaseFile string, anilistID int, allanimeID string) {
|
||||
animeList := [][]string{}
|
||||
file, err := os.Open(databaseFile)
|
||||
if err != nil {
|
||||
CurdOut(fmt.Sprintf("Error opening file: %v", err))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
CurdOut(fmt.Sprintf("Error reading file: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Filter out the anime entry
|
||||
for _, row := range records {
|
||||
aid, _ := strconv.Atoi(row[0]) // Anilist ID
|
||||
if aid != anilistID || row[1] != allanimeID {
|
||||
animeList = append(animeList, row)
|
||||
}
|
||||
}
|
||||
|
||||
// Write the filtered list back to the file
|
||||
fileWrite, err := os.OpenFile(databaseFile, os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
CurdOut(fmt.Sprintf("Error opening file for writing: %v", err))
|
||||
return
|
||||
}
|
||||
defer fileWrite.Close()
|
||||
|
||||
writer := csv.NewWriter(fileWrite)
|
||||
defer writer.Flush()
|
||||
|
||||
err = writer.WriteAll(animeList)
|
||||
if err != nil {
|
||||
CurdOut(fmt.Sprintf("Error writing to file: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Function to get all anime entries from the database
|
||||
func LocalGetAllAnime(databaseFile string) []Anime {
|
||||
animeList := []Anime{}
|
||||
|
||||
// Ensure the directory exists
|
||||
dir := filepath.Dir(databaseFile)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
CurdOut(fmt.Sprintf("Error creating directory: %v", err))
|
||||
return animeList
|
||||
}
|
||||
|
||||
// Open the file, create if it doesn't exist
|
||||
file, err := os.OpenFile(databaseFile, os.O_RDONLY|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
CurdOut(fmt.Sprintf("Error opening or creating file: %v", err))
|
||||
return animeList
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// If the file was just created, it will be empty, so return an empty list
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
CurdOut(fmt.Sprintf("Error getting file info: %v", err))
|
||||
return animeList
|
||||
}
|
||||
if fileInfo.Size() == 0 {
|
||||
return animeList
|
||||
}
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
CurdOut(fmt.Sprintf("Error reading file: %v", err))
|
||||
return animeList
|
||||
}
|
||||
|
||||
for _, row := range records {
|
||||
anime := parseAnimeRow(row)
|
||||
if anime != nil {
|
||||
animeList = append(animeList, *anime)
|
||||
}
|
||||
}
|
||||
|
||||
return animeList
|
||||
}
|
||||
|
||||
// Function to parse a single row of anime data
|
||||
func parseAnimeRow(row []string) *Anime {
|
||||
if len(row) < 5 {
|
||||
CurdOut(fmt.Sprintf("Invalid row format: %v", row))
|
||||
return nil
|
||||
}
|
||||
|
||||
anilistID, _ := strconv.Atoi(row[0])
|
||||
watchingEpisode, _ := strconv.Atoi(row[2])
|
||||
playbackTime, _ := strconv.Atoi(row[3])
|
||||
animeDuration, _ := strconv.Atoi(row[4])
|
||||
|
||||
anime := &Anime{
|
||||
AnilistId: anilistID,
|
||||
AllanimeId: row[1],
|
||||
Ep: Episode{
|
||||
Number: watchingEpisode,
|
||||
Player: playingVideo{
|
||||
PlaybackTime: playbackTime,
|
||||
},
|
||||
Duration: animeDuration,
|
||||
},
|
||||
}
|
||||
|
||||
if len(row) == 6 {
|
||||
anime.Title = AnimeTitle{
|
||||
English: row[5],
|
||||
Romaji: row[5],
|
||||
}
|
||||
} else if len(row) == 5 {
|
||||
anime.Title = AnimeTitle{
|
||||
English: row[4],
|
||||
Romaji: row[4],
|
||||
}
|
||||
}
|
||||
|
||||
return anime
|
||||
}
|
||||
|
||||
// Function to get the anime name (English or Romaji) from an Anime struct
|
||||
func GetAnimeName(anime Anime) string {
|
||||
userCurdConfig := GetGlobalConfig()
|
||||
if anime.Title.English != "" && userCurdConfig.AnimeNameLanguage == "english" {
|
||||
return anime.Title.English
|
||||
}
|
||||
return anime.Title.Romaji
|
||||
}
|
||||
|
||||
// Function to update or add a new anime entry
|
||||
func LocalUpdateAnime(databaseFile string, anilistID int, allanimeID string, watchingEpisode int, playbackTime int, animeDuration int, animeName string) error {
|
||||
// Read existing entries
|
||||
animeList := LocalGetAllAnime(databaseFile)
|
||||
|
||||
// Find and update existing entry or add new one
|
||||
updated := false
|
||||
for i, anime := range animeList {
|
||||
if anime.AnilistId == anilistID && anime.AllanimeId == allanimeID {
|
||||
animeList[i].Ep.Number = watchingEpisode
|
||||
animeList[i].Ep.Player.PlaybackTime = playbackTime
|
||||
animeList[i].Ep.Duration = animeDuration
|
||||
animeList[i].Title.English = animeName
|
||||
animeList[i].Title.Romaji = animeName
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !updated {
|
||||
newAnime := Anime{
|
||||
AnilistId: anilistID,
|
||||
AllanimeId: allanimeID,
|
||||
Ep: Episode{
|
||||
Number: watchingEpisode,
|
||||
Player: playingVideo{
|
||||
PlaybackTime: playbackTime,
|
||||
},
|
||||
Duration: animeDuration,
|
||||
},
|
||||
Title: AnimeTitle{
|
||||
English: animeName,
|
||||
Romaji: animeName,
|
||||
},
|
||||
}
|
||||
animeList = append(animeList, newAnime)
|
||||
}
|
||||
|
||||
// Write updated list back to file
|
||||
file, err := os.Create(databaseFile)
|
||||
if err != nil {
|
||||
CurdOut(fmt.Sprintf("Error creating file: %v", err))
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer := csv.NewWriter(file)
|
||||
defer writer.Flush()
|
||||
|
||||
for _, anime := range animeList {
|
||||
record := []string{
|
||||
strconv.Itoa(anime.AnilistId),
|
||||
anime.AllanimeId,
|
||||
strconv.Itoa(anime.Ep.Number),
|
||||
strconv.Itoa(anime.Ep.Player.PlaybackTime),
|
||||
strconv.Itoa(anime.Ep.Duration),
|
||||
GetAnimeName(anime),
|
||||
}
|
||||
if err := writer.Write(record); err != nil {
|
||||
CurdOut(fmt.Sprintf("Error writing record: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Function to find an anime by either Anilist ID or Allanime ID
|
||||
func LocalFindAnime(animeList []Anime, anilistID int, allanimeID string) *Anime {
|
||||
for _, anime := range animeList {
|
||||
if anime.AnilistId == anilistID || anime.AllanimeId == allanimeID {
|
||||
return &anime
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func WatchUntracked(userCurdConfig *CurdConfig, logFile string) {
|
||||
var query string
|
||||
var animeList map[string]string
|
||||
var err error
|
||||
var anime Anime
|
||||
|
||||
// Get anime name from user
|
||||
if userCurdConfig.RofiSelection {
|
||||
userInput, err := GetUserInputFromRofi("Enter the anime name")
|
||||
if err != nil {
|
||||
Log("Error getting user input: "+err.Error(), logFile)
|
||||
ExitCurd(fmt.Errorf("Error getting user input: " + err.Error()))
|
||||
}
|
||||
query = userInput
|
||||
} else {
|
||||
CurdOut("Enter the anime name:")
|
||||
fmt.Scanln(&query)
|
||||
}
|
||||
|
||||
// Search for the anime
|
||||
animeList, err = SearchAnime(query, userCurdConfig.SubOrDub)
|
||||
if err != nil {
|
||||
Log(fmt.Sprintf("Failed to search anime: %v", err), logFile)
|
||||
ExitCurd(fmt.Errorf("Failed to search anime"))
|
||||
}
|
||||
|
||||
if len(animeList) == 0 {
|
||||
ExitCurd(fmt.Errorf("No results found."))
|
||||
}
|
||||
|
||||
// Select anime from search results
|
||||
selectedAnime, err := DynamicSelect(animeList, false)
|
||||
if err != nil {
|
||||
Log(fmt.Sprintf("Failed to select anime: %v", err), logFile)
|
||||
ExitCurd(fmt.Errorf("Failed to select anime"))
|
||||
}
|
||||
|
||||
if selectedAnime.Key == "-1" {
|
||||
ExitCurd(nil)
|
||||
}
|
||||
|
||||
anime.AllanimeId = selectedAnime.Key
|
||||
anime.Title.English = selectedAnime.Label
|
||||
|
||||
// Get episode number
|
||||
var episodeNumber int
|
||||
if userCurdConfig.RofiSelection {
|
||||
userInput, err := GetUserInputFromRofi("Enter the episode number")
|
||||
if err != nil {
|
||||
Log("Error getting episode number: "+err.Error(), logFile)
|
||||
ExitCurd(fmt.Errorf("Error getting episode number: " + err.Error()))
|
||||
}
|
||||
episodeNumber, err = strconv.Atoi(userInput)
|
||||
if err != nil {
|
||||
Log(fmt.Sprintf("Invalid episode number: %v", err), logFile)
|
||||
ExitCurd(fmt.Errorf("Invalid episode number"))
|
||||
}
|
||||
} else {
|
||||
CurdOut("Enter the episode number:")
|
||||
fmt.Scanln(&episodeNumber)
|
||||
}
|
||||
|
||||
anime.Ep.Number = episodeNumber
|
||||
|
||||
for {
|
||||
// Get episode link
|
||||
link, err := GetEpisodeURL(*userCurdConfig, anime.AllanimeId, anime.Ep.Number)
|
||||
if err != nil {
|
||||
Log(fmt.Sprintf("Failed to get episode link: %v", err), logFile)
|
||||
ExitCurd(fmt.Errorf("Failed to get episode link"))
|
||||
}
|
||||
|
||||
if len(link) == 0 {
|
||||
ExitCurd(fmt.Errorf("No episode links found"))
|
||||
}
|
||||
|
||||
CurdOut(fmt.Sprintf("%s - Episode %d", GetAnimeName(anime), anime.Ep.Number))
|
||||
|
||||
// Start video playback
|
||||
mpvSocketPath, err := StartVideo(PrioritizeLink(link), []string{}, fmt.Sprintf("%s - Episode %d", GetAnimeName(anime), anime.Ep.Number))
|
||||
if err != nil {
|
||||
Log("Failed to start mpv", logFile)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
anime.Ep.Player.SocketPath = mpvSocketPath
|
||||
anime.Ep.Started = false
|
||||
|
||||
Log(fmt.Sprintf("Started mpvsocketpath ", anime.Ep.Player.SocketPath), logFile)
|
||||
|
||||
// Get video duration
|
||||
go func() {
|
||||
for {
|
||||
if anime.Ep.Started {
|
||||
if anime.Ep.Duration == 0 {
|
||||
// Get video duration
|
||||
durationPos, err := MPVSendCommand(anime.Ep.Player.SocketPath, []interface{}{"get_property", "duration"})
|
||||
if err != nil {
|
||||
Log("Error getting video duration: "+err.Error(), logFile)
|
||||
} else if durationPos != nil {
|
||||
if duration, ok := durationPos.(float64); ok {
|
||||
anime.Ep.Duration = int(duration + 0.5) // Round to nearest integer
|
||||
Log(fmt.Sprintf("Video duration: %d seconds", anime.Ep.Duration), logFile)
|
||||
} else {
|
||||
Log("Error: duration is not a float64", logFile)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}()
|
||||
|
||||
// Listen for video started
|
||||
for {
|
||||
timePos, err := MPVSendCommand(anime.Ep.Player.SocketPath, []interface{}{"get_property", "time-pos"})
|
||||
if err != nil {
|
||||
Log("Error getting playback time: "+err.Error(), logFile)
|
||||
|
||||
// Check if the error is due to invalid JSON
|
||||
// User closed the video
|
||||
if anime.Ep.Started {
|
||||
percentageWatched := PercentageWatched(anime.Ep.Player.PlaybackTime, anime.Ep.Duration)
|
||||
// Episode is completed
|
||||
Log(fmt.Sprint(percentageWatched), logFile)
|
||||
Log(fmt.Sprint(anime.Ep.Player.PlaybackTime), logFile)
|
||||
Log(fmt.Sprint(anime.Ep.Duration), logFile)
|
||||
Log(fmt.Sprint(userCurdConfig.PercentageToMarkComplete), logFile)
|
||||
if int(percentageWatched) >= userCurdConfig.PercentageToMarkComplete {
|
||||
anime.Ep.Number++
|
||||
anime.Ep.Started = false
|
||||
Log("Completed episode, starting next.", logFile)
|
||||
anime.Ep.IsCompleted = true
|
||||
// Exit the skip loop
|
||||
break
|
||||
} else if fmt.Sprintf("%v", err) == "invalid character '{' after top-level value" { // Episode is not completed
|
||||
Log("Received invalid JSON response, continuing...", logFile)
|
||||
} else {
|
||||
Log("Episode is not completed, exiting", logFile)
|
||||
ExitCurd(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert timePos to integer
|
||||
if timePos != nil {
|
||||
if !anime.Ep.Started {
|
||||
anime.Ep.Started = true
|
||||
}
|
||||
|
||||
animePosition, ok := timePos.(float64)
|
||||
if !ok {
|
||||
Log("Error: timePos is not a float64", logFile)
|
||||
continue
|
||||
}
|
||||
|
||||
anime.Ep.Player.PlaybackTime = int(animePosition + 0.5) // Round to nearest integer
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
206
curd/internal/player.go
Normal file
206
curd/internal/player.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
// "fmt"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/Microsoft/go-winio"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
var logFile = "debug.log"
|
||||
|
||||
func getMPVPath() (string, error) {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
exeDir := filepath.Dir(exePath)
|
||||
mpvPath := filepath.Join(exeDir, "bin", "mpv.exe") // Adjust the relative path
|
||||
return mpvPath, nil
|
||||
}
|
||||
|
||||
func StartVideo(link string, args []string, title string) (string, error) {
|
||||
var command *exec.Cmd
|
||||
|
||||
// Generate a random number for the socket path
|
||||
randomBytes := make([]byte, 4)
|
||||
_, err := rand.Read(randomBytes)
|
||||
if err != nil {
|
||||
Log("Failed to generate random number", logFile)
|
||||
return "", fmt.Errorf("failed to generate random number: %w", err)
|
||||
}
|
||||
|
||||
randomNumber := fmt.Sprintf("%x", randomBytes)
|
||||
|
||||
// Create the mpv socket path with the random number
|
||||
var mpvSocketPath string
|
||||
if runtime.GOOS == "windows" {
|
||||
mpvSocketPath = fmt.Sprintf(`\\.\pipe\curd_mpvsocket_%s`, randomNumber)
|
||||
} else {
|
||||
mpvSocketPath = fmt.Sprintf("/tmp/curd_mpvsocket_%s", randomNumber)
|
||||
}
|
||||
|
||||
// Add the title to MPV arguments
|
||||
titleArgs := []string{fmt.Sprintf("--title=%s", title), fmt.Sprintf("--force-media-title=%s", title)}
|
||||
args = append(args, titleArgs...)
|
||||
|
||||
// Prepare arguments for mpv
|
||||
var mpvArgs []string
|
||||
mpvArgs = append(mpvArgs, "--no-terminal", "--really-quiet", fmt.Sprintf("--input-ipc-server=%s", mpvSocketPath), link)
|
||||
// Add any additional arguments passed
|
||||
if len(args) > 0 {
|
||||
mpvArgs = append(mpvArgs, args...)
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
// Get the path to mpv.exe for Windows
|
||||
mpvPath, err := getMPVPath()
|
||||
if err != nil {
|
||||
CurdOut("Error: Failed to get MPV path")
|
||||
Log("Failed to get mpv path.", logFile)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Create command for Windows
|
||||
command = exec.Command(mpvPath, mpvArgs...)
|
||||
} else {
|
||||
// Create command for Unix-like systems
|
||||
command = exec.Command("mpv", mpvArgs...)
|
||||
}
|
||||
|
||||
// Start the mpv process
|
||||
err = command.Start()
|
||||
if err != nil {
|
||||
CurdOut("Error: Failed to start mpv process")
|
||||
return "", fmt.Errorf("failed to start mpv: %w", err)
|
||||
}
|
||||
return mpvSocketPath, nil
|
||||
}
|
||||
|
||||
// Helper function to join args with a space
|
||||
func joinArgs(args []string) string {
|
||||
result := ""
|
||||
for i, arg := range args {
|
||||
if i > 0 {
|
||||
result += " "
|
||||
}
|
||||
result += arg
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func MPVSendCommand(ipcSocketPath string, command []interface{}) (interface{}, error) {
|
||||
var conn net.Conn
|
||||
var err error
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
// Use named pipe for Windows
|
||||
conn, err = winio.DialPipe(ipcSocketPath, nil)
|
||||
} else {
|
||||
conn, err = net.Dial("unix", ipcSocketPath)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
commandStr, err := json.Marshal(map[string]interface{}{
|
||||
"command": command,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Send the command
|
||||
_, err = conn.Write(append(commandStr, '\n'))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Receive the response
|
||||
buf := make([]byte, 4096)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
if err := json.Unmarshal(buf[:n], &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if data, exists := response["data"]; exists {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func SeekMPV(ipcSocketPath string, time int) (interface{}, error) {
|
||||
command := []interface{}{"seek", time, "absolute"}
|
||||
return MPVSendCommand(ipcSocketPath, command)
|
||||
}
|
||||
|
||||
func GetMPVPausedStatus(ipcSocketPath string) (bool, error) {
|
||||
status, err := MPVSendCommand(ipcSocketPath, []interface{}{"get_property", "pause"})
|
||||
if err != nil || status == nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
paused, ok := status.(bool)
|
||||
if ok {
|
||||
return paused, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func GetMPVPlaybackSpeed(ipcSocketPath string) (float64, error) {
|
||||
speed, err := MPVSendCommand(ipcSocketPath, []interface{}{"get_property", "speed"})
|
||||
if err != nil || speed == nil {
|
||||
Log("Failed to get playback speed.", logFile)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
currentSpeed, ok := speed.(float64)
|
||||
if ok {
|
||||
return currentSpeed, nil
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func GetPercentageWatched(ipcSocketPath string) (float64, error) {
|
||||
currentTime, err := MPVSendCommand(ipcSocketPath, []interface{}{"get_property", "time-pos"})
|
||||
if err != nil || currentTime == nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
duration, err := MPVSendCommand(ipcSocketPath, []interface{}{"get_property", "duration"})
|
||||
if err != nil || duration == nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
currTime, ok1 := currentTime.(float64)
|
||||
dur, ok2 := duration.(float64)
|
||||
|
||||
if ok1 && ok2 && dur > 0 {
|
||||
percentageWatched := (currTime / dur) * 100
|
||||
return percentageWatched, nil
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func PercentageWatched(playbackTime int, duration int) float64 {
|
||||
if duration > 0 {
|
||||
percentage := (float64(playbackTime) / float64(duration)) * 100
|
||||
return percentage
|
||||
}
|
||||
return float64(0)
|
||||
}
|
||||
61
curd/internal/rofi.go
Normal file
61
curd/internal/rofi.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func GetTokenFromRofi() (string, error) {
|
||||
// The URL to open
|
||||
url := "https://anilist.co/api/v2/oauth/authorize?client_id=20686&response_type=token"
|
||||
|
||||
// Use rofi to display a prompt with the URL
|
||||
message := "Press enter to open the anilist token page in your browser"
|
||||
_, err := GetUserInputFromRofi(message)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Open the URL in the default browser
|
||||
err = exec.Command("xdg-open", url).Start()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Use rofi again to get the token input from the user
|
||||
token, err := GetUserInputFromRofi("Enter the token")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// GetUserInputFromRofi prompts the user for input using Rofi with a custom message
|
||||
func GetUserInputFromRofi(message string) (string, error) {
|
||||
userCurdConfig := GetGlobalConfig()
|
||||
if userCurdConfig.StoragePath == "" {
|
||||
userCurdConfig.StoragePath = os.ExpandEnv("${HOME}/.local/share/curd")
|
||||
}
|
||||
// Create the Rofi command
|
||||
cmd := exec.Command("rofi", "-dmenu", "-theme", filepath.Join(os.ExpandEnv(userCurdConfig.StoragePath), "userinput.rasi"), "-p", "Input", "-mesg", message)
|
||||
|
||||
// Set up pipes for output
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
|
||||
// Run the command
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to run Rofi: %w", err)
|
||||
}
|
||||
|
||||
// Get the entered input
|
||||
userInput := strings.TrimSpace(out.String())
|
||||
|
||||
return userInput, nil
|
||||
}
|
||||
423
curd/internal/selection_menu.go
Normal file
423
curd/internal/selection_menu.go
Normal file
@@ -0,0 +1,423 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"github.com/charmbracelet/bubbletea"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SelectionOption holds the label and the internal key
|
||||
type SelectionOption struct {
|
||||
Label string
|
||||
Key string
|
||||
}
|
||||
|
||||
// Model represents the application state for the selection prompt
|
||||
type Model struct {
|
||||
options map[string]string
|
||||
filter string
|
||||
filteredKeys []SelectionOption
|
||||
selected int
|
||||
terminalWidth int
|
||||
terminalHeight int
|
||||
scrollOffset int // Track the topmost visible item
|
||||
addNewOption bool // Add this field
|
||||
}
|
||||
|
||||
// Init initializes the model
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update handles user input and updates the model
|
||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// Handle terminal resize messages
|
||||
if wsm, ok := msg.(tea.WindowSizeMsg); ok {
|
||||
m.terminalWidth = wsm.Width
|
||||
m.terminalHeight = wsm.Height
|
||||
}
|
||||
|
||||
updateFilter := false
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
// Return quit selection option instead of quitting the program
|
||||
m.filteredKeys[m.selected] = SelectionOption{"quit", "-1"}
|
||||
return m, tea.Quit // Properly exit the program
|
||||
case "backspace":
|
||||
if len(m.filter) > 0 {
|
||||
m.filter = m.filter[:len(m.filter)-1]
|
||||
updateFilter = true
|
||||
}
|
||||
case "down":
|
||||
// Move the selection cursor down
|
||||
if m.selected < len(m.filteredKeys)-1 {
|
||||
m.selected++
|
||||
}
|
||||
|
||||
// Scroll the view if necessary
|
||||
if m.selected >= m.scrollOffset+m.visibleItemsCount() {
|
||||
m.scrollOffset++
|
||||
}
|
||||
case "up":
|
||||
// Move the selection cursor up
|
||||
if m.selected > 0 {
|
||||
m.selected--
|
||||
}
|
||||
|
||||
// Scroll the view if necessary
|
||||
if m.selected < m.scrollOffset {
|
||||
m.scrollOffset--
|
||||
}
|
||||
case "enter":
|
||||
if m.filteredKeys[m.selected].Key == "add_new" {
|
||||
CurdOut("Adding a new anime...")
|
||||
m.filteredKeys[m.selected] = SelectionOption{"add_new", "0"}
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, tea.Quit
|
||||
default:
|
||||
if len(msg.String()) == 1 && msg.String() >= " " && msg.String() <= "~" {
|
||||
m.filter += msg.String()
|
||||
updateFilter = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if updateFilter {
|
||||
m.filterOptions()
|
||||
m.selected = 0 // Reset selection to the first item after filtering
|
||||
m.scrollOffset = 0 // Reset scrolling
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// View renders the UI and only shows as many options as fit in the terminal
|
||||
func (m Model) View() string {
|
||||
var b strings.Builder
|
||||
|
||||
// Display the search prompt and filter
|
||||
b.WriteString("Search (Press Ctrl+C to quit):\n")
|
||||
b.WriteString("Filter: " + m.filter + "\n")
|
||||
|
||||
if len(m.filteredKeys) == 0 {
|
||||
b.WriteString("\nNo matches found.\n")
|
||||
} else {
|
||||
visibleItems := m.visibleItemsCount()
|
||||
|
||||
// Determine the slice of items to display based on scroll offset
|
||||
start := m.scrollOffset
|
||||
end := start + visibleItems
|
||||
if end > len(m.filteredKeys) {
|
||||
end = len(m.filteredKeys)
|
||||
}
|
||||
|
||||
// Render the options within the visible range
|
||||
for i := start; i < end; i++ {
|
||||
if i == m.selected {
|
||||
b.WriteString(fmt.Sprintf("▶ %s\n", m.filteredKeys[i].Label)) // Highlight the selected option
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf(" %s\n", m.filteredKeys[i].Label)) // Regular option
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// visibleItemsCount calculates how many options fit in the terminal
|
||||
func (m Model) visibleItemsCount() int {
|
||||
// Leave space for the filter and other UI elements
|
||||
return m.terminalHeight - 4 // Adjust this number based on your terminal layout
|
||||
}
|
||||
|
||||
// filterOptions filters and sorts options based on the search term
|
||||
func (m *Model) filterOptions() {
|
||||
m.filteredKeys = []SelectionOption{}
|
||||
|
||||
for key, value := range m.options {
|
||||
// When the key is " ", compare and display using the value instead
|
||||
if key == " " {
|
||||
if strings.Contains(strings.ToLower(value), strings.ToLower(m.filter)) {
|
||||
m.filteredKeys = append(m.filteredKeys, SelectionOption{Label: value, Key: key})
|
||||
}
|
||||
} else if strings.Contains(strings.ToLower(value), strings.ToLower(m.filter)) {
|
||||
m.filteredKeys = append(m.filteredKeys, SelectionOption{Label: value, Key: key})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort the filtered options alphabetically
|
||||
sort.Slice(m.filteredKeys, func(i, j int) bool {
|
||||
return m.filteredKeys[i].Label < m.filteredKeys[j].Label
|
||||
})
|
||||
|
||||
// Add "Add new anime" option if enabled
|
||||
if m.addNewOption {
|
||||
m.filteredKeys = append(m.filteredKeys, SelectionOption{
|
||||
Label: "Add new anime",
|
||||
Key: "add_new",
|
||||
})
|
||||
}
|
||||
|
||||
m.filteredKeys = append(m.filteredKeys, SelectionOption{
|
||||
Label: "Quit",
|
||||
Key: "-1",
|
||||
})
|
||||
}
|
||||
|
||||
func DynamicSelectPreview(options map[string]RofiSelectPreview, addnewoption bool) (SelectionOption, error) {
|
||||
// Pre-download first 14 images in background
|
||||
go preDownloadImages(options, 14)
|
||||
|
||||
userCurdConfig := GetGlobalConfig()
|
||||
if userCurdConfig.StoragePath == "" {
|
||||
userCurdConfig.StoragePath = os.ExpandEnv("${HOME}/.local/share/curd")
|
||||
}
|
||||
|
||||
// Prepare Rofi input with anime titles and their cached image paths
|
||||
var rofiInput strings.Builder
|
||||
for _, option := range options {
|
||||
// Download and get cache path for the image
|
||||
cachePath, err := downloadToCache(option.CoverImage)
|
||||
if err != nil {
|
||||
Log(fmt.Sprintf("Error caching image: %v", err), logFile)
|
||||
continue
|
||||
}
|
||||
|
||||
// Format: "Title\x00icon\x1f/path/to/cached/image\n"
|
||||
// This tells Rofi to use the image as an icon for this entry
|
||||
rofiInput.WriteString(fmt.Sprintf("%s\x00icon\x1f%s\n", option.Title, cachePath))
|
||||
}
|
||||
|
||||
// Add "Add new anime" and "Quit" options
|
||||
if addnewoption {
|
||||
rofiInput.WriteString("Add new anime\n")
|
||||
}
|
||||
rofiInput.WriteString("Quit\n")
|
||||
|
||||
// Get the absolute path to the rasi config
|
||||
configPath := filepath.Join(os.ExpandEnv(userCurdConfig.StoragePath), "selectanimepreview.rasi")
|
||||
|
||||
// Create the command with explicit arguments
|
||||
args := []string{
|
||||
"-dmenu",
|
||||
"-theme", configPath,
|
||||
"-show-icons",
|
||||
"-p", "Select Anime",
|
||||
"-i", // Case-insensitive matching
|
||||
"-no-custom", // Disable custom input
|
||||
}
|
||||
|
||||
// Create the command
|
||||
rofiCmd := exec.Command("rofi", args...)
|
||||
|
||||
// Set up pipes for input/output
|
||||
rofiCmd.Stdin = strings.NewReader(rofiInput.String())
|
||||
var stdout, stderr bytes.Buffer
|
||||
rofiCmd.Stdout = &stdout
|
||||
rofiCmd.Stderr = &stderr
|
||||
|
||||
// Run the command
|
||||
err := rofiCmd.Run()
|
||||
if err != nil {
|
||||
// Log both stdout and stderr for debugging
|
||||
Log(fmt.Sprintf("Rofi stderr: %s", stderr.String()), logFile)
|
||||
Log(fmt.Sprintf("Rofi stdout: %s", stdout.String()), logFile)
|
||||
return SelectionOption{}, fmt.Errorf("failed to execute rofi: %w", err)
|
||||
}
|
||||
|
||||
selectedTitle := strings.TrimSpace(stdout.String())
|
||||
|
||||
// Handle special cases
|
||||
switch selectedTitle {
|
||||
case "":
|
||||
return SelectionOption{}, fmt.Errorf("no selection made")
|
||||
case "Add new anime":
|
||||
return SelectionOption{Label: "Add new anime", Key: "add_new"}, nil
|
||||
case "Quit":
|
||||
return SelectionOption{Label: "Quit", Key: "-1"}, nil
|
||||
}
|
||||
|
||||
// Find the selected anime in options
|
||||
for id, option := range options {
|
||||
if option.Title == selectedTitle {
|
||||
return SelectionOption{
|
||||
Label: option.Title,
|
||||
Key: id,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return SelectionOption{}, fmt.Errorf("selection not found in options")
|
||||
}
|
||||
|
||||
func preDownloadImages(options map[string]RofiSelectPreview, count int) {
|
||||
i := 0
|
||||
for _, option := range options {
|
||||
if i >= count {
|
||||
break
|
||||
}
|
||||
downloadToCache(option.CoverImage)
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
func downloadToCache(imageURL string) (string, error) {
|
||||
cacheDir := os.ExpandEnv("${HOME}/.cache/curd/images")
|
||||
if err := os.MkdirAll(cacheDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
|
||||
// Create a hash of the URL to use as filename
|
||||
filename := fmt.Sprintf("%x.jpg", md5.Sum([]byte(imageURL)))
|
||||
cachePath := filepath.Join(cacheDir, filename)
|
||||
|
||||
// Check if file already exists in cache
|
||||
if _, err := os.Stat(cachePath); err == nil {
|
||||
return cachePath, nil
|
||||
}
|
||||
|
||||
// Download the image
|
||||
resp, err := http.Get(imageURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
file, err := os.Create(cachePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
os.Remove(cachePath) // Clean up on error
|
||||
return "", err
|
||||
}
|
||||
|
||||
return cachePath, nil
|
||||
}
|
||||
|
||||
func showCachedImagePreview(imageURL string) error {
|
||||
cachePath, err := downloadToCache(imageURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Display the image with ueberzugpp
|
||||
cmd := exec.Command("ueberzugpp", "layer", "--silent", "add", "preview", "--path", cachePath)
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start image preview: %w", err)
|
||||
}
|
||||
time.Sleep(2 * time.Second) // Allow image to load for a moment
|
||||
return nil
|
||||
}
|
||||
|
||||
func RofiSelect(options map[string]string, addanimeopt bool) (SelectionOption, error) {
|
||||
userCurdConfig := GetGlobalConfig()
|
||||
if userCurdConfig.StoragePath == "" {
|
||||
userCurdConfig.StoragePath = os.ExpandEnv("${HOME}/.local/share/curd")
|
||||
}
|
||||
|
||||
// Create a slice to store the options in the order we want
|
||||
var optionsList []string
|
||||
for _, value := range options {
|
||||
optionsList = append(optionsList, value)
|
||||
}
|
||||
|
||||
// Sort the options alphabetically
|
||||
sort.Strings(optionsList)
|
||||
|
||||
// Add "Add new anime" and "Quit" options
|
||||
if addanimeopt {
|
||||
optionsList = append(optionsList, "Add new anime", "Quit")
|
||||
} else {
|
||||
optionsList = append(optionsList, "Quit")
|
||||
}
|
||||
|
||||
// Join all options into a single string, separated by newlines
|
||||
optionsString := strings.Join(optionsList, "\n")
|
||||
|
||||
// Prepare the Rofi command
|
||||
cmd := exec.Command("rofi", "-dmenu", "-theme", filepath.Join(os.ExpandEnv(userCurdConfig.StoragePath), "selectanime.rasi"), "-i", "-p", "Select an anime")
|
||||
|
||||
// Set up pipes for input and output
|
||||
cmd.Stdin = strings.NewReader(optionsString)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
|
||||
// Run the command
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
return SelectionOption{}, fmt.Errorf("failed to run Rofi: %v", err)
|
||||
}
|
||||
|
||||
// Get the selected option
|
||||
selected := strings.TrimSpace(out.String())
|
||||
|
||||
// Handle special cases
|
||||
switch selected {
|
||||
case "":
|
||||
return SelectionOption{}, fmt.Errorf("no selection made")
|
||||
case "Add new anime":
|
||||
return SelectionOption{Label: "Add new anime", Key: "add_new"}, nil
|
||||
case "Quit":
|
||||
return SelectionOption{Label: "Quit", Key: "-1"}, nil
|
||||
}
|
||||
|
||||
// Find the key for the selected value
|
||||
for key, value := range options {
|
||||
if value == selected {
|
||||
return SelectionOption{Label: value, Key: key}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, the selected option wasn't found in the original map
|
||||
return SelectionOption{}, fmt.Errorf("selected option not found in original list")
|
||||
}
|
||||
|
||||
// DynamicSelect displays a simple selection prompt without extra features
|
||||
func DynamicSelect(options map[string]string, addnewoption bool) (SelectionOption, error) {
|
||||
|
||||
if GetGlobalConfig().RofiSelection {
|
||||
return RofiSelect(options, addnewoption)
|
||||
}
|
||||
|
||||
model := &Model{
|
||||
options: options,
|
||||
filteredKeys: make([]SelectionOption, 0),
|
||||
addNewOption: addnewoption,
|
||||
}
|
||||
|
||||
model.filterOptions()
|
||||
p := tea.NewProgram(model)
|
||||
|
||||
finalModel, err := p.Run()
|
||||
if err != nil {
|
||||
return SelectionOption{}, err
|
||||
}
|
||||
|
||||
finalSelectionModel, ok := finalModel.(*Model)
|
||||
if !ok {
|
||||
return SelectionOption{}, fmt.Errorf("unexpected model type")
|
||||
}
|
||||
|
||||
if finalSelectionModel.selected < len(finalSelectionModel.filteredKeys) {
|
||||
return finalSelectionModel.filteredKeys[finalSelectionModel.selected], nil
|
||||
}
|
||||
return SelectionOption{}, nil
|
||||
}
|
||||
111
curd/internal/structs.go
Normal file
111
curd/internal/structs.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package internal
|
||||
|
||||
type AnimeTitle struct {
|
||||
Romaji string `json:"title_romanji"`
|
||||
English string `json:"title"`
|
||||
Japanese string `json:"title_japanese"`
|
||||
}
|
||||
|
||||
type Anime struct {
|
||||
Title AnimeTitle `json:"title"`
|
||||
Ep Episode `json:"ep"`
|
||||
CoverImage string `json:"url"` // Assuming this field corresponds to the cover image URL
|
||||
TotalEpisodes int `json:"total_episodes"` // If provided by the API
|
||||
MalId int `json:"mal_id"`
|
||||
AnilistId int `json:"anilist_id"` // Assuming you have an Anilist ID in your struct
|
||||
Rewatching bool
|
||||
AllanimeId string // Can be populated as necessary
|
||||
}
|
||||
|
||||
type Skip struct {
|
||||
Start int `json:"start"`
|
||||
End int `json:"end"`
|
||||
}
|
||||
|
||||
type SkipTimes struct {
|
||||
Op Skip `json:"op"`
|
||||
Ed Skip `json:"ed"`
|
||||
}
|
||||
|
||||
type Episode struct {
|
||||
Title AnimeTitle `json:"title"`
|
||||
Number int `json:"number"`
|
||||
SkipTimes SkipTimes `json:"skip_times"`
|
||||
Player playingVideo `json:"player"`
|
||||
Resume bool `json:"resume"`
|
||||
Started bool `json:"started"`
|
||||
Duration int `json:"duration"`
|
||||
Links []string `json:"links"`
|
||||
IsFiller bool `json:"filler"`
|
||||
IsRecap bool `json:"recap"`
|
||||
Aired string `json:"aired"`
|
||||
Synopsis string `json:"synopsis"`
|
||||
ContinueLast bool
|
||||
LastWasSkipped bool // used in filler check
|
||||
IsCompleted bool
|
||||
}
|
||||
|
||||
type playingVideo struct {
|
||||
Url string
|
||||
Speed float64 `json:"speed"`
|
||||
PlaybackTime int `json:"playback_time"`
|
||||
SocketPath string
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Token string
|
||||
Username string
|
||||
Id int
|
||||
AnimeList AnimeList
|
||||
}
|
||||
|
||||
// AniListAnime is the struct for the API response
|
||||
type AniListAnime struct {
|
||||
ID int `json:"id"`
|
||||
Title struct {
|
||||
Romaji string `json:"romaji"`
|
||||
English string `json:"english"`
|
||||
Native string `json:"native"`
|
||||
} `json:"title"`
|
||||
CoverImage struct {
|
||||
Large string `json:"large"`
|
||||
} `json:"coverImage"`
|
||||
}
|
||||
|
||||
// Page represents the page in AniList response
|
||||
type Page struct {
|
||||
Media []AniListAnime `json:"media"`
|
||||
}
|
||||
|
||||
// ResponseData represents the full response structure
|
||||
type ResponseData struct {
|
||||
Page Page `json:"Page"`
|
||||
}
|
||||
|
||||
type Media struct {
|
||||
Duration int `json:"duration"`
|
||||
Episodes int `json:"episodes"`
|
||||
ID int `json:"id"`
|
||||
Title AnimeTitle `json:"title"`
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
Media Media `json:"media"`
|
||||
Progress int `json:"progress"`
|
||||
Score float64 `json:"score"`
|
||||
Status string `json:"status"`
|
||||
CoverImage string `json:"coverImage"`
|
||||
}
|
||||
|
||||
type AnimeList struct {
|
||||
Watching []Entry `json:"watching"`
|
||||
Completed []Entry `json:"completed"`
|
||||
Paused []Entry `json:"paused"`
|
||||
Dropped []Entry `json:"dropped"`
|
||||
Planning []Entry `json:"planning"`
|
||||
}
|
||||
|
||||
type RofiSelectPreview struct {
|
||||
Title string `json:"title"`
|
||||
CoverImage string `json:"coverImage"`
|
||||
}
|
||||
80
curd/rofi/selectanime.rasi
Normal file
80
curd/rofi/selectanime.rasi
Normal file
@@ -0,0 +1,80 @@
|
||||
configuration {
|
||||
font: "Sans 12";
|
||||
line-margin: 10;
|
||||
display-drun: "";
|
||||
}
|
||||
|
||||
* {
|
||||
background: #000000; /* Black background for everything */
|
||||
background-alt: #000000; /* Ensures no alternation */
|
||||
foreground: #CCCCCC;
|
||||
selected: #3584E4;
|
||||
active: #2E7D32;
|
||||
urgent: #C62828;
|
||||
}
|
||||
|
||||
window {
|
||||
fullscreen: false;
|
||||
background-color: rgba(0, 0, 0, 0.8); /* Solid black transparent background */
|
||||
border-radius: 50px;
|
||||
}
|
||||
|
||||
mainbox {
|
||||
padding: 50px 50px;
|
||||
background-color: transparent; /* Ensures black background fills entire main area */
|
||||
children: [inputbar, listview];
|
||||
spacing: 20px;
|
||||
}
|
||||
|
||||
inputbar {
|
||||
background-color: #333333; /* Dark gray background for input bar */
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
children: [prompt, entry];
|
||||
}
|
||||
|
||||
prompt {
|
||||
enabled: true;
|
||||
padding: 8px;
|
||||
background-color: @selected;
|
||||
text-color: #000000;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
entry {
|
||||
padding: 8px;
|
||||
background-color: transparent; /* Slightly lighter gray for visibility */
|
||||
text-color: #FFFFFF; /* White text to make typing visible */
|
||||
placeholder: "Search...";
|
||||
placeholder-color: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
listview {
|
||||
layout: vertical;
|
||||
spacing: 8px;
|
||||
lines: 9;
|
||||
background-color: transparent; /* Consistent black background for list items */
|
||||
}
|
||||
|
||||
element {
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
background-color: transparent; /* Uniform color for each list item */
|
||||
text-color: @foreground;
|
||||
}
|
||||
|
||||
element normal.normal {
|
||||
background-color: transparent; /* Ensures no alternating color */
|
||||
}
|
||||
|
||||
element selected.normal {
|
||||
background-color: @selected;
|
||||
text-color: #FFFFFF;
|
||||
}
|
||||
|
||||
element-text {
|
||||
background-color: transparent;
|
||||
text-color: inherit;
|
||||
vertical-align: 0.5;
|
||||
}
|
||||
120
curd/rofi/selectanimepreview.rasi
Normal file
120
curd/rofi/selectanimepreview.rasi
Normal file
@@ -0,0 +1,120 @@
|
||||
// Colours
|
||||
* {
|
||||
background-color: transparent; /* Transparent background for the global UI */
|
||||
background: #000000; /* Solid black background */
|
||||
background-transparent: #1D2330A0; /* Semi-transparent background */
|
||||
text-color: #BBBBBB; /* Default text color (light gray) */
|
||||
text-color-selected: #FFFFFF; /* Text color when selected (white) */
|
||||
primary: rgba(53, 132, 228, 0.75); /* Blusish primary color */
|
||||
important: rgba(53, 132, 228, 0.75); /* Bluish primary color */
|
||||
}
|
||||
|
||||
configuration {
|
||||
font: "Roboto 14"; /* Sets the global font to Roboto, size 14 */
|
||||
show-icons: true; /* Option to display icons in the UI */
|
||||
}
|
||||
|
||||
window {
|
||||
fullscreen: true; /* The window will open in fullscreen */
|
||||
height: 100%; /* Full window height */
|
||||
width: 100%; /* Full window width */
|
||||
transparency: "real"; /* Real transparency effect */
|
||||
background-color: @background-transparent; /* Transparent background */
|
||||
border: 0px; /* No border around the window */
|
||||
border-color: @primary; /* Border color set to the primary color */
|
||||
}
|
||||
|
||||
mainbox {
|
||||
children: [prompt, inputbar-box, listview]; /* Main box contains prompt, input bar, and list view */
|
||||
padding: 0px; /* No padding around the main box */
|
||||
}
|
||||
|
||||
prompt {
|
||||
width: 100%; /* Prompt takes full width */
|
||||
margin: 10px 0px 0px 30px; /* Margin around the prompt */
|
||||
text-color: @important; /* Text color for prompt (important color) */
|
||||
font: "Roboto Bold 27"; /* Bold Roboto font, size 27 */
|
||||
}
|
||||
|
||||
listview {
|
||||
layout: vertical; /* Vertical layout for list items */
|
||||
padding: 10px; /* Padding inside the list view */
|
||||
spacing: 20px; /* Space between items in the list */
|
||||
columns: 8; /* Maximum 8 items per row */
|
||||
dynamic: true; /* Allows the list to dynamically adjust */
|
||||
orientation: horizontal; /* Horizontal orientation for list items */
|
||||
}
|
||||
|
||||
inputbar-box {
|
||||
children: [dummy, inputbar, dummy]; /* Input bar is centered with dummy placeholders */
|
||||
orientation: horizontal; /* Horizontal layout for input bar */
|
||||
expand: false; /* Does not expand to fill the space */
|
||||
}
|
||||
|
||||
inputbar {
|
||||
children: [textbox-prompt, entry]; /* Contains a prompt and an entry field */
|
||||
margin: 0px; /* No margin around the input bar */
|
||||
background-color: @primary; /* Background color set to the primary color */
|
||||
border: 4px; /* Border thickness around the input bar */
|
||||
border-color: @primary; /* Border color matches the primary color */
|
||||
border-radius: 8px; /* Rounded corners for the input bar */
|
||||
}
|
||||
|
||||
textbox-prompt {
|
||||
text-color: @background; /* Text color inside prompt matches the background color */
|
||||
horizontal-align: 0.5; /* Horizontally centered */
|
||||
vertical-align: 0.5; /* Vertically centered */
|
||||
expand: false; /* Does not expand to fill available space */
|
||||
}
|
||||
|
||||
entry {
|
||||
expand: false; /* Entry field does not expand */
|
||||
padding: 8px; /* Padding inside the entry field */
|
||||
margin: -6px; /* Negative margin to position entry properly */
|
||||
horizontal-align: 0; /* Left-aligned text inside the entry field */
|
||||
width: 300; /* Fixed width for the entry field */
|
||||
background-color: @background; /* Entry background color matches the global background */
|
||||
border: 6px; /* Border thickness around the entry field */
|
||||
border-color: @primary; /* Border color matches the primary color */
|
||||
border-radius: 8px; /* Rounded corners for the entry field */
|
||||
cursor: text; /* Cursor changes to text input cursor inside the entry field */
|
||||
}
|
||||
|
||||
element {
|
||||
children: [dummy, element-box, dummy]; /* Contains an element box with dummy placeholders */
|
||||
padding: 5px; /* Padding around the element */
|
||||
orientation: vertical; /* Vertical layout for element content */
|
||||
border: 0px; /* No border around the element */
|
||||
border-radius: 16px; /* Rounded corners for the element */
|
||||
background-color: transparent; /* Transparent background */
|
||||
width: 100px; /* Width of each element */
|
||||
height: 50px; /* Height of each element */
|
||||
}
|
||||
|
||||
element selected {
|
||||
background-color: @primary; /* Background color of the element when selected */
|
||||
}
|
||||
|
||||
element-box {
|
||||
children: [element-icon, element-text]; /* Element box contains an icon and text */
|
||||
orientation: vertical; /* Vertical layout for icon and text */
|
||||
expand: false; /* Does not expand to fill available space */
|
||||
cursor: pointer; /* Cursor changes to a pointer when hovering over the element */
|
||||
}
|
||||
|
||||
element-icon {
|
||||
padding: 10px; /* Padding inside the icon */
|
||||
cursor: inherit; /* Inherits cursor style from the parent */
|
||||
size: 33%; /* Icon size is set to 33% of the parent element */
|
||||
margin: 10px; /* Margin around the icon */
|
||||
}
|
||||
|
||||
element-text {
|
||||
horizontal-align: 0.5; /* Horizontally center-aligns the text */
|
||||
cursor: inherit; /* Inherits cursor style from the parent */
|
||||
text-color: @text-color; /* Text color for element text */
|
||||
}
|
||||
|
||||
element-text selected {
|
||||
text-color: @text-color-selected; /* Text color when the element is selected */
|
||||
}
|
||||
55
curd/rofi/userinput.rasi
Normal file
55
curd/rofi/userinput.rasi
Normal file
@@ -0,0 +1,55 @@
|
||||
configuration {
|
||||
font: "Sans 12";
|
||||
}
|
||||
|
||||
* {
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
text-color: #FFFFFF;
|
||||
}
|
||||
|
||||
window {
|
||||
fullscreen: true;
|
||||
transparency: "real";
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
mainbox {
|
||||
children: [ message, listview, inputbar ];
|
||||
padding: 40% 30%;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
message {
|
||||
border: 0;
|
||||
padding: 10px;
|
||||
border-radius:20px;
|
||||
margin: 0 0 20px 0;
|
||||
font: "Sans Bold 24"; /* Increased font size and made it bold */
|
||||
}
|
||||
|
||||
inputbar {
|
||||
children: [ prompt, entry ];
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
prompt {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
entry {
|
||||
padding: 8px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
listview {
|
||||
lines: 0;
|
||||
}
|
||||
|
||||
/* Style for the message text specifically */
|
||||
textbox {
|
||||
horizontal-align: 0.5; /* Center the text */
|
||||
font: "Sans Bold 24"; /* Match message font */
|
||||
background-color: transparent;
|
||||
}
|
||||
Reference in New Issue
Block a user