Upload 38 files
Browse files- .dockerignore +4 -0
- .env.production +18 -0
- Dockerfile +18 -0
- LICENSE +201 -0
- cli.sh +295 -0
- docker-compose-local.yml +11 -0
- docker-compose-traefik.yml +49 -0
- docker-compose-tunnel.yml +7 -0
- docker-compose.yml +62 -0
- package.json +29 -0
- src/index.js +289 -0
- src/lib/cache.js +35 -0
- src/lib/config.js +130 -0
- src/lib/debrid.js +28 -0
- src/lib/debrid/alldebrid.js +140 -0
- src/lib/debrid/const.js +7 -0
- src/lib/debrid/debridlink.js +144 -0
- src/lib/debrid/premiumize.js +135 -0
- src/lib/debrid/realdebrid.js +203 -0
- src/lib/icon.js +34 -0
- src/lib/jackett.js +194 -0
- src/lib/jackettio.js +439 -0
- src/lib/mediaflowProxy.js +112 -0
- src/lib/meta.js +17 -0
- src/lib/meta/cinemeta.js +87 -0
- src/lib/meta/tmdb.js +244 -0
- src/lib/torrentInfos.js +182 -0
- src/lib/util.js +63 -0
- src/static/css/bootstrap.min.css +0 -0
- src/static/img/icon.png +0 -0
- src/static/js/vue.global.prod.js +0 -0
- src/static/videos/access_denied.mp4 +0 -0
- src/static/videos/error.mp4 +0 -0
- src/static/videos/expired_api_key.mp4 +0 -0
- src/static/videos/not_premium.mp4 +0 -0
- src/static/videos/not_ready.mp4 +0 -0
- src/static/videos/two_factor_auth.mp4 +0 -0
- src/template/configure.html +310 -0
.dockerignore
ADDED
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
1 |
+
node_modules
|
2 |
+
npm-debug.log
|
3 |
+
.env
|
4 |
+
.env.*
|
.env.production
ADDED
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
ACME_DOMAIN=
|
2 |
+
ACME_EMAIL=
|
3 |
+
INSTALL_TYPE=3
|
4 |
+
LOCALTUNNEL=
|
5 |
+
JACKETT_URL=http://jackett:9117
|
6 |
+
JACKETT_API_KEY=0rnk28cvqd1esg4uzk2jrfjvm3zqealy
|
7 |
+
PORT=4000
|
8 |
+
DEFAULT_QUALITIES=0, 720, 1080, 2160
|
9 |
+
DEFAULT_MAX_TORRENTS=15
|
10 |
+
DEFAULT_PRIOTIZE_PACK_TORRENTS=2
|
11 |
+
DEFAULT_FORCE_CACHE_NEXT_EPISODE=true
|
12 |
+
DEFAULT_SORT_CACHED=quality:true, size:true
|
13 |
+
DEFAULT_SORT_UNCACHED=seeders:true
|
14 |
+
DEFAULT_HIDE_UNCACHED=true
|
15 |
+
DEFAULT_INDEXERS=all
|
16 |
+
DEFAULT_INDEXER_TIMEOUT_SEC=60
|
17 |
+
COMPOSE_FILE=docker-compose.yml
|
18 |
+
|
Dockerfile
ADDED
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
FROM node:20-slim
|
2 |
+
|
3 |
+
RUN mkdir -p /home/node/app && chown -R node:node /home/node/app \
|
4 |
+
&& mkdir -p /data && chown -R node:node /data
|
5 |
+
|
6 |
+
WORKDIR /home/node/app
|
7 |
+
|
8 |
+
COPY --chown=node:node package*.json ./
|
9 |
+
|
10 |
+
USER node
|
11 |
+
|
12 |
+
RUN npm install
|
13 |
+
|
14 |
+
COPY --chown=node:node ./src ./src
|
15 |
+
|
16 |
+
EXPOSE 4000
|
17 |
+
|
18 |
+
CMD [ "node", "src/index.js" ]
|
LICENSE
ADDED
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
Apache License
|
2 |
+
Version 2.0, January 2004
|
3 |
+
http://www.apache.org/licenses/
|
4 |
+
|
5 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
6 |
+
|
7 |
+
1. Definitions.
|
8 |
+
|
9 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
10 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
11 |
+
|
12 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
13 |
+
the copyright owner that is granting the License.
|
14 |
+
|
15 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
16 |
+
other entities that control, are controlled by, or are under common
|
17 |
+
control with that entity. For the purposes of this definition,
|
18 |
+
"control" means (i) the power, direct or indirect, to cause the
|
19 |
+
direction or management of such entity, whether by contract or
|
20 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
21 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
22 |
+
|
23 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
24 |
+
exercising permissions granted by this License.
|
25 |
+
|
26 |
+
"Source" form shall mean the preferred form for making modifications,
|
27 |
+
including but not limited to software source code, documentation
|
28 |
+
source, and configuration files.
|
29 |
+
|
30 |
+
"Object" form shall mean any form resulting from mechanical
|
31 |
+
transformation or translation of a Source form, including but
|
32 |
+
not limited to compiled object code, generated documentation,
|
33 |
+
and conversions to other media types.
|
34 |
+
|
35 |
+
"Work" shall mean the work of authorship, whether in Source or
|
36 |
+
Object form, made available under the License, as indicated by a
|
37 |
+
copyright notice that is included in or attached to the work
|
38 |
+
(an example is provided in the Appendix below).
|
39 |
+
|
40 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
41 |
+
form, that is based on (or derived from) the Work and for which the
|
42 |
+
editorial revisions, annotations, elaborations, or other modifications
|
43 |
+
represent, as a whole, an original work of authorship. For the purposes
|
44 |
+
of this License, Derivative Works shall not include works that remain
|
45 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
46 |
+
the Work and Derivative Works thereof.
|
47 |
+
|
48 |
+
"Contribution" shall mean any work of authorship, including
|
49 |
+
the original version of the Work and any modifications or additions
|
50 |
+
to that Work or Derivative Works thereof, that is intentionally
|
51 |
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
52 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
53 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
54 |
+
means any form of electronic, verbal, or written communication sent
|
55 |
+
to the Licensor or its representatives, including but not limited to
|
56 |
+
communication on electronic mailing lists, source code control systems,
|
57 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
58 |
+
Licensor for the purpose of discussing and improving the Work, but
|
59 |
+
excluding communication that is conspicuously marked or otherwise
|
60 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
61 |
+
|
62 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
63 |
+
on behalf of whom a Contribution has been received by Licensor and
|
64 |
+
subsequently incorporated within the Work.
|
65 |
+
|
66 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
67 |
+
this License, each Contributor hereby grants to You a perpetual,
|
68 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
69 |
+
copyright license to reproduce, prepare Derivative Works of,
|
70 |
+
publicly display, publicly perform, sublicense, and distribute the
|
71 |
+
Work and such Derivative Works in Source or Object form.
|
72 |
+
|
73 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
74 |
+
this License, each Contributor hereby grants to You a perpetual,
|
75 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
76 |
+
(except as stated in this section) patent license to make, have made,
|
77 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
78 |
+
where such license applies only to those patent claims licensable
|
79 |
+
by such Contributor that are necessarily infringed by their
|
80 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
81 |
+
with the Work to which such Contribution(s) was submitted. If You
|
82 |
+
institute patent litigation against any entity (including a
|
83 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
84 |
+
or a Contribution incorporated within the Work constitutes direct
|
85 |
+
or contributory patent infringement, then any patent licenses
|
86 |
+
granted to You under this License for that Work shall terminate
|
87 |
+
as of the date such litigation is filed.
|
88 |
+
|
89 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
90 |
+
Work or Derivative Works thereof in any medium, with or without
|
91 |
+
modifications, and in Source or Object form, provided that You
|
92 |
+
meet the following conditions:
|
93 |
+
|
94 |
+
(a) You must give any other recipients of the Work or
|
95 |
+
Derivative Works a copy of this License; and
|
96 |
+
|
97 |
+
(b) You must cause any modified files to carry prominent notices
|
98 |
+
stating that You changed the files; and
|
99 |
+
|
100 |
+
(c) You must retain, in the Source form of any Derivative Works
|
101 |
+
that You distribute, all copyright, patent, trademark, and
|
102 |
+
attribution notices from the Source form of the Work,
|
103 |
+
excluding those notices that do not pertain to any part of
|
104 |
+
the Derivative Works; and
|
105 |
+
|
106 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
107 |
+
distribution, then any Derivative Works that You distribute must
|
108 |
+
include a readable copy of the attribution notices contained
|
109 |
+
within such NOTICE file, excluding those notices that do not
|
110 |
+
pertain to any part of the Derivative Works, in at least one
|
111 |
+
of the following places: within a NOTICE text file distributed
|
112 |
+
as part of the Derivative Works; within the Source form or
|
113 |
+
documentation, if provided along with the Derivative Works; or,
|
114 |
+
within a display generated by the Derivative Works, if and
|
115 |
+
wherever such third-party notices normally appear. The contents
|
116 |
+
of the NOTICE file are for informational purposes only and
|
117 |
+
do not modify the License. You may add Your own attribution
|
118 |
+
notices within Derivative Works that You distribute, alongside
|
119 |
+
or as an addendum to the NOTICE text from the Work, provided
|
120 |
+
that such additional attribution notices cannot be construed
|
121 |
+
as modifying the License.
|
122 |
+
|
123 |
+
You may add Your own copyright statement to Your modifications and
|
124 |
+
may provide additional or different license terms and conditions
|
125 |
+
for use, reproduction, or distribution of Your modifications, or
|
126 |
+
for any such Derivative Works as a whole, provided Your use,
|
127 |
+
reproduction, and distribution of the Work otherwise complies with
|
128 |
+
the conditions stated in this License.
|
129 |
+
|
130 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
131 |
+
any Contribution intentionally submitted for inclusion in the Work
|
132 |
+
by You to the Licensor shall be under the terms and conditions of
|
133 |
+
this License, without any additional terms or conditions.
|
134 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
135 |
+
the terms of any separate license agreement you may have executed
|
136 |
+
with Licensor regarding such Contributions.
|
137 |
+
|
138 |
+
6. Trademarks. This License does not grant permission to use the trade
|
139 |
+
names, trademarks, service marks, or product names of the Licensor,
|
140 |
+
except as required for reasonable and customary use in describing the
|
141 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
142 |
+
|
143 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
144 |
+
agreed to in writing, Licensor provides the Work (and each
|
145 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
146 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
147 |
+
implied, including, without limitation, any warranties or conditions
|
148 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
149 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
150 |
+
appropriateness of using or redistributing the Work and assume any
|
151 |
+
risks associated with Your exercise of permissions under this License.
|
152 |
+
|
153 |
+
8. Limitation of Liability. In no event and under no legal theory,
|
154 |
+
whether in tort (including negligence), contract, or otherwise,
|
155 |
+
unless required by applicable law (such as deliberate and grossly
|
156 |
+
negligent acts) or agreed to in writing, shall any Contributor be
|
157 |
+
liable to You for damages, including any direct, indirect, special,
|
158 |
+
incidental, or consequential damages of any character arising as a
|
159 |
+
result of this License or out of the use or inability to use the
|
160 |
+
Work (including but not limited to damages for loss of goodwill,
|
161 |
+
work stoppage, computer failure or malfunction, or any and all
|
162 |
+
other commercial damages or losses), even if such Contributor
|
163 |
+
has been advised of the possibility of such damages.
|
164 |
+
|
165 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
166 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
167 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
168 |
+
or other liability obligations and/or rights consistent with this
|
169 |
+
License. However, in accepting such obligations, You may act only
|
170 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
171 |
+
of any other Contributor, and only if You agree to indemnify,
|
172 |
+
defend, and hold each Contributor harmless for any liability
|
173 |
+
incurred by, or claims asserted against, such Contributor by reason
|
174 |
+
of your accepting any such warranty or additional liability.
|
175 |
+
|
176 |
+
END OF TERMS AND CONDITIONS
|
177 |
+
|
178 |
+
APPENDIX: How to apply the Apache License to your work.
|
179 |
+
|
180 |
+
To apply the Apache License to your work, attach the following
|
181 |
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
182 |
+
replaced with your own identifying information. (Don't include
|
183 |
+
the brackets!) The text should be enclosed in the appropriate
|
184 |
+
comment syntax for the file format. We also recommend that a
|
185 |
+
file or class name and description of purpose be included on the
|
186 |
+
same "printed page" as the copyright notice for easier
|
187 |
+
identification within third-party archives.
|
188 |
+
|
189 |
+
Copyright [yyyy] [name of copyright owner]
|
190 |
+
|
191 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
192 |
+
you may not use this file except in compliance with the License.
|
193 |
+
You may obtain a copy of the License at
|
194 |
+
|
195 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
196 |
+
|
197 |
+
Unless required by applicable law or agreed to in writing, software
|
198 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
199 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
200 |
+
See the License for the specific language governing permissions and
|
201 |
+
limitations under the License.
|
cli.sh
ADDED
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
#!/bin/bash
|
2 |
+
|
3 |
+
set -e
|
4 |
+
|
5 |
+
# Check if docker is installed
|
6 |
+
if ! command -v docker >/dev/null 2>&1; then
|
7 |
+
echo "Error: Docker is not installed on this machine."
|
8 |
+
echo "https://www.docker.com/products/docker-desktop/"
|
9 |
+
exit 1
|
10 |
+
fi
|
11 |
+
|
12 |
+
if ! command -v curl >/dev/null 2>&1; then
|
13 |
+
apt-get update && apt-get install curl -y
|
14 |
+
fi
|
15 |
+
|
16 |
+
COMMAND="$1"
|
17 |
+
RAW_GITHUB_URL="https://raw.githubusercontent.com/arvida42/jackettio/master"
|
18 |
+
DIR=$(dirname "$0")
|
19 |
+
ENV_FILE="$DIR/.env.production"
|
20 |
+
JACKETT_PASSWORD=""
|
21 |
+
|
22 |
+
ACME_DOMAIN=""
|
23 |
+
ACME_EMAIL=""
|
24 |
+
INSTALL_TYPE=""
|
25 |
+
LOCALTUNNEL=""
|
26 |
+
JACKETT_URL="http://jackett:9117"
|
27 |
+
JACKETT_API_KEY=""
|
28 |
+
PORT=4000
|
29 |
+
COMPOSE_FILE=""
|
30 |
+
|
31 |
+
cd $DIR
|
32 |
+
|
33 |
+
importConfig() {
|
34 |
+
if [ ! -f "$ENV_FILE" ]; then
|
35 |
+
echo "Configuration file not found: $ENV_FILE"
|
36 |
+
echo "Are you in the corect folder to run this command ?"
|
37 |
+
exit 1
|
38 |
+
fi
|
39 |
+
source $ENV_FILE
|
40 |
+
}
|
41 |
+
|
42 |
+
runDockerCompose() {
|
43 |
+
docker compose -f docker-compose.yml -f $COMPOSE_FILE --env-file $ENV_FILE "$@"
|
44 |
+
}
|
45 |
+
|
46 |
+
downloadComposeFiles() {
|
47 |
+
echo "Downloading compose files ..."
|
48 |
+
curl -fsSL "${RAW_GITHUB_URL}/docker-compose.yml" -o docker-compose.yml
|
49 |
+
curl -fsSL "${RAW_GITHUB_URL}/${COMPOSE_FILE}" -o $COMPOSE_FILE
|
50 |
+
}
|
51 |
+
|
52 |
+
sedReplace(){
|
53 |
+
if [[ "$OSTYPE" == darwin* ]]; then
|
54 |
+
sed -i '' "$@"
|
55 |
+
else
|
56 |
+
sed -i "$@"
|
57 |
+
fi
|
58 |
+
}
|
59 |
+
|
60 |
+
createJackettPassword(){
|
61 |
+
|
62 |
+
FILE=$1
|
63 |
+
JACKETT_API_KEY=$(sed -n 's/.*"APIKey": "\(.*\)",/\1/p' $FILE)
|
64 |
+
|
65 |
+
if command -v openssl &> /dev/null; then
|
66 |
+
echo " - Generate password using openssl"
|
67 |
+
JACKETT_PASSWORD=$(openssl rand -base64 12)
|
68 |
+
else
|
69 |
+
echo " - Generate password using /dev/urandom "
|
70 |
+
JACKETT_PASSWORD=$(tr -dc A-Za-z0-9_ < /dev/urandom | head -c 12)
|
71 |
+
fi
|
72 |
+
|
73 |
+
echo " - Create password hash ..."
|
74 |
+
# https://github.com/Jackett/Jackett/blob/d560175c20a64c0d5379ceb7178810d00b71498d/src/Jackett.Server/Services/SecurityService.cs#L26
|
75 |
+
NODE_COMMAND="node -e \"const crypto = require('crypto');
|
76 |
+
const input = '${JACKETT_PASSWORD}${JACKETT_API_KEY}';
|
77 |
+
const hash = crypto.createHash('sha512');
|
78 |
+
hash.update(Buffer.from(input, 'utf16le'));
|
79 |
+
console.log(hash.digest().toString('hex'));\""
|
80 |
+
JACKETT_PASSWORD_HASH=$(docker run --rm node:20-slim sh -c "$NODE_COMMAND")
|
81 |
+
|
82 |
+
sedReplace 's/"AdminPassword": .*,/"AdminPassword": "'"$JACKETT_PASSWORD_HASH"'",/' $FILE
|
83 |
+
|
84 |
+
}
|
85 |
+
|
86 |
+
showHelp(){
|
87 |
+
cat <<-END
|
88 |
+
|
89 |
+
Usage: sh ./cli.sh [command]
|
90 |
+
|
91 |
+
Available commands:
|
92 |
+
|
93 |
+
start Start all containers
|
94 |
+
stop Stop all containers
|
95 |
+
down Stop and remove all containers
|
96 |
+
update Update all containers
|
97 |
+
install Install and configure all containers
|
98 |
+
jackett-password Reset jackett password
|
99 |
+
|
100 |
+
END
|
101 |
+
}
|
102 |
+
|
103 |
+
# Store information in an environment file
|
104 |
+
saveConfig() {
|
105 |
+
cat <<EOF > $ENV_FILE
|
106 |
+
ACME_DOMAIN=$ACME_DOMAIN
|
107 |
+
ACME_EMAIL=$ACME_EMAIL
|
108 |
+
INSTALL_TYPE=$INSTALL_TYPE
|
109 |
+
LOCALTUNNEL=$LOCALTUNNEL
|
110 |
+
JACKETT_URL=$JACKETT_URL
|
111 |
+
JACKETT_API_KEY=$JACKETT_API_KEY
|
112 |
+
PORT=$PORT
|
113 |
+
COMPOSE_FILE=$COMPOSE_FILE
|
114 |
+
EOF
|
115 |
+
}
|
116 |
+
|
117 |
+
case "$COMMAND" in
|
118 |
+
"--help"|"help")
|
119 |
+
showHelp
|
120 |
+
exit 0
|
121 |
+
;;
|
122 |
+
"start")
|
123 |
+
importConfig
|
124 |
+
runDockerCompose up -d
|
125 |
+
exit 0
|
126 |
+
;;
|
127 |
+
"stop" | "down")
|
128 |
+
importConfig
|
129 |
+
runDockerCompose $COMMAND
|
130 |
+
exit 0
|
131 |
+
;;
|
132 |
+
"update")
|
133 |
+
importConfig
|
134 |
+
runDockerCompose down
|
135 |
+
downloadComposeFiles
|
136 |
+
runDockerCompose pull
|
137 |
+
runDockerCompose up -d
|
138 |
+
exit 0
|
139 |
+
;;
|
140 |
+
"jackett-password")
|
141 |
+
importConfig
|
142 |
+
docker cp jackett:/config/Jackett/ServerConfig.json /tmp/ServerConfig.json > /dev/null
|
143 |
+
createJackettPassword /tmp/ServerConfig.json
|
144 |
+
docker cp /tmp/ServerConfig.json jackett:/config/Jackett/ServerConfig.json > /dev/null
|
145 |
+
rm -f /tmp/ServerConfig.json
|
146 |
+
echo "Restart jackett ..."
|
147 |
+
docker restart jackett
|
148 |
+
echo "Your new password is: $JACKETT_PASSWORD"
|
149 |
+
echo "Please change it for security in jackett dashboard"
|
150 |
+
exit 0
|
151 |
+
;;
|
152 |
+
"install")
|
153 |
+
echo "Install ..."
|
154 |
+
;;
|
155 |
+
*)
|
156 |
+
echo -e "\033[0;31mInvalid command: ${COMMAND}\033[0m"
|
157 |
+
showHelp
|
158 |
+
exit 1
|
159 |
+
;;
|
160 |
+
esac
|
161 |
+
|
162 |
+
if [ -f "$ENV_FILE" ]; then
|
163 |
+
echo -e "\033[0;31mAn installation appears to already exist and will be overwritten ! ($ENV_FILE)\033[0m"
|
164 |
+
read -p " Continue and overwrite ? (y/n): " continue
|
165 |
+
if [[ $continue != "yes" && $continue != "y" ]]; then
|
166 |
+
echo "Exiting..."
|
167 |
+
exit
|
168 |
+
fi
|
169 |
+
fi
|
170 |
+
|
171 |
+
cat <<-END
|
172 |
+
|
173 |
+
This script will install compose and environments file in the following folder
|
174 |
+
-----------------------
|
175 |
+
${PWD}
|
176 |
+
-----------------------
|
177 |
+
END
|
178 |
+
read -p "Are you sure you want to continue? (y/n): " continue
|
179 |
+
if [[ $continue != "yes" && $continue != "y" ]]; then
|
180 |
+
echo "Exiting..."
|
181 |
+
exit
|
182 |
+
fi
|
183 |
+
|
184 |
+
cat <<-END
|
185 |
+
|
186 |
+
Please select an installation type:
|
187 |
+
|
188 |
+
1) Using traefik
|
189 |
+
You must have a domain configured for this machine, ports 80 and 443 must be opened.
|
190 |
+
Your Addon will be available on the address: https://your_domain
|
191 |
+
|
192 |
+
2) Using localtunnel
|
193 |
+
This installation use "localtunnel" to expose the app on Internet.
|
194 |
+
There's no need to configure a domain; you can run it directly on your local machine.
|
195 |
+
However, you may encounter limitations imposed by LocalTunnel.
|
196 |
+
All requests from the addons will go through LocalTunnel.
|
197 |
+
Your Addon will be available on the address: https://random-id.localtunnel.me
|
198 |
+
|
199 |
+
3) Local
|
200 |
+
Install locally without domain. Stremio App must run in same machine to works.
|
201 |
+
Your Addon will be available on the address: http://localhost
|
202 |
+
|
203 |
+
END
|
204 |
+
|
205 |
+
read -p "Please chose 1,2 or 3): " INSTALL_TYPE
|
206 |
+
|
207 |
+
case "$INSTALL_TYPE" in
|
208 |
+
"1")
|
209 |
+
echo "traefik selected"
|
210 |
+
read -p "Please enter your domain name (example.com): " ACME_DOMAIN
|
211 |
+
read -p "Please enter your email (email@example.com): " ACME_EMAIL
|
212 |
+
COMPOSE_FILE=docker-compose-traefik.yml
|
213 |
+
echo "Your domain: ${ACME_DOMAIN}"
|
214 |
+
echo "Your email: ${ACME_EMAIL}"
|
215 |
+
;;
|
216 |
+
"2")
|
217 |
+
echo "localtunnel selected"
|
218 |
+
COMPOSE_FILE=docker-compose-tunnel.yml
|
219 |
+
LOCALTUNNEL="true"
|
220 |
+
;;
|
221 |
+
"3")
|
222 |
+
echo "local selected"
|
223 |
+
COMPOSE_FILE=docker-compose-local.yml
|
224 |
+
;;
|
225 |
+
*)
|
226 |
+
echo "Invalid installation type: ${INSTALL_TYPE}"
|
227 |
+
echo "Must be 1,2 or 3"
|
228 |
+
exit 1
|
229 |
+
;;
|
230 |
+
esac
|
231 |
+
|
232 |
+
saveConfig
|
233 |
+
echo "------------------"
|
234 |
+
cat $ENV_FILE
|
235 |
+
echo ""
|
236 |
+
echo "Please confirm the above information before proceeding."
|
237 |
+
read -p "Continue ? (y/n): " continue
|
238 |
+
if [[ $continue != "yes" && $continue != "y" ]]; then
|
239 |
+
echo "Exiting..."
|
240 |
+
exit
|
241 |
+
fi
|
242 |
+
|
243 |
+
downloadComposeFiles
|
244 |
+
|
245 |
+
echo "Configure jackett ..."
|
246 |
+
runDockerCompose up -d jackett
|
247 |
+
echo "Wait for jackett ..."
|
248 |
+
sleep 6
|
249 |
+
docker cp jackett:/config/Jackett/ServerConfig.json /tmp/ServerConfig.json > /dev/null
|
250 |
+
JACKETT_API_KEY=$(sed -n 's/.*"APIKey": "\(.*\)",/\1/p' /tmp/ServerConfig.json)
|
251 |
+
JACKETT_PASSWORD_HASH=$(sed -n 's/.*"AdminPassword": "\(.*\)",/\1/p' /tmp/ServerConfig.json)
|
252 |
+
|
253 |
+
if [ -z "$JACKETT_PASSWORD_HASH" ]; then
|
254 |
+
echo " - Configure jackett admin password ..."
|
255 |
+
createJackettPassword /tmp/ServerConfig.json
|
256 |
+
fi
|
257 |
+
|
258 |
+
echo " - Configure jackett flaresolverr url ..."
|
259 |
+
sedReplace 's/"FlareSolverrUrl": .*,/"FlareSolverrUrl": "http:\/\/flaresolverr:8191",/' /tmp/ServerConfig.json
|
260 |
+
docker cp /tmp/ServerConfig.json jackett:/config/Jackett/ServerConfig.json > /dev/null
|
261 |
+
rm -f /tmp/ServerConfig.json
|
262 |
+
runDockerCompose down
|
263 |
+
|
264 |
+
saveConfig
|
265 |
+
|
266 |
+
echo "Start all containers ..."
|
267 |
+
runDockerCompose up -d
|
268 |
+
|
269 |
+
|
270 |
+
echo "-----------------------"
|
271 |
+
|
272 |
+
|
273 |
+
echo -e "\n\033[0;32mInstallation complete! \033[0m\n"
|
274 |
+
case "$INSTALL_TYPE" in
|
275 |
+
"1")
|
276 |
+
echo " - Your addon is available on the following address: https://${ACME_DOMAIN}/configure"
|
277 |
+
;;
|
278 |
+
"2")
|
279 |
+
echo "Wait for Jackettio ..."
|
280 |
+
sleep 4
|
281 |
+
runDockerCompose logs -n 30 jackettio
|
282 |
+
;;
|
283 |
+
"3")
|
284 |
+
echo " - Your addon is available on the following address: http://localhost:4000/configure"
|
285 |
+
;;
|
286 |
+
esac
|
287 |
+
|
288 |
+
echo " - Your Jackett instance to configure your trackers is available on the following address: http://localhost:9117 or http://${ACME_DOMAIN:-your_public_ip}:9117"
|
289 |
+
echo " Be aware that having a lot of trackers may slow down search queries within the addon. We recommend utilizing trackers that do not have Cloudflare protection."
|
290 |
+
|
291 |
+
if [ ! -z "$JACKETT_PASSWORD" ]; then
|
292 |
+
echo -e "\n - \033[0;31mIMPORTANT:\033[0m The default password for Jackett is \"${JACKETT_PASSWORD}\", Please change it for security in jackett dashboard."
|
293 |
+
fi
|
294 |
+
|
295 |
+
echo "-----------------------"
|
docker-compose-local.yml
ADDED
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
version: "3.3"
|
2 |
+
|
3 |
+
services:
|
4 |
+
|
5 |
+
jackettio:
|
6 |
+
ports:
|
7 |
+
- 4000:4000
|
8 |
+
|
9 |
+
jackett:
|
10 |
+
ports:
|
11 |
+
- 9117:9117
|
docker-compose-traefik.yml
ADDED
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
version: "3.3"
|
2 |
+
|
3 |
+
services:
|
4 |
+
|
5 |
+
jackettio:
|
6 |
+
labels:
|
7 |
+
- "traefik.enable=true"
|
8 |
+
- "traefik.docker.network=jackettio_traefik"
|
9 |
+
- "traefik.http.routers.jackettio.entrypoints=web,websecure"
|
10 |
+
- "traefik.http.routers.jackettio.rule=Host(`${ACME_DOMAIN:-}`)"
|
11 |
+
- "traefik.http.routers.jackettio.tls=true"
|
12 |
+
- "traefik.http.routers.jackettio.tls.certresolver=letsencryptresolver"
|
13 |
+
networks:
|
14 |
+
- traefik
|
15 |
+
|
16 |
+
jackett:
|
17 |
+
ports:
|
18 |
+
- 9117:9117
|
19 |
+
|
20 |
+
traefik:
|
21 |
+
image: "traefik:v2.10"
|
22 |
+
container_name: "traefik"
|
23 |
+
command:
|
24 |
+
#- "--log.level=DEBUG"
|
25 |
+
- "--api.insecure=true"
|
26 |
+
- "--providers.docker=true"
|
27 |
+
- "--providers.docker.exposedbydefault=false"
|
28 |
+
- "--entrypoints.web.address=:80"
|
29 |
+
- "--entrypoints.websecure.address=:443"
|
30 |
+
- "--certificatesresolvers.letsencryptresolver.acme.httpchallenge=true"
|
31 |
+
- "--certificatesresolvers.letsencryptresolver.acme.httpchallenge.entrypoint=web"
|
32 |
+
- "--certificatesresolvers.letsencryptresolver.acme.email=${ACME_EMAIL:-}"
|
33 |
+
- "--certificatesresolvers.letsencryptresolver.acme.storage=/letsencrypt/acme.json"
|
34 |
+
ports:
|
35 |
+
- "80:80"
|
36 |
+
- "443:443"
|
37 |
+
volumes:
|
38 |
+
# To persist certificates
|
39 |
+
- letsencrypt:/letsencrypt
|
40 |
+
# So that Traefik can listen to the Docker events
|
41 |
+
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
42 |
+
networks:
|
43 |
+
- traefik
|
44 |
+
|
45 |
+
networks:
|
46 |
+
traefik:
|
47 |
+
|
48 |
+
volumes:
|
49 |
+
letsencrypt:
|
docker-compose-tunnel.yml
ADDED
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
version: "3.3"
|
2 |
+
|
3 |
+
services:
|
4 |
+
|
5 |
+
jackett:
|
6 |
+
ports:
|
7 |
+
- 9117:9117
|
docker-compose.yml
ADDED
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
version: "3.3"
|
2 |
+
name: jackettio
|
3 |
+
services:
|
4 |
+
|
5 |
+
flaresolverr:
|
6 |
+
image: ghcr.io/flaresolverr/flaresolverr:latest
|
7 |
+
container_name: flaresolverr
|
8 |
+
environment:
|
9 |
+
- LOG_LEVEL=${LOG_LEVEL:-info}
|
10 |
+
- LOG_HTML=${LOG_HTML:-false}
|
11 |
+
- CAPTCHA_SOLVER=${CAPTCHA_SOLVER:-none}
|
12 |
+
networks:
|
13 |
+
- jackettio
|
14 |
+
ports:
|
15 |
+
- "8191:8191" # FlareSolverr default port
|
16 |
+
restart: unless-stopped
|
17 |
+
|
18 |
+
jackett:
|
19 |
+
image: lscr.io/linuxserver/jackett:latest
|
20 |
+
container_name: jackett
|
21 |
+
environment:
|
22 |
+
- AUTO_UPDATE=true #optional
|
23 |
+
- RUN_OPTS= #optional
|
24 |
+
depends_on:
|
25 |
+
- flaresolverr
|
26 |
+
networks:
|
27 |
+
- jackettio
|
28 |
+
ports:
|
29 |
+
- "9117:9117" # Jackett default port
|
30 |
+
restart: unless-stopped
|
31 |
+
volumes:
|
32 |
+
- jackett-config:/config
|
33 |
+
- jackett-downloads:/downloads
|
34 |
+
|
35 |
+
jackettio:
|
36 |
+
build:
|
37 |
+
context: .
|
38 |
+
dockerfile: Dockerfile
|
39 |
+
container_name: jackettio
|
40 |
+
env_file:
|
41 |
+
- .env.production
|
42 |
+
environment:
|
43 |
+
- NODE_ENV=production
|
44 |
+
- DATA_FOLDER=/data
|
45 |
+
depends_on:
|
46 |
+
- jackett
|
47 |
+
networks:
|
48 |
+
- jackettio
|
49 |
+
ports:
|
50 |
+
- "4000:4000" # Jackettio port
|
51 |
+
restart: unless-stopped
|
52 |
+
volumes:
|
53 |
+
- ./:/app # Mount the current directory to /app in the container
|
54 |
+
- jackettio-data:/data
|
55 |
+
|
56 |
+
networks:
|
57 |
+
jackettio:
|
58 |
+
|
59 |
+
volumes:
|
60 |
+
jackett-config:
|
61 |
+
jackett-downloads:
|
62 |
+
jackettio-data:
|
package.json
ADDED
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "jackettio",
|
3 |
+
"version": "1.6.0",
|
4 |
+
"description": "Jackett and Debrid on Stremio",
|
5 |
+
"main": "src/index.js",
|
6 |
+
"type": "module",
|
7 |
+
"scripts": {
|
8 |
+
"start": "node src/index.js"
|
9 |
+
},
|
10 |
+
"keywords": [
|
11 |
+
"stremio",
|
12 |
+
"stremio-addons",
|
13 |
+
"addons"
|
14 |
+
],
|
15 |
+
"license": "MIT",
|
16 |
+
"dependencies": {
|
17 |
+
"cache-manager": "^4.1.0",
|
18 |
+
"cache-manager-sqlite": "^0.2.0",
|
19 |
+
"compression": "^1.7.4",
|
20 |
+
"express": "^4.18.2",
|
21 |
+
"express-rate-limit": "^7.2.0",
|
22 |
+
"localtunnel": "^2.0.2",
|
23 |
+
"p-limit": "^5.0.0",
|
24 |
+
"parse-torrent": "^11.0.16",
|
25 |
+
"showdown": "^2.1.0",
|
26 |
+
"sqlite3": "^5.1.1",
|
27 |
+
"xml2js": "^0.6.2"
|
28 |
+
}
|
29 |
+
}
|
src/index.js
ADDED
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import showdown from 'showdown';
|
2 |
+
import compression from 'compression';
|
3 |
+
import express from 'express';
|
4 |
+
import localtunnel from 'localtunnel';
|
5 |
+
import { rateLimit } from 'express-rate-limit';
|
6 |
+
import {readFileSync} from "fs";
|
7 |
+
import config from './lib/config.js';
|
8 |
+
import cache, {vacuum as vacuumCache, clean as cleanCache} from './lib/cache.js';
|
9 |
+
import path from 'path';
|
10 |
+
import * as meta from './lib/meta.js';
|
11 |
+
import * as icon from './lib/icon.js';
|
12 |
+
import * as debrid from './lib/debrid.js';
|
13 |
+
import {getIndexers} from './lib/jackett.js';
|
14 |
+
import * as jackettio from "./lib/jackettio.js";
|
15 |
+
import {cleanTorrentFolder, createTorrentFolder} from './lib/torrentInfos.js';
|
16 |
+
|
17 |
+
const converter = new showdown.Converter();
|
18 |
+
const welcomeMessageHtml = config.welcomeMessage ? `${converter.makeHtml(config.welcomeMessage)}<div class="my-4 border-top border-secondary-subtle"></div>` : '';
|
19 |
+
const addon = JSON.parse(readFileSync(`./package.json`));
|
20 |
+
const app = express();
|
21 |
+
|
22 |
+
const respond = (res, data) => {
|
23 |
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
24 |
+
res.setHeader('Access-Control-Allow-Headers', '*')
|
25 |
+
res.setHeader('Content-Type', 'application/json')
|
26 |
+
res.send(data)
|
27 |
+
};
|
28 |
+
|
29 |
+
const limiter = rateLimit({
|
30 |
+
windowMs: config.rateLimitWindow * 1000,
|
31 |
+
max: config.rateLimitRequest,
|
32 |
+
legacyHeaders: false,
|
33 |
+
standardHeaders: 'draft-7',
|
34 |
+
keyGenerator: (req) => req.clientIp || req.ip,
|
35 |
+
handler: (req, res, next, options) => {
|
36 |
+
if(req.route.path == '/:userConfig/stream/:type/:id.json'){
|
37 |
+
const resetInMs = new Date(req.rateLimit.resetTime) - new Date();
|
38 |
+
return res.json({streams: [{
|
39 |
+
name: `${config.addonName}`,
|
40 |
+
title: `🛑 Too many requests, please try in ${Math.ceil(resetInMs / 1000 / 60)} minute(s).`,
|
41 |
+
url: '#'
|
42 |
+
}]})
|
43 |
+
}else{
|
44 |
+
return res.status(options.statusCode).send(options.message);
|
45 |
+
}
|
46 |
+
}
|
47 |
+
});
|
48 |
+
|
49 |
+
app.set('trust proxy', config.trustProxy);
|
50 |
+
|
51 |
+
app.use((req, res, next) => {
|
52 |
+
req.clientIp = req.ip;
|
53 |
+
if(req.get('CF-Connecting-IP')){
|
54 |
+
req.clientIp = req.get('CF-Connecting-IP');
|
55 |
+
}
|
56 |
+
next();
|
57 |
+
});
|
58 |
+
|
59 |
+
app.use(compression());
|
60 |
+
app.use(express.static(path.join(import.meta.dirname, 'static'), {maxAge: 86400e3}));
|
61 |
+
|
62 |
+
app.get('/', (req, res) => {
|
63 |
+
res.redirect('/configure')
|
64 |
+
res.end();
|
65 |
+
});
|
66 |
+
|
67 |
+
app.get('/icon', async (req, res) => {
|
68 |
+
const filePath = await icon.getLocation();
|
69 |
+
res.contentType(path.basename(filePath));
|
70 |
+
res.setHeader('Cache-Control', `public, max-age=${3600}`);
|
71 |
+
return res.sendFile(filePath);
|
72 |
+
});
|
73 |
+
|
74 |
+
app.use((req, res, next) => {
|
75 |
+
console.log(`${req.method} ${req.path.replace(/\/eyJ[\w\=]+/g, '/*******************')}`);
|
76 |
+
next();
|
77 |
+
});
|
78 |
+
|
79 |
+
app.get('/:userConfig?/configure', async(req, res) => {
|
80 |
+
let indexers = (await getIndexers().catch(() => []))
|
81 |
+
.map(indexer => ({
|
82 |
+
value: indexer.id,
|
83 |
+
label: indexer.title,
|
84 |
+
types: ['movie', 'series'].filter(type => indexer.searching[type].available)
|
85 |
+
}));
|
86 |
+
const templateConfig = {
|
87 |
+
debrids: await debrid.list(),
|
88 |
+
addon: {
|
89 |
+
version: addon.version,
|
90 |
+
name: config.addonName
|
91 |
+
},
|
92 |
+
userConfig: req.params.userConfig || '',
|
93 |
+
defaultUserConfig: config.defaultUserConfig,
|
94 |
+
qualities: config.qualities,
|
95 |
+
languages: config.languages.map(l => ({value: l.value, label: l.label})).filter(v => v.value != 'multi'),
|
96 |
+
metaLanguages: await meta.getLanguages(),
|
97 |
+
sorts: config.sorts,
|
98 |
+
indexers,
|
99 |
+
passkey: {enabled: false},
|
100 |
+
immulatableUserConfigKeys: config.immulatableUserConfigKeys
|
101 |
+
};
|
102 |
+
if(config.replacePasskey){
|
103 |
+
templateConfig.passkey = {
|
104 |
+
enabled: true,
|
105 |
+
infoUrl: config.replacePasskeyInfoUrl,
|
106 |
+
pattern: config.replacePasskeyPattern
|
107 |
+
}
|
108 |
+
}
|
109 |
+
let template = readFileSync(`./src/template/configure.html`).toString()
|
110 |
+
.replace('/** import-config */', `const config = ${JSON.stringify(templateConfig, null, 2)}`)
|
111 |
+
.replace('<!-- welcome-message -->', welcomeMessageHtml);
|
112 |
+
return res.send(template);
|
113 |
+
});
|
114 |
+
|
115 |
+
// https://github.com/Stremio/stremio-addon-sdk/blob/master/docs/advanced.md#using-user-data-in-addons
|
116 |
+
app.get("/:userConfig?/manifest.json", async(req, res) => {
|
117 |
+
const manifest = {
|
118 |
+
id: config.addonId,
|
119 |
+
version: addon.version,
|
120 |
+
name: config.addonName,
|
121 |
+
description: config.addonDescription,
|
122 |
+
icon: `${req.hostname == 'localhost' ? 'http' : 'https'}://${req.hostname}/icon`,
|
123 |
+
resources: ["stream"],
|
124 |
+
types: ["movie", "series"],
|
125 |
+
idPrefixes: ["tt","tmdb"],
|
126 |
+
catalogs: [],
|
127 |
+
behaviorHints: {configurable: true}
|
128 |
+
};
|
129 |
+
if(req.params.userConfig){
|
130 |
+
const userConfig = JSON.parse(atob(req.params.userConfig));
|
131 |
+
const debridInstance = debrid.instance(userConfig);
|
132 |
+
manifest.name += ` ${debridInstance.shortName}`;
|
133 |
+
}
|
134 |
+
respond(res, manifest);
|
135 |
+
});
|
136 |
+
|
137 |
+
app.get("/:userConfig/stream/:type/:id.json", limiter, async(req, res) => {
|
138 |
+
|
139 |
+
try {
|
140 |
+
|
141 |
+
const streams = await jackettio.getStreams(
|
142 |
+
Object.assign(JSON.parse(atob(req.params.userConfig)), {ip: req.clientIp}),
|
143 |
+
req.params.type,
|
144 |
+
req.params.id,
|
145 |
+
`${req.hostname == 'localhost' ? 'http' : 'https'}://${req.hostname}`
|
146 |
+
);
|
147 |
+
|
148 |
+
return respond(res, {streams});
|
149 |
+
|
150 |
+
}catch(err){
|
151 |
+
|
152 |
+
console.log(req.params.id, err);
|
153 |
+
return respond(res, {streams: []});
|
154 |
+
|
155 |
+
}
|
156 |
+
|
157 |
+
});
|
158 |
+
|
159 |
+
app.get("/stream/:type/:id.json", async(req, res) => {
|
160 |
+
|
161 |
+
return respond(res, {streams: [{
|
162 |
+
name: config.addonName,
|
163 |
+
title: `ℹ Kindly configure this addon to access streams.`,
|
164 |
+
url: '#'
|
165 |
+
}]});
|
166 |
+
|
167 |
+
});
|
168 |
+
|
169 |
+
app.use('/:userConfig/download/:type/:id/:torrentId', async(req, res, next) => {
|
170 |
+
|
171 |
+
if (req.method !== 'GET' && req.method !== 'HEAD'){
|
172 |
+
return next();
|
173 |
+
}
|
174 |
+
|
175 |
+
try {
|
176 |
+
|
177 |
+
const url = await jackettio.getDownload(
|
178 |
+
Object.assign(JSON.parse(atob(req.params.userConfig)), {ip: req.clientIp}),
|
179 |
+
req.params.type,
|
180 |
+
req.params.id,
|
181 |
+
req.params.torrentId
|
182 |
+
);
|
183 |
+
|
184 |
+
const parsed = new URL(url);
|
185 |
+
const cut = (value) => value ? `${value.substr(0, 5)}******${value.substr(-5)}` : '';
|
186 |
+
console.log(`${req.params.id} : Redirect: ${parsed.protocol}//${parsed.host}${cut(parsed.pathname)}${cut(parsed.search)}`);
|
187 |
+
|
188 |
+
res.status(302);
|
189 |
+
res.set('location', url);
|
190 |
+
res.send('');
|
191 |
+
|
192 |
+
}catch(err){
|
193 |
+
|
194 |
+
console.log(req.params.id, err);
|
195 |
+
|
196 |
+
switch(err.message){
|
197 |
+
case debrid.ERROR.NOT_READY:
|
198 |
+
res.status(302);
|
199 |
+
res.set('location', `/videos/not_ready.mp4`);
|
200 |
+
res.send('');
|
201 |
+
break;
|
202 |
+
case debrid.ERROR.EXPIRED_API_KEY:
|
203 |
+
res.status(302);
|
204 |
+
res.set('location', `/videos/expired_api_key.mp4`);
|
205 |
+
res.send('');
|
206 |
+
break;
|
207 |
+
case debrid.ERROR.NOT_PREMIUM:
|
208 |
+
res.status(302);
|
209 |
+
res.set('location', `/videos/not_premium.mp4`);
|
210 |
+
res.send('');
|
211 |
+
break;
|
212 |
+
case debrid.ERROR.ACCESS_DENIED:
|
213 |
+
res.status(302);
|
214 |
+
res.set('location', `/videos/access_denied.mp4`);
|
215 |
+
res.send('');
|
216 |
+
break;
|
217 |
+
case debrid.ERROR.TWO_FACTOR_AUTH:
|
218 |
+
res.status(302);
|
219 |
+
res.set('location', `/videos/two_factor_auth.mp4`);
|
220 |
+
res.send('');
|
221 |
+
break;
|
222 |
+
default:
|
223 |
+
res.status(302);
|
224 |
+
res.set('location', `/videos/error.mp4`);
|
225 |
+
res.send('');
|
226 |
+
}
|
227 |
+
|
228 |
+
}
|
229 |
+
|
230 |
+
});
|
231 |
+
|
232 |
+
app.use((req, res) => {
|
233 |
+
if (req.xhr) {
|
234 |
+
res.status(404).send({ error: 'Page not found!' })
|
235 |
+
} else {
|
236 |
+
res.status(404).send('Page not found!');
|
237 |
+
}
|
238 |
+
});
|
239 |
+
|
240 |
+
app.use((err, req, res, next) => {
|
241 |
+
console.error(err.stack)
|
242 |
+
if (req.xhr) {
|
243 |
+
res.status(500).send({ error: 'Something broke!' })
|
244 |
+
} else {
|
245 |
+
res.status(500).send('Something broke!');
|
246 |
+
}
|
247 |
+
})
|
248 |
+
|
249 |
+
const server = app.listen(config.port, async () => {
|
250 |
+
|
251 |
+
console.log('───────────────────────────────────────');
|
252 |
+
console.log(`Started addon ${addon.name} v${addon.version}`);
|
253 |
+
console.log(`Server listen at: http://localhost:${config.port}`);
|
254 |
+
console.log('───────────────────────────────────────');
|
255 |
+
|
256 |
+
let tunnel;
|
257 |
+
if(config.localtunnel){
|
258 |
+
let subdomain = await cache.get('localtunnel:subdomain');
|
259 |
+
tunnel = await localtunnel({port: config.port, subdomain});
|
260 |
+
await cache.set('localtunnel:subdomain', tunnel.clientId, {ttl: 86400*365});
|
261 |
+
console.log(`Your addon is available on the following address: ${tunnel.url}/configure`);
|
262 |
+
tunnel.on('close', () => console.log("tunnels are closed"));
|
263 |
+
}
|
264 |
+
|
265 |
+
icon.download().catch(err => console.log(`Failed to download icon: ${err}`));
|
266 |
+
|
267 |
+
const intervals = [];
|
268 |
+
createTorrentFolder();
|
269 |
+
intervals.push(setInterval(cleanTorrentFolder, 3600e3));
|
270 |
+
|
271 |
+
vacuumCache().catch(err => console.log(`Failed to vacuum cache: ${err}`));
|
272 |
+
intervals.push(setInterval(() => vacuumCache(), 86400e3*7));
|
273 |
+
|
274 |
+
cleanCache().catch(err => console.log(`Failed to clean cache: ${err}`));
|
275 |
+
intervals.push(setInterval(() => cleanCache(), 3600e3));
|
276 |
+
|
277 |
+
function closeGracefully(signal) {
|
278 |
+
console.log(`Received signal to terminate: ${signal}`);
|
279 |
+
if(tunnel)tunnel.close();
|
280 |
+
intervals.forEach(interval => clearInterval(interval));
|
281 |
+
server.close(() => {
|
282 |
+
console.log('Server closed');
|
283 |
+
process.kill(process.pid, signal);
|
284 |
+
});
|
285 |
+
}
|
286 |
+
process.once('SIGINT', closeGracefully);
|
287 |
+
process.once('SIGTERM', closeGracefully);
|
288 |
+
|
289 |
+
});
|
src/lib/cache.js
ADDED
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import sqlite3 from 'sqlite3';
|
2 |
+
import sqliteStore from 'cache-manager-sqlite';
|
3 |
+
import cacheManager from 'cache-manager';
|
4 |
+
import config from './config.js';
|
5 |
+
import {wait} from './util.js';
|
6 |
+
|
7 |
+
const db = new sqlite3.Database(`${config.dataFolder}/cache.db`);
|
8 |
+
|
9 |
+
const cache = await cacheManager.caching({
|
10 |
+
store: sqliteStore,
|
11 |
+
path: `${config.dataFolder}/cache.db`,
|
12 |
+
options: { ttl: 86400 }
|
13 |
+
});
|
14 |
+
|
15 |
+
export default cache;
|
16 |
+
|
17 |
+
export async function clean(){
|
18 |
+
// https://github.com/maxpert/node-cache-manager-sqlite/blob/36a1fe44a30b6af8d8c323c59e09fe81bde539d9/index.js#L146
|
19 |
+
// The cache will grow until an expired key is requested
|
20 |
+
// This hack should force node-cache-manager-sqlite to purge
|
21 |
+
await cache.set('_clean', 'todo', {ttl: 1});
|
22 |
+
await wait(3e3);
|
23 |
+
await cache.get('_clean');
|
24 |
+
}
|
25 |
+
|
26 |
+
export async function vacuum(){
|
27 |
+
return new Promise((resolve, reject) => {
|
28 |
+
db.serialize(() => {
|
29 |
+
db.run('VACUUM', err => {
|
30 |
+
if(err)return reject(err);
|
31 |
+
resolve();
|
32 |
+
})
|
33 |
+
});
|
34 |
+
});
|
35 |
+
}
|
src/lib/config.js
ADDED
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export default {
|
2 |
+
// Server port
|
3 |
+
port: parseInt(process.env.PORT || 4000),
|
4 |
+
// https://expressjs.com/en/guide/behind-proxies.html
|
5 |
+
trustProxy: boolOrString(process.env.TRUST_PROXY || 'loopback, linklocal, uniquelocal'),
|
6 |
+
// Jacket instance url
|
7 |
+
jackettUrl: process.env.JACKETT_URL || 'http://localhost:9117',
|
8 |
+
// Jacket API key
|
9 |
+
jackettApiKey: process.env.JACKETT_API_KEY || '',
|
10 |
+
// The Movie Database Access Token. Configure to use TMDB rather than cinemeta.
|
11 |
+
tmdbAccessToken: process.env.TMDB_ACCESS_TOKEN || '96ca5e1179f107ab7af156b0a3ae9ca5',
|
12 |
+
// Data folder for cache database, torrent files ... Must be persistent in production
|
13 |
+
dataFolder: process.env.DATA_FOLDER || '/tmp',
|
14 |
+
// Enable localtunnel feature
|
15 |
+
localtunnel: (process.env.LOCALTUNNEL || 'false') === 'true',
|
16 |
+
// Addon ID
|
17 |
+
addonId: process.env.ADDON_ID || 'community.stremio.jackettio',
|
18 |
+
// Addon Name
|
19 |
+
addonName: process.env.ADDON_NAME || 'TMDb UFC Jackettio',
|
20 |
+
// Addon Description
|
21 |
+
addonDescription: process.env.ADDON_DESCRIPTION || 'Stremio addon that resolve streams using Jackett and Debrid. It seamlessly integrates with private trackers.',
|
22 |
+
// Addon Icon
|
23 |
+
addonIcon: process.env.ADDON_ICON || 'https://avatars.githubusercontent.com/u/15383019?s=48&v=4',
|
24 |
+
// When hosting a public instance with a private tracker, you must configure this setting to:
|
25 |
+
// - Request the user's passkey on the /configure page.
|
26 |
+
// - Replace your passkey "REPLACE_PASSKEY" with theirs when sending uncached torrents to the debrid.
|
27 |
+
// If you do not configure this setting with private tracker, your passkey could be exposed to users who add uncached torrents.
|
28 |
+
replacePasskey: process.env.REPLACE_PASSKEY || '',
|
29 |
+
// The URL where the user can locate their passkey (typically the tracker URL).
|
30 |
+
replacePasskeyInfoUrl: process.env.REPLACE_PASSKEY_INFO_URL || '',
|
31 |
+
// The passkey pattern
|
32 |
+
replacePasskeyPattern: process.env.REPLACE_PASSKEY_PATTERN || '[a-zA-Z0-9]+',
|
33 |
+
// List of config keys that user can't configure
|
34 |
+
immulatableUserConfigKeys: commaListToArray(process.env.IMMULATABLE_USER_CONFIG_KEYS || 'hideUncached'),
|
35 |
+
// Welcome message in /configure page. Markdown format
|
36 |
+
welcomeMessage: process.env.WELCOME_MESSAGE || '',
|
37 |
+
// Trust the cf-connecting-ip header
|
38 |
+
trustCfIpHeader: (process.env.TRUST_CF_IP_HEADER || 'false') === 'true',
|
39 |
+
// Rate limit interval in seconds to resolve stream
|
40 |
+
rateLimitWindow: parseInt(process.env.RATE_LIMIT_WINDOW || 60 * 60),
|
41 |
+
// Rate limit the number of requests to resolve stream
|
42 |
+
rateLimitRequest: parseInt(process.env.RATE_LIMIT_REQUEST || 150),
|
43 |
+
// Time (in seconds) needed to identify an indexer as slow
|
44 |
+
slowIndexerDuration: parseInt(process.env.SLOW_INDEXER_DURATION || 20) * 1000,
|
45 |
+
// Time window (in seconds) to monitor and count slow indexer requests (only requests within this period are considered)
|
46 |
+
slowIndexerWindow: parseInt(process.env.SLOW_INDEXER_WINDOW || 1800) * 1000,
|
47 |
+
// Number of consecutive slow requests within the time window to disable the indexer
|
48 |
+
slowIndexerRequest: parseInt(process.env.SLOW_INDEXER_REQUEST || 5),
|
49 |
+
|
50 |
+
defaultUserConfig: {
|
51 |
+
qualities: commaListToArray(process.env.DEFAULT_QUALITIES || '0, 720, 1080, 2160').map(v => parseInt(v)),
|
52 |
+
excludeKeywords: commaListToArray(process.env.DEFAULT_EXCLUDE_KEYWORDS || ''),
|
53 |
+
maxTorrents: parseInt(process.env.DEFAULT_MAX_TORRENTS || 15),
|
54 |
+
priotizeLanguages: commaListToArray(process.env.DEFAULT_PRIOTIZE_LANGUAGES || ''),
|
55 |
+
priotizePackTorrents: parseInt(process.env.DEFAULT_PRIOTIZE_PACK_TORRENTS || 2),
|
56 |
+
forceCacheNextEpisode: (process.env.DEFAULT_FORCE_CACHE_NEXT_EPISODE || 'false') === 'true',
|
57 |
+
sortCached: sortCommaListToArray(process.env.DEFAULT_SORT_CACHED || 'quality:true, size:true'),
|
58 |
+
sortUncached: sortCommaListToArray(process.env.DEFAULT_SORT_UNCACHED || 'seeders:true'),
|
59 |
+
hideUncached: true, // Force hideUncached to always be true
|
60 |
+
indexers: commaListToArray(process.env.DEFAULT_INDEXERS || 'all'),
|
61 |
+
indexerTimeoutSec: parseInt(process.env.DEFAULT_INDEXER_TIMEOUT_SEC || '60'),
|
62 |
+
passkey: '',
|
63 |
+
// If not defined, the original title is used for search. If defined, the title in the given language is used for search
|
64 |
+
// format: ISO 639-1, example: en
|
65 |
+
metaLanguage: process.env.DEFAULT_META_LANGUAGE || '',
|
66 |
+
enableMediaFlow: (process.env.DEFAULT_ENABLE_MEDIA_FLOW || 'false') === 'true',
|
67 |
+
mediaflowProxyUrl: process.env.DEFAULT_MEDIA_FLOW_PROXY_URL || '',
|
68 |
+
mediaflowApiPassword: process.env.DEFAULT_MEDIA_FLOW_API_PASSWORD || '',
|
69 |
+
mediaflowPublicIp: process.env.DEFAULT_MEDIA_FLOW_PUBLIC_IP || ''
|
70 |
+
},
|
71 |
+
|
72 |
+
qualities: [
|
73 |
+
{value: 0, label: 'Unknown'},
|
74 |
+
{value: 360, label: '360p'},
|
75 |
+
{value: 480, label: '480p'},
|
76 |
+
{value: 720, label: '720p'},
|
77 |
+
{value: 1080, label: '1080p'},
|
78 |
+
{value: 2160, label: '4K'}
|
79 |
+
],
|
80 |
+
sorts: [
|
81 |
+
{value: [['quality', true], ['seeders', true]], label: 'By quality then seeders'},
|
82 |
+
{value: [['quality', true], ['size', true]], label: 'By quality then size'},
|
83 |
+
{value: [['seeders', true]], label: 'By seeders'},
|
84 |
+
{value: [['quality', true]], label: 'By quality'},
|
85 |
+
{value: [['size', true]], label: 'By size'}
|
86 |
+
],
|
87 |
+
languages: [
|
88 |
+
{value: 'multi', emoji: '🌎', iso639: '', pattern: 'multi'},
|
89 |
+
{value: 'arabic', emoji: '🇦🇪', iso639: 'ar', pattern: 'arabic'},
|
90 |
+
{value: 'chinese', emoji: '🇨🇳', iso639: 'zh', pattern: 'chinese'},
|
91 |
+
{value: 'german', emoji: '🇩🇪', iso639: 'de', pattern: 'german'},
|
92 |
+
{value: 'english', emoji: '🇺🇸', iso639: 'en', pattern: '(eng(lish)?)'},
|
93 |
+
{value: 'spanish', emoji: '🇪🇸', iso639: 'es', pattern: 'spa(nish)?'},
|
94 |
+
{value: 'french', emoji: '🇫🇷', iso639: 'fr', pattern: 'fre(nch)?'},
|
95 |
+
{value: 'dutch', emoji: '🇳🇱', iso639: 'nl', pattern: 'dutch'},
|
96 |
+
{value: 'italian', emoji: '🇮🇹', iso639: 'it', pattern: 'ita(lian)?'},
|
97 |
+
{value: 'lithuanian', emoji: '🇱🇹', iso639: 'lt', pattern: 'lithuanian'},
|
98 |
+
{value: 'korean', emoji: '🇰🇷', iso639: 'ko', pattern: 'korean'},
|
99 |
+
{value: 'portuguese', emoji: '🇵🇹', iso639: 'pt', pattern: 'portuguese'},
|
100 |
+
{value: 'russian', emoji: '🇷🇺', iso639: 'ru', pattern: 'rus(sian)?'},
|
101 |
+
{value: 'swedish', emoji: '🇸🇪', iso639: 'sv', pattern: 'swedish'},
|
102 |
+
{value: 'tamil', emoji: '🇮🇳', iso639: 'ta', pattern: 'tamil'},
|
103 |
+
{value: 'turkish', emoji: '🇹🇷', iso639: 'tr', pattern: 'turkish'}
|
104 |
+
].map(lang => {
|
105 |
+
lang.label = `${lang.emoji} ${lang.value.charAt(0).toUpperCase() + lang.value.slice(1)}`;
|
106 |
+
lang.pattern = new RegExp(` ${lang.pattern} `, 'i');
|
107 |
+
return lang;
|
108 |
+
})
|
109 |
+
}
|
110 |
+
|
111 |
+
function commaListToArray(str){
|
112 |
+
return str.split(',').map(str => str.trim()).filter(Boolean);
|
113 |
+
}
|
114 |
+
|
115 |
+
function sortCommaListToArray(str){
|
116 |
+
return commaListToArray(str).map(sort => {
|
117 |
+
const [key, reverse] = sort.split(':');
|
118 |
+
return [key.trim(), reverse.trim() == 'true'];
|
119 |
+
});
|
120 |
+
}
|
121 |
+
|
122 |
+
function boolOrString(str){
|
123 |
+
if(str.trim().toLowerCase() == 'true'){
|
124 |
+
return true;
|
125 |
+
}else if(str.trim().toLowerCase() == 'false'){
|
126 |
+
return false;
|
127 |
+
}else{
|
128 |
+
return str.trim();
|
129 |
+
}
|
130 |
+
}
|
src/lib/debrid.js
ADDED
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import debridlink from "./debrid/debridlink.js";
|
2 |
+
import alldebrid from "./debrid/alldebrid.js";
|
3 |
+
import realdebrid from './debrid/realdebrid.js';
|
4 |
+
export {ERROR} from './debrid/const.js';
|
5 |
+
import premiumize from "./debrid/premiumize.js";
|
6 |
+
const debrid = {debridlink, alldebrid, realdebrid, premiumize};
|
7 |
+
|
8 |
+
export function instance(userConfig){
|
9 |
+
|
10 |
+
if(!debrid[userConfig.debridId]){
|
11 |
+
throw new Error(`Debrid service "${userConfig.debridId} not exists`);
|
12 |
+
}
|
13 |
+
|
14 |
+
return new debrid[userConfig.debridId](userConfig);
|
15 |
+
}
|
16 |
+
|
17 |
+
export async function list(){
|
18 |
+
const values = [];
|
19 |
+
for(const instance of Object.values(debrid)){
|
20 |
+
values.push({
|
21 |
+
id: instance.id,
|
22 |
+
name: instance.name,
|
23 |
+
shortName: instance.shortName,
|
24 |
+
configFields: instance.configFields
|
25 |
+
})
|
26 |
+
}
|
27 |
+
return values;
|
28 |
+
}
|
src/lib/debrid/alldebrid.js
ADDED
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {createHash} from 'crypto';
|
2 |
+
import {ERROR} from './const.js';
|
3 |
+
import {wait} from '../util.js';
|
4 |
+
|
5 |
+
export default class AllDebrid {
|
6 |
+
|
7 |
+
static id = 'alldebrid';
|
8 |
+
static name = 'AllDebrid';
|
9 |
+
static shortName = 'AD';
|
10 |
+
static configFields = [
|
11 |
+
{
|
12 |
+
type: 'text',
|
13 |
+
name: 'debridApiKey',
|
14 |
+
label: `AllDebrid API Key`,
|
15 |
+
required: true,
|
16 |
+
href: {value: 'https://alldebrid.com/apikeys', label:'Get API Key Here'}
|
17 |
+
}
|
18 |
+
];
|
19 |
+
|
20 |
+
#apiKey;
|
21 |
+
#ip;
|
22 |
+
|
23 |
+
constructor(userConfig) {
|
24 |
+
Object.assign(this, this.constructor);
|
25 |
+
this.#apiKey = userConfig.debridApiKey;
|
26 |
+
this.#ip = userConfig.ip || '';
|
27 |
+
}
|
28 |
+
|
29 |
+
async getTorrentsCached(torrents){
|
30 |
+
const hashList = torrents.map(torrent => torrent.infos.infoHash).filter(Boolean);
|
31 |
+
const body = new FormData();
|
32 |
+
hashList.forEach(hash => body.append('magnets[]', hash));
|
33 |
+
const res = await this.#request('POST', '/magnet/instant', {body});
|
34 |
+
return torrents.filter(torrent => res.data.magnets.find(magnet => magnet.hash == torrent.infos.infoHash && magnet.instant));
|
35 |
+
}
|
36 |
+
|
37 |
+
async getProgressTorrents(torrents){
|
38 |
+
const res = await this.#request('GET', '/magnet/status');
|
39 |
+
return res.data.magnets.reduce((progress, magnet) => {
|
40 |
+
progress[magnet.hash] = {
|
41 |
+
percent: magnet.processingPerc || 0,
|
42 |
+
speed: magnet.downloadSpeed || 0
|
43 |
+
}
|
44 |
+
return progress;
|
45 |
+
}, {});
|
46 |
+
}
|
47 |
+
|
48 |
+
async getFilesFromHash(infoHash){
|
49 |
+
return this.getFilesFromMagnet(infoHash, infoHash);
|
50 |
+
}
|
51 |
+
|
52 |
+
async getFilesFromMagnet(url, infoHash){
|
53 |
+
const body = new FormData();
|
54 |
+
body.append('magnets[]', url);
|
55 |
+
const res = await this.#request('POST', `/magnet/upload`, {body});
|
56 |
+
const magnet = res.data.magnets[0] || res.data.magnets;
|
57 |
+
return this.#getFilesFromTorrent(magnet.id);
|
58 |
+
}
|
59 |
+
|
60 |
+
async getFilesFromBuffer(buffer, infoHash){
|
61 |
+
const body = new FormData();
|
62 |
+
body.append('files[0]', new Blob([buffer]), 'file.torrent');
|
63 |
+
const res = await this.#request('POST', `/magnet/upload/file`, {body});
|
64 |
+
const file = res.data.files[0] || res.data.files;
|
65 |
+
return this.#getFilesFromTorrent(file.id);
|
66 |
+
}
|
67 |
+
|
68 |
+
async getDownload(file){
|
69 |
+
const query = {link: file.url};
|
70 |
+
const res = await this.#request('GET', '/link/unlock', {query});
|
71 |
+
return res.data.link;
|
72 |
+
}
|
73 |
+
|
74 |
+
async getUserHash(){
|
75 |
+
return createHash('md5').update(this.#apiKey).digest('hex');
|
76 |
+
}
|
77 |
+
|
78 |
+
async #getFilesFromTorrent(id){
|
79 |
+
|
80 |
+
const query = {id};
|
81 |
+
let torrent = (await this.#request('GET', '/magnet/status', {query})).data.magnets;
|
82 |
+
|
83 |
+
if(torrent.status != 'Ready'){
|
84 |
+
throw new Error(ERROR.NOT_READY);
|
85 |
+
}
|
86 |
+
|
87 |
+
return torrent.links.map((file, index) => {
|
88 |
+
return {
|
89 |
+
name: file.filename,
|
90 |
+
size: file.size,
|
91 |
+
id: `${torrent.id}:${index}`,
|
92 |
+
url: file.link,
|
93 |
+
ready: true
|
94 |
+
};
|
95 |
+
});
|
96 |
+
|
97 |
+
}
|
98 |
+
|
99 |
+
async #request(method, path, opts){
|
100 |
+
|
101 |
+
opts = opts || {};
|
102 |
+
opts = Object.assign(opts, {
|
103 |
+
method,
|
104 |
+
headers: Object.assign({
|
105 |
+
'user-agent': 'jackettio',
|
106 |
+
'accept': 'application/json',
|
107 |
+
'authorization': `Bearer ${this.#apiKey}`
|
108 |
+
}, opts.headers || {}),
|
109 |
+
query: Object.assign({
|
110 |
+
'agent': 'jackettio',
|
111 |
+
'ip': this.#ip
|
112 |
+
}, opts.query || {})
|
113 |
+
});
|
114 |
+
|
115 |
+
const url = `https://api.alldebrid.com/v4${path}?${new URLSearchParams(opts.query).toString()}`;
|
116 |
+
const res = await fetch(url, opts);
|
117 |
+
const data = await res.json();
|
118 |
+
|
119 |
+
if(data.status != 'success'){
|
120 |
+
console.log(data);
|
121 |
+
switch(data.error.code || ''){
|
122 |
+
case 'AUTH_BAD_APIKEY':
|
123 |
+
case 'AUTH_MISSING_APIKEY':
|
124 |
+
throw new Error(ERROR.EXPIRED_API_KEY);
|
125 |
+
case 'AUTH_BLOCKED':
|
126 |
+
throw new Error(ERROR.TWO_FACTOR_AUTH);
|
127 |
+
case 'MAGNET_MUST_BE_PREMIUM':
|
128 |
+
case 'FREE_TRIAL_LIMIT_REACHED':
|
129 |
+
case 'MUST_BE_PREMIUM':
|
130 |
+
throw new Error(ERROR.NOT_PREMIUM);
|
131 |
+
default:
|
132 |
+
throw new Error(`Invalid AD api result: ${JSON.stringify(data)}`);
|
133 |
+
}
|
134 |
+
}
|
135 |
+
|
136 |
+
return data;
|
137 |
+
|
138 |
+
}
|
139 |
+
|
140 |
+
}
|
src/lib/debrid/const.js
ADDED
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export const ERROR = {
|
2 |
+
NOT_READY: 'File not ready on debrid',
|
3 |
+
NOT_PREMIUM: 'You must be premium on debrid',
|
4 |
+
EXPIRED_API_KEY: 'Api key expired',
|
5 |
+
ACCESS_DENIED: 'Access denied',
|
6 |
+
TWO_FACTOR_AUTH: 'Two-Factor authentication needed'
|
7 |
+
};
|
src/lib/debrid/debridlink.js
ADDED
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {createHash} from 'crypto';
|
2 |
+
import {ERROR} from './const.js';
|
3 |
+
import {wait} from '../util.js';
|
4 |
+
|
5 |
+
export default class DebridLink {
|
6 |
+
|
7 |
+
static id = 'debridlink';
|
8 |
+
static name = 'Debrid-Link';
|
9 |
+
static shortName = 'DL';
|
10 |
+
static configFields = [
|
11 |
+
{
|
12 |
+
type: 'text',
|
13 |
+
name: 'debridApiKey',
|
14 |
+
label: `Debrid-Link API Key`,
|
15 |
+
required: true,
|
16 |
+
href: {value: 'https://debrid-link.com/webapp/apikey', label:'Get API Key Here'}
|
17 |
+
}
|
18 |
+
];
|
19 |
+
|
20 |
+
#apiKey;
|
21 |
+
#ip;
|
22 |
+
|
23 |
+
constructor(userConfig) {
|
24 |
+
Object.assign(this, this.constructor);
|
25 |
+
this.#apiKey = userConfig.debridApiKey;
|
26 |
+
this.#ip = userConfig.ip || '';
|
27 |
+
}
|
28 |
+
|
29 |
+
async getTorrentsCached(torrents){
|
30 |
+
const hashList = torrents.map(torrent => torrent.infos.infoHash).filter(Boolean);
|
31 |
+
const query = {url: hashList.join(',')};
|
32 |
+
const res = await this.#request('GET', '/seedbox/cached', {query});
|
33 |
+
return torrents.filter(torrent => res.value[torrent.infos.infoHash]);
|
34 |
+
}
|
35 |
+
|
36 |
+
async getProgressTorrents(torrents){
|
37 |
+
const res = await this.#request('GET', '/seedbox/list');
|
38 |
+
return res.value.reduce((progress, torrent) => {
|
39 |
+
progress[torrent.hashString] = {
|
40 |
+
percent: torrent.downloadPercent || 0,
|
41 |
+
speed: torrent.downloadSpeed || 0
|
42 |
+
}
|
43 |
+
return progress;
|
44 |
+
}, {});
|
45 |
+
}
|
46 |
+
|
47 |
+
async getFilesFromHash(infoHash){
|
48 |
+
return this.getFilesFromMagnet(infoHash, infoHash);
|
49 |
+
}
|
50 |
+
|
51 |
+
async getFilesFromMagnet(url, infoHash){
|
52 |
+
const body = {url, async: true};
|
53 |
+
const res = await this.#request('POST', `/seedbox/add`, {body});
|
54 |
+
return this.#getFilesFromTorrent(res.value);
|
55 |
+
}
|
56 |
+
|
57 |
+
async getFilesFromBuffer(buffer, infoHash){
|
58 |
+
const body = new FormData();
|
59 |
+
body.append('file', new Blob([buffer]), 'file.torrent');
|
60 |
+
const res = await this.#request('POST', `/seedbox/add`, {body});
|
61 |
+
return this.#getFilesFromTorrent(res.value);
|
62 |
+
}
|
63 |
+
|
64 |
+
async getDownload(file){
|
65 |
+
|
66 |
+
if(!file.ready){
|
67 |
+
throw new Error(ERROR.NOT_READY);
|
68 |
+
}
|
69 |
+
|
70 |
+
return file.url;
|
71 |
+
|
72 |
+
}
|
73 |
+
|
74 |
+
async getUserHash(){
|
75 |
+
return createHash('md5').update(this.#apiKey).digest('hex');
|
76 |
+
}
|
77 |
+
|
78 |
+
async #getFilesFromTorrent(torrent){
|
79 |
+
|
80 |
+
if(!torrent.files.length){
|
81 |
+
throw new Error(ERROR.NOT_READY);
|
82 |
+
}
|
83 |
+
|
84 |
+
return torrent.files.map((file, index) => {
|
85 |
+
return {
|
86 |
+
name: file.name,
|
87 |
+
size: file.size,
|
88 |
+
id: `${torrent.id}:${index}`,
|
89 |
+
url: file.downloadUrl,
|
90 |
+
ready: file.downloadPercent === 100
|
91 |
+
};
|
92 |
+
});
|
93 |
+
|
94 |
+
}
|
95 |
+
|
96 |
+
async #request(method, path, opts){
|
97 |
+
|
98 |
+
opts = opts || {};
|
99 |
+
opts = Object.assign(opts, {
|
100 |
+
method,
|
101 |
+
headers: Object.assign(opts.headers || {}, {
|
102 |
+
'user-agent': 'Stremio',
|
103 |
+
'accept': 'application/json',
|
104 |
+
'authorization': `Bearer ${this.#apiKey}`
|
105 |
+
}),
|
106 |
+
query: Object.assign({ip: this.#ip}, opts.query || {})
|
107 |
+
});
|
108 |
+
|
109 |
+
if(method == 'POST'){
|
110 |
+
if(opts.body instanceof FormData){
|
111 |
+
opts.body.append('ip', this.#ip);
|
112 |
+
}else{
|
113 |
+
opts.body = JSON.stringify(Object.assign({ip: this.#ip}, opts.body || {}));
|
114 |
+
opts.headers['content-type'] = 'application/json';
|
115 |
+
}
|
116 |
+
}
|
117 |
+
|
118 |
+
const url = `https://debrid-link.com/api/v2${path}?${new URLSearchParams(opts.query).toString()}`;
|
119 |
+
const res = await fetch(url, opts);
|
120 |
+
const data = await res.json();
|
121 |
+
|
122 |
+
if(!data.success){
|
123 |
+
console.log(data);
|
124 |
+
switch(data.error || ''){
|
125 |
+
case 'badToken':
|
126 |
+
throw new Error(ERROR.EXPIRED_API_KEY);
|
127 |
+
case 'maxLink':
|
128 |
+
case 'maxLinkHost':
|
129 |
+
case 'maxData':
|
130 |
+
case 'maxDataHost':
|
131 |
+
case 'maxTorrent':
|
132 |
+
case 'torrentTooBig':
|
133 |
+
case 'freeServerOverload':
|
134 |
+
throw new Error(ERROR.NOT_PREMIUM);
|
135 |
+
default:
|
136 |
+
throw new Error(`Invalid DL api result: ${JSON.stringify(data)}`);
|
137 |
+
}
|
138 |
+
}
|
139 |
+
|
140 |
+
return data;
|
141 |
+
|
142 |
+
}
|
143 |
+
|
144 |
+
}
|
src/lib/debrid/premiumize.js
ADDED
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {createHash} from 'crypto';
|
2 |
+
import {ERROR} from './const.js';
|
3 |
+
|
4 |
+
export default class Premiumize {
|
5 |
+
static id = 'premiumize';
|
6 |
+
static name = 'Premiumize';
|
7 |
+
static shortName = 'PM';
|
8 |
+
static configFields = [
|
9 |
+
{
|
10 |
+
type: 'text',
|
11 |
+
name: 'debridApiKey',
|
12 |
+
label: `Premiumize API Key`,
|
13 |
+
required: true,
|
14 |
+
href: { value: 'https://www.premiumize.me/account', label: 'Get API Key Here' }
|
15 |
+
}
|
16 |
+
];
|
17 |
+
|
18 |
+
#apiKey;
|
19 |
+
#ip;
|
20 |
+
#apiUrl = 'https://www.premiumize.me/api';
|
21 |
+
|
22 |
+
constructor(userConfig) {
|
23 |
+
Object.assign(this, this.constructor);
|
24 |
+
this.#apiKey = userConfig.debridApiKey;
|
25 |
+
this.#ip = userConfig.ip || '';
|
26 |
+
}
|
27 |
+
|
28 |
+
async getTorrentsCached(torrents, isValidCachedFiles) {
|
29 |
+
const hashList = torrents.map(torrent => torrent.infos.infoHash);
|
30 |
+
const params = new URLSearchParams({ apikey: this.#apiKey });
|
31 |
+
hashList.forEach(hash => params.append('items[]', hash));
|
32 |
+
|
33 |
+
const res = await this.#request('GET', '/cache/check', { query: params });
|
34 |
+
return torrents.filter((torrent, index) => res.response[index]);
|
35 |
+
}
|
36 |
+
|
37 |
+
// Required by jackettio.js for progress tracking
|
38 |
+
async getProgressTorrents(torrents) {
|
39 |
+
const res = await this.#request('GET', '/transfer/list');
|
40 |
+
return res.transfers.reduce((progress, transfer) => {
|
41 |
+
progress[transfer.hash] = {
|
42 |
+
percent: transfer.progress * 100 || 0,
|
43 |
+
speed: transfer.speed || 0
|
44 |
+
};
|
45 |
+
return progress;
|
46 |
+
}, {});
|
47 |
+
}
|
48 |
+
|
49 |
+
async getFilesFromHash(infoHash) {
|
50 |
+
return this.getFilesFromMagnet(`magnet:?xt=urn:btih:${infoHash}`, infoHash);
|
51 |
+
}
|
52 |
+
|
53 |
+
async getFilesFromMagnet(magnet, infoHash) {
|
54 |
+
const body = new FormData();
|
55 |
+
body.append('apikey', this.#apiKey);
|
56 |
+
body.append('src', magnet);
|
57 |
+
|
58 |
+
const res = await this.#request('POST', '/transfer/directdl', { body });
|
59 |
+
|
60 |
+
return res.content.map((file, index) => ({
|
61 |
+
name: file.path.split('/').pop(),
|
62 |
+
size: file.size,
|
63 |
+
id: `${infoHash}:${index}`,
|
64 |
+
url: file.link,
|
65 |
+
ready: true
|
66 |
+
}));
|
67 |
+
}
|
68 |
+
|
69 |
+
async getFilesFromBuffer(buffer, infoHash) {
|
70 |
+
const body = new FormData();
|
71 |
+
body.append('apikey', this.#apiKey);
|
72 |
+
body.append('src', new Blob([buffer]), 'file.torrent');
|
73 |
+
|
74 |
+
try {
|
75 |
+
const res = await this.#request('POST', '/transfer/directdl', { body });
|
76 |
+
|
77 |
+
return res.content.map((file, index) => ({
|
78 |
+
name: file.path.split('/').pop(),
|
79 |
+
size: file.size,
|
80 |
+
id: `${infoHash}:${index}`,
|
81 |
+
url: file.link,
|
82 |
+
ready: true
|
83 |
+
}));
|
84 |
+
} catch(err) {
|
85 |
+
// If torrent upload fails, fall back to magnet
|
86 |
+
if(err.message === 'Src not compatible.') {
|
87 |
+
return this.getFilesFromHash(infoHash);
|
88 |
+
}
|
89 |
+
throw err;
|
90 |
+
}
|
91 |
+
}
|
92 |
+
|
93 |
+
async getDownload(file) {
|
94 |
+
if (!file.ready) {
|
95 |
+
throw new Error(ERROR.NOT_READY);
|
96 |
+
}
|
97 |
+
return file.url;
|
98 |
+
}
|
99 |
+
|
100 |
+
async getUserHash() {
|
101 |
+
return createHash('md5').update(this.#apiKey).digest('hex');
|
102 |
+
}
|
103 |
+
|
104 |
+
async #request(method, path, opts = {}) {
|
105 |
+
opts = Object.assign(opts, {
|
106 |
+
method,
|
107 |
+
headers: Object.assign({
|
108 |
+
'accept': 'application/json'
|
109 |
+
}, opts.headers || {}),
|
110 |
+
query: opts.query || new URLSearchParams({ apikey: this.#apiKey })
|
111 |
+
});
|
112 |
+
|
113 |
+
if (method === 'POST' && opts.body instanceof FormData) {
|
114 |
+
// FormData handles Content-Type header automatically
|
115 |
+
if (this.#ip) opts.body.append('ip', this.#ip);
|
116 |
+
}
|
117 |
+
|
118 |
+
const url = `${this.#apiUrl}${path}?${opts.query.toString()}`;
|
119 |
+
const res = await fetch(url, opts);
|
120 |
+
const data = await res.json();
|
121 |
+
|
122 |
+
if (!data || data.status !== "success") {
|
123 |
+
switch(data.message) {
|
124 |
+
case 'Invalid API key.':
|
125 |
+
throw new Error(ERROR.EXPIRED_API_KEY);
|
126 |
+
case 'Premium accounts only.':
|
127 |
+
throw new Error(ERROR.NOT_PREMIUM);
|
128 |
+
default:
|
129 |
+
throw new Error(data.message || 'Unknown Premiumize API error');
|
130 |
+
}
|
131 |
+
}
|
132 |
+
|
133 |
+
return data;
|
134 |
+
}
|
135 |
+
}
|
src/lib/debrid/realdebrid.js
ADDED
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {createHash} from 'crypto';
|
2 |
+
import {ERROR} from './const.js';
|
3 |
+
import {wait, isVideo} from '../util.js';
|
4 |
+
|
5 |
+
export default class RealDebrid {
|
6 |
+
|
7 |
+
static id = 'realdebrid';
|
8 |
+
static name = 'Real-Debrid';
|
9 |
+
static shortName = 'RD';
|
10 |
+
static configFields = [
|
11 |
+
{
|
12 |
+
type: 'text',
|
13 |
+
name: 'debridApiKey',
|
14 |
+
label: `Real-Debrid API Key`,
|
15 |
+
required: true,
|
16 |
+
href: {value: 'https://real-debrid.com/apitoken', label:'Get API Key Here'}
|
17 |
+
}
|
18 |
+
];
|
19 |
+
|
20 |
+
#apiKey;
|
21 |
+
#ip;
|
22 |
+
|
23 |
+
constructor(userConfig) {
|
24 |
+
Object.assign(this, this.constructor);
|
25 |
+
this.#apiKey = userConfig.debridApiKey;
|
26 |
+
this.#ip = userConfig.ip || '';
|
27 |
+
}
|
28 |
+
|
29 |
+
async getTorrentsCached(torrents, isValidCachedFiles){
|
30 |
+
const hashList = torrents.map(torrent => torrent.infos.infoHash).filter(Boolean);
|
31 |
+
const res = await this.#request('GET', `/torrents/instantAvailability/${hashList.join('/')}`);
|
32 |
+
return torrents.filter(torrent => {
|
33 |
+
const cachedFiles = [];
|
34 |
+
const caches = (res[torrent.infos.infoHash]?.rd || []).filter(this.#isVideoCache);
|
35 |
+
for(const cache of caches){
|
36 |
+
for(const file of Object.values(cache)){
|
37 |
+
const f = {name: file.filename, size: file.filesize};
|
38 |
+
if(!cachedFiles.includes(f))cachedFiles.push(f);
|
39 |
+
}
|
40 |
+
}
|
41 |
+
return cachedFiles.length > 0 && isValidCachedFiles(cachedFiles);
|
42 |
+
});
|
43 |
+
}
|
44 |
+
|
45 |
+
async getProgressTorrents(torrents){
|
46 |
+
const res = await this.#request('GET', '/torrents');
|
47 |
+
return res.reduce((progress, torrent) => {
|
48 |
+
progress[torrent.hash] = {
|
49 |
+
percent: torrent.progress || 0,
|
50 |
+
speed: torrent.speed || 0
|
51 |
+
}
|
52 |
+
return progress;
|
53 |
+
}, {});
|
54 |
+
}
|
55 |
+
|
56 |
+
async getFilesFromHash(infoHash){
|
57 |
+
return this.getFilesFromMagnet(`magnet:?xt=urn:btih:${infoHash}`, infoHash);
|
58 |
+
}
|
59 |
+
|
60 |
+
async getFilesFromMagnet(magnet, infoHash){
|
61 |
+
const torrentId = await this.#searchTorrentIdByHash(infoHash);
|
62 |
+
if(torrentId)return this.#getFilesFromTorrent(torrentId);
|
63 |
+
const body = new FormData();
|
64 |
+
body.append('magnet', magnet);
|
65 |
+
const res = await this.#request('POST', `/torrents/addMagnet`, {body});
|
66 |
+
return this.#getFilesFromTorrent(res.id);
|
67 |
+
}
|
68 |
+
|
69 |
+
async getFilesFromBuffer(buffer, infoHash){
|
70 |
+
const torrentId = await this.#searchTorrentIdByHash(infoHash);
|
71 |
+
if(torrentId)return this.#getFilesFromTorrent(torrentId);
|
72 |
+
const body = buffer;
|
73 |
+
const res = await this.#request('PUT', `/torrents/addTorrent`, {body});
|
74 |
+
return this.#getFilesFromTorrent(res.id);
|
75 |
+
}
|
76 |
+
|
77 |
+
async getDownload(file){
|
78 |
+
|
79 |
+
const [torrentId, fileId] = file.id.split(':');
|
80 |
+
|
81 |
+
let torrent = await this.#request('GET', `/torrents/info/${torrentId}`);
|
82 |
+
let body;
|
83 |
+
|
84 |
+
if(torrent.status == 'waiting_files_selection'){
|
85 |
+
|
86 |
+
const caches = await this.#request('GET', `/torrents/instantAvailability/${torrent.hash}`);
|
87 |
+
const bestCache = (caches[torrent.hash]?.rd || [])
|
88 |
+
.filter(cache => cache[fileId] && this.#isVideoCache(cache))
|
89 |
+
.sort((a, b) => Object.values(b).length - Object.values(a).length)
|
90 |
+
.shift();
|
91 |
+
|
92 |
+
const fileIds = bestCache ? Object.keys(bestCache) : torrent.files.filter(file => isVideo(file.path)).map(file => file.id);
|
93 |
+
body = new FormData();
|
94 |
+
body.append('files', fileIds.join(','));
|
95 |
+
|
96 |
+
await this.#request('POST', `/torrents/selectFiles/${torrentId}`, {body});
|
97 |
+
torrent = await this.#request('GET', `/torrents/info/${torrentId}`);
|
98 |
+
|
99 |
+
}
|
100 |
+
|
101 |
+
if(torrent.status != 'downloaded'){
|
102 |
+
throw new Error(ERROR.NOT_READY);
|
103 |
+
}
|
104 |
+
|
105 |
+
const linkIndex = torrent.files.filter(file => file.selected).findIndex(file => file.id == fileId);
|
106 |
+
const link = torrent.links[linkIndex] || false;
|
107 |
+
|
108 |
+
if(!link){
|
109 |
+
throw new Error(`LinkIndex or link not found`);
|
110 |
+
}
|
111 |
+
|
112 |
+
body = new FormData();
|
113 |
+
body.append('link', link);
|
114 |
+
const res = await this.#request('POST', '/unrestrict/link', {body});
|
115 |
+
return res.download;
|
116 |
+
|
117 |
+
}
|
118 |
+
|
119 |
+
async getUserHash(){
|
120 |
+
return createHash('md5').update(this.#apiKey).digest('hex');
|
121 |
+
}
|
122 |
+
|
123 |
+
// Return false when a non video file is available in the cache to avoid rar files
|
124 |
+
#isVideoCache(cache){
|
125 |
+
return !Object.values(cache).find(file => !isVideo(file.filename));
|
126 |
+
}
|
127 |
+
|
128 |
+
async #getFilesFromTorrent(id){
|
129 |
+
|
130 |
+
let torrent = await this.#request('GET', `/torrents/info/${id}`);
|
131 |
+
|
132 |
+
return torrent.files.map((file, index) => {
|
133 |
+
return {
|
134 |
+
name: file.path.split('/').pop(),
|
135 |
+
size: file.bytes,
|
136 |
+
id: `${torrent.id}:${file.id}`,
|
137 |
+
url: '',
|
138 |
+
ready: null
|
139 |
+
};
|
140 |
+
});
|
141 |
+
|
142 |
+
}
|
143 |
+
|
144 |
+
async #searchTorrentIdByHash(hash){
|
145 |
+
|
146 |
+
const torrents = await this.#request('GET', `/torrents`);
|
147 |
+
|
148 |
+
for(let torrent of torrents){
|
149 |
+
if(torrent.hash == hash && ['magnet_conversion', 'waiting_files_selection', 'queued', 'downloading', 'downloaded'].includes(torrent.status)){
|
150 |
+
return torrent.id;
|
151 |
+
}
|
152 |
+
}
|
153 |
+
|
154 |
+
}
|
155 |
+
|
156 |
+
async #request(method, path, opts){
|
157 |
+
|
158 |
+
opts = opts || {};
|
159 |
+
opts = Object.assign(opts, {
|
160 |
+
method,
|
161 |
+
headers: Object.assign(opts.headers || {}, {
|
162 |
+
'accept': 'application/json',
|
163 |
+
'authorization': `Bearer ${this.#apiKey}`
|
164 |
+
}),
|
165 |
+
query: opts.query || {}
|
166 |
+
});
|
167 |
+
|
168 |
+
if(method == 'POST' || method == 'PUT'){
|
169 |
+
opts.body = opts.body || new FormData();
|
170 |
+
if(this.#ip && opts.body instanceof FormData)opts.body.append('ip', this.#ip);
|
171 |
+
}
|
172 |
+
|
173 |
+
const url = `https://api.real-debrid.com/rest/1.0${path}?${new URLSearchParams(opts.query).toString()}`;
|
174 |
+
const res = await fetch(url, opts);
|
175 |
+
let data;
|
176 |
+
|
177 |
+
try {
|
178 |
+
data = await res.json();
|
179 |
+
}catch(err){
|
180 |
+
data = res.status >= 400 ? {error_code: -2, error: `Empty response ${res.status}`} : {};
|
181 |
+
}
|
182 |
+
|
183 |
+
if(data.error_code){
|
184 |
+
switch(data.error_code){
|
185 |
+
case 8:
|
186 |
+
throw new Error(ERROR.EXPIRED_API_KEY);
|
187 |
+
case 9:
|
188 |
+
throw new Error(ERROR.ACCESS_DENIED);
|
189 |
+
case 10:
|
190 |
+
case 11:
|
191 |
+
throw new Error(ERROR.TWO_FACTOR_AUTH);
|
192 |
+
case 20:
|
193 |
+
throw new Error(ERROR.NOT_PREMIUM);
|
194 |
+
default:
|
195 |
+
throw new Error(`Invalid RD api result: ${JSON.stringify(data)}`);
|
196 |
+
}
|
197 |
+
}
|
198 |
+
|
199 |
+
return data;
|
200 |
+
|
201 |
+
}
|
202 |
+
|
203 |
+
}
|
src/lib/icon.js
ADDED
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { writeFile, readFile } from 'node:fs/promises';
|
2 |
+
import path from 'path';
|
3 |
+
import config from './config.js';
|
4 |
+
|
5 |
+
let ICON_LOCATION = path.join(import.meta.dirname, '../static/img/icon.png');
|
6 |
+
|
7 |
+
export async function download(){
|
8 |
+
const res = await fetch(config.addonIcon, {method: 'GET'});
|
9 |
+
if(!res.ok){
|
10 |
+
throw new Error('Network response was not ok');
|
11 |
+
}
|
12 |
+
let extension = null;
|
13 |
+
if(res.headers.has('content-type')){
|
14 |
+
const matches = res.headers.get('content-type').match(/image\/([a-z0-9]+)/i);
|
15 |
+
if(matches && matches.length > 1)extension = matches[1];
|
16 |
+
}
|
17 |
+
if(!extension && res.headers.has('content-disposition')){
|
18 |
+
const matches = res.headers.get('content-disposition').match(/filename\*?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']*)['"]?;?/i);
|
19 |
+
if(matches && matches.length > 1)extension = matches[1].split('.').pop();
|
20 |
+
}
|
21 |
+
if(!extension){
|
22 |
+
throw new Error(`No valid image found: ${res.headers.get('content-type')} / ${res.headers.get('content-disposition')}`);
|
23 |
+
}
|
24 |
+
const location = `${config.dataFolder}/icon.${extension}`;
|
25 |
+
const buffer = await res.arrayBuffer();
|
26 |
+
await writeFile(location, new Uint8Array(buffer));
|
27 |
+
console.log(`Icon downloaded: ${location}`);
|
28 |
+
ICON_LOCATION = location;
|
29 |
+
return location;
|
30 |
+
}
|
31 |
+
|
32 |
+
export async function getLocation(){
|
33 |
+
return ICON_LOCATION;
|
34 |
+
}
|
src/lib/jackett.js
ADDED
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import crypto from 'crypto';
|
2 |
+
import {Parser} from "xml2js";
|
3 |
+
import config from './config.js';
|
4 |
+
import cache from './cache.js';
|
5 |
+
import {numberPad, parseWords} from './util.js';
|
6 |
+
|
7 |
+
export const CATEGORY = {
|
8 |
+
MOVIE: [2000,5000],
|
9 |
+
SERIES: 5000
|
10 |
+
};
|
11 |
+
|
12 |
+
export async function searchMovieTorrents({indexer, name, year}){
|
13 |
+
|
14 |
+
indexer = indexer || 'all';
|
15 |
+
const cacheKey = `jackettItems:2:movie:${indexer}:${name}:${year}`;
|
16 |
+
let items = await cache.get(cacheKey);
|
17 |
+
|
18 |
+
if(!items){
|
19 |
+
const res = await jackettApi(
|
20 |
+
`/api/v2.0/indexers/${indexer}/results/torznab/api`,
|
21 |
+
// year is buggy with some indexers
|
22 |
+
{t: 'search', cat: CATEGORY.MOVIE, q: name /*, year: year*/}
|
23 |
+
);
|
24 |
+
items = res?.rss?.channel?.item || [];
|
25 |
+
cache.set(cacheKey, items, {ttl: items.length > 0 ? 3600*36 : 60});
|
26 |
+
}
|
27 |
+
|
28 |
+
return normalizeItems(items);
|
29 |
+
|
30 |
+
}
|
31 |
+
|
32 |
+
export async function searchSerieTorrents({indexer, name, year}){
|
33 |
+
|
34 |
+
indexer = indexer || 'all';
|
35 |
+
const cacheKey = `jackettItems:2:serie:${indexer}:${name}:${year}`;
|
36 |
+
let items = await cache.get(cacheKey);
|
37 |
+
|
38 |
+
if(!items){
|
39 |
+
const res = await jackettApi(
|
40 |
+
`/api/v2.0/indexers/${indexer}/results/torznab/api`,
|
41 |
+
{t: 'search', cat: CATEGORY.SERIES, q: `${name}`}
|
42 |
+
);
|
43 |
+
items = res?.rss?.channel?.item || [];
|
44 |
+
cache.set(cacheKey, items, {ttl: items.length > 0 ? 3600*36 : 60});
|
45 |
+
}
|
46 |
+
|
47 |
+
return normalizeItems(items);
|
48 |
+
|
49 |
+
}
|
50 |
+
|
51 |
+
export async function searchSeasonTorrents({indexer, name, year, season}){
|
52 |
+
|
53 |
+
indexer = indexer || 'all';
|
54 |
+
const cacheKey = `jackettItems:2:season:${indexer}:${name}:${year}:${season}`;
|
55 |
+
let items = await cache.get(cacheKey);
|
56 |
+
|
57 |
+
if(!items){
|
58 |
+
const res = await jackettApi(
|
59 |
+
`/api/v2.0/indexers/${indexer}/results/torznab/api`,
|
60 |
+
{t: 'search', cat: CATEGORY.SERIES, q: `${name} S${numberPad(season)}`}
|
61 |
+
);
|
62 |
+
items = res?.rss?.channel?.item || [];
|
63 |
+
cache.set(cacheKey, items, {ttl: items.length > 0 ? 3600*36 : 60});
|
64 |
+
}
|
65 |
+
|
66 |
+
return normalizeItems(items);
|
67 |
+
|
68 |
+
}
|
69 |
+
|
70 |
+
export async function searchEpisodeTorrents({indexer, name, year, season, episode}){
|
71 |
+
|
72 |
+
indexer = indexer || 'all';
|
73 |
+
const cacheKey = `jackettItems:2:episode:${indexer}:${name}:${year}:${season}:${episode}`;
|
74 |
+
let items = await cache.get(cacheKey);
|
75 |
+
|
76 |
+
if(!items){
|
77 |
+
const res = await jackettApi(
|
78 |
+
`/api/v2.0/indexers/${indexer}/results/torznab/api`,
|
79 |
+
{t: 'search', cat: CATEGORY.SERIES, q: `${name} S${numberPad(season)}E${numberPad(episode)}`}
|
80 |
+
);
|
81 |
+
items = res?.rss?.channel?.item || [];
|
82 |
+
cache.set(cacheKey, items, {ttl: items.length > 0 ? 3600*36 : 60});
|
83 |
+
}
|
84 |
+
|
85 |
+
return normalizeItems(items);
|
86 |
+
|
87 |
+
}
|
88 |
+
|
89 |
+
export async function getIndexers(){
|
90 |
+
|
91 |
+
const res = await jackettApi(
|
92 |
+
'/api/v2.0/indexers/all/results/torznab/api',
|
93 |
+
{t: 'indexers', configured: 'true'}
|
94 |
+
);
|
95 |
+
|
96 |
+
return normalizeIndexers(res?.indexers?.indexer || []);
|
97 |
+
|
98 |
+
}
|
99 |
+
|
100 |
+
async function jackettApi(path, query){
|
101 |
+
|
102 |
+
const params = new URLSearchParams(query || {});
|
103 |
+
params.set('apikey', config.jackettApiKey);
|
104 |
+
|
105 |
+
const url = `${config.jackettUrl}${path}?${params.toString()}`;
|
106 |
+
|
107 |
+
let data;
|
108 |
+
const res = await fetch(url);
|
109 |
+
if(res.headers.get('content-type').includes('application/json')){
|
110 |
+
data = await res.json();
|
111 |
+
}else{
|
112 |
+
const text = await res.text();
|
113 |
+
const parser = new Parser({explicitArray: false, ignoreAttrs: false});
|
114 |
+
data = await parser.parseStringPromise(text);
|
115 |
+
}
|
116 |
+
|
117 |
+
if(data.error){
|
118 |
+
throw new Error(`jackettApi: ${url.replace(/apikey=[a-z0-9\-]+/, 'apikey=****')} : ${data.error?.$?.description || data.error}`);
|
119 |
+
}
|
120 |
+
|
121 |
+
return data;
|
122 |
+
|
123 |
+
}
|
124 |
+
|
125 |
+
function normalizeItems(items){
|
126 |
+
return forceArray(items).map(item => {
|
127 |
+
item = mergeDollarKeys(item);
|
128 |
+
const attr = item['torznab:attr'].reduce((obj, item) => {
|
129 |
+
obj[item.name] = item.value;
|
130 |
+
return obj;
|
131 |
+
}, {});
|
132 |
+
const quality = item.title.match(/(2160|1080|720|480|360)p/);
|
133 |
+
const title = parseWords(item.title).join(' ');
|
134 |
+
const year = item.title.replace(quality ? quality[1] : '', '').match(/(19|20[\d]{2})/);
|
135 |
+
return {
|
136 |
+
name: item.title,
|
137 |
+
guid: item.guid,
|
138 |
+
indexerId: item.jackettindexer.id,
|
139 |
+
id: crypto.createHash('sha1').update(item.guid).digest('hex'),
|
140 |
+
size: parseInt(item.size),
|
141 |
+
link: item.link,
|
142 |
+
seeders: parseInt(attr.seeders || 0),
|
143 |
+
peers: parseInt(attr.peers || 0),
|
144 |
+
infoHash: attr.infohash || '',
|
145 |
+
magneturl: attr.magneturl || '',
|
146 |
+
type: item.type,
|
147 |
+
quality: quality ? parseInt(quality[1]) : 0,
|
148 |
+
year: year ? parseInt(year.pop()) : 0,
|
149 |
+
languages: config.languages.filter(lang => title.match(lang.pattern))
|
150 |
+
};
|
151 |
+
});
|
152 |
+
}
|
153 |
+
|
154 |
+
function normalizeIndexers(items){
|
155 |
+
return forceArray(items).map(item => {
|
156 |
+
item = mergeDollarKeys(item);
|
157 |
+
const searching = item.caps.searching;
|
158 |
+
return {
|
159 |
+
id: item.id,
|
160 |
+
configured: item.configured == 'true',
|
161 |
+
title: item.title,
|
162 |
+
language: item.language,
|
163 |
+
type: item.type,
|
164 |
+
categories: forceArray(item.caps.categories.category).map(category => parseInt(category.id)),
|
165 |
+
searching: {
|
166 |
+
movie: {
|
167 |
+
available: searching['movie-search'].available == 'yes',
|
168 |
+
supportedParams: searching['movie-search'].supportedParams.split(',')
|
169 |
+
},
|
170 |
+
series: {
|
171 |
+
available: searching['tv-search'].available == 'yes',
|
172 |
+
supportedParams: searching['tv-search'].supportedParams.split(',')
|
173 |
+
}
|
174 |
+
}
|
175 |
+
};
|
176 |
+
});
|
177 |
+
}
|
178 |
+
|
179 |
+
function mergeDollarKeys(item){
|
180 |
+
if(item.$){
|
181 |
+
item = {...item.$, ...item};
|
182 |
+
delete item.$;
|
183 |
+
}
|
184 |
+
for(let key in item){
|
185 |
+
if(typeof(item[key]) === 'object'){
|
186 |
+
item[key] = mergeDollarKeys(item[key]);
|
187 |
+
}
|
188 |
+
}
|
189 |
+
return item;
|
190 |
+
}
|
191 |
+
|
192 |
+
function forceArray(value){
|
193 |
+
return Array.isArray(value) ? value : [value];
|
194 |
+
}
|
src/lib/jackettio.js
ADDED
@@ -0,0 +1,439 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import pLimit from 'p-limit';
|
2 |
+
import {parseWords, numberPad, sortBy, bytesToSize, wait, promiseTimeout} from './util.js';
|
3 |
+
import config from './config.js';
|
4 |
+
import cache from './cache.js';
|
5 |
+
import { updateUserConfigWithMediaFlowIp, applyMediaflowProxyIfNeeded } from './mediaflowProxy.js';
|
6 |
+
import * as meta from './meta.js';
|
7 |
+
import * as jackett from './jackett.js';
|
8 |
+
import * as debrid from './debrid.js';
|
9 |
+
import * as torrentInfos from './torrentInfos.js';
|
10 |
+
|
11 |
+
const slowIndexers = {};
|
12 |
+
|
13 |
+
const actionInProgress = {
|
14 |
+
getTorrents: {},
|
15 |
+
getDownload: {}
|
16 |
+
};
|
17 |
+
|
18 |
+
function parseStremioId(stremioId){
|
19 |
+
const [id, season, episode] = stremioId.split(':');
|
20 |
+
return {id, season: parseInt(season || 0), episode: parseInt(episode || 0)};
|
21 |
+
}
|
22 |
+
|
23 |
+
async function getMetaInfos(type, stremioId, language){
|
24 |
+
const {id, season, episode} = parseStremioId(stremioId);
|
25 |
+
if(type == 'movie'){
|
26 |
+
return meta.getMovieById(id, language);
|
27 |
+
}else if(type == 'series'){
|
28 |
+
return meta.getEpisodeById(id, season, episode, language);
|
29 |
+
}else{
|
30 |
+
throw new Error(`Unsuported type ${type}`);
|
31 |
+
}
|
32 |
+
}
|
33 |
+
|
34 |
+
async function mergeDefaultUserConfig(userConfig){
|
35 |
+
config.immulatableUserConfigKeys.forEach(key => delete userConfig[key]);
|
36 |
+
userConfig = Object.assign({}, config.defaultUserConfig, userConfig);
|
37 |
+
userConfig = await updateUserConfigWithMediaFlowIp(userConfig);
|
38 |
+
return userConfig;
|
39 |
+
}
|
40 |
+
|
41 |
+
function priotizeItems(allItems, priotizeItems, max){
|
42 |
+
max = max || 0;
|
43 |
+
if(typeof(priotizeItems) == 'function'){
|
44 |
+
priotizeItems = allItems.filter(priotizeItems);
|
45 |
+
if(max > 0)priotizeItems.splice(max);
|
46 |
+
}
|
47 |
+
if(priotizeItems && priotizeItems.length){
|
48 |
+
allItems = allItems.filter(item => !priotizeItems.find(i => i == item));
|
49 |
+
allItems.unshift(...priotizeItems);
|
50 |
+
}
|
51 |
+
return allItems;
|
52 |
+
}
|
53 |
+
|
54 |
+
function searchEpisodeFile(files, season, episode){
|
55 |
+
return files.find(file => file.name.includes(`S${numberPad(season, 2)}E${numberPad(episode, 3)}`))
|
56 |
+
|| files.find(file => file.name.includes(`S${numberPad(season, 2)}E${numberPad(episode, 2)}`))
|
57 |
+
|| files.find(file => file.name.includes(`${season}${numberPad(episode, 2)}`))
|
58 |
+
|| files.find(file => file.name.includes(`${numberPad(episode, 2)}`))
|
59 |
+
|| false;
|
60 |
+
}
|
61 |
+
|
62 |
+
function getSlowIndexerStats(indexerId){
|
63 |
+
slowIndexers[indexerId] = (slowIndexers[indexerId] || []).filter(item => new Date() - item.date < config.slowIndexerWindow);
|
64 |
+
return {
|
65 |
+
min: Math.min(...slowIndexers[indexerId].map(item => item.duration)),
|
66 |
+
avg: Math.round(slowIndexers[indexerId].reduce((acc, item) => acc + item.duration, 0) / slowIndexers[indexerId].length),
|
67 |
+
max: Math.max(...slowIndexers[indexerId].map(item => item.duration)),
|
68 |
+
count: slowIndexers[indexerId].length
|
69 |
+
}
|
70 |
+
}
|
71 |
+
|
72 |
+
async function timeoutIndexerSearch(indexerId, promise, timeout){
|
73 |
+
const start = new Date();
|
74 |
+
const res = await promiseTimeout(promise, timeout).catch(err => []);
|
75 |
+
const duration = new Date() - start;
|
76 |
+
if(timeout > config.slowIndexerDuration){
|
77 |
+
if(duration > config.slowIndexerDuration){
|
78 |
+
console.log(`Slow indexer detected : ${indexerId} : ${duration}ms`);
|
79 |
+
slowIndexers[indexerId].push({duration, date: new Date()});
|
80 |
+
}else{
|
81 |
+
slowIndexers[indexerId] = [];
|
82 |
+
}
|
83 |
+
}
|
84 |
+
return res;
|
85 |
+
}
|
86 |
+
|
87 |
+
async function getTorrents(userConfig, metaInfos, debridInstance){
|
88 |
+
|
89 |
+
while(actionInProgress.getTorrents[metaInfos.stremioId]){
|
90 |
+
await wait(500);
|
91 |
+
}
|
92 |
+
actionInProgress.getTorrents[metaInfos.stremioId] = true;
|
93 |
+
|
94 |
+
try {
|
95 |
+
|
96 |
+
const {qualities, excludeKeywords, maxTorrents, sortCached, sortUncached, priotizePackTorrents, priotizeLanguages, indexerTimeoutSec} = userConfig;
|
97 |
+
const {id, season, episode, type, stremioId, year} = metaInfos;
|
98 |
+
|
99 |
+
let torrents = [];
|
100 |
+
let startDate = new Date();
|
101 |
+
|
102 |
+
console.log(`${stremioId} : Searching torrents ...`);
|
103 |
+
|
104 |
+
const sortSearch = [['seeders', true]];
|
105 |
+
const filterSearch = (torrent) => {
|
106 |
+
if(!qualities.includes(torrent.quality))return false;
|
107 |
+
const torrentWords = parseWords(torrent.name.toLowerCase());
|
108 |
+
if(excludeKeywords.find(word => torrentWords.includes(word)))return false;
|
109 |
+
return true;
|
110 |
+
};
|
111 |
+
const filterLanguage = (torrent) => {
|
112 |
+
if(priotizeLanguages.length == 0)return true;
|
113 |
+
return torrent.languages.find(lang => ['multi'].concat(priotizeLanguages).includes(lang.value));
|
114 |
+
};
|
115 |
+
const filterYear = (torrent) => !torrent.year || torrent.year == year;
|
116 |
+
const filterSlowIndexer = (indexer) => config.slowIndexerRequest <= 0 || getSlowIndexerStats(indexer.id).count < config.slowIndexerRequest;
|
117 |
+
|
118 |
+
let indexers = (await jackett.getIndexers());
|
119 |
+
let availableIndexers = indexers.filter(indexer => indexer.searching[type].available);
|
120 |
+
let availableFastIndexers = availableIndexers.filter(filterSlowIndexer);
|
121 |
+
if(availableFastIndexers.length)availableIndexers = availableFastIndexers;
|
122 |
+
let userIndexers = availableIndexers.filter(indexer => (userConfig.indexers.includes(indexer.id) || userConfig.indexers.includes('all')));
|
123 |
+
|
124 |
+
if(userIndexers.length){
|
125 |
+
indexers = userIndexers;
|
126 |
+
}else if(availableIndexers.length){
|
127 |
+
console.log(`${stremioId} : User defined indexers "${userConfig.indexers.join(', ')}" not available, fallback to all "${type}" indexers`);
|
128 |
+
indexers = availableIndexers;
|
129 |
+
}else if(indexers.length){
|
130 |
+
console.log(`${stremioId} : User defined indexers "${userConfig.indexers.join(', ')}" or "${type}" indexers not available, fallback to all indexers`);
|
131 |
+
}else{
|
132 |
+
throw new Error(`${stremioId} : No indexer configured in jackett`);
|
133 |
+
}
|
134 |
+
|
135 |
+
console.log(`${stremioId} : ${indexers.length} indexers selected : ${indexers.map(indexer => indexer.title).join(', ')}`);
|
136 |
+
|
137 |
+
if(type == 'movie'){
|
138 |
+
|
139 |
+
const promises = indexers.map(indexer => timeoutIndexerSearch(indexer.id, jackett.searchMovieTorrents({...metaInfos, indexer: indexer.id}), indexerTimeoutSec*1000));
|
140 |
+
torrents = [].concat(...(await Promise.all(promises)));
|
141 |
+
|
142 |
+
console.log(`${stremioId} : ${torrents.length} torrents found in ${(new Date() - startDate) / 1000}s`);
|
143 |
+
|
144 |
+
const yearTorrents = torrents.filter(filterYear);
|
145 |
+
if(yearTorrents.length)torrents = yearTorrents;
|
146 |
+
torrents = torrents.filter(filterSearch).sort(sortBy(...sortSearch));
|
147 |
+
torrents = priotizeItems(torrents, filterLanguage, Math.max(1, Math.round(maxTorrents * 0.33)));
|
148 |
+
torrents = torrents.slice(0, maxTorrents + 2);
|
149 |
+
|
150 |
+
}else if(type == 'series'){
|
151 |
+
|
152 |
+
const episodesPromises = indexers.map(indexer => timeoutIndexerSearch(indexer.id, jackett.searchEpisodeTorrents({...metaInfos, indexer: indexer.id}), indexerTimeoutSec*1000));
|
153 |
+
// const packsPromises = indexers.map(indexer => promiseTimeout(jackett.searchSeasonTorrents({...metaInfos, indexer: indexer.id}), indexerTimeoutSec*1000).catch(err => []));
|
154 |
+
const packsPromises = indexers.map(indexer => timeoutIndexerSearch(indexer.id, jackett.searchSerieTorrents({...metaInfos, indexer: indexer.id}), indexerTimeoutSec*1000));
|
155 |
+
|
156 |
+
const episodesTorrents = [].concat(...(await Promise.all(episodesPromises))).filter(filterSearch);
|
157 |
+
// const packsTorrents = [].concat(...(await Promise.all(packsPromises))).filter(torrent => filterSearch(torrent) && parseWords(torrent.name.toUpperCase()).includes(`S${numberPad(season)}`));
|
158 |
+
const packsTorrents = [].concat(...(await Promise.all(packsPromises))).filter(torrent => {
|
159 |
+
if(!filterSearch(torrent))return false;
|
160 |
+
const words = parseWords(torrent.name.toLowerCase());
|
161 |
+
const wordsStr = words.join(' ');
|
162 |
+
if(
|
163 |
+
// Season x
|
164 |
+
wordsStr.includes(`season ${season}`)
|
165 |
+
// SXX
|
166 |
+
|| words.includes(`s${numberPad(season)}`)
|
167 |
+
){
|
168 |
+
return true;
|
169 |
+
}
|
170 |
+
// From SXX to SXX
|
171 |
+
const range = wordsStr.match(/s([\d]{2,}) s([\d]{2,})/);
|
172 |
+
if(range && season >= parseInt(range[1]) && season <= parseInt(range[2])){
|
173 |
+
return true;
|
174 |
+
}
|
175 |
+
// Complete without season number (serie pack)
|
176 |
+
if(words.includes('complete') && !wordsStr.match(/ (s[\d]{2,}|season [\d]) /)){
|
177 |
+
return true;
|
178 |
+
}
|
179 |
+
return false;
|
180 |
+
});
|
181 |
+
|
182 |
+
torrents = [].concat(episodesTorrents, packsTorrents);
|
183 |
+
|
184 |
+
console.log(`${stremioId} : ${torrents.length} torrents found in ${(new Date() - startDate) / 1000}s`);
|
185 |
+
|
186 |
+
const yearTorrents = torrents.filter(filterYear);
|
187 |
+
if(yearTorrents.length)torrents = yearTorrents;
|
188 |
+
torrents = torrents.filter(filterSearch).sort(sortBy(...sortSearch));
|
189 |
+
torrents = priotizeItems(torrents, filterLanguage, Math.max(1, Math.round(maxTorrents * 0.33)));
|
190 |
+
torrents = torrents.slice(0, maxTorrents + 2);
|
191 |
+
|
192 |
+
if(priotizePackTorrents > 0 && packsTorrents.length && !torrents.find(t => packsTorrents.includes(t))){
|
193 |
+
const bestPackTorrents = packsTorrents.slice(0, Math.min(packsTorrents.length, priotizePackTorrents));
|
194 |
+
torrents.splice(bestPackTorrents.length * -1, bestPackTorrents.length, ...bestPackTorrents);
|
195 |
+
}
|
196 |
+
|
197 |
+
}
|
198 |
+
|
199 |
+
console.log(`${stremioId} : ${torrents.length} torrents filtered, get torrents infos ...`);
|
200 |
+
startDate = new Date();
|
201 |
+
|
202 |
+
const limit = pLimit(5);
|
203 |
+
torrents = await Promise.all(torrents.map(torrent => limit(async () => {
|
204 |
+
try {
|
205 |
+
torrent.infos = await promiseTimeout(torrentInfos.get(torrent), Math.min(30, indexerTimeoutSec)*1000);
|
206 |
+
return torrent;
|
207 |
+
}catch(err){
|
208 |
+
console.log(`${stremioId} Failed getting torrent infos for ${torrent.id} from indexer ${torrent.indexerId}`);
|
209 |
+
console.log(`${stremioId} ${torrent.link.replace(/apikey=[a-z0-9\-]+/, 'apikey=****')}`, err);
|
210 |
+
return false;
|
211 |
+
}
|
212 |
+
})));
|
213 |
+
torrents = torrents.filter(torrent => torrent && torrent.infos)
|
214 |
+
.filter((torrent, index, items) => items.findIndex(t => t.infos.infoHash == torrent.infos.infoHash) === index)
|
215 |
+
.slice(0, maxTorrents);
|
216 |
+
|
217 |
+
console.log(`${stremioId} : ${torrents.length} torrents infos found in ${(new Date() - startDate) / 1000}s`);
|
218 |
+
|
219 |
+
if(torrents.length == 0){
|
220 |
+
throw new Error(`No torrent infos for type ${type} and id ${stremioId}`);
|
221 |
+
}
|
222 |
+
|
223 |
+
if(debridInstance){
|
224 |
+
|
225 |
+
try {
|
226 |
+
|
227 |
+
const isValidCachedFiles = type == 'series' ? files => !!searchEpisodeFile(files, season, episode) : files => true;
|
228 |
+
const cachedTorrents = (await debridInstance.getTorrentsCached(torrents, isValidCachedFiles)).map(torrent => {
|
229 |
+
torrent.isCached = true;
|
230 |
+
return torrent;
|
231 |
+
});
|
232 |
+
const uncachedTorrents = torrents.filter(torrent => cachedTorrents.indexOf(torrent) === -1);
|
233 |
+
|
234 |
+
if(config.replacePasskey && !(userConfig.passkey && userConfig.passkey.match(new RegExp(config.replacePasskeyPattern)))){
|
235 |
+
uncachedTorrents.forEach(torrent => {
|
236 |
+
if(torrent.infos.private){
|
237 |
+
torrent.disabled = true;
|
238 |
+
torrent.infoText = 'Uncached torrent require a passkey configuration';
|
239 |
+
}
|
240 |
+
});
|
241 |
+
}
|
242 |
+
|
243 |
+
console.log(`${stremioId} : ${cachedTorrents.length} cached torrents on ${debridInstance.shortName}`);
|
244 |
+
|
245 |
+
torrents = priotizeItems(cachedTorrents.sort(sortBy(...sortCached)), filterLanguage);
|
246 |
+
|
247 |
+
if(!userConfig.hideUncached){
|
248 |
+
torrents.push(...priotizeItems(uncachedTorrents.sort(sortBy(...sortUncached)), filterLanguage));
|
249 |
+
}
|
250 |
+
|
251 |
+
const progress = await debridInstance.getProgressTorrents(torrents);
|
252 |
+
torrents.forEach(torrent => torrent.progress = progress[torrent.infos.infoHash] || null);
|
253 |
+
|
254 |
+
}catch(err){
|
255 |
+
|
256 |
+
console.log(`${stremioId} : ${debridInstance.shortName} : ${err.message || err}`);
|
257 |
+
|
258 |
+
if(err.message == debrid.ERROR.EXPIRED_API_KEY){
|
259 |
+
torrents.forEach(torrent => {
|
260 |
+
torrent.disabled = true;
|
261 |
+
torrent.infoText = 'Unable to verify cache (+): Expired Debrid API Key.';
|
262 |
+
});
|
263 |
+
}
|
264 |
+
|
265 |
+
}
|
266 |
+
|
267 |
+
}
|
268 |
+
|
269 |
+
return torrents;
|
270 |
+
|
271 |
+
}finally{
|
272 |
+
|
273 |
+
delete actionInProgress.getTorrents[metaInfos.stremioId];
|
274 |
+
|
275 |
+
}
|
276 |
+
|
277 |
+
}
|
278 |
+
|
279 |
+
async function prepareNextEpisode(userConfig, metaInfos, debridInstance){
|
280 |
+
|
281 |
+
try {
|
282 |
+
|
283 |
+
const {stremioId} = metaInfos;
|
284 |
+
const nextEpisodeIndex = metaInfos.episodes.findIndex(e => e.episode == metaInfos.episode && e.season == metaInfos.season) + 1;
|
285 |
+
const nextEpisode = metaInfos.episodes[nextEpisodeIndex] || false;
|
286 |
+
|
287 |
+
if(nextEpisode){
|
288 |
+
|
289 |
+
metaInfos = await meta.getEpisodeById(metaInfos.id, nextEpisode.season, nextEpisode.episode, userConfig.metaLanguage);
|
290 |
+
const torrents = await getTorrents(userConfig, metaInfos, debridInstance);
|
291 |
+
|
292 |
+
// Cache next episode on debrid when not cached
|
293 |
+
if(userConfig.forceCacheNextEpisode && torrents.length && !torrents.find(torrent => torrent.isCached)){
|
294 |
+
console.log(`${stremioId} : Force cache next episode (${metaInfos.episode}) on debrid`);
|
295 |
+
const bestTorrent = torrents.find(torrent => !torrent.disabled);
|
296 |
+
if(bestTorrent)await getDebridFiles(userConfig, bestTorrent.infos, debridInstance);
|
297 |
+
}
|
298 |
+
|
299 |
+
}
|
300 |
+
|
301 |
+
}catch(err){
|
302 |
+
|
303 |
+
if(err.message != debrid.ERROR.NOT_READY){
|
304 |
+
console.log('cache next episode:', err);
|
305 |
+
}
|
306 |
+
|
307 |
+
}
|
308 |
+
|
309 |
+
}
|
310 |
+
|
311 |
+
async function getDebridFiles(userConfig, infos, debridInstance){
|
312 |
+
|
313 |
+
if(infos.magnetUrl){
|
314 |
+
|
315 |
+
return debridInstance.getFilesFromMagnet(infos.magnetUrl, infos.infoHash);
|
316 |
+
|
317 |
+
}else{
|
318 |
+
|
319 |
+
let buffer = await torrentInfos.getTorrentFile(infos);
|
320 |
+
|
321 |
+
if(config.replacePasskey){
|
322 |
+
|
323 |
+
if(infos.private && !userConfig.passkey){
|
324 |
+
return debridInstance.getFilesFromHash(infos.infoHash);
|
325 |
+
}
|
326 |
+
|
327 |
+
if(!userConfig.passkey.match(new RegExp(config.replacePasskeyPattern))){
|
328 |
+
throw new Error(`Invalid user passkey, pattern not match: ${config.replacePasskeyPattern}`);
|
329 |
+
}
|
330 |
+
|
331 |
+
const from = buffer.toString('binary');
|
332 |
+
let to = from.replace(new RegExp(config.replacePasskey, 'g'), userConfig.passkey);
|
333 |
+
const diffLength = from.length - to.length;
|
334 |
+
const announceLength = from.match(/:announce([\d]+):/);
|
335 |
+
if(diffLength && announceLength && announceLength[1]){
|
336 |
+
to = to.replace(announceLength[0], `:announce${parseInt(announceLength[1]) - diffLength}:`);
|
337 |
+
}
|
338 |
+
buffer = Buffer.from(to, 'binary');
|
339 |
+
|
340 |
+
}
|
341 |
+
|
342 |
+
return debridInstance.getFilesFromBuffer(buffer, infos.infoHash);
|
343 |
+
|
344 |
+
}
|
345 |
+
|
346 |
+
}
|
347 |
+
|
348 |
+
export async function getStreams(userConfig, type, stremioId, publicUrl){
|
349 |
+
|
350 |
+
userConfig = await mergeDefaultUserConfig(userConfig);
|
351 |
+
const {id, season, episode} = parseStremioId(stremioId);
|
352 |
+
const debridInstance = debrid.instance(userConfig);
|
353 |
+
|
354 |
+
let metaInfos = await getMetaInfos(type, stremioId, userConfig.metaLanguage);
|
355 |
+
|
356 |
+
const torrents = await getTorrents(userConfig, metaInfos, debridInstance);
|
357 |
+
|
358 |
+
// Prepare next expisode torrents list
|
359 |
+
if(type == 'series'){
|
360 |
+
prepareNextEpisode({...userConfig, forceCacheNextEpisode: false}, metaInfos, debridInstance);
|
361 |
+
}
|
362 |
+
|
363 |
+
return torrents.map(torrent => {
|
364 |
+
const file = type == 'series' && torrent.infos.files.length ? searchEpisodeFile(torrent.infos.files.sort(sortBy('size', true)), season, episode) : {};
|
365 |
+
const quality = torrent.quality > 0 ? config.qualities.find(q => q.value == torrent.quality).label : '';
|
366 |
+
const rows = [torrent.name];
|
367 |
+
if(type == 'series' && file.name)rows.push(file.name);
|
368 |
+
if(torrent.infoText)rows.push(`ℹ️ ${torrent.infoText}`);
|
369 |
+
rows.push([`💾${bytesToSize(file.size || torrent.size)}`, `👥${torrent.seeders}`, `⚙️${torrent.indexerId}`, ...(torrent.languages || []).map(language => language.emoji)].join(' '));
|
370 |
+
if(torrent.progress && !torrent.isCached){
|
371 |
+
rows.push(`⬇️ ${torrent.progress.percent}% ${bytesToSize(torrent.progress.speed)}/s`);
|
372 |
+
}
|
373 |
+
return {
|
374 |
+
name: `[${debridInstance.shortName}${torrent.isCached ? '+' : ''}] ${userConfig.enableMediaFlow ? '🕵🏼♂️ ' : ''}${config.addonName} ${quality}`,
|
375 |
+
title: rows.join("\n"),
|
376 |
+
url: torrent.disabled ? '#' : `${publicUrl}/${btoa(JSON.stringify(userConfig))}/download/${type}/${stremioId}/${torrent.id}`
|
377 |
+
};
|
378 |
+
});
|
379 |
+
|
380 |
+
}
|
381 |
+
|
382 |
+
export async function getDownload(userConfig, type, stremioId, torrentId){
|
383 |
+
|
384 |
+
userConfig = await mergeDefaultUserConfig(userConfig);
|
385 |
+
const debridInstance = debrid.instance(userConfig);
|
386 |
+
const infos = await torrentInfos.getById(torrentId);
|
387 |
+
const {id, season, episode} = parseStremioId(stremioId);
|
388 |
+
const cacheKey = `download:2:${await debridInstance.getUserHash()}${userConfig.enableMediaFlow ? ':mfp': ''}:${stremioId}:${torrentId}`;
|
389 |
+
let files;
|
390 |
+
let download;
|
391 |
+
let waitMs = 0;
|
392 |
+
|
393 |
+
while(actionInProgress.getDownload[cacheKey]){
|
394 |
+
await wait(Math.min(300, waitMs+=50));
|
395 |
+
}
|
396 |
+
actionInProgress.getDownload[cacheKey] = true;
|
397 |
+
|
398 |
+
try {
|
399 |
+
|
400 |
+
// Prepare next expisode debrid cache
|
401 |
+
if(type == 'series' && userConfig.forceCacheNextEpisode){
|
402 |
+
getMetaInfos(type, stremioId, userConfig.metaLanguage).then(metaInfos => prepareNextEpisode(userConfig, metaInfos, debridInstance));
|
403 |
+
}
|
404 |
+
|
405 |
+
download = await cache.get(cacheKey);
|
406 |
+
if(download)return download;
|
407 |
+
|
408 |
+
console.log(`${stremioId} : ${debridInstance.shortName} : ${infos.infoHash} : get files ...`);
|
409 |
+
files = await getDebridFiles(userConfig, infos, debridInstance);
|
410 |
+
console.log(`${stremioId} : ${debridInstance.shortName} : ${infos.infoHash} : ${files.length} files found`);
|
411 |
+
|
412 |
+
files = files.sort(sortBy('size', true));
|
413 |
+
|
414 |
+
if(type == 'movie'){
|
415 |
+
|
416 |
+
download = await debridInstance.getDownload(files[0]);
|
417 |
+
|
418 |
+
}else if(type == 'series'){
|
419 |
+
|
420 |
+
let bestFile = searchEpisodeFile(files, season, episode) || files[0];
|
421 |
+
download = await debridInstance.getDownload(bestFile);
|
422 |
+
|
423 |
+
}
|
424 |
+
|
425 |
+
if(download){
|
426 |
+
download = applyMediaflowProxyIfNeeded(download, userConfig);
|
427 |
+
await cache.set(cacheKey, download, {ttl: 3600});
|
428 |
+
return download;
|
429 |
+
}
|
430 |
+
|
431 |
+
throw new Error(`No download for type ${type} and ID ${torrentId}`);
|
432 |
+
|
433 |
+
}finally{
|
434 |
+
|
435 |
+
delete actionInProgress.getDownload[cacheKey];
|
436 |
+
|
437 |
+
}
|
438 |
+
|
439 |
+
}
|
src/lib/mediaflowProxy.js
ADDED
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import crypto from 'crypto';
|
2 |
+
import { URL } from 'url';
|
3 |
+
import path from 'path';
|
4 |
+
import cache from './cache.js';
|
5 |
+
|
6 |
+
const PRIVATE_CIDR = /^(10\.|127\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/;
|
7 |
+
|
8 |
+
function getTextHash(text) {
|
9 |
+
return crypto.createHash('sha256').update(text).digest('hex');
|
10 |
+
}
|
11 |
+
|
12 |
+
async function getMediaflowProxyPublicIp(userConfig) {
|
13 |
+
// If the user has already provided a public IP, use it
|
14 |
+
if (userConfig.mediaflowPublicIp) return userConfig.mediaflowPublicIp;
|
15 |
+
|
16 |
+
const parsedUrl = new URL(userConfig.mediaflowProxyUrl);
|
17 |
+
if (PRIVATE_CIDR.test(parsedUrl.hostname)) {
|
18 |
+
// MediaFlow proxy URL is a private IP address
|
19 |
+
return null;
|
20 |
+
}
|
21 |
+
|
22 |
+
const cacheKey = `mediaflowPublicIp:${getTextHash(`${userConfig.mediaflowProxyUrl}:${userConfig.mediaflowApiPassword}`)}`;
|
23 |
+
try {
|
24 |
+
const cachedIp = await cache.get(cacheKey);
|
25 |
+
if (cachedIp) {
|
26 |
+
return cachedIp;
|
27 |
+
}
|
28 |
+
|
29 |
+
const response = await fetch(new URL(`/proxy/ip?api_password=${userConfig.mediaflowApiPassword}`, userConfig.mediaflowProxyUrl).toString(), {
|
30 |
+
method: 'GET',
|
31 |
+
headers: {
|
32 |
+
'Content-Type': 'application/json',
|
33 |
+
},
|
34 |
+
});
|
35 |
+
|
36 |
+
if (!response.ok) {
|
37 |
+
throw new Error(`HTTP error! status: ${response.status}`);
|
38 |
+
}
|
39 |
+
|
40 |
+
const data = await response.json();
|
41 |
+
const publicIp = data.ip;
|
42 |
+
if (publicIp) {
|
43 |
+
await cache.set(cacheKey, publicIp, { ttl: 300 }); // Cache for 5 minutes
|
44 |
+
return publicIp;
|
45 |
+
}
|
46 |
+
} catch (error) {
|
47 |
+
console.error('An error occurred:', error);
|
48 |
+
}
|
49 |
+
|
50 |
+
return null;
|
51 |
+
}
|
52 |
+
|
53 |
+
|
54 |
+
function encodeMediaflowProxyUrl(
|
55 |
+
mediaflowProxyUrl,
|
56 |
+
endpoint,
|
57 |
+
destinationUrl = null,
|
58 |
+
queryParams = {},
|
59 |
+
requestHeaders = null,
|
60 |
+
responseHeaders = null
|
61 |
+
) {
|
62 |
+
if (destinationUrl !== null) {
|
63 |
+
queryParams.d = destinationUrl;
|
64 |
+
}
|
65 |
+
|
66 |
+
// Add headers if provided
|
67 |
+
if (requestHeaders) {
|
68 |
+
Object.entries(requestHeaders).forEach(([key, value]) => {
|
69 |
+
queryParams[`h_${key}`] = value;
|
70 |
+
});
|
71 |
+
}
|
72 |
+
if (responseHeaders) {
|
73 |
+
Object.entries(responseHeaders).forEach(([key, value]) => {
|
74 |
+
queryParams[`r_${key}`] = value;
|
75 |
+
});
|
76 |
+
}
|
77 |
+
|
78 |
+
const encodedParams = new URLSearchParams(queryParams).toString();
|
79 |
+
|
80 |
+
// Construct the full URL
|
81 |
+
const baseUrl = new URL(endpoint, mediaflowProxyUrl).toString();
|
82 |
+
return `${baseUrl}?${encodedParams}`;
|
83 |
+
}
|
84 |
+
|
85 |
+
export async function updateUserConfigWithMediaFlowIp(userConfig){
|
86 |
+
if (userConfig.enableMediaFlow && userConfig.mediaflowProxyUrl && userConfig.mediaflowApiPassword) {
|
87 |
+
const mediaflowPublicIp = await getMediaflowProxyPublicIp(userConfig);
|
88 |
+
if (mediaflowPublicIp) {
|
89 |
+
userConfig.ip = mediaflowPublicIp;
|
90 |
+
}
|
91 |
+
}
|
92 |
+
return userConfig;
|
93 |
+
}
|
94 |
+
|
95 |
+
|
96 |
+
export function applyMediaflowProxyIfNeeded(videoUrl, userConfig) {
|
97 |
+
if (userConfig.enableMediaFlow && userConfig.mediaflowProxyUrl && userConfig.mediaflowApiPassword) {
|
98 |
+
return encodeMediaflowProxyUrl(
|
99 |
+
userConfig.mediaflowProxyUrl,
|
100 |
+
"/proxy/stream",
|
101 |
+
videoUrl,
|
102 |
+
{
|
103 |
+
api_password: userConfig.mediaflowApiPassword
|
104 |
+
},
|
105 |
+
null,
|
106 |
+
{
|
107 |
+
"Content-Disposition": `attachment; filename=${path.basename(videoUrl)}`
|
108 |
+
}
|
109 |
+
);
|
110 |
+
}
|
111 |
+
return videoUrl;
|
112 |
+
}
|
src/lib/meta.js
ADDED
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import config from './config.js';
|
2 |
+
import Cinemeta from './meta/cinemeta.js';
|
3 |
+
import Tmdb from './meta/tmdb.js';
|
4 |
+
|
5 |
+
const client = config.tmdbAccessToken ? new Tmdb() : new Cinemeta();
|
6 |
+
|
7 |
+
export async function getMovieById(id, language){
|
8 |
+
return client.getMovieById(id, language);
|
9 |
+
}
|
10 |
+
|
11 |
+
export async function getEpisodeById(id, season, episode, language){
|
12 |
+
return client.getEpisodeById(id, season, episode, language);
|
13 |
+
}
|
14 |
+
|
15 |
+
export async function getLanguages(){
|
16 |
+
return client.getLanguages();
|
17 |
+
}
|
src/lib/meta/cinemeta.js
ADDED
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import cache from '../cache.js';
|
2 |
+
|
3 |
+
export default class Cinemeta {
|
4 |
+
|
5 |
+
static id = 'cinemeta';
|
6 |
+
static name = 'Cinemeta';
|
7 |
+
|
8 |
+
async getMovieById(id){
|
9 |
+
|
10 |
+
const data = await this.#request('GET', `/meta/movie/${id}.json`, {}, {key: id, ttl: 3600*3});
|
11 |
+
const meta = data.meta;
|
12 |
+
|
13 |
+
return {
|
14 |
+
name: meta.name,
|
15 |
+
year: parseInt(meta.releaseInfo),
|
16 |
+
imdb_id: meta.imdb_id,
|
17 |
+
type: 'movie',
|
18 |
+
stremioId: id,
|
19 |
+
id,
|
20 |
+
};
|
21 |
+
|
22 |
+
}
|
23 |
+
|
24 |
+
async getEpisodeById(id, season, episode){
|
25 |
+
|
26 |
+
const data = await this.#request('GET', `/meta/series/${id}.json`, {}, {key: id, ttl: 3600*3});
|
27 |
+
const meta = data.meta;
|
28 |
+
|
29 |
+
return {
|
30 |
+
name: meta.name,
|
31 |
+
year: parseInt(`${meta.releaseInfo}`.split('-').shift()),
|
32 |
+
imdb_id: meta.imdb_id,
|
33 |
+
type: 'series',
|
34 |
+
stremioId: `${id}:${season}:${episode}`,
|
35 |
+
id,
|
36 |
+
season,
|
37 |
+
episode,
|
38 |
+
episodes: meta.videos.map(video => {
|
39 |
+
return {
|
40 |
+
season: video.season,
|
41 |
+
episode: video.number,
|
42 |
+
stremioId: video.id
|
43 |
+
}
|
44 |
+
})
|
45 |
+
};
|
46 |
+
|
47 |
+
}
|
48 |
+
|
49 |
+
async getLanguages(){
|
50 |
+
return [];
|
51 |
+
}
|
52 |
+
|
53 |
+
async #request(method, path, opts, cacheOpts){
|
54 |
+
|
55 |
+
cacheOpts = Object.assign({key: '', ttl: 0}, cacheOpts || {});
|
56 |
+
opts = opts || {};
|
57 |
+
opts = Object.assign(opts, {
|
58 |
+
method,
|
59 |
+
headers: Object.assign(opts.headers || {}, {
|
60 |
+
'accept': 'application/json'
|
61 |
+
})
|
62 |
+
});
|
63 |
+
|
64 |
+
let data;
|
65 |
+
|
66 |
+
if(cacheOpts.key){
|
67 |
+
data = await cache.get(`cinemeta:${cacheOpts.key}`);
|
68 |
+
if(data)return data;
|
69 |
+
}
|
70 |
+
|
71 |
+
const url = `https://v3-cinemeta.strem.io${path}?${new URLSearchParams(opts.query).toString()}`;
|
72 |
+
const res = await fetch(url, opts);
|
73 |
+
data = await res.json();
|
74 |
+
|
75 |
+
if(!res.ok){
|
76 |
+
throw new Error(`Invalid Cinemeta api result: ${JSON.stringify(data)}`);
|
77 |
+
}
|
78 |
+
|
79 |
+
if(data && cacheOpts.key && cacheOpts.ttl > 0){
|
80 |
+
await cache.set(`cinemeta:${cacheOpts.key}`, data, {ttl: cacheOpts.ttl})
|
81 |
+
}
|
82 |
+
|
83 |
+
return data;
|
84 |
+
|
85 |
+
}
|
86 |
+
|
87 |
+
}
|
src/lib/meta/tmdb.js
ADDED
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import cache from '../cache.js';
|
2 |
+
import config from '../config.js';
|
3 |
+
|
4 |
+
export default class Tmdb {
|
5 |
+
|
6 |
+
static id = 'tmdb';
|
7 |
+
static name = 'The Movie Database';
|
8 |
+
|
9 |
+
#cleanTmdbId(id) {
|
10 |
+
return id.replace(/^tmdb-/, '');
|
11 |
+
}
|
12 |
+
|
13 |
+
#getSearchTitle(title) {
|
14 |
+
// Special handling for UFC events
|
15 |
+
const ufcMatch = title.match(/UFC Fight Night (\d+):/i) || title.match(/UFC (\d+):/i);
|
16 |
+
if (ufcMatch) {
|
17 |
+
return `UFC ${ufcMatch[1]}`;
|
18 |
+
}
|
19 |
+
return title;
|
20 |
+
}
|
21 |
+
|
22 |
+
async getMovieById(id, language){
|
23 |
+
if (id.startsWith('tmdb-')) {
|
24 |
+
try {
|
25 |
+
const cleanId = this.#cleanTmdbId(id);
|
26 |
+
const movie = await this.#request('GET', `/3/movie/${cleanId}`, {
|
27 |
+
query: {
|
28 |
+
language: language || 'en-US'
|
29 |
+
}
|
30 |
+
}, {
|
31 |
+
key: `movie:${cleanId}:${language || '-'}`,
|
32 |
+
ttl: 3600*3
|
33 |
+
});
|
34 |
+
|
35 |
+
return {
|
36 |
+
name: this.#getSearchTitle(language ? movie.title || movie.original_title : movie.original_title || movie.title),
|
37 |
+
originalName: language ? movie.title || movie.original_title : movie.original_title || movie.title,
|
38 |
+
year: parseInt(`${movie.release_date}`.split('-').shift()),
|
39 |
+
imdb_id: movie.imdb_id || id,
|
40 |
+
type: 'movie',
|
41 |
+
stremioId: id,
|
42 |
+
id,
|
43 |
+
};
|
44 |
+
} catch (err) {
|
45 |
+
console.log(`Failed to fetch movie directly with TMDB ID ${id}:`, err.message);
|
46 |
+
}
|
47 |
+
}
|
48 |
+
|
49 |
+
// Fallback to IMDb lookup
|
50 |
+
const searchId = await this.#request('GET', `/3/find/${id}`, {
|
51 |
+
query: {
|
52 |
+
external_source: 'imdb_id',
|
53 |
+
language: language || 'en-US'
|
54 |
+
}
|
55 |
+
}, {
|
56 |
+
key: `searchId:${id}:${language || '-'}`,
|
57 |
+
ttl: 3600*3
|
58 |
+
});
|
59 |
+
|
60 |
+
if (!searchId.movie_results?.[0]) {
|
61 |
+
throw new Error(`Movie not found: ${id}`);
|
62 |
+
}
|
63 |
+
|
64 |
+
const meta = searchId.movie_results[0];
|
65 |
+
|
66 |
+
return {
|
67 |
+
name: this.#getSearchTitle(language ? meta.title || meta.original_title : meta.original_title || meta.title),
|
68 |
+
originalName: language ? meta.title || meta.original_title : meta.original_title || meta.title,
|
69 |
+
year: parseInt(`${meta.release_date}`.split('-').shift()),
|
70 |
+
imdb_id: id,
|
71 |
+
type: 'movie',
|
72 |
+
stremioId: id,
|
73 |
+
id,
|
74 |
+
};
|
75 |
+
}
|
76 |
+
|
77 |
+
async getEpisodeById(id, season, episode, language){
|
78 |
+
if (id.startsWith('tmdb-')) {
|
79 |
+
try {
|
80 |
+
const cleanId = this.#cleanTmdbId(id);
|
81 |
+
const show = await this.#request('GET', `/3/tv/${cleanId}`, {
|
82 |
+
query: {
|
83 |
+
language: language || 'en-US'
|
84 |
+
}
|
85 |
+
}, {
|
86 |
+
key: `tv:${cleanId}:${language || '-'}`,
|
87 |
+
ttl: 3600*3
|
88 |
+
});
|
89 |
+
|
90 |
+
const episodes = [];
|
91 |
+
show.seasons.forEach(s => {
|
92 |
+
for(let e = 1; e <= s.episode_count; e++){
|
93 |
+
episodes.push({
|
94 |
+
season: s.season_number,
|
95 |
+
episode: e,
|
96 |
+
stremioId: `${id}:${s.season_number}:${e}`
|
97 |
+
});
|
98 |
+
}
|
99 |
+
});
|
100 |
+
|
101 |
+
return {
|
102 |
+
name: this.#getSearchTitle(language ? show.name || show.original_name : show.original_name || show.name),
|
103 |
+
originalName: language ? show.name || show.original_name : show.original_name || show.name,
|
104 |
+
year: parseInt(`${show.first_air_date}`.split('-').shift()),
|
105 |
+
imdb_id: show.external_ids?.imdb_id || id,
|
106 |
+
type: 'series',
|
107 |
+
stremioId: `${id}:${season}:${episode}`,
|
108 |
+
id,
|
109 |
+
season,
|
110 |
+
episode,
|
111 |
+
episodes
|
112 |
+
};
|
113 |
+
} catch (err) {
|
114 |
+
console.log(`Failed to fetch show directly with TMDB ID ${id}:`, err.message);
|
115 |
+
}
|
116 |
+
}
|
117 |
+
|
118 |
+
const searchId = await this.#request('GET', `/3/find/${id}`, {
|
119 |
+
query: {
|
120 |
+
external_source: 'imdb_id'
|
121 |
+
}
|
122 |
+
}, {
|
123 |
+
key: `searchId:${id}`,
|
124 |
+
ttl: 3600*3
|
125 |
+
});
|
126 |
+
|
127 |
+
if (!searchId.tv_results?.[0]) {
|
128 |
+
throw new Error(`TV series not found: ${id}`);
|
129 |
+
}
|
130 |
+
|
131 |
+
const meta = await this.#request('GET', `/3/tv/${searchId.tv_results[0].id}`, {
|
132 |
+
query: {
|
133 |
+
language: language || 'en-US'
|
134 |
+
}
|
135 |
+
}, {
|
136 |
+
key: `${id}:${language}`,
|
137 |
+
ttl: 3600*3
|
138 |
+
});
|
139 |
+
|
140 |
+
const episodes = [];
|
141 |
+
meta.seasons.forEach(s => {
|
142 |
+
for(let e = 1; e <= s.episode_count; e++){
|
143 |
+
episodes.push({
|
144 |
+
season: s.season_number,
|
145 |
+
episode: e,
|
146 |
+
stremioId: `${id}:${s.season_number}:${e}`
|
147 |
+
});
|
148 |
+
}
|
149 |
+
});
|
150 |
+
|
151 |
+
return {
|
152 |
+
name: this.#getSearchTitle(language ? meta.name || meta.original_name : meta.original_name || meta.name),
|
153 |
+
originalName: language ? meta.name || meta.original_name : meta.original_name || meta.name,
|
154 |
+
year: parseInt(`${meta.first_air_date}`.split('-').shift()),
|
155 |
+
imdb_id: id,
|
156 |
+
type: 'series',
|
157 |
+
stremioId: `${id}:${season}:${episode}`,
|
158 |
+
id,
|
159 |
+
season,
|
160 |
+
episode,
|
161 |
+
episodes
|
162 |
+
};
|
163 |
+
}
|
164 |
+
|
165 |
+
async getLanguages(){
|
166 |
+
return [{value: '', label: '🌎Original (Recommended)'}].concat(
|
167 |
+
...config.languages
|
168 |
+
.map(language => ({value: language.iso639, label: language.label}))
|
169 |
+
.filter(language => language.value)
|
170 |
+
);
|
171 |
+
}
|
172 |
+
|
173 |
+
async #request(method, path, opts = {}, cacheOpts = {}) {
|
174 |
+
const apiKey = config.tmdbAccessToken;
|
175 |
+
|
176 |
+
// Normalize cache options
|
177 |
+
cacheOpts = {
|
178 |
+
key: '',
|
179 |
+
ttl: 0,
|
180 |
+
...cacheOpts
|
181 |
+
};
|
182 |
+
|
183 |
+
// Check cache first
|
184 |
+
if (cacheOpts.key) {
|
185 |
+
const cached = await cache.get(`tmdb:${cacheOpts.key}`);
|
186 |
+
if (cached) return cached;
|
187 |
+
}
|
188 |
+
|
189 |
+
// Clean up the path - remove any trailing slashes
|
190 |
+
path = path.replace(/\/+$/, '');
|
191 |
+
|
192 |
+
// Prepare query parameters including API key
|
193 |
+
const queryParams = new URLSearchParams({
|
194 |
+
api_key: apiKey,
|
195 |
+
...(opts.query || {})
|
196 |
+
});
|
197 |
+
|
198 |
+
// Build the complete URL
|
199 |
+
const url = `https://api.themoviedb.org${path}?${queryParams}`;
|
200 |
+
|
201 |
+
// Prepare request options
|
202 |
+
const requestOpts = {
|
203 |
+
method,
|
204 |
+
headers: {
|
205 |
+
'Accept': 'application/json',
|
206 |
+
'Content-Type': 'application/json;charset=utf-8',
|
207 |
+
...opts.headers
|
208 |
+
}
|
209 |
+
};
|
210 |
+
|
211 |
+
// Debug log the full URL
|
212 |
+
console.log('TMDB Request URL:', url);
|
213 |
+
console.log('TMDB Request Headers:', requestOpts.headers);
|
214 |
+
|
215 |
+
try {
|
216 |
+
const res = await fetch(url, requestOpts);
|
217 |
+
const data = await res.json();
|
218 |
+
|
219 |
+
// Debug log the response
|
220 |
+
console.log('TMDB Response Status:', res.status);
|
221 |
+
console.log('TMDB Response Data:', JSON.stringify(data, null, 2));
|
222 |
+
|
223 |
+
if (!res.ok) {
|
224 |
+
console.error('TMDB API Error:', {
|
225 |
+
status: res.status,
|
226 |
+
url: url,
|
227 |
+
headers: requestOpts.headers,
|
228 |
+
response: data
|
229 |
+
});
|
230 |
+
throw new Error(`TMDB API error: ${data.status_message || 'Unknown error'}`);
|
231 |
+
}
|
232 |
+
|
233 |
+
// Cache successful response if needed
|
234 |
+
if (cacheOpts.key && cacheOpts.ttl > 0) {
|
235 |
+
await cache.set(`tmdb:${cacheOpts.key}`, data, {ttl: cacheOpts.ttl});
|
236 |
+
}
|
237 |
+
|
238 |
+
return data;
|
239 |
+
} catch (error) {
|
240 |
+
console.error('TMDB Request failed:', error);
|
241 |
+
throw error;
|
242 |
+
}
|
243 |
+
}
|
244 |
+
}
|
src/lib/torrentInfos.js
ADDED
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import crypto from 'crypto';
|
2 |
+
import path from 'path';
|
3 |
+
import { writeFile, readFile, mkdir, readdir, unlink, stat } from 'node:fs/promises';
|
4 |
+
import parseTorrent from 'parse-torrent';
|
5 |
+
import {toMagnetURI} from 'parse-torrent';
|
6 |
+
import cache from './cache.js';
|
7 |
+
import config from './config.js';
|
8 |
+
|
9 |
+
const TORRENT_FOLDER = `${config.dataFolder}/torrents`;
|
10 |
+
const CACHE_FILE_DAYS = 7;
|
11 |
+
|
12 |
+
export async function createTorrentFolder(){
|
13 |
+
return mkdir(TORRENT_FOLDER).catch(() => false);
|
14 |
+
}
|
15 |
+
|
16 |
+
export async function cleanTorrentFolder(){
|
17 |
+
const files = await readdir(TORRENT_FOLDER);
|
18 |
+
const expireTime = new Date().getTime() - 86400*CACHE_FILE_DAYS*1000;
|
19 |
+
for (const file of files) {
|
20 |
+
if(!file.endsWith('.torrent'))continue;
|
21 |
+
const filePath = path.join(TORRENT_FOLDER, file);
|
22 |
+
const stats = await stat(filePath);
|
23 |
+
if(stats.ctimeMs < expireTime){
|
24 |
+
await unlink(filePath);
|
25 |
+
}
|
26 |
+
}
|
27 |
+
}
|
28 |
+
|
29 |
+
export async function get({link, id, magnetUrl, infoHash, name, size, type}){
|
30 |
+
|
31 |
+
try {
|
32 |
+
return await getById(id);
|
33 |
+
}catch(err){}
|
34 |
+
|
35 |
+
let parseInfos = null;
|
36 |
+
let torrentLocation = '';
|
37 |
+
|
38 |
+
if(magnetUrl && infoHash && name && size > 0 && type){
|
39 |
+
|
40 |
+
parseInfos = {
|
41 |
+
infoHash,
|
42 |
+
name,
|
43 |
+
length: size,
|
44 |
+
private: (type == 'private')
|
45 |
+
};
|
46 |
+
|
47 |
+
}else{
|
48 |
+
|
49 |
+
if(link.startsWith('http')){
|
50 |
+
|
51 |
+
try {
|
52 |
+
|
53 |
+
torrentLocation = `${TORRENT_FOLDER}/${id}.torrent`;
|
54 |
+
const buffer = await downloadTorrentFile({link, id, torrentLocation});
|
55 |
+
parseInfos = await parseTorrent(new Uint8Array(buffer));
|
56 |
+
|
57 |
+
if(!parseInfos.private){
|
58 |
+
magnetUrl = toMagnetURI(parseInfos);
|
59 |
+
}
|
60 |
+
|
61 |
+
}catch(err){
|
62 |
+
|
63 |
+
torrentLocation = '';
|
64 |
+
if(err.redirection && err.redirection.startsWith('magnet')){
|
65 |
+
link = err.redirection;
|
66 |
+
}else{
|
67 |
+
// Add indexer info to error message if available
|
68 |
+
const errorMessage = err.message + (err.indexerId ? ` (Indexer: ${err.indexerId})` : '');
|
69 |
+
throw new Error(errorMessage);
|
70 |
+
}
|
71 |
+
|
72 |
+
}
|
73 |
+
|
74 |
+
}
|
75 |
+
|
76 |
+
if(link.startsWith('magnet')){
|
77 |
+
|
78 |
+
parseInfos = await parseTorrent(link);
|
79 |
+
magnetUrl = link;
|
80 |
+
|
81 |
+
}
|
82 |
+
|
83 |
+
}
|
84 |
+
|
85 |
+
if(!parseInfos){
|
86 |
+
throw new Error(`Invalid link ${link}`);
|
87 |
+
}
|
88 |
+
|
89 |
+
const torrentInfos = {
|
90 |
+
id,
|
91 |
+
link,
|
92 |
+
magnetUrl: magnetUrl || '',
|
93 |
+
torrentLocation,
|
94 |
+
infoHash: (parseInfos.infoHash || '').toLowerCase(),
|
95 |
+
name: parseInfos.name || '',
|
96 |
+
private: parseInfos.private || false,
|
97 |
+
size: parseInfos.length || -1,
|
98 |
+
files: (parseInfos.files || []).map(file => {
|
99 |
+
return {
|
100 |
+
name: file.name,
|
101 |
+
size: file.length
|
102 |
+
}
|
103 |
+
})
|
104 |
+
};
|
105 |
+
|
106 |
+
await setById(id, torrentInfos);
|
107 |
+
|
108 |
+
return torrentInfos;
|
109 |
+
|
110 |
+
}
|
111 |
+
|
112 |
+
export async function getById(id){
|
113 |
+
const cacheKey = `torrentInfos:${id}`;
|
114 |
+
const infos = await cache.get(cacheKey);
|
115 |
+
|
116 |
+
if(!infos){
|
117 |
+
throw new Error(`Torrent infos cache seem expired for id ${id}`);
|
118 |
+
}
|
119 |
+
|
120 |
+
return infos;
|
121 |
+
}
|
122 |
+
|
123 |
+
async function setById(id, infos){
|
124 |
+
const cacheKey = `torrentInfos:${id}`;
|
125 |
+
await cache.set(cacheKey, infos, {ttl: 86400*CACHE_FILE_DAYS});
|
126 |
+
return infos;
|
127 |
+
}
|
128 |
+
|
129 |
+
export async function getTorrentFile(infos){
|
130 |
+
if(infos.torrentLocation){
|
131 |
+
try {
|
132 |
+
return await readFile(infos.torrentLocation);
|
133 |
+
}catch(err){}
|
134 |
+
}
|
135 |
+
|
136 |
+
return downloadTorrentFile(infos);
|
137 |
+
}
|
138 |
+
|
139 |
+
async function downloadTorrentFile({link, id, torrentLocation, indexerId}){
|
140 |
+
const res = await fetch(link, {redirect: 'manual'});
|
141 |
+
|
142 |
+
// Handle redirections
|
143 |
+
if(res.headers.has('location')){
|
144 |
+
throw Object.assign(new Error(`Redirection detected ...`), {redirection: res.headers.get('location')});
|
145 |
+
}
|
146 |
+
|
147 |
+
const contentType = res.headers.get('content-type') || '';
|
148 |
+
|
149 |
+
// Check if we got an HTML response (usually an error page)
|
150 |
+
if(contentType.includes('text/html')){
|
151 |
+
const htmlContent = await res.text();
|
152 |
+
let errorMessage = 'Site returned an error page';
|
153 |
+
|
154 |
+
// Try to extract meaningful error messages
|
155 |
+
if(htmlContent.includes('ratio is dangerously low')){
|
156 |
+
errorMessage = 'Download blocked due to low ratio';
|
157 |
+
}else if(htmlContent.includes('do not have permission')){
|
158 |
+
errorMessage = 'Permission denied';
|
159 |
+
}
|
160 |
+
// Add indexer information to error
|
161 |
+
throw Object.assign(new Error(errorMessage), { indexerId });
|
162 |
+
}
|
163 |
+
|
164 |
+
// Verify we got a torrent file
|
165 |
+
if(!contentType.includes('application/x-bittorrent')){
|
166 |
+
throw Object.assign(
|
167 |
+
new Error(`Invalid content-type: ${contentType}`),
|
168 |
+
{ indexerId }
|
169 |
+
);
|
170 |
+
}
|
171 |
+
|
172 |
+
if(res.status != 200){
|
173 |
+
throw Object.assign(
|
174 |
+
new Error(`Invalid status: ${res.status}`),
|
175 |
+
{ indexerId }
|
176 |
+
);
|
177 |
+
}
|
178 |
+
|
179 |
+
const buffer = await res.arrayBuffer();
|
180 |
+
writeFile(torrentLocation, new Uint8Array(buffer));
|
181 |
+
return buffer;
|
182 |
+
}
|
src/lib/util.js
ADDED
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {setTimeout} from 'timers/promises';
|
2 |
+
|
3 |
+
export function numberPad(number, count){
|
4 |
+
return `${number}`.padStart(count || 2, 0);
|
5 |
+
}
|
6 |
+
|
7 |
+
export function parseWords(str){
|
8 |
+
return str.replace(/[^a-zA-Z0-9]+/g, ' ').split(' ').filter(Boolean);
|
9 |
+
}
|
10 |
+
|
11 |
+
export function sortBy(...keys){
|
12 |
+
return (a, b) => {
|
13 |
+
if(typeof(keys[0]) == 'string')keys = [keys];
|
14 |
+
for(const [key, reverse] of keys){
|
15 |
+
if(a[key] > b[key])return reverse ? -1 : 1;
|
16 |
+
if(a[key] < b[key])return reverse ? 1 : -1;
|
17 |
+
}
|
18 |
+
return 0;
|
19 |
+
}
|
20 |
+
}
|
21 |
+
|
22 |
+
export function bytesToSize(bytes){
|
23 |
+
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
24 |
+
if (bytes === 0) return '0 Byte';
|
25 |
+
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)));
|
26 |
+
return (Math.round(bytes / Math.pow(1024, i) * 100) / 100) + ' ' + sizes[i];
|
27 |
+
}
|
28 |
+
|
29 |
+
export function wait(ms){
|
30 |
+
return setTimeout(ms);
|
31 |
+
}
|
32 |
+
|
33 |
+
export function isVideo(filename){
|
34 |
+
return [
|
35 |
+
"3g2",
|
36 |
+
"3gp",
|
37 |
+
"avi",
|
38 |
+
"flv",
|
39 |
+
"mkv",
|
40 |
+
"mk3d",
|
41 |
+
"mov",
|
42 |
+
"mp2",
|
43 |
+
"mp4",
|
44 |
+
"m4v",
|
45 |
+
"mpe",
|
46 |
+
"mpeg",
|
47 |
+
"mpg",
|
48 |
+
"mpv",
|
49 |
+
"webm",
|
50 |
+
"wmv",
|
51 |
+
"ogm",
|
52 |
+
"ts",
|
53 |
+
"m2ts"
|
54 |
+
].includes(filename?.split('.').pop());
|
55 |
+
}
|
56 |
+
|
57 |
+
export async function promiseTimeout(promise, ms){
|
58 |
+
const ac = new AbortController();
|
59 |
+
const waitPromise = setTimeout(ms, null, { signal: ac.signal }).then(() => Promise.reject(`Max execution time reached ${ms}`));
|
60 |
+
return Promise.race([waitPromise, promise.finally(() => {
|
61 |
+
ac.abort();
|
62 |
+
})]);
|
63 |
+
}
|
src/static/css/bootstrap.min.css
ADDED
The diff for this file is too large to render.
See raw diff
|
|
src/static/img/icon.png
ADDED
src/static/js/vue.global.prod.js
ADDED
The diff for this file is too large to render.
See raw diff
|
|
src/static/videos/access_denied.mp4
ADDED
Binary file (42.9 kB). View file
|
|
src/static/videos/error.mp4
ADDED
Binary file (39.3 kB). View file
|
|
src/static/videos/expired_api_key.mp4
ADDED
Binary file (38.9 kB). View file
|
|
src/static/videos/not_premium.mp4
ADDED
Binary file (30 kB). View file
|
|
src/static/videos/not_ready.mp4
ADDED
Binary file (37 kB). View file
|
|
src/static/videos/two_factor_auth.mp4
ADDED
Binary file (42.6 kB). View file
|
|
src/template/configure.html
ADDED
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!doctype html>
|
2 |
+
<html lang="en" data-bs-theme="dark">
|
3 |
+
<head>
|
4 |
+
<meta charset="utf-8">
|
5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
6 |
+
<title>Jackettio</title>
|
7 |
+
<link rel="icon" href="/icon">
|
8 |
+
<link href="/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
|
9 |
+
<style>
|
10 |
+
.container {
|
11 |
+
max-width: 600px;
|
12 |
+
}
|
13 |
+
[v-cloak] { display: none; }
|
14 |
+
</style>
|
15 |
+
</head>
|
16 |
+
<body id="app">
|
17 |
+
<div class="container my-5" v-cloak>
|
18 |
+
<h1 class="mb-4">{{addon.name}} <span style="font-size:.6em">v{{addon.version}}</span></h1>
|
19 |
+
<form class="shadow p-3 bg-dark-subtle z-3 rounded">
|
20 |
+
|
21 |
+
<!-- welcome-message -->
|
22 |
+
|
23 |
+
<h5 class="mt-4">Indexers</h5>
|
24 |
+
<div class="ps-2 border-start border-secondary-subtle">
|
25 |
+
<div class="mb-3 alert alert-warning" v-if="indexers.length == 0">
|
26 |
+
No indexers available, Jackett instance does not seem to be configured correctly.
|
27 |
+
</div>
|
28 |
+
<div class="mb-3" v-if="indexers.length >= 1 && !immulatableUserConfigKeys.includes('indexers')">
|
29 |
+
<label>Indexers enabled:</label>
|
30 |
+
<div class="d-flex flex-wrap">
|
31 |
+
<div v-for="indexer in indexers" class="me-3" :title="indexer.types.join(', ')">
|
32 |
+
<input class="form-check-input me-1" type="checkbox" v-model="indexer.checked" :id="indexer.label">
|
33 |
+
<label class="form-check-label" :for="indexer.label">{{indexer.label}}</label>
|
34 |
+
</div>
|
35 |
+
</div>
|
36 |
+
</div>
|
37 |
+
<div class="mb-3" v-if="!immulatableUserConfigKeys.includes('indexerTimeoutSec')">
|
38 |
+
<label>Indexer Timeout</label>
|
39 |
+
<input type="number" v-model="form.indexerTimeoutSec" min="6" max="120" class="form-control">
|
40 |
+
<small class="text-muted">Max execution time in seconds before timeout.</small>
|
41 |
+
</div>
|
42 |
+
<div class="mb-3" v-if="passkey && passkey.enabled">
|
43 |
+
<label>Private indexer Passkey <i>(Recommended)</i></label>
|
44 |
+
<small v-if="passkey.infoUrl" class="ms-2"><a :href="passkey.infoUrl" target="_blank" rel="noreferrer">Get It here</a></small>
|
45 |
+
<input type="text" v-model="form.passkey" class="form-control" :pattern="passkey.pattern">
|
46 |
+
<small class="text-muted">With the Passkey you can stream both cached and uncached torrents, while without the Passkey you can only stream cached torrents.</small>
|
47 |
+
</div>
|
48 |
+
</div>
|
49 |
+
|
50 |
+
<h5 class="mt-4">Filters & Sorts</h5>
|
51 |
+
<div class="ps-2 border-start border-secondary-subtle">
|
52 |
+
<div class="mb-3" v-if="!immulatableUserConfigKeys.includes('qualities')">
|
53 |
+
<label>Qualities:</label>
|
54 |
+
<div class="d-flex flex-wrap">
|
55 |
+
<div v-for="quality in qualities" class="me-3">
|
56 |
+
<input class="form-check-input me-1" type="checkbox" v-model="quality.checked" :id="quality.label">
|
57 |
+
<label class="form-check-label" :for="quality.label">{{quality.label}}</label>
|
58 |
+
</div>
|
59 |
+
</div>
|
60 |
+
</div>
|
61 |
+
<div class="mb-3" v-if="!immulatableUserConfigKeys.includes('excludeKeywords')">
|
62 |
+
<label>Exclude keywords in torrent name</label>
|
63 |
+
<input type="text" v-model="form.excludeKeywords" placeholder="keyword1,keyword2" class="form-control">
|
64 |
+
<small class="text-muted">Example: cam,xvid</small>
|
65 |
+
</div>
|
66 |
+
<div class="mb-3 d-flex flex-row" v-if="!immulatableUserConfigKeys.includes('hideUncached')">
|
67 |
+
<div class="form-check form-switch">
|
68 |
+
<input class="form-check-input me-1" type="checkbox" v-model="form.hideUncached" id="hideUncached">
|
69 |
+
<label for="hideUncached" class="d-flex flex-column">
|
70 |
+
<span>Display only cached torrents</span>
|
71 |
+
</label>
|
72 |
+
</div>
|
73 |
+
</div>
|
74 |
+
<div class="mb-3" v-if="!immulatableUserConfigKeys.includes('sortCached')">
|
75 |
+
<label>Cached torrents sorting</label>
|
76 |
+
<select v-model="form.sortCached" class="form-select">
|
77 |
+
<option v-for="sort in sorts" :value="sort.value">{{sort.label}}</option>
|
78 |
+
</select>
|
79 |
+
</div>
|
80 |
+
<div class="mb-3" v-if="!immulatableUserConfigKeys.includes('sortUncached') && !form.hideUncached">
|
81 |
+
<label>Uncached torrents sorting</label>
|
82 |
+
<select v-model="form.sortUncached" class="form-select">
|
83 |
+
<option v-for="sort in sorts" :value="sort.value">{{sort.label}}</option>
|
84 |
+
</select>
|
85 |
+
</div>
|
86 |
+
<div class="mb-3" v-if="!immulatableUserConfigKeys.includes('maxTorrents')">
|
87 |
+
<label>Max Torrents in search</label>
|
88 |
+
<input type="number" v-model="form.maxTorrents" min="1" max="30" class="form-control">
|
89 |
+
<small class="text-muted">A high number can significantly slow down the request</small>
|
90 |
+
</div>
|
91 |
+
<div class="mb-3" v-if="!immulatableUserConfigKeys.includes('priotizePackTorrents')">
|
92 |
+
<label>Force include <small>n</small> series pack in search</label>
|
93 |
+
<input type="number" v-model="form.priotizePackTorrents" min="1" max="30" class="form-control">
|
94 |
+
<small class="text-muted">This could increase the chance of cached torrents. 2 is a good number</small>
|
95 |
+
</div>
|
96 |
+
<div class="mb-3" v-if="!immulatableUserConfigKeys.includes('priotizeLanguages')">
|
97 |
+
<label>Priotize audio languages</label>
|
98 |
+
<select v-model="form.priotizeLanguages" class="form-select" multiple>
|
99 |
+
<option v-for="language in languages" :value="language.value">{{language.label}}</option>
|
100 |
+
</select>
|
101 |
+
</div>
|
102 |
+
<div class="mb-3" v-if="!immulatableUserConfigKeys.includes('metaLanguage') && metaLanguages.length > 0">
|
103 |
+
<label>Search languages</label>
|
104 |
+
<select v-model="form.metaLanguage" class="form-select">
|
105 |
+
<option v-for="metaLanguage in metaLanguages" :value="metaLanguage.value">{{metaLanguage.label}}</option>
|
106 |
+
</select>
|
107 |
+
<small class="text-muted">By default, the search uses the original title and works in most cases, but you can force the search to use a specific language.</small>
|
108 |
+
</div>
|
109 |
+
</div>
|
110 |
+
|
111 |
+
<h5 class="mt-4">Debrid</h5>
|
112 |
+
<div class="ps-2 border-start border-secondary-subtle">
|
113 |
+
<div class="mb-3 d-flex flex-row" v-if="!immulatableUserConfigKeys.includes('forceCacheNextEpisode')">
|
114 |
+
<div class="form-check form-switch">
|
115 |
+
<input class="form-check-input me-1" type="checkbox" v-model="form.forceCacheNextEpisode" id="forceCacheNextEpisode">
|
116 |
+
<label for="forceCacheNextEpisode" class="d-flex flex-column">
|
117 |
+
<span>Prepare the next episode on Debrid. (Recommended)</span>
|
118 |
+
<small class="text-muted">Automatically add the next espisode on debrid when not avaiable to instantally stream it later.</small>
|
119 |
+
</label>
|
120 |
+
</div>
|
121 |
+
</div>
|
122 |
+
<div class="mb-3">
|
123 |
+
<label>Debrid provider:</label>
|
124 |
+
<select v-model="debrid" class="form-select" @change="form.debridId = debrid.id">
|
125 |
+
<option v-for="option in debrids" :value="option">{{ option.name }}</option>
|
126 |
+
</select>
|
127 |
+
</div>
|
128 |
+
<div v-for="field in debrid.configFields" class="mb-3">
|
129 |
+
<label>{{field.label}}:</label>
|
130 |
+
<small v-if="field.href" class="ms-2"><a :href="field.href.value" target="_blank" rel="noreferrer">{{field.href.label}}</a></small>
|
131 |
+
<input type="{{field.type}}" v-model="field.value" class="form-control">
|
132 |
+
</div>
|
133 |
+
</div>
|
134 |
+
|
135 |
+
<h5 class="mt-4">MediaFlow Proxy</h5>
|
136 |
+
<div class="ps-2 border-start border-secondary-subtle">
|
137 |
+
<div class="mb-3" v-if="!immulatableUserConfigKeys.includes('enableMediaFlow')">
|
138 |
+
<div class="form-check form-switch">
|
139 |
+
<input class="form-check-input" type="checkbox" v-model="form.enableMediaFlow" id="enableMediaFlow">
|
140 |
+
<label class="form-check-label" for="enableMediaFlow">Enable MediaFlow Proxy</label>
|
141 |
+
</div>
|
142 |
+
</div>
|
143 |
+
<div v-if="form.enableMediaFlow">
|
144 |
+
<div class="mb-2">
|
145 |
+
<a href="https://github.com/mhdzumair/mediaflow-proxy?tab=readme-ov-file#mediaflow-proxy" target="_blank" rel="noopener">
|
146 |
+
MediaFlow Setup Guide
|
147 |
+
</a>
|
148 |
+
</div>
|
149 |
+
<div class="mb-3" v-if="!immulatableUserConfigKeys.includes('mediaflowProxyUrl')">
|
150 |
+
<label for="mediaflowProxyUrl">MediaFlow Proxy URL:</label>
|
151 |
+
<input type="text" v-model="form.mediaflowProxyUrl" class="form-control" id="mediaflowProxyUrl" placeholder="https://your-mediaflow-proxy-url.com">
|
152 |
+
</div>
|
153 |
+
<div class="mb-3" v-if="!immulatableUserConfigKeys.includes('mediaflowApiPassword')">
|
154 |
+
<label for="mediaflowApiPassword">MediaFlow API Password:</label>
|
155 |
+
<div class="input-group">
|
156 |
+
<input :type="showMediaFlowPassword ? 'text' : 'password'" v-model="form.mediaflowApiPassword" class="form-control" id="mediaflowApiPassword">
|
157 |
+
<button class="btn btn-outline-secondary" type="button" @click="toggleMediaFlowPassword">
|
158 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eye" viewBox="0 0 16 16" v-if="showMediaFlowPassword">
|
159 |
+
<path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8M1.173 8a13 13 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5s3.879 1.168 5.168 2.457A13 13 0 0 1 14.828 8q-.086.13-.195.288c-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5s-3.879-1.168-5.168-2.457A13 13 0 0 1 1.172 8z"/>
|
160 |
+
<path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5M4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0"/>
|
161 |
+
</svg>
|
162 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eye-slash-fill" viewBox="0 0 16 16" v-else>
|
163 |
+
<path d="m10.79 12.912-1.614-1.615a3.5 3.5 0 0 1-4.474-4.474l-2.06-2.06C.938 6.278 0 8 0 8s3 5.5 8 5.5a7 7 0 0 0 2.79-.588M5.21 3.088A7 7 0 0 1 8 2.5c5 0 8 5.5 8 5.5s-.939 1.721-2.641 3.238l-2.062-2.062a3.5 3.5 0 0 0-4.474-4.474z"/>
|
164 |
+
<path d="M5.525 7.646a2.5 2.5 0 0 0 2.829 2.829zm4.95.708-2.829-2.83a2.5 2.5 0 0 1 2.829 2.829zm3.171 6-12-12 .708-.708 12 12z"/>
|
165 |
+
</svg>
|
166 |
+
</button>
|
167 |
+
</div>
|
168 |
+
</div>
|
169 |
+
<div class="mb-3" v-if="!immulatableUserConfigKeys.includes('mediaflowPublicIp')">
|
170 |
+
<label for="mediaflowPublicIp">MediaFlow Public IP (Optional):</label>
|
171 |
+
<input type="text" v-model="form.mediaflowPublicIp" class="form-control" id="mediaflowPublicIp" placeholder="Enter public IP address">
|
172 |
+
<small class="text-muted">
|
173 |
+
Configure this only when running MediaFlow locally with a proxy service. Leave empty if MediaFlow is configured locally without a proxy server or if it's hosted on a remote server.
|
174 |
+
</small>
|
175 |
+
</div>
|
176 |
+
</div>
|
177 |
+
</div>
|
178 |
+
|
179 |
+
<div class="my-3 d-flex align-items-center">
|
180 |
+
<button @click="configure" type="button" class="btn btn-primary" :disabled="!debrid.id">{{isUpdate ? 'Update' : 'Install'}}</button>
|
181 |
+
<div v-if="error" class="text-danger ms-2">{{error}}</div>
|
182 |
+
<div class="ms-auto">
|
183 |
+
<a v-if="manifestUrl" :href="manifestUrl">Stremio Link</a>
|
184 |
+
</div>
|
185 |
+
</div>
|
186 |
+
|
187 |
+
</form>
|
188 |
+
</div>
|
189 |
+
|
190 |
+
<script src="/js/vue.global.prod.js"></script>
|
191 |
+
<script type="text/javascript">/** import-config */</script>
|
192 |
+
<script type="text/javascript">
|
193 |
+
const { createApp, ref } = Vue
|
194 |
+
createApp({
|
195 |
+
setup() {
|
196 |
+
|
197 |
+
const {addon, debrids, defaultUserConfig, qualities, languages, sorts, indexers, passkey, immulatableUserConfigKeys, metaLanguages} = config;
|
198 |
+
|
199 |
+
const debrid = ref({});
|
200 |
+
const error = ref('');
|
201 |
+
const manifestUrl = ref('');
|
202 |
+
let isUpdate = false;
|
203 |
+
const showMediaFlowPassword = ref(false);
|
204 |
+
|
205 |
+
function toggleMediaFlowPassword() {
|
206 |
+
showMediaFlowPassword.value = !showMediaFlowPassword.value;
|
207 |
+
}
|
208 |
+
|
209 |
+
if(config.userConfig){
|
210 |
+
try {
|
211 |
+
const savedUserConfig = JSON.parse(atob(config.userConfig));
|
212 |
+
Object.assign(defaultUserConfig, savedUserConfig);
|
213 |
+
debrid.value = debrids.find(debrid => debrid.id == savedUserConfig.debridId) || {};
|
214 |
+
debrid.value.configFields.forEach(field => field.value = savedUserConfig[field.name] || null);
|
215 |
+
isUpdate = true;
|
216 |
+
}catch(err){}
|
217 |
+
}
|
218 |
+
|
219 |
+
const form = ref({
|
220 |
+
maxTorrents: defaultUserConfig.maxTorrents,
|
221 |
+
priotizePackTorrents: defaultUserConfig.priotizePackTorrents,
|
222 |
+
excludeKeywords: defaultUserConfig.excludeKeywords.join(','),
|
223 |
+
debridId: defaultUserConfig.debridId || '',
|
224 |
+
hideUncached: defaultUserConfig.hideUncached,
|
225 |
+
sortCached: defaultUserConfig.sortCached,
|
226 |
+
sortUncached: defaultUserConfig.sortUncached,
|
227 |
+
forceCacheNextEpisode: defaultUserConfig.forceCacheNextEpisode,
|
228 |
+
priotizeLanguages: defaultUserConfig.priotizeLanguages,
|
229 |
+
indexerTimeoutSec: defaultUserConfig.indexerTimeoutSec,
|
230 |
+
metaLanguage: defaultUserConfig.metaLanguage,
|
231 |
+
enableMediaFlow: defaultUserConfig.enableMediaFlow,
|
232 |
+
mediaflowProxyUrl: defaultUserConfig.mediaflowProxyUrl,
|
233 |
+
mediaflowApiPassword: defaultUserConfig.mediaflowApiPassword,
|
234 |
+
mediaflowPublicIp: defaultUserConfig.mediaflowPublicIp
|
235 |
+
});
|
236 |
+
|
237 |
+
qualities.forEach(quality => quality.checked = defaultUserConfig.qualities.includes(quality.value));
|
238 |
+
indexers.forEach(indexer => indexer.checked = defaultUserConfig.indexers.includes(indexer.value) || defaultUserConfig.indexers.includes('all'));
|
239 |
+
|
240 |
+
async function configure(){
|
241 |
+
try {
|
242 |
+
error.value = '';
|
243 |
+
const userConfig = Object.assign({}, form.value);
|
244 |
+
userConfig.qualities = qualities.filter(quality => quality.checked).map(quality => quality.value);
|
245 |
+
userConfig.indexers = indexers.filter(indexer => indexer.checked).map(indexer => indexer.value);
|
246 |
+
userConfig.excludeKeywords = form.value.excludeKeywords.split(',').filter(Boolean);
|
247 |
+
debrid.value.configFields.forEach(field => {
|
248 |
+
if(field.required && !field.value)throw new Error(`${field.label} is required`);
|
249 |
+
userConfig[field.name] = field.value
|
250 |
+
});
|
251 |
+
|
252 |
+
if(!userConfig.debridId){
|
253 |
+
throw new Error(`Debrid is required`);
|
254 |
+
}
|
255 |
+
|
256 |
+
if(!userConfig.qualities.length){
|
257 |
+
throw new Error(`Quality is required`);
|
258 |
+
}
|
259 |
+
|
260 |
+
if(!userConfig.indexers.length && indexers.length){
|
261 |
+
throw new Error(`Indexer is required`);
|
262 |
+
}
|
263 |
+
|
264 |
+
if(passkey.enabled){
|
265 |
+
if(userConfig.passkey && !userConfig.passkey.match(new RegExp(passkey.pattern))){
|
266 |
+
throw new Error(`Tracker passkey have invalid format: ${passkey.pattern}`);
|
267 |
+
}
|
268 |
+
}
|
269 |
+
|
270 |
+
// MediaFlow config validation
|
271 |
+
if (userConfig.enableMediaFlow) {
|
272 |
+
if (!userConfig.mediaflowProxyUrl) {
|
273 |
+
throw new Error('MediaFlow Proxy URL is required when MediaFlow is enabled');
|
274 |
+
}
|
275 |
+
if (!userConfig.mediaflowApiPassword) {
|
276 |
+
throw new Error('MediaFlow API Password is required when MediaFlow is enabled');
|
277 |
+
}
|
278 |
+
}
|
279 |
+
|
280 |
+
manifestUrl.value = `stremio://${document.location.host}/${btoa(JSON.stringify(userConfig))}/manifest.json`;
|
281 |
+
document.location.href = manifestUrl.value;
|
282 |
+
}catch(err){
|
283 |
+
error.value = err.message || err;
|
284 |
+
}
|
285 |
+
}
|
286 |
+
|
287 |
+
return {
|
288 |
+
addon,
|
289 |
+
debrids,
|
290 |
+
debrid,
|
291 |
+
qualities,
|
292 |
+
sorts,
|
293 |
+
form,
|
294 |
+
configure,
|
295 |
+
error,
|
296 |
+
manifestUrl,
|
297 |
+
indexers,
|
298 |
+
passkey,
|
299 |
+
immulatableUserConfigKeys,
|
300 |
+
languages,
|
301 |
+
isUpdate,
|
302 |
+
metaLanguages,
|
303 |
+
showMediaFlowPassword,
|
304 |
+
toggleMediaFlowPassword
|
305 |
+
}
|
306 |
+
}
|
307 |
+
}).mount('#app')
|
308 |
+
</script>
|
309 |
+
</body>
|
310 |
+
</html>
|