no1b4me commited on
Commit
1fa2c88
·
verified ·
1 Parent(s): 8f25742

Upload 38 files

Browse files
.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>