From 02cfd622cb569fdec1e9f86b66b66f38c65c3506 Mon Sep 17 00:00:00 2001 From: hitmant Date: Thu, 7 Aug 2025 09:35:15 +0800 Subject: [PATCH] =?UTF-8?q?add:=E5=88=9D=E5=A7=8B=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 163 ++++ LICENSE | 674 +++++++++++++ README.md | 152 +++ docs/V2_Plugin_Development.md | 573 +++++++++++ icons/nullbr.png | Bin 0 -> 10175 bytes package.json | 14 + package.v2.json | 14 + plugins.v2/nullbrsearch/__init__.py | 1095 ++++++++++++++++++++++ plugins.v2/nullbrsearch/requirements.txt | 2 + plugins/nullbrsearch/__init__.py | 1095 ++++++++++++++++++++++ plugins/nullbrsearch/requirements.txt | 2 + 11 files changed, 3784 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docs/V2_Plugin_Development.md create mode 100644 icons/nullbr.png create mode 100644 package.json create mode 100644 package.v2.json create mode 100644 plugins.v2/nullbrsearch/__init__.py create mode 100644 plugins.v2/nullbrsearch/requirements.txt create mode 100644 plugins/nullbrsearch/__init__.py create mode 100644 plugins/nullbrsearch/requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ae6c655 --- /dev/null +++ b/.gitignore @@ -0,0 +1,163 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +.idea/ +.vscode/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + Copyright (C) + + 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 . + +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: + + Copyright (C) + 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 +. + + 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 +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..debfa3c --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +# Nullbr资源搜索插件 + +## 📝 插件简介 + +Nullbr资源搜索插件是为MoviePilot-v2设计的资源搜索增强插件,通过集成Nullbr API,为用户提供优先级资源搜索功能。 + +**主要特点:** +- 🚀 **优先搜索**: 在MoviePilot搜索其他资源站之前,优先使用Nullbr API查找资源 +- 🔍 **智能跳过**: 如果Nullbr找到资源,自动跳过后续搜索,提高效率 +- 🎯 **多种资源**: 支持115网盘、磁力链接、ed2k、m3u8等多种资源类型 +- 📊 **统计监控**: 提供详细的使用统计和状态监控 +- ⚙️ **灵活配置**: 支持自定义资源类型和搜索参数 + +## 🛠️ 安装方法 + +### 方法1: 直接复制文件 +1. 将`nullbrsearch`目录复制到MoviePilot的`app/plugins/`目录下 +2. 重启MoviePilot服务 + +### 方法2: Git克隆 +```bash +cd /path/to/moviepilot/app/plugins/ +git clone nullbrsearch +``` + +## 🔧 配置说明 + +### 必需配置 +- **APP_ID**: Nullbr API的应用ID,用于基本搜索功能(必填) + +### 可选配置 +- **API_KEY**: Nullbr API的密钥,用于获取具体下载链接(可选) +- **资源类型**: 可以选择启用/禁用不同的资源类型 + - 115网盘分享 + - 磁力链接 + - M3U8在线视频 + - ED2K链接 + +### 高级设置 +- **搜索超时**: 设置API请求的超时时间(10-120秒) + +## 📖 使用方法 + +1. **获取API密钥**: 从Nullbr官方获取APP_ID和API_KEY +2. **配置插件**: 在MoviePilot插件设置中填入相关信息 +3. **启用插件**: 打开插件开关,插件开始工作 +4. **正常使用**: 通过MoviePilot的任何搜索功能,插件会自动优先搜索Nullbr资源 + +## 🌐 支持的API接口 + +插件提供了以下REST API接口: + +### `/nullbr/search` +- **方法**: GET +- **参数**: `keyword`, `page` +- **功能**: 搜索影视资源 + +### `/nullbr/resources` +- **方法**: POST +- **参数**: `media_type`, `tmdbid`, `resource_type` +- **功能**: 获取具体资源链接 + +### `/nullbr/test` +- **方法**: GET +- **功能**: 测试API连接状态 + +## 📊 工作原理 + +``` +用户搜索请求 + ↓ +MoviePilot接收 + ↓ +Nullbr插件拦截 ← 优先级最高 + ↓ +调用Nullbr API + ↓ +找到资源? → 是 → 返回结果给用户 → 结束搜索 + ↓ + 否 + ↓ +继续搜索其他资源站 +``` + +## 🔍 支持的媒体类型 + +- **电影** (movie): 支持获取电影资源 +- **剧集** (tv): 支持获取完整剧集资源 +- **合集** (collection): 支持搜索系列合集 +- **人物** (person): 支持人物相关搜索 + +## 📈 状态监控 + +插件提供详细的使用统计: +- 总搜索次数 +- 成功搜索次数 +- 失败搜索次数 +- 最后搜索时间 +- API连接状态 +- 资源类型启用状态 + +## ⚠️ 注意事项 + +1. **API限制**: 请遵守Nullbr API的使用限制和频率限制 +2. **网络连接**: 确保MoviePilot服务器能够访问`api.nullbr.eu.org` +3. **权限要求**: API_KEY的权限级别决定了能获取的资源类型 +4. **日志监控**: 如遇问题请检查MoviePilot日志中的相关错误信息 + +## 🐛 故障排除 + +### 常见问题 + +**Q: 插件显示已启用但不工作** +A: 检查APP_ID是否正确配置,查看日志中的错误信息 + +**Q: 能搜索但无法获取下载链接** +A: 需要配置有效的API_KEY才能获取具体资源链接 + +**Q: API请求超时** +A: 可能是网络问题,尝试增加超时时间或检查网络连接 + +**Q: 搜索结果为空** +A: 检查搜索关键词,或者查看Nullbr API是否有该资源 + +### 日志排查 +在MoviePilot日志中搜索以下关键词: +- `Nullbr` +- `nullbr` +- `NullbrSearch` + +## 📝 更新日志 + +### v1.0.0 (2024-08-06) +- 🎉 首次发布 +- ✅ 支持基本搜索功能 +- ✅ 支持多种资源类型 +- ✅ 提供完整的配置界面 +- ✅ 集成API接口 +- ✅ 添加使用统计功能 + +## 🤝 贡献 + +欢迎提交Issue和Pull Request来改进这个插件! + +## 📄 许可证 + +本插件基于GPL-3.0许可证开源。 + +## 🙏 致谢 + +- 感谢MoviePilot项目提供的优秀插件框架 +- 感谢Nullbr提供的资源API服务 \ No newline at end of file diff --git a/docs/V2_Plugin_Development.md b/docs/V2_Plugin_Development.md new file mode 100644 index 0000000..05c11de --- /dev/null +++ b/docs/V2_Plugin_Development.md @@ -0,0 +1,573 @@ +# MoviePilot V2 插件开发指南(更新版) + +本指南详细介绍了如何开发适用于 MoviePilot V2 版本的插件,并实现插件的多版本兼容性,同时包括了服务封装类的使用示例,帮助开发者快速升级插件至 V2 版本。 + +## 1. 多版本插件开发与兼容性 + +### 1.1 开发 V2 版本的插件 + +要开发适用于 MoviePilot V2 版本的插件,请按照以下步骤操作: + +1. **目录结构调整**: + - 将插件代码放置在 `plugins.v2` 文件夹中。 + - 将插件的定义放置在 `package.v2.json` 中,以实现该插件仅在 MoviePilot V2 版本中可见。 + +2. **插件定义示例**: + + ```json + { + "CustomSites": { + "name": "自定义站点", + "description": "增加自定义站点为签到和统计使用。", + "labels": "站点", + "version": "1.0", + "icon": "world.png", + "author": "lightolly", + "level": 2 + } + } + ``` + +### 1.2 实现插件多版本兼容 + +如果 V1 版本插件在 V2 版本中实际可用,或在插件中主动兼容了 V1 和 V2 版本,则可以在 `package.json` 中定义 `"v2": true` 属性,以便在 MoviePilot V2 版本插件市场中显示。 + +```json +{ + "CustomSites": { + "name": "自定义站点", + "description": "增加自定义站点为签到和统计使用。", + "labels": "站点", + "version": "1.0", + "icon": "world.png", + "author": "lightolly", + "level": 2, + "v2": true + } +} +``` + +- **目录结构示例**: + + ``` + plugins/ + ├── customsites/ + │ ├── __init__.py + │ └── ... + plugins.v2/ + ├── customsites/ + │ ├── __init__.py + │ └── ... + package.json + package.v2.json + ``` + +- **插件代码中实现版本兼容**: + + 在插件代码中,可以根据 `version` 变量执行不同的逻辑,以适应不同的 MoviePilot 版本。 + + ```python + from app.core.config import settings + + class MyPlugin: + def init_plugin(self, config: dict = None): + if hasattr(settings, 'VERSION_FLAG'): + version = settings.VERSION_FLAG # V2 + else: + version = "v1" + + if version == "v2": + self.setup_v2() + else: + self.setup_v1() + + def setup_v2(self): + # V2版本特有的初始化逻辑 + pass + + def setup_v1(self): + # V1版本特有的初始化逻辑 + pass + ``` + +## 2. 服务封装与使用示例 + +为了插件调用并共享实例,主程序针对几种服务进行了封装。以下是相关实现及如何在插件中使用这些封装的详细说明,帮助开发者快速将插件从 V1 升级到 V2。 + +### 2.1 服务封装类介绍 + +#### `ServiceInfo` +`ServiceInfo` 是一个数据类,用于封装服务的相关信息。 + +```python +from dataclasses import dataclass +from typing import Optional, Any + +@dataclass +class ServiceInfo: + """ + 封装服务相关信息的数据类 + """ + # 名称 + name: Optional[str] = None + # 实例 + instance: Optional[Any] = None + # 模块 + module: Optional[Any] = None + # 类型 + type: Optional[str] = None + # 配置 + config: Optional[Any] = None +``` + +#### `ServiceConfigHelper` +`ServiceConfigHelper` 是一个配置帮助类,用于获取不同类型的服务配置。 + +```python +from typing import List, Optional + +from app.db.systemconfig_oper import SystemConfigOper +from app.schemas import DownloaderConf, MediaServerConf, NotificationConf, NotificationSwitchConf + +class ServiceConfigHelper: + """ + 配置帮助类,获取不同类型的服务配置 + """ + + @staticmethod + def get_configs(config_key: SystemConfigKey, conf_type: Type) -> List: + """ + 通用获取配置的方法,根据 config_key 获取相应的配置并返回指定类型的配置列表 + + :param config_key: 系统配置的 key + :param conf_type: 用于实例化配置对象的类类型 + :return: 配置对象列表 + """ + config_data = SystemConfigOper().get(config_key) + if not config_data: + return [] + # 直接使用 conf_type 来实例化配置对象 + return [conf_type(**conf) for conf in config_data] + + @staticmethod + def get_downloader_configs() -> List[DownloaderConf]: + """ + 获取下载器的配置 + """ + return ServiceConfigHelper.get_configs(SystemConfigKey.Downloaders, DownloaderConf) + + @staticmethod + def get_mediaserver_configs() -> List[MediaServerConf]: + """ + 获取媒体服务器的配置 + """ + return ServiceConfigHelper.get_configs(SystemConfigKey.MediaServers, MediaServerConf) + + @staticmethod + def get_notification_configs() -> List[NotificationConf]: + """ + 获取消息通知渠道的配置 + """ + return ServiceConfigHelper.get_configs(SystemConfigKey.Notifications, NotificationConf) + + @staticmethod + def get_notification_switches() -> List[NotificationSwitchConf]: + """ + 获取消息通知场景的开关 + """ + return ServiceConfigHelper.get_configs(SystemConfigKey.NotificationSwitchs, NotificationSwitchConf) + + @staticmethod + def get_notification_switch(mtype: NotificationType) -> Optional[str]: + """ + 获取指定类型的消息通知场景的开关 + """ + switchs = ServiceConfigHelper.get_notification_switches() + for switch in switchs: + if switch.type == mtype.value: + return switch.action + return None +``` + +#### `ServiceBaseHelper` +`ServiceBaseHelper` 是一个通用的服务帮助类,提供了获取配置和服务实例的通用逻辑。 + +```python +from typing import Dict, List, Optional, Type, TypeVar, Generic, Iterator + +from app.core.module import ModuleManager +from app.schemas import ServiceInfo +from app.schemas.types import SystemConfigKey, ModuleType + +TConf = TypeVar("TConf") + +class ServiceBaseHelper(Generic[TConf]): + """ + 通用服务帮助类,抽象获取配置和服务实例的通用逻辑 + """ + + def __init__(self, config_key: SystemConfigKey, conf_type: Type[TConf], module_type: ModuleType): + self.modulemanager = ModuleManager() + self.config_key = config_key + self.conf_type = conf_type + self.module_type = module_type + + def get_configs(self, include_disabled: bool = False) -> Dict[str, TConf]: + """ + 获取配置列表 + + :param include_disabled: 是否包含禁用的配置,默认 False(仅返回启用的配置) + :return: 配置字典 + """ + configs: List[TConf] = ServiceConfigHelper.get_configs(self.config_key, self.conf_type) + return { + config.name: config + for config in configs + if (config.name and config.type and config.enabled) or include_disabled + } if configs else {} + + def get_config(self, name: str) -> Optional[TConf]: + """ + 获取指定名称配置 + """ + if not name: + return None + configs = self.get_configs() + return configs.get(name) + + def iterate_module_instances(self) -> Iterator[ServiceInfo]: + """ + 迭代所有模块的实例及其对应的配置,返回 ServiceInfo 实例 + """ + configs = self.get_configs() + modules = self.modulemanager.get_running_type_modules(self.module_type) + for module in modules: + if not module: + continue + module_instances = module.get_instances() + if not isinstance(module_instances, dict): + continue + for name, instance in module_instances.items(): + if not instance: + continue + config = configs.get(name) + service_info = ServiceInfo( + name=name, + instance=instance, + module=module, + type=config.type if config else None, + config=config + ) + yield service_info + + def get_services(self, type_filter: Optional[str] = None, name_filters: Optional[List[str]] = None) \ + -> Dict[str, ServiceInfo]: + """ + 获取服务信息列表,并根据类型和名称列表进行过滤 + + :param type_filter: 需要过滤的服务类型 + :param name_filters: 需要过滤的服务名称列表 + :return: 过滤后的服务信息字典 + """ + name_filters_set = set(name_filters) if name_filters else None + + return { + service_info.name: service_info + for service_info in self.iterate_module_instances() + if service_info.config and ( + type_filter is None or service_info.type == type_filter + ) and ( + name_filters_set is None or service_info.name in name_filters_set) + } + + def get_service(self, name: str, type_filter: Optional[str] = None) -> Optional[ServiceInfo]: + """ + 获取指定名称的服务信息,并根据类型过滤 + + :param name: 服务名称 + :param type_filter: 需要过滤的服务类型 + :return: 对应的服务信息,若不存在或类型不匹配则返回 None + """ + if not name: + return None + for service_info in self.iterate_module_instances(): + if service_info.name == name: + if service_info.config and (type_filter is None or service_info.type == type_filter): + return service_info + return None +``` + +### 2.2 特定服务的帮助类 + +以下是针对不同服务类型的帮助类,这些类继承自 `ServiceBaseHelper`,并预设了特定的配置。同时,为了简化类型检查,新增了相应的方法来判断服务类型。 + +#### `DownloaderHelper` +用于管理下载器服务。 + +```python +from typing import Optional + +from app.helper.service import ServiceBaseHelper +from app.schemas import DownloaderConf, ServiceInfo +from app.schemas.types import SystemConfigKey, ModuleType + + +class DownloaderHelper(ServiceBaseHelper[DownloaderConf]): + """ + 下载器帮助类 + """ + + def __init__(self): + super().__init__( + config_key=SystemConfigKey.Downloaders, + conf_type=DownloaderConf, + module_type=ModuleType.Downloader + ) + + def is_downloader( + self, + service_type: Optional[str] = None, + service: Optional[ServiceInfo] = None, + name: Optional[str] = None, + ) -> bool: + """ + 通用的下载器类型判断方法 + + :param service_type: 下载器的类型名称(如 'qbittorrent', 'transmission') + :param service: 要判断的服务信息 + :param name: 服务的名称 + :return: 如果服务类型或实例为指定类型,返回 True;否则返回 False + """ + # 如果未提供 service 则通过 name 获取服务 + service = service or self.get_service(name=name) + + # 判断服务类型是否为指定类型 + return bool(service and service.type == service_type) +``` + +#### `MediaServerHelper` +用于管理媒体服务器服务。 + +```python +from typing import Optional + +from app.helper.service import ServiceBaseHelper +from app.schemas import MediaServerConf, ServiceInfo +from app.schemas.types import SystemConfigKey, ModuleType + + +class MediaServerHelper(ServiceBaseHelper[MediaServerConf]): + """ + 媒体服务器帮助类 + """ + + def __init__(self): + super().__init__( + config_key=SystemConfigKey.MediaServers, + conf_type=MediaServerConf, + module_type=ModuleType.MediaServer + ) + + def is_media_server( + self, + service_type: Optional[str] = None, + service: Optional[ServiceInfo] = None, + name: Optional[str] = None, + ) -> bool: + """ + 通用的媒体服务器类型判断方法 + :param service_type: 媒体服务器的类型名称(如 'plex', 'emby', 'jellyfin') + :param service: 要判断的服务信息 + :param name: 服务的名称 + :return: 如果服务类型或实例为指定类型,返回 True;否则返回 False + """ + # 如果未提供 service 则通过 name 获取服务 + service = service or self.get_service(name=name) + + # 判断服务类型是否为指定类型 + return bool(service and service.type == service_type) +``` + +#### `NotificationHelper` +用于管理消息通知服务。 + +```python +from typing import Optional + +from app.helper.service import ServiceBaseHelper +from app.schemas import NotificationConf, ServiceInfo +from app.schemas.types import SystemConfigKey, ModuleType + + +class NotificationHelper(ServiceBaseHelper[NotificationConf]): + """ + 消息通知帮助类 + """ + + def __init__(self): + super().__init__( + config_key=SystemConfigKey.Notifications, + conf_type=NotificationConf, + module_type=ModuleType.Notification + ) + + def is_notification( + self, + service_type: Optional[str] = None, + service: Optional[ServiceInfo] = None, + name: Optional[str] = None, + ) -> bool: + """ + 通用的消息通知服务类型判断方法 + + :param service_type: 消息通知服务的类型名称(如 'wechat', 'voicechat', 'telegram', 等) + :param service: 要判断的服务信息 + :param name: 服务的名称 + :return: 如果服务类型或实例为指定类型,返回 True;否则返回 False + """ + # 如果未提供 service 则通过 name 获取服务 + service = service or self.get_service(name=name) + + # 判断服务类型是否为指定类型 + return bool(service and service.type == service_type) +``` + +### 2.3 在插件中使用服务帮助类 + +通过这些帮助类,插件可以方便地获取和管理各种服务。以下是 `DownloaderHelper` 的使用示例,包括类型检查服务和监听模块重载事件的两种方法。 + +#### 获取下载器选项 + +插件可以通过 `DownloaderHelper` 获取所有可用的下载器配置,并生成选项列表供用户选择。 + +```python +from app.helper.downloader import DownloaderHelper + +class MyPlugin: + def init_plugin(self, config: dict = None): + self.downloaderhelper = DownloaderHelper() + self.downloader_options = [ + {"title": config.name, "value": config.name} + for config in self.downloaderhelper.get_configs().values() + ] +``` + +#### 获取特定下载器服务 + +根据用户选择的下载器名称,插件可以获取对应的服务实例,并执行相应的操作。以下展示了两种方法: + +1. **使用事件监听进行模块重载,从而保持服务实例共享** + + 如果外部模块进行了重载,需要监听模块重载事件以重置下载器服务。 + + ```python + from typing import Optional, Union + from app.helper.downloader import DownloaderHelper + from app.modules.qbittorrent import Qbittorrent + from app.modules.transmission import Transmission + from app.events import EventType, eventmanager + + class MyPlugin: + def init_plugin(self, config: dict = None): + self.downloaderhelper = DownloaderHelper() + self._downloader = None + self.__setup_downloader(config.get("downloader_name")) + + def __setup_downloader(self, downloader_name: str): + self._downloader = self.downloaderhelper.get_service(name=downloader_name) + + def __get_downloader(self) -> Optional[Union[Transmission, Qbittorrent]]: + """ + 获取下载器实例 + """ + if not self._downloader: + return None + return self._downloader.instance + + @eventmanager.register(EventType.ModuleReload) + def module_reload(self, event: Event): + """ + 模块重载事件 + """ + if not event: + return + event_data = event.event_data or {} + module_id = event_data.get("module_id") + # 如果模块标识不存在,则说明所有模块均发生重载 + if not module_id: + self.__setup_downloader() + + def check_downloader_type(self) -> bool: + """ + 检查下载器类型是否为 qbittorrent 或 transmission + """ + downloader = self.__get_downloader() + if self.downloaderhelper.is_downloader(service_type="qbittorrent", service=downloader): + # 处理 qbittorrent 类型 + return True + elif self.downloaderhelper.is_downloader(service_type="transmission", service=downloader): + # 处理 transmission 类型 + return True + return False + ``` + +2. **使用 Property 实现服务实例共享** + + 通过 `Property` 方法,从而保持服务实例共享,而无需通过事件监听。 + + ```python + from typing import Optional, Union + from app.helper.downloader import DownloaderHelper + from app.modules.qbittorrent import Qbittorrent + from app.modules.transmission import Transmission + + class MyPlugin: + def init_plugin(self, config: dict = None): + self.downloaderhelper = DownloaderHelper() + self.downloader_name = config.get("downloader_name") + + @property + def service_info(self) -> Optional[ServiceInfo]: + """ + 服务信息 + """ + service = self.downloaderhelper.get_service(name=self.downloader_name) + if not service: + return None + + if service.instance.is_inactive(): + return None + + return service + + @property + def downloader(self) -> Optional[Union[Qbittorrent, Transmission]]: + """ + 下载器实例 + """ + return self.service_info.instance if self.service_info else None + + def check_downloader_type(self) -> bool: + """ + 检查下载器类型是否为 qbittorrent 或 transmission + """ + if self.downloaderhelper.is_downloader(service_type="qbittorrent", service=self.service_info): + # 处理 qbittorrent 类型 + return True + elif self.downloaderhelper.is_downloader(service_type="transmission", service=self.service_info): + # 处理 transmission 类型 + return True + return False + ``` + +### 2.4 服务封装的优势 + +- **统一管理**:通过 `ServiceBaseHelper`,不同类型的服务配置和实例管理变得统一和简洁。 +- **灵活扩展**:新增服务类型时,只需创建相应的帮助类,无需修改现有逻辑。 +- **便捷调用**:插件可以轻松获取所需的服务实例,简化了服务的调用过程。 + +### 2.5 从 V1 升级到 V2 的注意事项 + +- **使用帮助类**:确保插件中使用了新的服务帮助类,如 `DownloaderHelper`、`MediaServerHelper`、`NotificationHelper` 等,而不是直接操作服务实例。 +- **更新依赖**:检查并更新 `requirements.txt` 中的依赖,确保与 V2 的服务封装兼容。 +- **测试插件**:在 V2 环境中全面测试插件,确保所有服务调用正常工作。 \ No newline at end of file diff --git a/icons/nullbr.png b/icons/nullbr.png new file mode 100644 index 0000000000000000000000000000000000000000..78f88563f4568073a1e6222ea7f744de782f32cf GIT binary patch literal 10175 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 literal 0 HcmV?d00001 diff --git a/package.json b/package.json new file mode 100644 index 0000000..742e84f --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "nullbrsearch": { + "name": "Nullbr资源搜索", + "description": "优先使用Nullbr API搜索影视资源,支持115网盘、磁力、ed2k、m3u8等多种资源类型。在MoviePilot搜索其他资源站之前优先查找Nullbr资源,提高搜索效率。", + "labels": "资源", + "version": "1.0.4", + "icon": "https://raw.githubusercontent.com/Hqyel/MoviePilot-Plugins/main/icons/nullbr.png", + "author": "Hqyel", + "level": 1, + "history": { + "v1.0.4": "初始上架版本" + } + } +} \ No newline at end of file diff --git a/package.v2.json b/package.v2.json new file mode 100644 index 0000000..742e84f --- /dev/null +++ b/package.v2.json @@ -0,0 +1,14 @@ +{ + "nullbrsearch": { + "name": "Nullbr资源搜索", + "description": "优先使用Nullbr API搜索影视资源,支持115网盘、磁力、ed2k、m3u8等多种资源类型。在MoviePilot搜索其他资源站之前优先查找Nullbr资源,提高搜索效率。", + "labels": "资源", + "version": "1.0.4", + "icon": "https://raw.githubusercontent.com/Hqyel/MoviePilot-Plugins/main/icons/nullbr.png", + "author": "Hqyel", + "level": 1, + "history": { + "v1.0.4": "初始上架版本" + } + } +} \ No newline at end of file diff --git a/plugins.v2/nullbrsearch/__init__.py b/plugins.v2/nullbrsearch/__init__.py new file mode 100644 index 0000000..b0e9f0f --- /dev/null +++ b/plugins.v2/nullbrsearch/__init__.py @@ -0,0 +1,1095 @@ +import re +import time +from typing import Any, List, Dict, Tuple, Optional +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from app.core.config import settings +from app.core.event import eventmanager, Event +from app.log import logger +from app.plugins import _PluginBase +from app.schemas.types import EventType + + +class NullbrApiClient: + """Nullbr API客户端""" + + def __init__(self, app_id: str, api_key: str = None): + self._app_id = app_id + self._api_key = api_key + self._base_url = "https://api.nullbr.eu.org" + + # 配置请求会话 + self._session = requests.Session() + self._session.headers.update({ + 'User-Agent': 'MoviePilot-NullbrSearch/1.0.4', + 'Content-Type': 'application/json' + }) + + # 配置重试策略 + try: + retry_strategy = Retry( + total=3, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=["HEAD", "GET", "OPTIONS"], + backoff_factor=1 + ) + except TypeError: + try: + retry_strategy = Retry( + total=3, + status_forcelist=[429, 500, 502, 503, 504], + method_whitelist=["HEAD", "GET", "OPTIONS"], + backoff_factor=1 + ) + except Exception: + retry_strategy = Retry(total=3, backoff_factor=1) + + adapter = HTTPAdapter(max_retries=retry_strategy) + self._session.mount("http://", adapter) + self._session.mount("https://", adapter) + + def search(self, query: str, page: int = 1) -> Optional[Dict]: + """搜索媒体资源""" + try: + # 根据API文档,APP_ID应该放在Header中 + headers = {'X-APP-ID': self._app_id} + + # API_KEY如果存在,也放在Header中 + if self._api_key: + headers['X-API-KEY'] = self._api_key + + params = { + 'query': query, + 'page': page + } + + logger.info(f"请求参数: {params}") + logger.info(f"请求头: X-APP-ID={self._app_id}, X-API-KEY={'已设置' if self._api_key else '未设置'}") + + response = self._session.get( + f"{self._base_url}/search", + params=params, + headers=headers, + timeout=30 + ) + + logger.info(f"响应状态码: {response.status_code}") + + if response.status_code == 200: + return response.json() + elif response.status_code == 403: + logger.error("Nullbr API认证失败,请检查APP_ID") + return None + else: + logger.warning(f"Nullbr API搜索失败: {response.status_code}, 响应内容: {response.text}") + return None + + except Exception as e: + logger.error(f"Nullbr API请求异常: {str(e)}") + return None + + def get_movie_resources(self, tmdbid: int, resource_type: str = "115") -> Optional[Dict]: + """获取电影资源链接""" + if not self._api_key: + logger.warning("获取资源链接需要API_KEY") + return None + + try: + headers = {'X-APP-ID': self._app_id, 'X-API-KEY': self._api_key} + + response = self._session.get( + f"{self._base_url}/movie/{tmdbid}/{resource_type}", + headers=headers, + timeout=30 + ) + + logger.info(f"获取电影资源响应状态码: {response.status_code}") + + if response.status_code == 200: + return response.json() + elif response.status_code == 401: + logger.error("API_KEY权限不足") + return None + elif response.status_code == 403: + logger.error("API认证失败") + return None + elif response.status_code == 429: + logger.warning("API请求过快,请稍后重试") + return None + else: + logger.warning(f"获取电影资源失败: {response.status_code}, 响应: {response.text}") + return None + + except Exception as e: + logger.error(f"获取电影资源异常: {str(e)}") + return None + + def get_tv_resources(self, tmdbid: int, resource_type: str = "115") -> Optional[Dict]: + """获取剧集资源链接""" + if not self._api_key: + logger.warning("获取资源链接需要API_KEY") + return None + + try: + headers = {'X-APP-ID': self._app_id, 'X-API-KEY': self._api_key} + + response = self._session.get( + f"{self._base_url}/tv/{tmdbid}/{resource_type}", + headers=headers, + timeout=30 + ) + + logger.info(f"获取剧集资源响应状态码: {response.status_code}") + + if response.status_code == 200: + return response.json() + elif response.status_code == 401: + logger.error("API_KEY权限不足") + return None + elif response.status_code == 403: + logger.error("API认证失败") + return None + elif response.status_code == 429: + logger.warning("API请求过快,请稍后重试") + return None + else: + logger.warning(f"获取剧集资源失败: {response.status_code}, 响应: {response.text}") + return None + + except Exception as e: + logger.error(f"获取剧集资源异常: {str(e)}") + return None + + +class NullbrSearch(_PluginBase): + # 插件基本信息 + plugin_name = "Nullbr资源搜索" + plugin_desc = "优先使用Nullbr API搜索影视资源,支持多种资源类型(115网盘、磁力、ed2k、m3u8)" + plugin_icon = "nullbr.png" + plugin_version = "1.0.4" + plugin_author = "Hqyel" + author_url = "https://github.com/Hqyel" + plugin_config_prefix = "nullbr_" + plugin_order = 1 + auth_level = 1 + + def __init__(self): + super().__init__() + self._enabled = False + self._app_id = None + self._api_key = None + self._resource_priority = ["115", "magnet", "video", "ed2k"] + self._enable_115 = True + self._enable_magnet = True + self._enable_video = True + self._enable_ed2k = True + self._search_timeout = 30 + self._client = None + + # 用户搜索结果缓存 + self._user_search_cache = {} # {userid: {'results': [...], 'timestamp': time.time()}} + + def init_plugin(self, config: dict = None): + if config: + self._enabled = config.get("enabled", False) + self._app_id = config.get("app_id") + self._api_key = config.get("api_key") + self._resource_priority = config.get("resource_priority", ["115", "magnet", "video", "ed2k"]) + self._enable_115 = config.get("enable_115", True) + self._enable_magnet = config.get("enable_magnet", True) + self._enable_video = config.get("enable_video", True) + self._enable_ed2k = config.get("enable_ed2k", True) + self._search_timeout = config.get("search_timeout", 30) + + # 初始化API客户端 + if self._enabled and self._app_id: + try: + self._client = NullbrApiClient(self._app_id, self._api_key) + logger.info("Nullbr资源搜索插件已启动") + except Exception as e: + logger.error(f"Nullbr插件初始化失败: {str(e)}") + self._enabled = False + else: + if not self._app_id: + logger.warning("Nullbr插件配置错误: 缺少APP_ID") + self._client = None + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12}, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '🌟 Nullbr资源搜索插件将优先使用Nullbr API查找资源。支持115网盘、磁力、ed2k、m3u8等多种资源类型。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 6}, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + 'hint': '开启后插件将开始工作,优先搜索Nullbr资源', + 'persistent-hint': True + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 6}, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'app_id', + 'label': 'APP_ID *', + 'placeholder': '请输入Nullbr API的APP_ID', + 'hint': '必填:用于API认证的应用ID', + 'persistent-hint': True, + 'clearable': True + } + } + ] + }, + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 6}, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'api_key', + 'label': 'API_KEY', + 'placeholder': '请输入Nullbr API的API_KEY', + 'hint': '可选:用于获取资源链接,没有则只能搜索不能获取下载链接', + 'persistent-hint': True, + 'clearable': True, + 'type': 'password' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12}, + 'content': [ + { + 'component': 'VExpansionPanels', + 'content': [ + { + 'component': 'VExpansionPanel', + 'props': {'title': '⚙️ 高级设置'}, + 'content': [ + { + 'component': 'VExpansionPanelText', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 3}, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enable_115', + 'label': '115网盘', + 'hint': '搜索115网盘分享资源', + 'persistent-hint': True + } + } + ] + }, + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 3}, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enable_magnet', + 'label': '磁力链接', + 'hint': '搜索磁力链接资源', + 'persistent-hint': True + } + } + ] + }, + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 3}, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enable_video', + 'label': 'M3U8视频', + 'hint': '搜索在线观看资源', + 'persistent-hint': True + } + } + ] + }, + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 3}, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enable_ed2k', + 'label': 'ED2K链接', + 'hint': '搜索ED2K链接资源', + 'persistent-hint': True + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 6}, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'search_timeout', + 'label': '搜索超时时间(秒)', + 'placeholder': '30', + 'hint': '单次API请求的超时时间', + 'persistent-hint': True, + 'type': 'number', + 'min': 10, + 'max': 120 + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "app_id": "", + "api_key": "", + "enable_115": True, + "enable_magnet": True, + "enable_video": True, + "enable_ed2k": True, + "search_timeout": 30 + } + + def get_page(self) -> List[dict]: + stats = {"total_searches": 0, "success_searches": 0, "failed_searches": 0, "last_search": "从未"} + return [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12}, + 'content': [ + { + 'component': 'VCard', + 'props': {'class': 'mb-4'}, + 'content': [ + { + 'component': 'VCardTitle', + 'props': {'text': '🌟 Nullbr资源搜索状态'} + }, + { + 'component': 'VCardText', + 'content': [ + { + 'component': 'VList', + 'content': [ + { + 'component': 'VListItem', + 'content': [ + { + 'component': 'VListItemTitle', + 'props': {'text': f"插件状态: {'🟢 运行中' if self._enabled else '🔴 已停止'}"} + } + ] + }, + { + 'component': 'VListItem', + 'content': [ + { + 'component': 'VListItemTitle', + 'props': {'text': f"API认证: {'✅ 已配置' if self._app_id else '❌ 未配置'}"} + } + ] + }, + { + 'component': 'VListItem', + 'content': [ + { + 'component': 'VListItemTitle', + 'props': {'text': f"资源获取: {'✅ 可用' if self._api_key else '❌ 仅搜索'}"} + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 6}, + 'content': [ + { + 'component': 'VCard', + 'content': [ + { + 'component': 'VCardTitle', + 'props': {'text': '📊 支持的资源类型'} + }, + { + 'component': 'VCardText', + 'content': [ + { + 'component': 'VList', + 'content': [ + { + 'component': 'VListItem', + 'content': [ + { + 'component': 'VListItemTitle', + 'props': {'text': f"115网盘: {'✅ 启用' if self._enable_115 else '❌ 禁用'}"} + } + ] + }, + { + 'component': 'VListItem', + 'content': [ + { + 'component': 'VListItemTitle', + 'props': {'text': f"磁力链接: {'✅ 启用' if self._enable_magnet else '❌ 禁用'}"} + } + ] + }, + { + 'component': 'VListItem', + 'content': [ + { + 'component': 'VListItemTitle', + 'props': {'text': f"M3U8视频: {'✅ 启用' if self._enable_video else '❌ 禁用'}"} + } + ] + }, + { + 'component': 'VListItem', + 'content': [ + { + 'component': 'VListItemTitle', + 'props': {'text': f"ED2K链接: {'✅ 启用' if self._enable_ed2k else '❌ 禁用'}"} + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 6}, + 'content': [ + { + 'component': 'VCard', + 'content': [ + { + 'component': 'VCardTitle', + 'props': {'text': '📈 使用统计'} + }, + { + 'component': 'VCardText', + 'content': [ + { + 'component': 'VList', + 'content': [ + { + 'component': 'VListItem', + 'content': [ + { + 'component': 'VListItemTitle', + 'props': {'text': f"总搜索次数: {stats.get('total_searches', 0)}"} + } + ] + }, + { + 'component': 'VListItem', + 'content': [ + { + 'component': 'VListItemTitle', + 'props': {'text': f"成功次数: {stats.get('success_searches', 0)}"} + } + ] + }, + { + 'component': 'VListItem', + 'content': [ + { + 'component': 'VListItemTitle', + 'props': {'text': f"失败次数: {stats.get('failed_searches', 0)}"} + } + ] + }, + { + 'component': 'VListItem', + 'content': [ + { + 'component': 'VListItemTitle', + 'props': {'text': f"最后搜索: {stats.get('last_search', '从未')}"} + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12}, + 'content': [ + { + 'component': 'VCard', + 'content': [ + { + 'component': 'VCardTitle', + 'props': {'text': '💡 使用说明'} + }, + { + 'component': 'VCardText', + 'props': { + 'text': '''🔑 配置步骤: + 1. 在插件设置中填入您的 Nullbr API APP_ID (必填) + 2. 如需获取下载链接,请填入 API_KEY (可选) + 3. 根据需要启用不同的资源类型 + 4. 保存配置并启用插件 + + ⚡ 工作原理: + • 插件通过API接口提供Nullbr资源搜索服务 + • 可在MoviePilot中手动调用搜索功能 + • 支持电影、剧集、合集等多种媒体类型 + • 支持115网盘、磁力、ed2k、m3u8等多种资源格式 + + 📞 技术支持: + 如遇问题请检查 MoviePilot 日志中的错误信息''' + } + } + ] + } + ] + } + ] + } + ] + + @eventmanager.register(EventType.UserMessage) + def talk(self, event: Event): + """ + 监听用户消息,识别搜索请求和编号选择 + """ + if not self._enabled or not self._client: + return + + text = event.event_data.get("text") + userid = event.event_data.get("userid") + channel = event.event_data.get("channel") + + if not text: + return + + logger.info(f"收到用户消息: {text}") + + # 检查是否为回退搜索触发的消息,避免无限循环 + if event.event_data.get('source') == 'nullbr_fallback': + logger.info("检测到回退搜索消息,跳过处理避免循环") + return + + # 先检查是否为获取资源的请求(包含问号的情况,如 "1.115?" "2.magnet?") + clean_text = text.rstrip('??').strip() + if re.match(r'^\d+\.(115|magnet|video|ed2k)$', clean_text): + parts = clean_text.split('.') + number = int(parts[0]) + resource_type = parts[1] + logger.info(f"检测到资源获取请求: {number}.{resource_type}") + self.handle_get_resources(number, resource_type, channel, userid) + + # 检查是否为编号选择(纯数字,包含问号的情况) + elif clean_text.isdigit(): + number = int(clean_text) + logger.info(f"检测到编号选择: {number}") + self.handle_resource_selection(number, channel, userid) + + # 检查是否为搜索请求(以?结尾,但不是数字或资源请求) + elif text.endswith('?') or text.endswith('?'): + # 提取搜索关键词(去掉问号) + keyword = clean_text + + if keyword: + logger.info(f"检测到搜索请求: {keyword}") + self.search_and_reply(keyword, channel, userid) + + def search_and_reply(self, keyword: str, channel: str, userid: str): + """执行搜索并回复结果""" + try: + # 调用Nullbr API搜索 + result = self._client.search(keyword) + + if not result or not result.get('items'): + # Nullbr没有搜索结果,回退到MoviePilot原始搜索 + logger.info(f"Nullbr未找到「{keyword}」的搜索结果,回退到MoviePilot搜索") + self.post_message( + channel=channel, + title="切换搜索", + text=f"Nullbr没有找到「{keyword}」的资源,正在使用MoviePilot原始搜索...", + userid=userid + ) + + # 调用MoviePilot的原始搜索功能 + self.fallback_to_moviepilot_search(keyword, channel, userid) + return + + items = result.get('items', [])[:10] # 最多显示10个结果 + + # 缓存搜索结果 + self._user_search_cache[userid] = { + 'results': items, + 'keyword': keyword, + 'timestamp': time.time() + } + + # 格式化搜索结果 + reply_text = f"🔍 找到「{keyword}」的资源:\n\n" + + for i, item in enumerate(items, 1): + title = item.get('title', '未知标题') + media_type = item.get('media_type', 'unknown') + year = item.get('release_date', item.get('first_air_date', ''))[:4] if item.get('release_date') or item.get('first_air_date') else '' + + # 检查可用的资源类型 + available_types = [] + if item.get('115-flg') and self._enable_115: + available_types.append('115') + if item.get('magnet-flg') and self._enable_magnet: + available_types.append('磁力') + if item.get('video-flg') and self._enable_video: + available_types.append('在线') + if item.get('ed2k-flg') and self._enable_ed2k: + available_types.append('ed2k') + + type_text = '、'.join(available_types) if available_types else '无' + media_text = '电影' if media_type == 'movie' else '剧集' if media_type == 'tv' else media_type + + reply_text += f"{i}. {title}" + if year: + reply_text += f" ({year})" + reply_text += f" - {media_text}\n" + reply_text += f" 资源: {type_text}\n\n" + + if len(result.get('items', [])) > 10: + reply_text += f"... 还有 {len(result.get('items', [])) - 10} 个结果\n\n" + + if self._api_key: + reply_text += "📋 使用方法:\n" + reply_text += "• 发送数字选择项目: 如 \"1\"\n" + reply_text += "• 发送数字.资源类型获取链接: 如 \"1.115\" \"2.magnet\"" + else: + reply_text += "💡 提示: 请配置API_KEY以获取下载链接" + + self.post_message( + channel=channel, + title="Nullbr搜索结果", + text=reply_text, + userid=userid + ) + + except Exception as e: + logger.error(f"搜索处理异常: {str(e)}") + self.post_message( + channel=channel, + title="搜索错误", + text=f"搜索「{keyword}」时出现错误: {str(e)}", + userid=userid + ) + + def handle_resource_selection(self, number: int, channel: str, userid: str): + """处理用户的编号选择""" + try: + # 检查缓存 + cache = self._user_search_cache.get(userid) + if not cache or time.time() - cache['timestamp'] > 3600: # 缓存1小时 + self.post_message( + channel=channel, + title="提示", + text="搜索结果已过期,请重新搜索。", + userid=userid + ) + return + + results = cache['results'] + if number < 1 or number > len(results): + self.post_message( + channel=channel, + title="提示", + text=f"请输入有效的编号 (1-{len(results)})。", + userid=userid + ) + return + + # 获取选中的项目 + selected = results[number - 1] + title = selected.get('title', '未知标题') + media_type = selected.get('media_type', 'unknown') + year = selected.get('release_date', selected.get('first_air_date', ''))[:4] if selected.get('release_date') or selected.get('first_air_date') else '' + + # 显示详细信息 + reply_text = f"📺 选择的资源: {title}" + if year: + reply_text += f" ({year})" + reply_text += f"\n类型: {'电影' if media_type == 'movie' else '剧集' if media_type == 'tv' else media_type}" + reply_text += f"\nTMDB ID: {selected.get('tmdbid')}" + + if selected.get('overview'): + reply_text += f"\n简介: {selected.get('overview')[:100]}..." + + # 显示可用的资源类型 + reply_text += f"\n\n🔗 可用资源类型:" + resource_options = [] + + if selected.get('115-flg') and self._enable_115: + resource_options.append(f"• 115网盘: 发送 \"{number}.115\"") + if selected.get('magnet-flg') and self._enable_magnet: + resource_options.append(f"• 磁力链接: 发送 \"{number}.magnet\"") + if selected.get('video-flg') and self._enable_video: + resource_options.append(f"• 在线观看: 发送 \"{number}.video\"") + if selected.get('ed2k-flg') and self._enable_ed2k: + resource_options.append(f"• ED2K链接: 发送 \"{number}.ed2k\"") + + if resource_options: + reply_text += f"\n" + "\n".join(resource_options) + + if not self._api_key: + reply_text += "\n\n⚠️ 注意: 需要配置API_KEY才能获取具体下载链接" + else: + reply_text += f"\n暂无可用资源类型" + + self.post_message( + channel=channel, + title="资源详情", + text=reply_text, + userid=userid + ) + + except Exception as e: + logger.error(f"处理资源选择异常: {str(e)}") + self.post_message( + channel=channel, + title="错误", + text=f"处理选择时出现错误: {str(e)}", + userid=userid + ) + + def handle_get_resources(self, number: int, resource_type: str, channel: str, userid: str): + """处理获取具体资源链接的请求""" + try: + # 检查API_KEY + if not self._api_key: + self.post_message( + channel=channel, + title="配置错误", + text="获取下载链接需要配置API_KEY,请在插件设置中添加。", + userid=userid + ) + return + + # 检查缓存 + cache = self._user_search_cache.get(userid) + if not cache or time.time() - cache['timestamp'] > 3600: + self.post_message( + channel=channel, + title="提示", + text="搜索结果已过期,请重新搜索。", + userid=userid + ) + return + + results = cache['results'] + if number < 1 or number > len(results): + self.post_message( + channel=channel, + title="提示", + text=f"请输入有效的编号 (1-{len(results)})。", + userid=userid + ) + return + + # 获取选中的项目 + selected = results[number - 1] + title = selected.get('title', '未知标题') + media_type = selected.get('media_type', 'unknown') + tmdbid = selected.get('tmdbid') + + if not tmdbid: + self.post_message( + channel=channel, + title="错误", + text="该资源缺少TMDB ID,无法获取下载链接。", + userid=userid + ) + return + + # 发送获取中的提示 + self.post_message( + channel=channel, + title="获取中", + text=f"正在获取「{title}」的{resource_type}资源...", + userid=userid + ) + + # 调用相应的API获取资源 + resources = None + if media_type == 'movie': + resources = self._client.get_movie_resources(tmdbid, resource_type) + elif media_type == 'tv': + resources = self._client.get_tv_resources(tmdbid, resource_type) + + if not resources: + # Nullbr没有找到资源,回退到MoviePilot原始搜索 + logger.info(f"Nullbr未找到「{title}」的{resource_type}资源,回退到MoviePilot搜索") + self.post_message( + channel=channel, + title="切换搜索", + text=f"Nullbr没有找到「{title}」的{resource_type}资源,正在使用MoviePilot原始搜索...", + userid=userid + ) + + # 调用MoviePilot的原始搜索功能 + self.fallback_to_moviepilot_search(title, channel, userid) + return + + # 格式化资源链接 + self._format_and_send_resources(resources, resource_type, title, channel, userid) + + except Exception as e: + logger.error(f"获取资源链接异常: {str(e)}") + self.post_message( + channel=channel, + title="错误", + text=f"获取资源链接时出现错误: {str(e)}", + userid=userid + ) + + def _format_and_send_resources(self, resources: dict, resource_type: str, title: str, channel: str, userid: str): + """格式化并发送资源链接""" + try: + reply_text = f"🎯 「{title}」的{resource_type}资源:\n\n" + + if resource_type == "115": + resource_list = resources.get('115', []) + for i, res in enumerate(resource_list[:10], 1): # 最多显示10个 + reply_text += f"{i}. {res.get('title', '未知')}\n" + reply_text += f" 大小: {res.get('size', '未知')}\n" + reply_text += f" 链接: {res.get('share_link', '无')}\n\n" + + elif resource_type == "magnet": + resource_list = resources.get('magnet', []) + for i, res in enumerate(resource_list[:10], 1): + reply_text += f"{i}. {res.get('name', '未知')}\n" + reply_text += f" 大小: {res.get('size', '未知')}\n" + reply_text += f" 分辨率: {res.get('resolution', '未知')}\n" + reply_text += f" 中文字幕: {'✅' if res.get('zh_sub') else '❌'}\n" + reply_text += f" 磁力: {res.get('magnet', '无')}\n\n" + + elif resource_type in ["video", "ed2k"]: + resource_list = resources.get(resource_type, []) + for i, res in enumerate(resource_list[:10], 1): + reply_text += f"{i}. {res.get('name', res.get('title', '未知'))}\n" + if res.get('size'): + reply_text += f" 大小: {res.get('size')}\n" + reply_text += f" 链接: {res.get('url', res.get('link', '无'))}\n\n" + + if len(reply_text) > 4000: # Telegram消息长度限制 + reply_text = reply_text[:3900] + "...\n\n(内容过长已截断)" + + if not reply_text.strip().endswith('无'): + reply_text += f"📊 共找到 {len(resources.get(resource_type, []))} 个资源" + + self.post_message( + channel=channel, + title=f"{resource_type.upper()}资源", + text=reply_text, + userid=userid + ) + + except Exception as e: + logger.error(f"格式化资源异常: {str(e)}") + self.post_message( + channel=channel, + title="错误", + text=f"处理资源信息时出现错误: {str(e)}", + userid=userid + ) + + def fallback_to_moviepilot_search(self, title: str, channel: str, userid: str): + """回退到MoviePilot原始搜索功能""" + logger.info(f"启动MoviePilot原始搜索: {title}") + + # 直接尝试各种搜索方法,不再触发事件避免循环 + self.try_alternative_search(title, channel, userid) + + def try_alternative_search(self, title: str, channel: str, userid: str): + """尝试其他搜索方式""" + try: + logger.info(f"尝试MoviePilot原始搜索: {title}") + + # 简化策略:直接发送搜索建议和提示 + # 避免复杂的模块调用导致的错误 + + success = False + + # 方法1: 尝试调用站点助手的简单方法 + try: + from app.helper.sites import SitesHelper + sites_helper = SitesHelper() + + # 只是检查是否有配置的站点 + if hasattr(sites_helper, 'get_indexers'): + indexers = sites_helper.get_indexers() + if indexers: + logger.info(f"检测到 {len(indexers)} 个配置的站点") + + self.post_message( + channel=channel, + title="搜索提示", + text=f"🔍 Nullbr未找到「{title}」的资源\n\n" + + f"💡 系统检测到您已配置 {len(indexers)} 个搜索站点\n" + + f"建议通过以下方式继续搜索:\n\n" + + f"🌐 MoviePilot Web界面搜索\n" + + f"📱 其他搜索渠道\n" + + f"⚙️ 检查站点配置状态", + userid=userid + ) + success = True + + except Exception as e: + logger.warning(f"站点检测失败: {str(e)}") + + # 如果上面的方法也失败,发送通用建议 + if not success: + self._send_manual_search_suggestion(title, channel, userid) + + except Exception as e: + logger.error(f"备用搜索失败: {str(e)}") + self._send_manual_search_suggestion(title, channel, userid) + + + def _send_manual_search_suggestion(self, title: str, channel: str, userid: str): + """发送手动搜索建议""" + self.post_message( + channel=channel, + title="搜索建议", + text=f"📋 「{title}」未找到资源,建议:\n\n" + + f"🔍 在MoviePilot Web界面搜索\n" + + f"⚙️ 检查资源站点配置\n" + + f"🔄 尝试其他关键词\n" + + f"📱 使用其他搜索渠道", + userid=userid + ) + + def stop_service(self): + """ + 退出插件 + """ + if self._client and hasattr(self._client, '_session'): + self._client._session.close() + self._client = None + self._enabled = False + logger.info("Nullbr资源搜索插件已停止") \ No newline at end of file diff --git a/plugins.v2/nullbrsearch/requirements.txt b/plugins.v2/nullbrsearch/requirements.txt new file mode 100644 index 0000000..79fda6e --- /dev/null +++ b/plugins.v2/nullbrsearch/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.25.0 +urllib3>=1.26.0 \ No newline at end of file diff --git a/plugins/nullbrsearch/__init__.py b/plugins/nullbrsearch/__init__.py new file mode 100644 index 0000000..b0e9f0f --- /dev/null +++ b/plugins/nullbrsearch/__init__.py @@ -0,0 +1,1095 @@ +import re +import time +from typing import Any, List, Dict, Tuple, Optional +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from app.core.config import settings +from app.core.event import eventmanager, Event +from app.log import logger +from app.plugins import _PluginBase +from app.schemas.types import EventType + + +class NullbrApiClient: + """Nullbr API客户端""" + + def __init__(self, app_id: str, api_key: str = None): + self._app_id = app_id + self._api_key = api_key + self._base_url = "https://api.nullbr.eu.org" + + # 配置请求会话 + self._session = requests.Session() + self._session.headers.update({ + 'User-Agent': 'MoviePilot-NullbrSearch/1.0.4', + 'Content-Type': 'application/json' + }) + + # 配置重试策略 + try: + retry_strategy = Retry( + total=3, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=["HEAD", "GET", "OPTIONS"], + backoff_factor=1 + ) + except TypeError: + try: + retry_strategy = Retry( + total=3, + status_forcelist=[429, 500, 502, 503, 504], + method_whitelist=["HEAD", "GET", "OPTIONS"], + backoff_factor=1 + ) + except Exception: + retry_strategy = Retry(total=3, backoff_factor=1) + + adapter = HTTPAdapter(max_retries=retry_strategy) + self._session.mount("http://", adapter) + self._session.mount("https://", adapter) + + def search(self, query: str, page: int = 1) -> Optional[Dict]: + """搜索媒体资源""" + try: + # 根据API文档,APP_ID应该放在Header中 + headers = {'X-APP-ID': self._app_id} + + # API_KEY如果存在,也放在Header中 + if self._api_key: + headers['X-API-KEY'] = self._api_key + + params = { + 'query': query, + 'page': page + } + + logger.info(f"请求参数: {params}") + logger.info(f"请求头: X-APP-ID={self._app_id}, X-API-KEY={'已设置' if self._api_key else '未设置'}") + + response = self._session.get( + f"{self._base_url}/search", + params=params, + headers=headers, + timeout=30 + ) + + logger.info(f"响应状态码: {response.status_code}") + + if response.status_code == 200: + return response.json() + elif response.status_code == 403: + logger.error("Nullbr API认证失败,请检查APP_ID") + return None + else: + logger.warning(f"Nullbr API搜索失败: {response.status_code}, 响应内容: {response.text}") + return None + + except Exception as e: + logger.error(f"Nullbr API请求异常: {str(e)}") + return None + + def get_movie_resources(self, tmdbid: int, resource_type: str = "115") -> Optional[Dict]: + """获取电影资源链接""" + if not self._api_key: + logger.warning("获取资源链接需要API_KEY") + return None + + try: + headers = {'X-APP-ID': self._app_id, 'X-API-KEY': self._api_key} + + response = self._session.get( + f"{self._base_url}/movie/{tmdbid}/{resource_type}", + headers=headers, + timeout=30 + ) + + logger.info(f"获取电影资源响应状态码: {response.status_code}") + + if response.status_code == 200: + return response.json() + elif response.status_code == 401: + logger.error("API_KEY权限不足") + return None + elif response.status_code == 403: + logger.error("API认证失败") + return None + elif response.status_code == 429: + logger.warning("API请求过快,请稍后重试") + return None + else: + logger.warning(f"获取电影资源失败: {response.status_code}, 响应: {response.text}") + return None + + except Exception as e: + logger.error(f"获取电影资源异常: {str(e)}") + return None + + def get_tv_resources(self, tmdbid: int, resource_type: str = "115") -> Optional[Dict]: + """获取剧集资源链接""" + if not self._api_key: + logger.warning("获取资源链接需要API_KEY") + return None + + try: + headers = {'X-APP-ID': self._app_id, 'X-API-KEY': self._api_key} + + response = self._session.get( + f"{self._base_url}/tv/{tmdbid}/{resource_type}", + headers=headers, + timeout=30 + ) + + logger.info(f"获取剧集资源响应状态码: {response.status_code}") + + if response.status_code == 200: + return response.json() + elif response.status_code == 401: + logger.error("API_KEY权限不足") + return None + elif response.status_code == 403: + logger.error("API认证失败") + return None + elif response.status_code == 429: + logger.warning("API请求过快,请稍后重试") + return None + else: + logger.warning(f"获取剧集资源失败: {response.status_code}, 响应: {response.text}") + return None + + except Exception as e: + logger.error(f"获取剧集资源异常: {str(e)}") + return None + + +class NullbrSearch(_PluginBase): + # 插件基本信息 + plugin_name = "Nullbr资源搜索" + plugin_desc = "优先使用Nullbr API搜索影视资源,支持多种资源类型(115网盘、磁力、ed2k、m3u8)" + plugin_icon = "nullbr.png" + plugin_version = "1.0.4" + plugin_author = "Hqyel" + author_url = "https://github.com/Hqyel" + plugin_config_prefix = "nullbr_" + plugin_order = 1 + auth_level = 1 + + def __init__(self): + super().__init__() + self._enabled = False + self._app_id = None + self._api_key = None + self._resource_priority = ["115", "magnet", "video", "ed2k"] + self._enable_115 = True + self._enable_magnet = True + self._enable_video = True + self._enable_ed2k = True + self._search_timeout = 30 + self._client = None + + # 用户搜索结果缓存 + self._user_search_cache = {} # {userid: {'results': [...], 'timestamp': time.time()}} + + def init_plugin(self, config: dict = None): + if config: + self._enabled = config.get("enabled", False) + self._app_id = config.get("app_id") + self._api_key = config.get("api_key") + self._resource_priority = config.get("resource_priority", ["115", "magnet", "video", "ed2k"]) + self._enable_115 = config.get("enable_115", True) + self._enable_magnet = config.get("enable_magnet", True) + self._enable_video = config.get("enable_video", True) + self._enable_ed2k = config.get("enable_ed2k", True) + self._search_timeout = config.get("search_timeout", 30) + + # 初始化API客户端 + if self._enabled and self._app_id: + try: + self._client = NullbrApiClient(self._app_id, self._api_key) + logger.info("Nullbr资源搜索插件已启动") + except Exception as e: + logger.error(f"Nullbr插件初始化失败: {str(e)}") + self._enabled = False + else: + if not self._app_id: + logger.warning("Nullbr插件配置错误: 缺少APP_ID") + self._client = None + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12}, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '🌟 Nullbr资源搜索插件将优先使用Nullbr API查找资源。支持115网盘、磁力、ed2k、m3u8等多种资源类型。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 6}, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + 'hint': '开启后插件将开始工作,优先搜索Nullbr资源', + 'persistent-hint': True + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 6}, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'app_id', + 'label': 'APP_ID *', + 'placeholder': '请输入Nullbr API的APP_ID', + 'hint': '必填:用于API认证的应用ID', + 'persistent-hint': True, + 'clearable': True + } + } + ] + }, + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 6}, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'api_key', + 'label': 'API_KEY', + 'placeholder': '请输入Nullbr API的API_KEY', + 'hint': '可选:用于获取资源链接,没有则只能搜索不能获取下载链接', + 'persistent-hint': True, + 'clearable': True, + 'type': 'password' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12}, + 'content': [ + { + 'component': 'VExpansionPanels', + 'content': [ + { + 'component': 'VExpansionPanel', + 'props': {'title': '⚙️ 高级设置'}, + 'content': [ + { + 'component': 'VExpansionPanelText', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 3}, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enable_115', + 'label': '115网盘', + 'hint': '搜索115网盘分享资源', + 'persistent-hint': True + } + } + ] + }, + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 3}, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enable_magnet', + 'label': '磁力链接', + 'hint': '搜索磁力链接资源', + 'persistent-hint': True + } + } + ] + }, + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 3}, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enable_video', + 'label': 'M3U8视频', + 'hint': '搜索在线观看资源', + 'persistent-hint': True + } + } + ] + }, + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 3}, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enable_ed2k', + 'label': 'ED2K链接', + 'hint': '搜索ED2K链接资源', + 'persistent-hint': True + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 6}, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'search_timeout', + 'label': '搜索超时时间(秒)', + 'placeholder': '30', + 'hint': '单次API请求的超时时间', + 'persistent-hint': True, + 'type': 'number', + 'min': 10, + 'max': 120 + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "app_id": "", + "api_key": "", + "enable_115": True, + "enable_magnet": True, + "enable_video": True, + "enable_ed2k": True, + "search_timeout": 30 + } + + def get_page(self) -> List[dict]: + stats = {"total_searches": 0, "success_searches": 0, "failed_searches": 0, "last_search": "从未"} + return [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12}, + 'content': [ + { + 'component': 'VCard', + 'props': {'class': 'mb-4'}, + 'content': [ + { + 'component': 'VCardTitle', + 'props': {'text': '🌟 Nullbr资源搜索状态'} + }, + { + 'component': 'VCardText', + 'content': [ + { + 'component': 'VList', + 'content': [ + { + 'component': 'VListItem', + 'content': [ + { + 'component': 'VListItemTitle', + 'props': {'text': f"插件状态: {'🟢 运行中' if self._enabled else '🔴 已停止'}"} + } + ] + }, + { + 'component': 'VListItem', + 'content': [ + { + 'component': 'VListItemTitle', + 'props': {'text': f"API认证: {'✅ 已配置' if self._app_id else '❌ 未配置'}"} + } + ] + }, + { + 'component': 'VListItem', + 'content': [ + { + 'component': 'VListItemTitle', + 'props': {'text': f"资源获取: {'✅ 可用' if self._api_key else '❌ 仅搜索'}"} + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 6}, + 'content': [ + { + 'component': 'VCard', + 'content': [ + { + 'component': 'VCardTitle', + 'props': {'text': '📊 支持的资源类型'} + }, + { + 'component': 'VCardText', + 'content': [ + { + 'component': 'VList', + 'content': [ + { + 'component': 'VListItem', + 'content': [ + { + 'component': 'VListItemTitle', + 'props': {'text': f"115网盘: {'✅ 启用' if self._enable_115 else '❌ 禁用'}"} + } + ] + }, + { + 'component': 'VListItem', + 'content': [ + { + 'component': 'VListItemTitle', + 'props': {'text': f"磁力链接: {'✅ 启用' if self._enable_magnet else '❌ 禁用'}"} + } + ] + }, + { + 'component': 'VListItem', + 'content': [ + { + 'component': 'VListItemTitle', + 'props': {'text': f"M3U8视频: {'✅ 启用' if self._enable_video else '❌ 禁用'}"} + } + ] + }, + { + 'component': 'VListItem', + 'content': [ + { + 'component': 'VListItemTitle', + 'props': {'text': f"ED2K链接: {'✅ 启用' if self._enable_ed2k else '❌ 禁用'}"} + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + 'component': 'VCol', + 'props': {'cols': 12, 'md': 6}, + 'content': [ + { + 'component': 'VCard', + 'content': [ + { + 'component': 'VCardTitle', + 'props': {'text': '📈 使用统计'} + }, + { + 'component': 'VCardText', + 'content': [ + { + 'component': 'VList', + 'content': [ + { + 'component': 'VListItem', + 'content': [ + { + 'component': 'VListItemTitle', + 'props': {'text': f"总搜索次数: {stats.get('total_searches', 0)}"} + } + ] + }, + { + 'component': 'VListItem', + 'content': [ + { + 'component': 'VListItemTitle', + 'props': {'text': f"成功次数: {stats.get('success_searches', 0)}"} + } + ] + }, + { + 'component': 'VListItem', + 'content': [ + { + 'component': 'VListItemTitle', + 'props': {'text': f"失败次数: {stats.get('failed_searches', 0)}"} + } + ] + }, + { + 'component': 'VListItem', + 'content': [ + { + 'component': 'VListItemTitle', + 'props': {'text': f"最后搜索: {stats.get('last_search', '从未')}"} + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': {'cols': 12}, + 'content': [ + { + 'component': 'VCard', + 'content': [ + { + 'component': 'VCardTitle', + 'props': {'text': '💡 使用说明'} + }, + { + 'component': 'VCardText', + 'props': { + 'text': '''🔑 配置步骤: + 1. 在插件设置中填入您的 Nullbr API APP_ID (必填) + 2. 如需获取下载链接,请填入 API_KEY (可选) + 3. 根据需要启用不同的资源类型 + 4. 保存配置并启用插件 + + ⚡ 工作原理: + • 插件通过API接口提供Nullbr资源搜索服务 + • 可在MoviePilot中手动调用搜索功能 + • 支持电影、剧集、合集等多种媒体类型 + • 支持115网盘、磁力、ed2k、m3u8等多种资源格式 + + 📞 技术支持: + 如遇问题请检查 MoviePilot 日志中的错误信息''' + } + } + ] + } + ] + } + ] + } + ] + + @eventmanager.register(EventType.UserMessage) + def talk(self, event: Event): + """ + 监听用户消息,识别搜索请求和编号选择 + """ + if not self._enabled or not self._client: + return + + text = event.event_data.get("text") + userid = event.event_data.get("userid") + channel = event.event_data.get("channel") + + if not text: + return + + logger.info(f"收到用户消息: {text}") + + # 检查是否为回退搜索触发的消息,避免无限循环 + if event.event_data.get('source') == 'nullbr_fallback': + logger.info("检测到回退搜索消息,跳过处理避免循环") + return + + # 先检查是否为获取资源的请求(包含问号的情况,如 "1.115?" "2.magnet?") + clean_text = text.rstrip('??').strip() + if re.match(r'^\d+\.(115|magnet|video|ed2k)$', clean_text): + parts = clean_text.split('.') + number = int(parts[0]) + resource_type = parts[1] + logger.info(f"检测到资源获取请求: {number}.{resource_type}") + self.handle_get_resources(number, resource_type, channel, userid) + + # 检查是否为编号选择(纯数字,包含问号的情况) + elif clean_text.isdigit(): + number = int(clean_text) + logger.info(f"检测到编号选择: {number}") + self.handle_resource_selection(number, channel, userid) + + # 检查是否为搜索请求(以?结尾,但不是数字或资源请求) + elif text.endswith('?') or text.endswith('?'): + # 提取搜索关键词(去掉问号) + keyword = clean_text + + if keyword: + logger.info(f"检测到搜索请求: {keyword}") + self.search_and_reply(keyword, channel, userid) + + def search_and_reply(self, keyword: str, channel: str, userid: str): + """执行搜索并回复结果""" + try: + # 调用Nullbr API搜索 + result = self._client.search(keyword) + + if not result or not result.get('items'): + # Nullbr没有搜索结果,回退到MoviePilot原始搜索 + logger.info(f"Nullbr未找到「{keyword}」的搜索结果,回退到MoviePilot搜索") + self.post_message( + channel=channel, + title="切换搜索", + text=f"Nullbr没有找到「{keyword}」的资源,正在使用MoviePilot原始搜索...", + userid=userid + ) + + # 调用MoviePilot的原始搜索功能 + self.fallback_to_moviepilot_search(keyword, channel, userid) + return + + items = result.get('items', [])[:10] # 最多显示10个结果 + + # 缓存搜索结果 + self._user_search_cache[userid] = { + 'results': items, + 'keyword': keyword, + 'timestamp': time.time() + } + + # 格式化搜索结果 + reply_text = f"🔍 找到「{keyword}」的资源:\n\n" + + for i, item in enumerate(items, 1): + title = item.get('title', '未知标题') + media_type = item.get('media_type', 'unknown') + year = item.get('release_date', item.get('first_air_date', ''))[:4] if item.get('release_date') or item.get('first_air_date') else '' + + # 检查可用的资源类型 + available_types = [] + if item.get('115-flg') and self._enable_115: + available_types.append('115') + if item.get('magnet-flg') and self._enable_magnet: + available_types.append('磁力') + if item.get('video-flg') and self._enable_video: + available_types.append('在线') + if item.get('ed2k-flg') and self._enable_ed2k: + available_types.append('ed2k') + + type_text = '、'.join(available_types) if available_types else '无' + media_text = '电影' if media_type == 'movie' else '剧集' if media_type == 'tv' else media_type + + reply_text += f"{i}. {title}" + if year: + reply_text += f" ({year})" + reply_text += f" - {media_text}\n" + reply_text += f" 资源: {type_text}\n\n" + + if len(result.get('items', [])) > 10: + reply_text += f"... 还有 {len(result.get('items', [])) - 10} 个结果\n\n" + + if self._api_key: + reply_text += "📋 使用方法:\n" + reply_text += "• 发送数字选择项目: 如 \"1\"\n" + reply_text += "• 发送数字.资源类型获取链接: 如 \"1.115\" \"2.magnet\"" + else: + reply_text += "💡 提示: 请配置API_KEY以获取下载链接" + + self.post_message( + channel=channel, + title="Nullbr搜索结果", + text=reply_text, + userid=userid + ) + + except Exception as e: + logger.error(f"搜索处理异常: {str(e)}") + self.post_message( + channel=channel, + title="搜索错误", + text=f"搜索「{keyword}」时出现错误: {str(e)}", + userid=userid + ) + + def handle_resource_selection(self, number: int, channel: str, userid: str): + """处理用户的编号选择""" + try: + # 检查缓存 + cache = self._user_search_cache.get(userid) + if not cache or time.time() - cache['timestamp'] > 3600: # 缓存1小时 + self.post_message( + channel=channel, + title="提示", + text="搜索结果已过期,请重新搜索。", + userid=userid + ) + return + + results = cache['results'] + if number < 1 or number > len(results): + self.post_message( + channel=channel, + title="提示", + text=f"请输入有效的编号 (1-{len(results)})。", + userid=userid + ) + return + + # 获取选中的项目 + selected = results[number - 1] + title = selected.get('title', '未知标题') + media_type = selected.get('media_type', 'unknown') + year = selected.get('release_date', selected.get('first_air_date', ''))[:4] if selected.get('release_date') or selected.get('first_air_date') else '' + + # 显示详细信息 + reply_text = f"📺 选择的资源: {title}" + if year: + reply_text += f" ({year})" + reply_text += f"\n类型: {'电影' if media_type == 'movie' else '剧集' if media_type == 'tv' else media_type}" + reply_text += f"\nTMDB ID: {selected.get('tmdbid')}" + + if selected.get('overview'): + reply_text += f"\n简介: {selected.get('overview')[:100]}..." + + # 显示可用的资源类型 + reply_text += f"\n\n🔗 可用资源类型:" + resource_options = [] + + if selected.get('115-flg') and self._enable_115: + resource_options.append(f"• 115网盘: 发送 \"{number}.115\"") + if selected.get('magnet-flg') and self._enable_magnet: + resource_options.append(f"• 磁力链接: 发送 \"{number}.magnet\"") + if selected.get('video-flg') and self._enable_video: + resource_options.append(f"• 在线观看: 发送 \"{number}.video\"") + if selected.get('ed2k-flg') and self._enable_ed2k: + resource_options.append(f"• ED2K链接: 发送 \"{number}.ed2k\"") + + if resource_options: + reply_text += f"\n" + "\n".join(resource_options) + + if not self._api_key: + reply_text += "\n\n⚠️ 注意: 需要配置API_KEY才能获取具体下载链接" + else: + reply_text += f"\n暂无可用资源类型" + + self.post_message( + channel=channel, + title="资源详情", + text=reply_text, + userid=userid + ) + + except Exception as e: + logger.error(f"处理资源选择异常: {str(e)}") + self.post_message( + channel=channel, + title="错误", + text=f"处理选择时出现错误: {str(e)}", + userid=userid + ) + + def handle_get_resources(self, number: int, resource_type: str, channel: str, userid: str): + """处理获取具体资源链接的请求""" + try: + # 检查API_KEY + if not self._api_key: + self.post_message( + channel=channel, + title="配置错误", + text="获取下载链接需要配置API_KEY,请在插件设置中添加。", + userid=userid + ) + return + + # 检查缓存 + cache = self._user_search_cache.get(userid) + if not cache or time.time() - cache['timestamp'] > 3600: + self.post_message( + channel=channel, + title="提示", + text="搜索结果已过期,请重新搜索。", + userid=userid + ) + return + + results = cache['results'] + if number < 1 or number > len(results): + self.post_message( + channel=channel, + title="提示", + text=f"请输入有效的编号 (1-{len(results)})。", + userid=userid + ) + return + + # 获取选中的项目 + selected = results[number - 1] + title = selected.get('title', '未知标题') + media_type = selected.get('media_type', 'unknown') + tmdbid = selected.get('tmdbid') + + if not tmdbid: + self.post_message( + channel=channel, + title="错误", + text="该资源缺少TMDB ID,无法获取下载链接。", + userid=userid + ) + return + + # 发送获取中的提示 + self.post_message( + channel=channel, + title="获取中", + text=f"正在获取「{title}」的{resource_type}资源...", + userid=userid + ) + + # 调用相应的API获取资源 + resources = None + if media_type == 'movie': + resources = self._client.get_movie_resources(tmdbid, resource_type) + elif media_type == 'tv': + resources = self._client.get_tv_resources(tmdbid, resource_type) + + if not resources: + # Nullbr没有找到资源,回退到MoviePilot原始搜索 + logger.info(f"Nullbr未找到「{title}」的{resource_type}资源,回退到MoviePilot搜索") + self.post_message( + channel=channel, + title="切换搜索", + text=f"Nullbr没有找到「{title}」的{resource_type}资源,正在使用MoviePilot原始搜索...", + userid=userid + ) + + # 调用MoviePilot的原始搜索功能 + self.fallback_to_moviepilot_search(title, channel, userid) + return + + # 格式化资源链接 + self._format_and_send_resources(resources, resource_type, title, channel, userid) + + except Exception as e: + logger.error(f"获取资源链接异常: {str(e)}") + self.post_message( + channel=channel, + title="错误", + text=f"获取资源链接时出现错误: {str(e)}", + userid=userid + ) + + def _format_and_send_resources(self, resources: dict, resource_type: str, title: str, channel: str, userid: str): + """格式化并发送资源链接""" + try: + reply_text = f"🎯 「{title}」的{resource_type}资源:\n\n" + + if resource_type == "115": + resource_list = resources.get('115', []) + for i, res in enumerate(resource_list[:10], 1): # 最多显示10个 + reply_text += f"{i}. {res.get('title', '未知')}\n" + reply_text += f" 大小: {res.get('size', '未知')}\n" + reply_text += f" 链接: {res.get('share_link', '无')}\n\n" + + elif resource_type == "magnet": + resource_list = resources.get('magnet', []) + for i, res in enumerate(resource_list[:10], 1): + reply_text += f"{i}. {res.get('name', '未知')}\n" + reply_text += f" 大小: {res.get('size', '未知')}\n" + reply_text += f" 分辨率: {res.get('resolution', '未知')}\n" + reply_text += f" 中文字幕: {'✅' if res.get('zh_sub') else '❌'}\n" + reply_text += f" 磁力: {res.get('magnet', '无')}\n\n" + + elif resource_type in ["video", "ed2k"]: + resource_list = resources.get(resource_type, []) + for i, res in enumerate(resource_list[:10], 1): + reply_text += f"{i}. {res.get('name', res.get('title', '未知'))}\n" + if res.get('size'): + reply_text += f" 大小: {res.get('size')}\n" + reply_text += f" 链接: {res.get('url', res.get('link', '无'))}\n\n" + + if len(reply_text) > 4000: # Telegram消息长度限制 + reply_text = reply_text[:3900] + "...\n\n(内容过长已截断)" + + if not reply_text.strip().endswith('无'): + reply_text += f"📊 共找到 {len(resources.get(resource_type, []))} 个资源" + + self.post_message( + channel=channel, + title=f"{resource_type.upper()}资源", + text=reply_text, + userid=userid + ) + + except Exception as e: + logger.error(f"格式化资源异常: {str(e)}") + self.post_message( + channel=channel, + title="错误", + text=f"处理资源信息时出现错误: {str(e)}", + userid=userid + ) + + def fallback_to_moviepilot_search(self, title: str, channel: str, userid: str): + """回退到MoviePilot原始搜索功能""" + logger.info(f"启动MoviePilot原始搜索: {title}") + + # 直接尝试各种搜索方法,不再触发事件避免循环 + self.try_alternative_search(title, channel, userid) + + def try_alternative_search(self, title: str, channel: str, userid: str): + """尝试其他搜索方式""" + try: + logger.info(f"尝试MoviePilot原始搜索: {title}") + + # 简化策略:直接发送搜索建议和提示 + # 避免复杂的模块调用导致的错误 + + success = False + + # 方法1: 尝试调用站点助手的简单方法 + try: + from app.helper.sites import SitesHelper + sites_helper = SitesHelper() + + # 只是检查是否有配置的站点 + if hasattr(sites_helper, 'get_indexers'): + indexers = sites_helper.get_indexers() + if indexers: + logger.info(f"检测到 {len(indexers)} 个配置的站点") + + self.post_message( + channel=channel, + title="搜索提示", + text=f"🔍 Nullbr未找到「{title}」的资源\n\n" + + f"💡 系统检测到您已配置 {len(indexers)} 个搜索站点\n" + + f"建议通过以下方式继续搜索:\n\n" + + f"🌐 MoviePilot Web界面搜索\n" + + f"📱 其他搜索渠道\n" + + f"⚙️ 检查站点配置状态", + userid=userid + ) + success = True + + except Exception as e: + logger.warning(f"站点检测失败: {str(e)}") + + # 如果上面的方法也失败,发送通用建议 + if not success: + self._send_manual_search_suggestion(title, channel, userid) + + except Exception as e: + logger.error(f"备用搜索失败: {str(e)}") + self._send_manual_search_suggestion(title, channel, userid) + + + def _send_manual_search_suggestion(self, title: str, channel: str, userid: str): + """发送手动搜索建议""" + self.post_message( + channel=channel, + title="搜索建议", + text=f"📋 「{title}」未找到资源,建议:\n\n" + + f"🔍 在MoviePilot Web界面搜索\n" + + f"⚙️ 检查资源站点配置\n" + + f"🔄 尝试其他关键词\n" + + f"📱 使用其他搜索渠道", + userid=userid + ) + + def stop_service(self): + """ + 退出插件 + """ + if self._client and hasattr(self._client, '_session'): + self._client._session.close() + self._client = None + self._enabled = False + logger.info("Nullbr资源搜索插件已停止") \ No newline at end of file diff --git a/plugins/nullbrsearch/requirements.txt b/plugins/nullbrsearch/requirements.txt new file mode 100644 index 0000000..79fda6e --- /dev/null +++ b/plugins/nullbrsearch/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.25.0 +urllib3>=1.26.0 \ No newline at end of file