Compare commits

...

275 Commits

Author SHA1 Message Date
Jip Frijlink
1f6318360e Merge pull request #485 from movie-web/dev
Fix poster issue (into prod)
2023-11-09 15:53:54 +01:00
Jip Frijlink
791299dd43 Merge pull request #484 from movie-web/fix-posters
Fix poster issue
2023-11-09 15:50:22 +01:00
Jip Fr
2c92bbf94e Fix poster issue 2023-11-09 15:34:35 +01:00
mrjvs
e3569c7ed7 Merge pull request #479 from movie-web/dev
v3.2.3 - fixing upcloud
2023-11-07 21:14:16 +01:00
mrjvs
196a805d32 Merge branch 'master' into dev 2023-11-07 21:13:07 +01:00
William Oldham
94d6d7b37e Merge pull request #478 from movie-web/fix-upcloud
Fix upcloud
2023-11-07 20:10:22 +00:00
mrjvs
fde5f0c82e version bump 2023-11-07 21:07:39 +01:00
mrjvs
bb449d6dfb Fix upcloud 2023-11-07 21:04:43 +01:00
William Oldham
bb8b21324b Merge pull request #475 from movie-web/dev
Fix superstream
2023-11-01 14:07:45 +00:00
Jip Fr
53fe6031d1 Fix superstream 2023-11-01 15:04:52 +01:00
William Oldham
ee9400373d Merge pull request #409 from movie-web/dev
Release 3.2.1
2023-08-16 20:09:03 +01:00
William Oldham
6c8cc63cbc Merge pull request #408 from movie-web/gone-is-fast-cdn
Goodbye faster cdn
2023-08-16 19:33:22 +01:00
mrjvs
8c105e78b5 bump version 2023-08-16 19:26:43 +02:00
mrjvs
28f253c542 Remove faster cdn, as its broken 2023-08-16 19:26:29 +02:00
William Oldham
38fa25da2c Merge pull request #405 from movie-web/dev
Version 3.2.0
2023-08-15 22:33:15 +01:00
mrjvs
efb9a7a076 bump version 2023-08-15 23:30:41 +02:00
William Oldham
5eab635f19 Merge pull request #404 from movie-web/more-providers
Updated providers
2023-08-15 22:25:29 +01:00
mrjvs
c1dceab8eb Fix class sorting 2023-08-15 23:17:57 +02:00
mrjvs
e202229766 add todo 2023-08-15 23:04:01 +02:00
mrjvs
2e3684eaad rip out consumet and fix upcloud 2023-08-15 22:46:48 +02:00
mrjvs
31fcd22822 Make superstream a fast boi 2023-08-15 20:19:25 +02:00
mrjvs
1524a3af39 faster superstream url 2023-08-15 20:13:35 +02:00
mrjvs
072b2d134b Disabled broken providers 2023-08-15 20:10:51 +02:00
William Oldham
606e55d552 Create CODEOWNERS 2023-08-14 23:28:30 +01:00
William Oldham
0b8aeb1832 Merge pull request #391 from movie-web/dev
Patch for FlixHQ
2023-07-27 23:09:12 +01:00
William Oldham
3bd2bb4b2c chore(flixhq): remove wrong comment 2023-07-27 23:09:03 +01:00
William Oldham
6e8e323417 fix(flixhq): change consumet api to official URL 2023-07-27 23:05:26 +01:00
William Oldham
50fdf230a1 Merge branch 'master' into dev 2023-07-27 22:53:35 +01:00
St Peter and St Pauls Catholic Voluntary Academy
765cf2a17a chore: bump version for FlixHQ patch 2023-07-27 22:51:34 +01:00
William Oldham
2d431595cd Merge pull request #390 from kacperkwapisz/patch-1
Update flixhq.ts
2023-07-27 22:47:50 +01:00
Kacper Kwapisz
3bceb2a905 Update flixhq.ts
flixHqBase changed.

Old: `https://consumet-api-clone.vercel.app`
New: `https://consumet-api-clone-six.vercel.app`
2023-07-27 13:39:09 +02:00
William Oldham
e9c0e64cf0 Merge pull request #384 from movie-web/dev
v3.1.3
2023-07-13 18:41:11 +01:00
William Oldham
d9c944a8fa chore: bump version number for patch fix 2023-07-13 18:37:38 +01:00
William Oldham
bd40165bc2 docs(pr-template): minor changes to pr template
Remove heading
Update closes issue to conform to the spec
fix grammary
2023-07-13 18:37:03 +01:00
William Oldham
65c7a461d7 Merge pull request #383 from movie-web/fix-ep
Fix wrong targeted episode on GoMovies
2023-07-13 18:32:32 +01:00
mrjvs
3103ecd004 Fix wrong targeted episode on GoMovies 2023-07-13 19:15:57 +02:00
William Oldham
c744d3bc7d Merge pull request #352 from movie-web/meta/code-of-conduct
Community Standards
2023-07-06 17:46:49 +01:00
William Oldham
e0009c8f29 docs(contributing): add new line to warning message 2023-07-04 17:16:21 +01:00
William Oldham
f6af13f7a6 docs(contributing): change issue page link to one with approved label filter 2023-07-04 17:16:04 +01:00
William Oldham
f7ebb6ed89 docs(contributing): fix contents link 2023-07-04 17:12:46 +01:00
William Oldham
468ee4dcf6 docs(pr-template): add pull request template 2023-07-04 17:08:33 +01:00
William Oldham
bc21fa4749 docs(contributing): rename planned label to approved 2023-07-03 19:00:56 +01:00
William Oldham
3cdb056d43 docs(contributing): fix grammar error in language contributions 2023-07-03 18:39:31 +01:00
William Oldham
6a926ec7fe docs(contributing): reword note to be based off the planned label rather than awaiting approval 2023-07-03 18:30:28 +01:00
William Oldham
0c18d8f04b docs(contributing): add note about editorconfig ext and vsc auto install 2023-07-03 18:29:56 +01:00
William Oldham
6705683c19 doc(selfhosting): update example message to be more clear
Co-authored-by: Jip Fr <jipfrijlink@gmail.com>
2023-07-03 18:14:17 +01:00
William Oldham
91f9f56174 doc: use discord.com instead of discordapp.com 2023-07-03 18:08:03 +01:00
William Oldham
ce71a2d638 docs(selfhosting): match update with dev branch 2023-07-03 18:07:45 +01:00
William Oldham
d2d710ad37 Merge branch 'dev' into meta/code-of-conduct 2023-07-03 17:59:45 +01:00
William Oldham
8d82ee5f88 docs(contributing): proof reading pass 2023-07-03 17:52:09 +01:00
William Oldham
b1663a919f docs(contributing): add links and tweak text of language guidelines 2023-07-03 10:34:39 +01:00
William Oldham
bc3848fae4 docs(contributing): Add the meat of the language contribution section 2023-07-03 10:31:39 +01:00
William Oldham
76a12b8f7a docs(contributing): add start of language contributions 2023-07-02 23:26:17 +01:00
William Oldham
8d0cd59d85 docs(contributing): Add a contents sections 2023-07-02 23:26:02 +01:00
William Oldham
8b1a5bce4a doc(selfhosting): emphasis DO NOT and fix further grammar 2023-07-02 16:56:20 +01:00
William Oldham
9b852f12cf doc(selfhosting): tweak text for grammar and add links to common services 2023-07-02 16:54:49 +01:00
William Oldham
81f0425755 Apply suggestions from code review
Co-authored-by: James Hawkins <jhawki2005@gmail.com>
2023-06-25 18:29:50 +01:00
William Oldham
a9a3eac4ea Add contributing, dev env and tips section to CONTRIBUTING.md 2023-06-24 21:52:16 +01:00
mrjvs
06e54886e5 Merge branch 'dev' 2023-06-23 23:04:52 +02:00
mrjvs
ce00f1c5c2 version bump 2023-06-23 23:04:42 +02:00
mrjvs
244c603ad7 Merge branch 'dev' 2023-06-23 23:00:40 +02:00
mrjvs
ea52156bb8 fix config.js preset and typo in documentation 2023-06-23 23:00:28 +02:00
mrjvs
1c6b0ae3e8 Merge pull request #367 from movie-web/dev
v3.1.1
2023-06-23 22:07:34 +02:00
mrjvs
00e25f1ae4 Merge branch 'master' into dev 2023-06-23 22:04:27 +02:00
mrjvs
6aa0c86e42 bump version 2023-06-23 21:58:45 +02:00
mrjvs
fcf8a9e755 update configuration documentation 2023-06-23 21:58:33 +02:00
mrjvs
e5e45c4fa0 Merge pull request #365 from castdrian/poster-hotfix
fix(metadata): hotfix lonely poster path
2023-06-23 21:30:04 +02:00
adrifcastr
f68c8148d8 fix poster path 2023-06-23 14:20:04 +02:00
mrjvs
4563ea2c18 Merge pull request #361 from movie-web/dev
v3.1.0
2023-06-22 22:44:06 +02:00
mrjvs
eea9c19b56 Merge branch 'master' into dev 2023-06-22 22:37:48 +02:00
mrjvs
c4c7816543 migrations but better
Co-authored-by: William Oldham <github@binaryoverload.co.uk>
2023-06-22 22:37:16 +02:00
mrjvs
545120d5cc bump version 2023-06-22 20:58:44 +02:00
mrjvs
4ff3e43c78 Merge pull request #328 from castdrian/refactor-metadata
refactor(metadata): use tmdb for search and metadata
2023-06-22 20:32:11 +02:00
adrifcastr
845fd93597 fix small oversight 2023-06-22 20:29:10 +02:00
adrifcastr
e0bf711a79 cleanup 2023-06-22 10:48:00 +02:00
adrifcastr
9fbba7ea55 localstorage migration 2023-06-22 10:47:14 +02:00
mrjvs
f892a3037f fix redirection issues 2023-06-21 21:35:25 +02:00
adrifcastr
394271857f refactor and improve legacy redirect 2023-06-21 18:16:41 +02:00
adrifcastr
f5f69ca7d4 default to season 1, with specials still playable 2023-06-21 15:14:48 +02:00
adrifcastr
1c17ef679d clean up requests 2023-06-21 14:04:37 +02:00
adrifcastr
09f6a3125b clean up remnants from details fetch 2023-06-21 13:54:34 +02:00
adrifcastr
436fb2707b update all remaining imports 2023-06-21 13:38:48 +02:00
adrifcastr
a46cfa43d3 fix test imports 2023-06-21 13:31:50 +02:00
adrifcastr
dccab9b0bf directly get poster url 2023-06-21 13:26:03 +02:00
adrifcastr
7c3d4aac27 refactor typedefs 2023-06-21 13:23:39 +02:00
adrifcastr
1408fcde93 export functions directly 2023-06-21 13:07:33 +02:00
adrifcastr
89cdf74b2f readd vanished comment 2023-06-21 12:51:30 +02:00
adrifcastr
984d215312 parse dates instead of cringe string manipulation 2023-06-21 12:50:41 +02:00
adrifcastr
430486a9b9 direct return 2023-06-21 12:48:33 +02:00
adrifcastr
9495a3bf41 reduce casts 2023-06-21 12:47:09 +02:00
adrifcastr
33b67f32b1 no undef for tmdbmetaresult 2023-06-21 12:43:36 +02:00
William Oldham
03d414a200 Add initial content to contributing guidelines 2023-06-20 21:04:29 +01:00
William Oldham
81b22b0473 Add "awaiting-approval" label to issue templates 2023-06-20 21:04:29 +01:00
William Oldham
5c50155718 Update Discord invite to discord.movie-web.app 2023-06-20 21:04:29 +01:00
William Oldham
102d252f82 Move code of conduct and security police into .github folder 2023-06-20 21:04:29 +01:00
William Oldham
969aa6156e Create SECURITY.md 2023-06-20 21:04:29 +01:00
William Oldham
d19f0cf305 Create CODE_OF_CONDUCT.md 2023-06-20 21:04:29 +01:00
castdrian
3f241c2d07 fix idiotism 2023-06-20 19:39:16 +02:00
castdrian
5661a7873a remove seasons from search result 2023-06-19 17:03:12 +02:00
castdrian
4f5a926c90 Merge branch 'refactor-metadata' of https://github.com/castdrian/movie-web into refactor-metadata 2023-06-19 16:57:53 +02:00
castdrian
205248a376 use external ids endpoint for imdb ids 2023-06-18 17:45:41 +02:00
castdrian
0d249a3e27 fix typo 'cause I can't type 2023-06-18 17:45:41 +02:00
castdrian
4d51de3bd1 undo duplicate path 2023-06-18 17:45:41 +02:00
castdrian
c08a6c7e54 set adult false in query 2023-06-18 17:45:41 +02:00
castdrian
c9bac3ed68 show poster in bookmarks 2023-06-18 17:45:41 +02:00
castdrian
06eb8e6b6d cleanup 2023-06-18 17:45:41 +02:00
castdrian
0e9263b619 fix movie metadata 2023-06-18 17:45:41 +02:00
castdrian
763de37e9e cleanup 2023-06-18 17:45:41 +02:00
castdrian
46bd20f718 refactor everything to use tmdb exclusively 2023-06-18 17:45:41 +02:00
castdrian
8da155ba2b cleanup 2023-06-18 17:45:41 +02:00
castdrian
b5c330d4e3 refactor to initial prefix choice 2023-06-18 17:45:41 +02:00
castdrian
879271c239 implement legacy url conversion 2023-06-18 17:45:41 +02:00
castdrian
70f8355386 refactor url prefix 2023-06-18 17:45:41 +02:00
castdrian
3af98373fb finish initial refactor 2023-06-18 17:45:41 +02:00
castdrian
c17f8a15e8 more refactorings 2023-06-18 17:45:41 +02:00
castdrian
63f26b81de preliminary refactor 2023-06-18 17:45:41 +02:00
castdrian
70852773f9 partial refactor 2023-06-18 17:45:41 +02:00
mrjvs
7e5c2f9b88 Merge pull request #356 from frost768/320-persist-language
fix: language preference persistence
2023-06-18 14:22:07 +02:00
frost768
a4bd9bb87a fix: language preference persistence 2023-06-18 15:10:26 +03:00
mrjvs
89af8156f4 Merge pull request #354 from movie-web/dev
Update v4 branch
2023-06-17 21:01:57 +02:00
mrjvs
443ab476d8 Merge pull request #333 from Jordaar/dev
feat(providers): add gomovies, kissasian providers and upcloud, streamsb, mp4upload embed scrapers
2023-06-17 20:38:17 +02:00
mrjvs
524c57d4fc Merge branch 'dev' into dev 2023-06-17 20:24:59 +02:00
mrjvs
ffa1ad3b8a Merge pull request #331 from spinixster/dev
Vietnamese language translation
2023-06-17 20:22:54 +02:00
mrjvs
d47acada58 Update i18n.ts 2023-06-17 20:20:38 +02:00
mrjvs
682017977b Merge branch 'dev' into dev 2023-06-17 20:20:03 +02:00
mrjvs
ab1dd18d39 Merge pull request #324 from lem6ns/dev
feat(provider): streamflix
2023-06-17 20:19:49 +02:00
mrjvs
cffe5080f6 Merge branch 'dev' into dev 2023-06-17 20:18:20 +02:00
mrjvs
60142acbda Merge pull request #326 from lem6ns/remotestream
feat(provider): Remote Stream (watchamovie.cc)
2023-06-17 20:18:03 +02:00
mrjvs
688e1ff24a Merge branch 'dev' into remotestream 2023-06-17 20:13:57 +02:00
mrjvs
0066cff111 Merge branch 'dev' into dev 2023-06-17 20:13:37 +02:00
mrjvs
d06f379d1b Merge branch 'dev' into dev 2023-06-17 20:06:22 +02:00
mrjvs
a04cd37307 Merge pull request #315 from fexxdev/feat/italian_language
Add Italian language translations
2023-06-17 20:03:04 +02:00
mrjvs
dd3c533349 Merge branch 'dev' into feat/italian_language 2023-06-17 20:01:33 +02:00
mrjvs
ec5f1dfad9 Merge pull request #312 from frost768/dev
add missing translation keys and polish translation
2023-06-17 20:01:20 +02:00
Jordaar
bc0f9a6abf feat(kissasian): additional mp4upload embed scraper 2023-06-16 16:15:41 +05:30
Jordaar
a0bb03790a refactor(streamsb): improve quality sorting 2023-06-16 16:14:05 +05:30
Jordaar
7e948c60c1 feat(enum): add mp4upload enum 2023-06-16 16:12:53 +05:30
Jordaar
9003bf6788 feat(embed): add mp4upload embed scraper 2023-06-16 16:12:07 +05:30
Jordaar
e912ea4715 cleanup 2023-06-16 15:05:42 +05:30
Jordaar
58ca372a49 refactor(kissasian): change rank 2023-06-16 14:52:42 +05:30
castdrian
ad26391645 use external ids endpoint for imdb ids 2023-06-16 11:18:32 +02:00
Jordaar
f6b830d06d feat(register): new providers and embed scrapers 2023-06-16 14:44:54 +05:30
Jordaar
d4c6dac9f2 disable 2embed 2023-06-16 14:43:36 +05:30
Jordaar
2db7e0bef8 feat(enum): add upcloud and streamsb enum 2023-06-16 14:41:30 +05:30
Jordaar
d198760f9c feat(provider): add kissasian provider 2023-06-16 14:37:57 +05:30
Jordaar
7e696d5c2c feat(provider): add gomovies provider 2023-06-16 14:37:41 +05:30
Jordaar
4bd00eb47a feat(embed): add upcloud and streamsb embed scrapers 2023-06-16 14:37:07 +05:30
castdrian
d961655186 fix typo 'cause I can't type 2023-06-15 22:13:19 +02:00
castdrian
330cbf2d9e undo duplicate path 2023-06-15 11:06:24 +02:00
castdrian
28d2dd0e89 set adult false in query 2023-06-15 08:30:57 +02:00
castdrian
74cc50cfa2 show poster in bookmarks 2023-06-15 08:30:05 +02:00
spinixster
07deb1897d Update i18n.ts 2023-06-15 10:55:02 +07:00
spinixster
be90b02043 Update translation.json 2023-06-15 10:53:08 +07:00
spinixster
61c3ed076f Delete translation.json 2023-06-15 10:48:45 +07:00
spinixster
80dd2158df Create translation.json 2023-06-15 10:48:26 +07:00
spinixster
db75f2320d Add files via upload
add translation
2023-06-15 10:46:05 +07:00
spinixster
f9d756e0ef Update i18n.ts 2023-06-15 09:06:19 +07:00
spinixster
424ee6fe77 Update i18n.ts 2023-06-15 08:55:40 +07:00
castdrian
5d56b847c6 cleanup 2023-06-14 07:52:04 +02:00
castdrian
20c4b14799 fix movie metadata 2023-06-14 07:48:31 +02:00
castdrian
c4afc37217 cleanup 2023-06-13 21:26:58 +02:00
castdrian
3ee9ee43a5 refactor everything to use tmdb exclusively 2023-06-13 21:23:47 +02:00
castdrian
b22e3ff8c1 cleanup 2023-06-13 14:25:31 +02:00
castdrian
a7af045308 refactor to initial prefix choice 2023-06-13 14:20:33 +02:00
castdrian
e889eaebaa implement legacy url conversion 2023-06-13 14:06:37 +02:00
castdrian
baf744b5d6 refactor url prefix 2023-06-13 11:01:07 +02:00
castdrian
e5ddb98162 finish initial refactor 2023-06-13 10:41:54 +02:00
castdrian
1eac9f886e more refactorings 2023-06-12 21:25:24 +02:00
castdrian
dfe67157d4 preliminary refactor 2023-06-12 20:17:42 +02:00
castdrian
40e45ae103 partial refactor 2023-06-12 20:06:46 +02:00
cloud
1a613287f8 feat(provider): streamflix 2023-06-11 14:16:05 -06:00
cloud
ef782974fe fix(remotestream): Duplicate rank number 2023-06-11 11:36:05 -06:00
cloud
893a385f00 fix(remotestream): additional path for tv 2023-06-11 11:34:57 -06:00
cloud
18bde24b3a feat(provider): Remote Stream 2023-06-11 11:31:02 -06:00
Federico Benedetti
b7033a31c4 Fix locale import position 2023-06-03 12:15:19 +02:00
Federico Benedetti
cc4f64032a Add Italian language support 2023-06-03 11:55:57 +02:00
frost768
30e5ae7121 add missing translation keys and polish translation 2023-05-29 22:10:07 +03:00
mrjvs
ce4721e1bb Merge pull request #306 from JipFr/dev
Add T query param for time and make scrollbar styles global
2023-05-26 23:12:45 +02:00
mrjvs
534edd5883 Merge branch 'dev' into dev 2023-05-26 23:07:30 +02:00
Jip Fr
02135527c1 Use URLSearchParams 2023-05-26 23:04:11 +02:00
mrjvs
12ebee622a Merge pull request #305 from Jordaar/sflix-provider
Add Sflix provider
2023-05-26 22:58:53 +02:00
mrjvs
8c52371c6d Merge branch 'dev' into sflix-provider 2023-05-26 22:57:30 +02:00
JORDAAR
3c096c069c lower rank 2023-05-27 02:27:04 +05:30
mrjvs
f20cb5aad2 Merge pull request #307 from zisra/pirate-speak
Pirate speak!
2023-05-26 22:35:31 +02:00
zisra
519e74480e Update translation.json 2023-05-26 10:45:45 -05:00
zisra
be03a8eb42 Update src/setup/locales/pirate/translation.json
Co-authored-by: Jip Frijlink <jipfrijlink@gmail.com>
2023-05-26 08:01:55 -05:00
d586899dbf Pirate speak! 2023-05-25 22:38:58 -05:00
Jip Fr
525f9d0b74 chore(player): revert timeArr order for improved readability 2023-05-26 00:38:51 +02:00
Jip Fr
01b019365d Yeet log 2023-05-25 23:01:42 +02:00
Jip Fr
5e0e223851 style: make scrollbar style global 2023-05-25 22:57:00 +02:00
Jip Fr
a648f45694 feat(player): add T query param for starting time 2023-05-25 22:54:35 +02:00
JORDAAR
ffc772727a register sflix provider 2023-05-25 00:16:00 +05:30
JORDAAR
77a0c36a58 add sflix provider 2023-05-25 00:15:22 +05:30
mrjvs
766dc63bfa Merge pull request #303 from thehairy/dev
chore: some corrections in the german translation
2023-05-22 20:12:09 +02:00
thehairy
e3d6ec93c7 chore: some corrections in the german translation 2023-05-22 20:07:19 +02:00
mrjvs
1fd458fa27 Merge pull request #302 from movie-web/dev
Version 3.0.15
2023-05-22 19:42:58 +02:00
mrjvs
e4c15c624b Merge branch 'master' into dev 2023-05-22 19:42:50 +02:00
mrjvs
b12649bd2e Remove browser language detector, only configurable through settings now 2023-05-22 19:26:57 +02:00
mrjvs
37e10fb40e bump version 🎉 2023-05-22 19:24:45 +02:00
mrjvs
61b75da402 Merge pull request #292 from Jordaar/dev
Add 2embed provider
2023-05-22 19:22:23 +02:00
mrjvs
73b2f57fdc Merge branch 'dev' into dev 2023-05-22 19:22:00 +02:00
mrjvs
0b8c6439d7 Merge pull request #300 from thehairy/dev
fix: move meta id check to providers
2023-05-22 19:18:58 +02:00
mrjvs
4ad0d53683 Merge branch 'dev' into dev 2023-05-22 19:16:54 +02:00
mrjvs
3958df8e29 Merge pull request #301 from lem6ns/dev
refactor: use mwFetch instead of proxiedFetch
2023-05-22 19:16:03 +02:00
thehairy
fa36493c50 re-add tmdbId 2023-05-21 21:00:35 +02:00
mrjvs
efd87ab96e Merge branch 'dev' into dev 2023-05-21 20:47:16 +02:00
cloud
f80d79070e refactor: use mwFetch instead of proxiedFetch 2023-05-21 11:46:10 -07:00
mrjvs
be7b875666 Merge pull request #299 from lem6ns/dev
fix: replace consumet instance
2023-05-21 20:34:31 +02:00
thehairy
bb869fd7e3 fix: move meta id check to providers 2023-05-21 18:12:45 +02:00
cloud
2b30bb0e2b fix: replace consumet instance 2023-05-21 00:15:11 -07:00
mrjvs
b9448b5231 Merge pull request #250 from frost768/subtitle-file-type-control
Subtitle tests and type controls
2023-05-19 19:42:08 +02:00
mrjvs
7a6af6c072 remove unnecesary eslint ignore 2023-05-19 19:41:20 +02:00
frost768
2657d1f856 Merge branch 'subtitle-file-type-control' of https://github.com/frost768/movie-web into subtitle-file-type-control 2023-05-12 22:40:20 +03:00
frost768
21cc8c16d6 Merge branch 'dev' of https://github.com/frost768/movie-web into subtitle-file-type-control 2023-05-12 22:40:15 +03:00
Emre Can Minnet
b04209d9b3 Merge branch 'dev' into subtitle-file-type-control 2023-05-10 22:34:12 +03:00
James Hawkins
55bfa2be9d Merge pull request #274 from frost768/time-format
language based time formatting
2023-05-10 10:46:32 +01:00
James Hawkins
dd8b6c3f9e Merge branch 'dev' into time-format 2023-05-10 10:44:48 +01:00
James Hawkins
835e818ca0 Merge pull request #293 from zisra/fix-time
Fix "Finish at xx:xxPM/AM"
2023-05-10 10:40:17 +01:00
942725d04c Fix time finished 2023-05-09 14:07:09 -05:00
JORDAAR
010f1d3987 register 2Embed provider 2023-05-09 12:52:54 +05:30
JORDAAR
7bad6eaff9 add 2Embed provider 2023-05-09 12:52:13 +05:30
JORDAAR
bcff5a8972 add rawProxiedFetch 2023-05-09 12:51:13 +05:30
Emre Can Minnet
caba492ca2 Merge branch 'dev' into subtitle-file-type-control 2023-05-07 22:08:03 +03:00
frost768
f03145ee6d formatting added to new translation files 2023-05-06 15:03:13 +03:00
Emre Can Minnet
c0aebca4d9 Merge branch 'dev' into time-format 2023-05-06 14:42:05 +03:00
James Hawkins
c7651950ce Merge pull request #288 from zisra/deutsch
German Language
2023-05-06 10:26:17 +01:00
James Hawkins
cd3bd22a2c Update i18n.ts 2023-05-06 10:24:57 +01:00
James Hawkins
9773fcc7b5 Merge branch 'dev' into deutsch 2023-05-06 10:22:55 +01:00
James Hawkins
c937acfb09 Merge pull request #283 from raymond-nee/dev
Chinese(Simplified) Translation
2023-05-06 10:22:02 +01:00
James Hawkins
d1f3a7ad24 Merge branch 'dev' into dev 2023-05-06 10:20:24 +01:00
James Hawkins
cd0e4522c9 Merge pull request #280 from panmlg/dev
Czech language
2023-05-06 10:19:13 +01:00
f4be26d92d Deutsch! 2023-04-30 16:18:20 -05:00
frost768
22f8d8a581 Merge branch 'subtitle-file-type-control' of https://github.com/frost768/movie-web into subtitle-file-type-control 2023-05-01 00:15:59 +03:00
frost768
6cfd1235bc thumbnailCreator deleted 2023-05-01 00:15:18 +03:00
Emre Can Minnet
bdeaca3062 prefer length over falsy check
Co-authored-by: Jip Frijlink <jipfrijlink@gmail.com>
2023-05-01 00:02:14 +03:00
zisra
15e95923be English first! 2023-04-30 15:48:03 -05:00
panmlg
571df9e0ad Merge branch 'dev' into dev 2023-04-28 12:32:49 +02:00
panmlg
cce47fab5d Update src/setup/locales/cs/translation.json
Co-authored-by: James Hawkins <jhawki2005@gmail.com>
2023-04-27 23:45:21 +02:00
Emre Can Minnet
6eb25fb49c Merge branch 'dev' into time-format 2023-04-27 22:30:29 +03:00
frost768
e61937b5c4 refactor(lint): apply lint rules 2023-04-27 22:01:14 +03:00
frost768
2338b0d652 chore: allow updates on subsrt-ts 2023-04-27 21:58:45 +03:00
frost768
37463afc8d refactor(subtitles): use official subsrt-ts package 2023-04-27 21:56:02 +03:00
frost768
9c8e89a274 lint fixes 2023-04-27 21:54:36 +03:00
frost768
bf135a2bdf Merge branch 'dev' of https://github.com/movie-web/movie-web into subtitle-file-type-control 2023-04-27 21:54:24 +03:00
Monstorix
4dc6658e67 Chinese Simplified Translation 2023-04-27 00:42:23 +08:00
James Hawkins
fffc119e88 Update translation.json 2023-04-26 10:53:48 +01:00
mrjvs
5468a4677b Merge pull request #282 from movie-web/dev
v3.0.14
2023-04-25 17:47:13 +02:00
mrjvs
85cfba1a7a Merge branch 'master' into dev 2023-04-25 17:41:12 +02:00
mrjvs
fd6895c326 Merge pull request #281 from movie-web/fix-referer-maybe
Fix referer maybe
2023-04-25 17:39:11 +02:00
mrjvs
dfc3d9e50f Merge branch 'dev' into fix-referer-maybe 2023-04-25 17:38:27 +02:00
mrjvs
fcdf45d3f5 bump version for real 2023-04-25 17:37:49 +02:00
mrjvs
592837e2a6 bump version for a small release 2023-04-25 17:35:59 +02:00
mrjvs
9b3c1ffa28 add some dev routes back 2023-04-25 17:35:09 +02:00
mrjvs
7cb9ccaf14 referrer policy 2023-04-25 17:35:03 +02:00
panmlg
aa91bae418 fix of broken thingy 2023-04-25 16:59:19 +02:00
panmlg
7737bd1866 Czech language 2023-04-25 16:36:16 +02:00
mrjvs
4c0c61b0b9 Merge pull request #278 from yilmazcabuk/dev
style: sort imports according to ESLint rules
2023-04-25 00:55:41 +02:00
Yılmaz ÇABUK
4880d46dc4 style: sort imports according to ESLint rules
This commit updates the import statements in the codebase to comply with ESLint rules for import ordering. All imports have been sorted alphabetically and grouped according to the specified import groups in the ESLint configuration. This improves the codebase's consistency and maintainability.
2023-04-24 18:41:54 +03:00
frost768
ef39d87b4b update yarn.lock registry 2023-04-24 05:32:34 +03:00
frost768
e2a4caa8aa update version 2023-04-24 05:19:25 +03:00
frost768
b6a60cf5f8 patch subsrt-ts 2023-04-24 05:17:34 +03:00
frost768
f784f5f4b2 Merge branch 'dev' of https://github.com/movie-web/movie-web into subtitle-file-type-control 2023-04-24 05:10:07 +03:00
Emre Can Minnet
01348f2f9a Merge branch 'dev' into time-format 2023-04-24 01:14:44 +03:00
frost768
0e77d63caf Merge branch 'dev' of https://github.com/movie-web/movie-web into time-format 2023-04-23 20:15:35 +03:00
Emre Can Minnet
16841b8e69 Merge branch 'dev' into time-format 2023-04-23 20:06:34 +03:00
frost768
6ef41bdf1c language based time formatting 2023-04-23 20:01:12 +03:00
frost768
33ebd34808 fix multiline in subtitles 2023-04-23 18:44:16 +03:00
frost768
68e5742c25 add multine test 2023-04-22 13:10:02 +03:00
Emre Can Minnet
283b9cc996 Merge branch 'dev' into subtitle-file-type-control 2023-04-22 12:52:55 +03:00
frost768
75ef831ddc Merge branch 'dev' of https://github.com/movie-web/movie-web into subtitle-file-type-control 2023-04-20 22:32:42 +03:00
frost768
e2d1842946 Merge branch 'dev' of https://github.com/movie-web/movie-web into subtitle-file-type-control 2023-04-20 22:29:50 +03:00
frost768
f12f53d32c Merge branch 'dev' of https://github.com/movie-web/movie-web into subtitle-file-type-control 2023-04-20 22:22:10 +03:00
frost768
a910c1c18c Merge branch 'dev' of https://github.com/movie-web/movie-web into subtitle-file-type-control 2023-04-17 17:49:31 +03:00
frost768
42dee51570 subtitle tests 2023-04-03 23:18:28 +03:00
frost768
9c13be37e8 subtitle type checks 2023-04-03 23:18:10 +03:00
196 changed files with 5042 additions and 1272 deletions

View File

@@ -8,27 +8,28 @@ const a11yOff = Object.keys(require("eslint-plugin-jsx-a11y").rules).reduce(
module.exports = { module.exports = {
env: { env: {
browser: true browser: true,
}, },
extends: [ extends: [
"airbnb", "airbnb",
"airbnb/hooks", "airbnb/hooks",
"plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended",
"prettier", "plugin:prettier/recommended",
"plugin:prettier/recommended"
], ],
ignorePatterns: ["public/*", "dist/*", "/*.js", "/*.ts"], ignorePatterns: ["public/*", "dist/*", "/*.js", "/*.ts"],
parser: "@typescript-eslint/parser", parser: "@typescript-eslint/parser",
parserOptions: { parserOptions: {
project: "./tsconfig.json", project: "./tsconfig.json",
tsconfigRootDir: "./" tsconfigRootDir: "./",
}, },
settings: { settings: {
"import/resolver": { "import/resolver": {
typescript: {} typescript: {
} project: "./tsconfig.json",
}, },
plugins: ["@typescript-eslint", "import"], },
},
plugins: ["@typescript-eslint", "import", "prettier"],
rules: { rules: {
"react/jsx-uses-react": "off", "react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off", "react/react-in-jsx-scope": "off",
@@ -54,16 +55,44 @@ module.exports = {
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
"react/jsx-filename-extension": [ "react/jsx-filename-extension": [
"error", "error",
{ extensions: [".js", ".tsx", ".jsx"] } { extensions: [".js", ".tsx", ".jsx"] },
], ],
"import/extensions": [ "import/extensions": [
"error", "error",
"ignorePackages", "ignorePackages",
{ {
ts: "never", ts: "never",
tsx: "never" tsx: "never",
} },
], ],
...a11yOff "import/order": [
} "error",
{
groups: [
"builtin",
"external",
"internal",
["sibling", "parent"],
"index",
"unknown",
],
"newlines-between": "always",
alphabetize: {
order: "asc",
caseInsensitive: true,
},
},
],
"sort-imports": [
"error",
{
ignoreCase: false,
ignoreDeclarationSort: true,
ignoreMemberSort: false,
memberSyntaxSortOrder: ["none", "all", "multiple", "single"],
allowSeparatedGroups: true,
},
],
...a11yOff,
},
}; };

3
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,3 @@
* @movie-web/core
.github @binaryoverload

128
.github/CODE_OF_CONDUCT.md vendored Normal file
View File

@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
codeofconduct@movie-web.app.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

112
.github/CONTRIBUTING.md vendored Normal file
View File

@@ -0,0 +1,112 @@
# Contributing Guidelines for movie-web
Thank you for investing your time in contributing to our project! Your contribution will be reflected on [movie-web.app](https://movie-web.app).
Please read our [Code of Conduct](./CODE_OF_CONDUCT.md) to keep our community approachable and respectable.
## Contents
- [New Contributor Guide](#new-contributor-guide)
- [Requesting a feature or reporting a bug](#requesting-a-feature-or-reporting-a-bug)
- [Discord Server](#discord-server)
- [GitHub Issues](#github-issues)
- [Before you start](#before-you-start)
- [Contributing](#before-you-start)
- [Recommended Development Environment](#recommended-development-environment)
- [Tips](#tips)
- [Language Contributions](#language-contributions)
## New contributor guide
To get an overview of the project, read the [README](README.md). Here are some resources to help you get started with open-source contributions:
- [Finding ways to contribute to open-source on GitHub](https://docs.github.com/en/get-started/exploring-projects-on-github/finding-ways-to-contribute-to-open-source-on-github)
- [Set up Git](https://docs.github.com/en/get-started/quickstart/set-up-git)
- [GitHub flow](https://docs.github.com/en/get-started/quickstart/github-flow)
- [Collaborating with pull requests](https://docs.github.com/en/github/collaborating-with-pull-requests)
## Requesting a feature or reporting a bug
There are two places where to request features or report bugs:
- GitHub Issues
- The movie-web Discord server
### Discord Server
If you do not have a GitHub account or want to discuss a feature or bug with us before making an issue, you can join our Discord server.
<a href="https://discord.movie-web.app"><img src="https://discord.com/api/guilds/871713465100816424/widget.png?style=banner2" alt="Discord Server"></a>
### GitHub Issues
To make a GitHub issue for movie-web, please visit the [new issue page](https://github.com/movie-web/movie-web/issues/new/choose) where you can pick either the "Bug Report" or "Feature Request" template.
When filling out an issue template, please include as much detail as possible and any screenshots or console logs as appropriate.
After an issue is created, it will be assigned either the https://github.com/movie-web/movie-web/labels/bug or https://github.com/movie-web/movie-web/labels/feature label, along with https://github.com/movie-web/movie-web/labels/awaiting-approval. One of our maintainers will review your issue and, if it's accepted, will set the https://github.com/movie-web/movie-web/labels/approved label.
## Before you start!
Before starting a contribution, please check your contribution is part of an open issue on [our issues page](https://github.com/movie-web/movie-web/issues?q=is%3Aopen+is%3Aissue+label%3Aapproved).
GitHub issues are how we track our bugs and feature requests that will be implemented into movie-web - all contributions **must** have an issue and be approved by a maintainer before a pull request can be worked on.
If a pull request is opened before an issue is created and accepted, you may risk having your pull request rejected! Always check with us before starting work on a feature - we don't want to waste your time!
> **Note**
> The exception to this are language contributions, which are discussed in [this section](#language-contributions)
Also, make sure that the issue you would like to work on has been given the https://github.com/movie-web/movie-web/labels/approved label by a maintainer. Otherwise, if we reject the issue, it means your work will have gone to waste!
## Contributing
If you're here because you'd like to work on an issue, amazing! Thank you for even considering contributing to movie-web; it means a lot :heart:
Firstly, make sure you've read the [Before you start!](#before-you-start) section!
When you have found a GitHub issue you would like to work on, you can request to be assigned to the issue by commenting on the GitHub issue.
If you are assigned to an issue but can't complete it for whatever reason, no problem! Just let us know, and we will open up the issue to have someone else assigned.
### Recommended Development Environment
Our recommended development environment to work on movie-web is:
- [Visual Studio Code](https://code.visualstudio.com/)
- [ESLint Extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint)
- [EditorConfig Extension](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig)
When opening Visual Studio Code, you will be prompted to install our recommended extensions if they are not installed for you.
Our project is set up to enforce formatting and code style standards using ESLint.
### Tips
Here are some tips to make sure that your pull requests are :pinched_fingers: first time:
- KISS - Keep It Simple Soldier! - Simple code makes readable and efficient code!
- Follow standard best practices for TypeScript and React.
- Keep as much as possible to the style of movie-web. Look around our codebase to familiarise yourself with how we do things!
- Ensure to take note of the ESLint errors and warnings! **Do not ignore them!** They are there for a reason.
- Test, test, test! Make sure you thoroughly test the features you are contributing.
### Language Contributions
Language contributions help movie-web massively, allowing people worldwide to use our app!
Like most apps, our translations are stored in `.json` files. Each language string has a unique key (For example, `notFound.genericTitle`) that references a language string in the appropriate language file.
Each language file is called `translation.json` and is stored in the `src/setup/languages/<language code>/` folder. For example, the English language file is located at `src/setup/languages/en/translation.json`.
> **Warning**
>
> Before you start a translation, please:
> - Check there isn't an existing GitHub [issue](https://github.com/movie-web/movie-web/issues) or [pull request](https://github.com/movie-web/movie-web/pulls) open for the language.
> - Make sure we aren't in the middle of a new feature update. When releasing major versions, we only accept changes to translations once the new version is complete. Otherwise, the language files would need to be updated.
>
> Please speak to us before starting a language PR. We want to use your time effectively.
To make a translation:
- Copy the `en` folder inside the `src/setup/languages` folder
- Rename the copied folder to the 2-letter code for the country/language which is being translated.
- [Click this link to see a list of codes](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes). Use the codes in the `639-1` column!
- For example, Arabic is `ar`
- Edit the language strings inside the `translation.json` file
- **Do not** edit the keys. Only edit the values.
- e.g. in `"stopEditing": "Stop editing",` - only change the `Stop editing` part, not the `stopEditing` part.
- In the `src/setup/i18n.ts` file:
- Import your new translation file, e.g. `import ar from "./locales/ar/translation.json";`
- Add your translation to the `locales` object (Look at other languages for an example)
Once you have completed your translation, please open a pull request. We do not accept partial translations, so please ensure every language string is translated to the intended language.

View File

@@ -1,7 +1,7 @@
name: Bug Report name: Bug Report
description: File a bug report description: File a bug report
title: "[Bug]: " title: "[Bug]: "
labels: ["bug"] labels: ["bug", "awaiting-approval"]
assignees: [] assignees: []
body: body:
- type: markdown - type: markdown

View File

@@ -1,7 +1,7 @@
name: Feature request name: Feature Request
description: Suggest a new feature description: Suggest a new feature
title: "[Feature]: " title: "[Feature]: "
labels: ["enhancement"] labels: ["feature", "awaiting-approval"]
assignees: [] assignees: []
body: body:
- type: textarea - type: textarea

13
.github/SECURITY.md vendored Normal file
View File

@@ -0,0 +1,13 @@
# Security Policy
## Supported Versions
The movie-web maintainers only support the latest version of movie-web published at https://movie-web.app.
Support is not provided for any forks or mirrors of movie-web.
## Reporting a Vulnerability
There are two ways you can contact the movie-web maintainers to report a vulnerability:
- Email [security@movie-web.app](mailto:security@movie-web.app)
- Report the vulnerability in the [movie-web Discord server](https://discord.movie-web.app)

6
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,6 @@
This pull request resolves #XXX
- [ ] I have read and agreed to the [code of conduct](https://github.com/movie-web/movie-web/blob/dev/.github/CODE_OF_CONDUCT.md).
- [ ] I have read and complied with the [contributing guidelines](https://github.com/movie-web/movie-web/blob/dev/.github/CONTRIBUTING.md).
- [ ] What I'm implementing was assigned to me and is an [approved issue](https://github.com/movie-web/movie-web/issues?q=is%3Aopen+is%3Aissue+label%3Aapproved). For reference, please take a look at our [GitHub projects](https://github.com/movie-web/movie-web/projects).
- [ ] I have tested all of my changes.

View File

@@ -5,7 +5,7 @@
<a href="https://github.com/movie-web/movie-web/blob/master/LICENSE.md"><img alt="GitHub license" src="https://img.shields.io/github/license/movie-web/movie-web?style=flat-square"></a> <a href="https://github.com/movie-web/movie-web/blob/master/LICENSE.md"><img alt="GitHub license" src="https://img.shields.io/github/license/movie-web/movie-web?style=flat-square"></a>
<a href="https://github.com/movie-web/movie-web/network"><img alt="GitHub forks" src="https://img.shields.io/github/forks/movie-web/movie-web?style=flat-square"></a> <a href="https://github.com/movie-web/movie-web/network"><img alt="GitHub forks" src="https://img.shields.io/github/forks/movie-web/movie-web?style=flat-square"></a>
<a href="https://github.com/movie-web/movie-web/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/movie-web/movie-web?style=flat-square"></a><br/> <a href="https://github.com/movie-web/movie-web/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/movie-web/movie-web?style=flat-square"></a><br/>
<a href="https://discord.gg/vXsRvye8BS"><img src="https://discordapp.com/api/guilds/871713465100816424/widget.png?style=banner2" alt="Discord Server"></a> <a href="https://discord.movie-web.app"><img src="https://discord.com/api/guilds/871713465100816424/widget.png?style=banner2" alt="Discord Server"></a>
</p> </p>
movie-web is a web app for watching movies easily. Check it out at **[movie-web.app](https://movie-web.app)**. movie-web is a web app for watching movies easily. Check it out at **[movie-web.app](https://movie-web.app)**.

View File

@@ -1,38 +1,41 @@
# Selfhosting tutorial # Self-hosting tutorial
> **Note:** We do not provide support on how to selfhost, if you cant figure it out then tough luck. Please do not make Github issues or ask in our Discord server for support on how to selfhost. > **Note**
> We **do not** provide support on how to self-host. If you can't figure it out then tough luck. Please do not make GitHub issues or ask in our Discord server for support on how to self-host.
So you wanna selfhost. This app is made of two parts: So you would like to self-host. This app is made of two parts:
- The proxy - The proxy
- The client - The client
## Hosting the proxy ## Hosting the proxy
The proxy is made as a cloudflare worker, cloudflare has a generous free plan, so you don't need to pay anything unless you get hundreds of users. The proxy is made as a Cloudflare worker. Cloudflare has a generous free plan, so you don't need to pay anything unless you get hundreds of users.
1. Create a cloudflare account at [https://dash.cloudflare.com](https://dash.cloudflare.com) 1. Create a Cloudflare account at [https://dash.cloudflare.com](https://dash.cloudflare.com).
2. Navigate to `Workers`. 2. Navigate to `Workers`.
3. If it asks you, choose a subdomain 3. If it asks you, choose a subdomain.
4. If it asks for a workers plan, press "Continue with free" 4. If it asks for a workers plan, press "Continue with free".
5. Create a new service with a name of your choice. Must be type `HTTP handler` 5. Create a new service with a name of your choice. Must be type `HTTP handler`.
6. On the service page, Click `Quick edit` 6. On the service page, Click `Quick edit`.
7. Download the `worker.js` file from the latest release of the proxy: [https://github.com/movie-web/simple-proxy/releases/latest](https://github.com/movie-web/simple-proxy/releases/latest) 7. Remove the template code in the quick edit window.
8. Open the downloaded `worker.js` file in notepad, VScode or similar. 7. Download the `worker.js` file from the latest release of the proxy: [https://github.com/movie-web/simple-proxy/releases/latest](https://github.com/movie-web/simple-proxy/releases/latest).
8. Open the downloaded `worker.js` file in Notepad, Visual Studio Code or similar.
9. Copy the text contents of the `worker.js` file. 9. Copy the text contents of the `worker.js` file.
10. Paste the text contents into the edit screen of the cloudflare service worker. 10. Paste the text contents into the edit screen of the Cloudflare service worker.
11. Click `Save and deploy` and confirm. 11. Click `Save and deploy` and confirm.
Your proxy is now hosted on cloudflare. Note the url of your worker. you will need it later. Your proxy is now hosted on Cloudflare. Note the url of your worker as you will need it later.
## Hosting the client ## Hosting the client
1. Download the file `movie-web.zip` from the latest release: [https://github.com/movie-web/movie-web/releases/latest](https://github.com/movie-web/movie-web/releases/latest) 1. Download the file `movie-web.zip` from the latest release: [https://github.com/movie-web/movie-web/releases/latest](https://github.com/movie-web/movie-web/releases/latest).
2. Extract the zip file so you can edit the files. 2. Extract the zip file so you can edit the files.
3. Open `config.js` in notepad, VScode or similar. 3. Open `config.js` in Notepad, Visual Studio Code or similar.
4. Put your cloudflare proxy URL inbetween the double qoutes of `VITE_CORS_PROXY_URL: "",`. Make sure to not have a slash at the end of your URL. 4. Put your Cloudflare proxy URL in-between the double quotes of `VITE_CORS_PROXY_URL: ""`. Make sure to not have a slash at the end of your URL.
Example (THIS IS MINE, IT WONT WORK FOR YOU): `VITE_CORS_PROXY_URL: "https://test-proxy.test.workers.dev",` Example (THIS IS AN EXAMPLE, IT WON'T WORK FOR YOU): `VITE_CORS_PROXY_URL: "https://test-proxy.test.workers.dev"`
5. Save the file 5. Put your TMDB read access token inside the quotes of `VITE_TMDB_READ_API_KEY: ""`. You can generate it for free at [https://www.themoviedb.org/settings/api](https://www.themoviedb.org/settings/api).
6. Save the file
Your client has been prepared, you can now host it on any webhost. Your client has now been prepared, you can now host it with any static website hosting (Common ones include [GitHub Pages](https://pages.github.com/), [Netlify](https://www.netlify.com/) and [Vercel](https://vercel.com/) but any will work!).
It doesn't require php, its just a standard static page. It doesn't require PHP, it's just a standard static page.

View File

@@ -1,6 +1,3 @@
# make sure the cors proxy url does NOT have a slash at the end # make sure the cors proxy url does NOT have a slash at the end
VITE_CORS_PROXY_URL=... VITE_CORS_PROXY_URL=...
VITE_TMDB_READ_API_KEY=...
# the keys below are optional - defaults are provided
VITE_TMDB_API_KEY=...
VITE_OMDB_API_KEY=...

View File

@@ -29,6 +29,9 @@
<!-- prevent darkreader extension from messing with our already dark site --> <!-- prevent darkreader extension from messing with our already dark site -->
<meta name="darkreader-lock" /> <meta name="darkreader-lock" />
<!-- disabling referrer can fix some provider problems -->
<meta name="referrer" content="no-referrer" />
<title>movie-web</title> <title>movie-web</title>
</head> </head>
<body> <body>

View File

@@ -1,6 +1,6 @@
{ {
"name": "movie-web", "name": "movie-web",
"version": "3.0.13", "version": "3.2.4",
"private": true, "private": true,
"homepage": "https://movie-web.app", "homepage": "https://movie-web.app",
"dependencies": { "dependencies": {
@@ -32,7 +32,7 @@
"react-stickynode": "^4.1.0", "react-stickynode": "^4.1.0",
"react-transition-group": "^4.4.5", "react-transition-group": "^4.4.5",
"react-use": "^17.4.0", "react-use": "^17.4.0",
"subsrt-ts": "^2.1.0", "subsrt-ts": "^2.1.1",
"unpacker": "^1.0.1" "unpacker": "^1.0.1"
}, },
"scripts": { "scripts": {
@@ -82,7 +82,7 @@
"eslint-config-airbnb": "19.0.4", "eslint-config-airbnb": "19.0.4",
"eslint-config-prettier": "^8.6.0", "eslint-config-prettier": "^8.6.0",
"eslint-import-resolver-typescript": "^2.5.0", "eslint-import-resolver-typescript": "^2.5.0",
"eslint-plugin-import": "^2.25.4", "eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "7.29.4", "eslint-plugin-react": "7.29.4",

View File

@@ -1,6 +1,5 @@
window.__CONFIG__ = { window.__CONFIG__ = {
// url must NOT end with a slash // url must NOT end with a slash
VITE_CORS_PROXY_URL: "", VITE_CORS_PROXY_URL: "",
VITE_TMDB_API_KEY: "b030404650f279792a8d3287232358e3", VITE_TMDB_READ_API_KEY: ""
VITE_OMDB_API_KEY: "aa0937c0",
}; };

View File

@@ -1,9 +1,10 @@
import { describe, it } from "vitest"; import { describe, it } from "vitest";
import "@/backend"; import "@/backend";
import { getProviders } from "@/backend/helpers/register";
import { MWMediaType } from "@/backend/metadata/types";
import { runProvider } from "@/backend/helpers/run";
import { testData } from "@/__tests__/providers/testdata"; import { testData } from "@/__tests__/providers/testdata";
import { getProviders } from "@/backend/helpers/register";
import { runProvider } from "@/backend/helpers/run";
import { MWMediaType } from "@/backend/metadata/types/mw";
describe("providers", () => { describe("providers", () => {
const providers = getProviders(); const providers = getProviders();

View File

@@ -1,5 +1,5 @@
import { DetailedMeta } from "@/backend/metadata/getmeta"; import { DetailedMeta } from "@/backend/metadata/getmeta";
import { MWMediaType } from "@/backend/metadata/types"; import { MWMediaType } from "@/backend/metadata/types/mw";
export const testData: DetailedMeta[] = [ export const testData: DetailedMeta[] = [
{ {

View File

@@ -0,0 +1,152 @@
import { describe, it } from "vitest";
import {
getMWCaptionTypeFromUrl,
isSupportedSubtitle,
parseSubtitles,
} from "@/backend/helpers/captions";
import { MWCaptionType } from "@/backend/helpers/streams";
import {
ass,
multilineSubtitlesTestVtt,
srt,
visibleSubtitlesTestVtt,
vtt,
} from "./testdata";
describe("subtitles", () => {
it("should return true if given url ends with a known subtitle type", ({
expect,
}) => {
expect(isSupportedSubtitle("https://example.com/test.srt")).toBe(true);
expect(isSupportedSubtitle("https://example.com/test.vtt")).toBe(true);
expect(isSupportedSubtitle("https://example.com/test.txt")).toBe(false);
});
it("should return corresponding MWCaptionType", ({ expect }) => {
expect(getMWCaptionTypeFromUrl("https://example.com/test.srt")).toBe(
MWCaptionType.SRT
);
expect(getMWCaptionTypeFromUrl("https://example.com/test.vtt")).toBe(
MWCaptionType.VTT
);
expect(getMWCaptionTypeFromUrl("https://example.com/test.txt")).toBe(
MWCaptionType.UNKNOWN
);
});
it("should throw when empty text is given", ({ expect }) => {
expect(() => parseSubtitles("")).toThrow("Given text is empty");
});
it("should parse srt", ({ expect }) => {
const parsed = parseSubtitles(srt);
const parsedSrt = [
{
type: "caption",
index: 1,
start: 0,
end: 0,
duration: 0,
content: "Test",
text: "Test",
},
{
type: "caption",
index: 2,
start: 0,
end: 0,
duration: 0,
content: "Test",
text: "Test",
},
];
expect(parsed).toHaveLength(2);
expect(parsed).toEqual(parsedSrt);
});
it("should parse vtt", ({ expect }) => {
const parsed = parseSubtitles(vtt);
const parsedVtt = [
{
type: "caption",
index: 1,
start: 0,
end: 4000,
duration: 4000,
content: "Where did he go?",
text: "Where did he go?",
},
{
type: "caption",
index: 2,
start: 3000,
end: 6500,
duration: 3500,
content: "I think he went down this lane.",
text: "I think he went down this lane.",
},
{
type: "caption",
index: 3,
start: 4000,
end: 6500,
duration: 2500,
content: "What are you waiting for?",
text: "What are you waiting for?",
},
];
expect(parsed).toHaveLength(3);
expect(parsed).toEqual(parsedVtt);
});
it("should parse ass", ({ expect }) => {
const parsed = parseSubtitles(ass);
expect(parsed).toHaveLength(3);
});
it("should delay subtitles when given a delay", ({ expect }) => {
const videoTime = 11;
let delayedSeconds = 0;
const parsed = parseSubtitles(visibleSubtitlesTestVtt);
const isVisible = (start: number, end: number, delay: number): boolean => {
const delayedStart = start / 1000 + delay;
const delayedEnd = end / 1000 + delay;
return (
Math.max(0, delayedStart) <= videoTime &&
Math.max(0, delayedEnd) >= videoTime
);
};
const visibleSubtitles = parsed.filter((c) =>
isVisible(c.start, c.end, delayedSeconds)
);
expect(visibleSubtitles).toHaveLength(1);
delayedSeconds = 10;
const delayedVisibleSubtitles = parsed.filter((c) =>
isVisible(c.start, c.end, delayedSeconds)
);
expect(delayedVisibleSubtitles).toHaveLength(1);
delayedSeconds = -10;
const delayedVisibleSubtitles2 = parsed.filter((c) =>
isVisible(c.start, c.end, delayedSeconds)
);
expect(delayedVisibleSubtitles2).toHaveLength(1);
delayedSeconds = -20;
const delayedVisibleSubtitles3 = parsed.filter((c) =>
isVisible(c.start, c.end, delayedSeconds)
);
expect(delayedVisibleSubtitles3).toHaveLength(1);
});
it("should parse multiline captions", ({ expect }) => {
const parsed = parseSubtitles(multilineSubtitlesTestVtt);
expect(parsed[0].text).toBe(`- Test 1\n- Test 2\n- Test 3`);
expect(parsed[1].text).toBe(`- Test 4`);
expect(parsed[2].text).toBe(`- Test 6`);
});
});

View File

@@ -0,0 +1,68 @@
const srt = `
1
00:00:00,000 --> 00:00:00,000
Test
2
00:00:00,000 --> 00:00:00,000
Test
`;
const vtt = `
WEBVTT
00:00:00.000 --> 00:00:04.000 position:10%,line-left align:left size:35%
Where did he go?
00:00:03.000 --> 00:00:06.500 position:90% align:right size:35%
I think he went down this lane.
00:00:04.000 --> 00:00:06.500 position:45%,line-right align:center size:35%
What are you waiting for?
`;
const ass = `[Script Info]
; Generated by Ebby.co
Title:
Original Script:
ScriptType: v4.00+
Collisions: Normal
PlayResX: 384
PlayResY: 288
PlayDepth: 0
Timer: 100.0
WrapStyle: 0
[v4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default, Arial, 16, &H00FFFFFF, &H00000000, &H00000000, &H00000000, 0, 0, 0, 0, 100, 100, 0, 0, 1, 1, 0, 2, 15, 15, 15, 0
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:10.00,0:00:20.00,Default,,0000,0000,0000,,This is the first subtitle.
Dialogue: 0,0:00:30.00,0:00:34.00,Default,,0000,0000,0000,,This is the second.
Dialogue: 0,0:00:34.00,0:00:35.00,Default,,0000,0000,0000,,Third`;
const visibleSubtitlesTestVtt = `WEBVTT
00:00:00.000 --> 00:00:10.000 position:10%,line-left align:left size:35%
Test 1
00:00:10.000 --> 00:00:20.000 position:90% align:right size:35%
Test 2
00:00:20.000 --> 00:00:31.000 position:45%,line-right align:center size:35%
Test 3
`;
const multilineSubtitlesTestVtt = `WEBVTT
00:00:00.000 --> 00:00:10.000
- Test 1\n- Test 2\n- Test 3
00:00:10.000 --> 00:00:20.000
- Test 4
00:00:20.000 --> 00:00:31.000
- Test 6
`;
export { vtt, srt, ass, visibleSubtitlesTestVtt, multilineSubtitlesTestVtt };

View File

@@ -0,0 +1,32 @@
import { MWEmbedType } from "@/backend/helpers/embed";
import { registerEmbedScraper } from "@/backend/helpers/register";
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
import { proxiedFetch } from "../helpers/fetch";
registerEmbedScraper({
id: "mp4upload",
displayName: "mp4upload",
for: MWEmbedType.MP4UPLOAD,
rank: 170,
async getStream({ url }) {
const embed = await proxiedFetch<any>(url);
const playerSrcRegex =
/(?<=player\.src\()\s*{\s*type:\s*"[^"]+",\s*src:\s*"([^"]+)"\s*}\s*(?=\);)/s;
const playerSrc = embed.match(playerSrcRegex);
const streamUrl = playerSrc[1];
if (!streamUrl) throw new Error("Stream url not found");
return {
embedId: MWEmbedType.MP4UPLOAD,
streamUrl,
quality: MWStreamQuality.Q1080P,
captions: [],
type: MWStreamType.MP4,
};
},
});

View File

@@ -1,11 +1,11 @@
import { MWEmbedType } from "@/backend/helpers/embed"; import { MWEmbedType } from "@/backend/helpers/embed";
import { proxiedFetch } from "@/backend/helpers/fetch";
import { registerEmbedScraper } from "@/backend/helpers/register"; import { registerEmbedScraper } from "@/backend/helpers/register";
import { import {
MWEmbedStream,
MWStreamQuality, MWStreamQuality,
MWStreamType, MWStreamType,
MWEmbedStream,
} from "@/backend/helpers/streams"; } from "@/backend/helpers/streams";
import { proxiedFetch } from "@/backend/helpers/fetch";
const HOST = "streamm4u.club"; const HOST = "streamm4u.club";
const URL_BASE = `https://${HOST}`; const URL_BASE = `https://${HOST}`;

View File

@@ -0,0 +1,211 @@
import Base64 from "crypto-js/enc-base64";
import Utf8 from "crypto-js/enc-utf8";
import { MWEmbedType } from "@/backend/helpers/embed";
import { proxiedFetch } from "@/backend/helpers/fetch";
import { registerEmbedScraper } from "@/backend/helpers/register";
import {
MWCaptionType,
MWStreamQuality,
MWStreamType,
} from "@/backend/helpers/streams";
const qualityOrder = [
MWStreamQuality.Q1080P,
MWStreamQuality.Q720P,
MWStreamQuality.Q480P,
MWStreamQuality.Q360P,
];
async function fetchCaptchaToken(domain: string, recaptchaKey: string) {
const domainHash = Base64.stringify(Utf8.parse(domain)).replace(/=/g, ".");
const recaptchaRender = await proxiedFetch<any>(
`https://www.google.com/recaptcha/api.js?render=${recaptchaKey}`
);
const vToken = recaptchaRender.substring(
recaptchaRender.indexOf("/releases/") + 10,
recaptchaRender.indexOf("/recaptcha__en.js")
);
const recaptchaAnchor = await proxiedFetch<any>(
`https://www.google.com/recaptcha/api2/anchor?ar=1&hl=en&size=invisible&cb=flicklax&k=${recaptchaKey}&co=${domainHash}&v=${vToken}`
);
const cToken = new DOMParser()
.parseFromString(recaptchaAnchor, "text/html")
.getElementById("recaptcha-token")
?.getAttribute("value");
if (!cToken) throw new Error("Unable to find cToken");
const payload = {
v: vToken,
reason: "q",
k: recaptchaKey,
c: cToken,
sa: "",
co: domain,
};
const tokenData = await proxiedFetch<string>(
`https://www.google.com/recaptcha/api2/reload?${new URLSearchParams(
payload
).toString()}`,
{
headers: { referer: "https://www.google.com/recaptcha/api2/" },
method: "POST",
}
);
const token = tokenData.match('rresp","(.+?)"');
return token ? token[1] : null;
}
registerEmbedScraper({
id: "streamsb",
displayName: "StreamSB",
for: MWEmbedType.STREAMSB,
rank: 150,
async getStream({ url, progress }) {
/* Url variations
- domain.com/{id}?.html
- domain.com/{id}
- domain.com/embed-{id}
- domain.com/d/{id}
- domain.com/e/{id}
- domain.com/e/{id}-embed
*/
const streamsbUrl = url
.replace(".html", "")
.replace("embed-", "")
.replace("e/", "")
.replace("d/", "");
const parsedUrl = new URL(streamsbUrl);
const base = await proxiedFetch<any>(
`${parsedUrl.origin}/d${parsedUrl.pathname}`
);
progress(20);
// Parse captions from url
const captionUrl = parsedUrl.searchParams.get("caption_1");
const captionLang = parsedUrl.searchParams.get("sub_1");
const basePage = new DOMParser().parseFromString(base, "text/html");
const downloadVideoFunctions = basePage.querySelectorAll(
"[onclick^=download_video]"
);
let dlDetails = [];
for (const func of downloadVideoFunctions) {
const funcContents = func.getAttribute("onclick");
const regExpFunc = /download_video\('(.+?)','(.+?)','(.+?)'\)/;
const matchesFunc = regExpFunc.exec(funcContents ?? "");
if (matchesFunc !== null) {
const quality = func.querySelector("span")?.textContent;
const regExpQuality = /(.+?) \((.+?)\)/;
const matchesQuality = regExpQuality.exec(quality ?? "");
if (matchesQuality !== null) {
dlDetails.push({
parameters: [matchesFunc[1], matchesFunc[2], matchesFunc[3]],
quality: {
label: matchesQuality[1].trim(),
size: matchesQuality[2],
},
});
}
}
}
dlDetails = dlDetails.sort((a, b) => {
const aQuality = qualityOrder.indexOf(a.quality.label as MWStreamQuality);
const bQuality = qualityOrder.indexOf(b.quality.label as MWStreamQuality);
return aQuality - bQuality;
});
progress(40);
let dls = await Promise.all(
dlDetails.map(async (dl) => {
const getDownload = await proxiedFetch<any>(
`/dl?op=download_orig&id=${dl.parameters[0]}&mode=${dl.parameters[1]}&hash=${dl.parameters[2]}`,
{
baseURL: parsedUrl.origin,
}
);
const downloadPage = new DOMParser().parseFromString(
getDownload,
"text/html"
);
const recaptchaKey = downloadPage
.querySelector(".g-recaptcha")
?.getAttribute("data-sitekey");
if (!recaptchaKey) throw new Error("Unable to get captcha key");
const captchaToken = await fetchCaptchaToken(
parsedUrl.origin,
recaptchaKey
);
if (!captchaToken) throw new Error("Unable to get captcha token");
const dlForm = new FormData();
dlForm.append("op", "download_orig");
dlForm.append("id", dl.parameters[0]);
dlForm.append("mode", dl.parameters[1]);
dlForm.append("hash", dl.parameters[2]);
dlForm.append("g-recaptcha-response", captchaToken);
const download = await proxiedFetch<any>(
`/dl?op=download_orig&id=${dl.parameters[0]}&mode=${dl.parameters[1]}&hash=${dl.parameters[2]}`,
{
baseURL: parsedUrl.origin,
method: "POST",
body: dlForm,
}
);
const dlLink = new DOMParser()
.parseFromString(download, "text/html")
.querySelector(".btn.btn-light.btn-lg")
?.getAttribute("href");
return {
quality: dl.quality.label as MWStreamQuality,
url: dlLink,
size: dl.quality.size,
captions:
captionUrl && captionLang
? [
{
url: captionUrl,
langIso: captionLang,
type: MWCaptionType.VTT,
},
]
: [],
};
})
);
dls = dls.filter((d) => !!d.url);
progress(60);
// TODO: Quality selection for embed scrapers
const dl = dls[0];
if (!dl.url) throw new Error("No stream url found");
return {
embedId: MWEmbedType.STREAMSB,
streamUrl: dl.url,
quality: dl.quality,
captions: dl.captions,
type: MWStreamType.MP4,
};
},
});

View File

@@ -0,0 +1,135 @@
import { AES, enc } from "crypto-js";
import { MWEmbedType } from "@/backend/helpers/embed";
import { registerEmbedScraper } from "@/backend/helpers/register";
import {
MWCaptionType,
MWStreamQuality,
MWStreamType,
} from "@/backend/helpers/streams";
import { proxiedFetch } from "../helpers/fetch";
interface StreamRes {
server: number;
sources: string;
tracks: {
file: string;
kind: "captions" | "thumbnails";
label: string;
}[];
}
function isJSON(json: string) {
try {
JSON.parse(json);
return true;
} catch {
return false;
}
}
function extractKey(script: string): [number, number][] | null {
const startOfSwitch = script.lastIndexOf("switch");
const endOfCases = script.indexOf("partKeyStartPosition");
const switchBody = script.slice(startOfSwitch, endOfCases);
const nums: [number, number][] = [];
const matches = switchBody.matchAll(
/:[a-zA-Z0-9]+=([a-zA-Z0-9]+),[a-zA-Z0-9]+=([a-zA-Z0-9]+);/g
);
for (const match of matches) {
const innerNumbers: number[] = [];
for (const varMatch of [match[1], match[2]]) {
const regex = new RegExp(`${varMatch}=0x([a-zA-Z0-9]+)`, "g");
const varMatches = [...script.matchAll(regex)];
const lastMatch = varMatches[varMatches.length - 1];
if (!lastMatch) return null;
const number = parseInt(lastMatch[1], 16);
innerNumbers.push(number);
}
nums.push([innerNumbers[0], innerNumbers[1]]);
}
return nums;
}
registerEmbedScraper({
id: "upcloud",
displayName: "UpCloud",
for: MWEmbedType.UPCLOUD,
rank: 200,
async getStream({ url }) {
// Example url: https://dokicloud.one/embed-4/{id}?z=
const parsedUrl = new URL(url.replace("embed-5", "embed-4"));
const dataPath = parsedUrl.pathname.split("/");
const dataId = dataPath[dataPath.length - 1];
const streamRes = await proxiedFetch<StreamRes>(
`${parsedUrl.origin}/ajax/embed-4/getSources?id=${dataId}`,
{
headers: {
Referer: parsedUrl.origin,
"X-Requested-With": "XMLHttpRequest",
},
}
);
let sources: { file: string; type: string } | null = null;
if (!isJSON(streamRes.sources)) {
const scriptJs = await proxiedFetch<string>(
`https://rabbitstream.net/js/player/prod/e4-player.min.js`,
{
responseType: "text" as any,
}
);
const decryptionKey = extractKey(scriptJs);
if (!decryptionKey) throw new Error("Key extraction failed");
let extractedKey = "";
let strippedSources = streamRes.sources;
let totalledOffset = 0;
decryptionKey.forEach(([a, b]) => {
const start = a + totalledOffset;
const end = start + b;
extractedKey += streamRes.sources.slice(start, end);
strippedSources = strippedSources.replace(
streamRes.sources.substring(start, end),
""
);
totalledOffset += b;
});
const decryptedStream = AES.decrypt(
strippedSources,
extractedKey
).toString(enc.Utf8);
const parsedStream = JSON.parse(decryptedStream)[0];
if (!parsedStream) throw new Error("No stream found");
sources = parsedStream;
}
if (!sources) throw new Error("upcloud source not found");
return {
embedId: MWEmbedType.UPCLOUD,
streamUrl: sources.file,
quality: MWStreamQuality.Q1080P,
type: MWStreamType.HLS,
captions: streamRes.tracks
.filter((sub) => sub.kind === "captions")
.map((sub) => {
return {
langIso: sub.label,
url: sub.file,
type: sub.file.endsWith("vtt")
? MWCaptionType.VTT
: MWCaptionType.UNKNOWN,
};
}),
};
},
});

View File

@@ -1,19 +1,33 @@
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
import { MWCaption } from "@/backend/helpers/streams";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { parse, detect, list } from "subsrt-ts"; import { convert, detect, list, parse } from "subsrt-ts";
import { ContentCaption } from "subsrt-ts/dist/types/handler"; import { ContentCaption } from "subsrt-ts/dist/types/handler";
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
export const customCaption = "external-custom"; export const customCaption = "external-custom";
export function makeCaptionId(caption: MWCaption, isLinked: boolean): string { export function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`; return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`;
} }
export const subtitleTypeList = list().map((type) => `.${type}`); export const subtitleTypeList = list().map((type) => `.${type}`);
export function isSupportedSubtitle(url: string): boolean {
return subtitleTypeList.some((type) => url.endsWith(type));
}
export function getMWCaptionTypeFromUrl(url: string): MWCaptionType {
if (!isSupportedSubtitle(url)) return MWCaptionType.UNKNOWN;
const type = subtitleTypeList.find((t) => url.endsWith(t));
if (!type) return MWCaptionType.UNKNOWN;
return type.slice(1) as MWCaptionType;
}
export const sanitize = DOMPurify.sanitize; export const sanitize = DOMPurify.sanitize;
export async function getCaptionUrl(caption: MWCaption): Promise<string> { export async function getCaptionUrl(caption: MWCaption): Promise<string> {
if (caption.url.startsWith("blob:")) return caption.url;
let captionBlob: Blob; let captionBlob: Blob;
if (caption.needsProxy) { if (caption.url.startsWith("blob:")) {
// custom subtitle
captionBlob = await (await fetch(caption.url)).blob();
} else if (caption.needsProxy) {
captionBlob = await proxiedFetch<Blob>(caption.url, { captionBlob = await proxiedFetch<Blob>(caption.url, {
responseType: "blob" as any, responseType: "blob" as any,
}); });
@@ -22,7 +36,10 @@ export async function getCaptionUrl(caption: MWCaption): Promise<string> {
responseType: "blob" as any, responseType: "blob" as any,
}); });
} }
return URL.createObjectURL(captionBlob); // convert to vtt for track element source which will be used in PiP mode
const text = await captionBlob.text();
const vtt = convert(text, "vtt");
return URL.createObjectURL(new Blob([vtt], { type: "text/vtt" }));
} }
export function revokeCaptionBlob(url: string | undefined) { export function revokeCaptionBlob(url: string | undefined) {
@@ -32,10 +49,14 @@ export function revokeCaptionBlob(url: string | undefined) {
} }
export function parseSubtitles(text: string): ContentCaption[] { export function parseSubtitles(text: string): ContentCaption[] {
if (detect(text) === "") { const textTrimmed = text.trim();
if (textTrimmed === "") {
throw new Error("Given text is empty");
}
if (detect(textTrimmed) === "") {
throw new Error("Invalid subtitle format"); throw new Error("Invalid subtitle format");
} }
return parse(text).filter( return parse(textTrimmed).filter(
(cue) => cue.type === "caption" (cue) => cue.type === "caption"
) as ContentCaption[]; ) as ContentCaption[];
} }

View File

@@ -4,6 +4,9 @@ export enum MWEmbedType {
M4UFREE = "m4ufree", M4UFREE = "m4ufree",
STREAMM4U = "streamm4u", STREAMM4U = "streamm4u",
PLAYM4U = "playm4u", PLAYM4U = "playm4u",
UPCLOUD = "upcloud",
STREAMSB = "streamsb",
MP4UPLOAD = "mp4upload",
} }
export type MWEmbed = { export type MWEmbed = {

View File

@@ -1,5 +1,6 @@
import { FetchOptions, FetchResponse, ofetch } from "ofetch";
import { conf } from "@/setup/config"; import { conf } from "@/setup/config";
import { ofetch } from "ofetch";
let proxyUrlIndex = Math.floor(Math.random() * conf().PROXY_URLS.length); let proxyUrlIndex = Math.floor(Math.random() * conf().PROXY_URLS.length);
@@ -58,3 +59,36 @@ export function proxiedFetch<T>(url: string, ops: P<T>[1] = {}): R<T> {
}, },
}); });
} }
export function rawProxiedFetch<T>(
url: string,
ops: FetchOptions = {}
): Promise<FetchResponse<T>> {
let combinedUrl = ops?.baseURL ?? "";
if (
combinedUrl.length > 0 &&
combinedUrl.endsWith("/") &&
url.startsWith("/")
)
combinedUrl += url.slice(1);
else if (
combinedUrl.length > 0 &&
!combinedUrl.endsWith("/") &&
!url.startsWith("/")
)
combinedUrl += `/${url}`;
else combinedUrl += url;
const parsedUrl = new URL(combinedUrl);
Object.entries(ops?.params ?? {}).forEach(([k, v]) => {
parsedUrl.searchParams.set(k, v);
});
return baseFetch.raw(getProxyUrl(), {
...ops,
baseURL: undefined,
params: {
destination: parsedUrl.toString(),
},
});
}

View File

@@ -1,7 +1,7 @@
import { DetailedMeta } from "../metadata/getmeta";
import { MWMediaType } from "../metadata/types";
import { MWEmbed } from "./embed"; import { MWEmbed } from "./embed";
import { MWStream } from "./streams"; import { MWStream } from "./streams";
import { DetailedMeta } from "../metadata/getmeta";
import { MWMediaType } from "../metadata/types/mw";
export type MWProviderScrapeResult = { export type MWProviderScrapeResult = {
stream?: MWStream; stream?: MWStream;

View File

@@ -3,7 +3,7 @@ import { getEmbedScraperByType, getProviders } from "./register";
import { runEmbedScraper, runProvider } from "./run"; import { runEmbedScraper, runProvider } from "./run";
import { MWStream } from "./streams"; import { MWStream } from "./streams";
import { DetailedMeta } from "../metadata/getmeta"; import { DetailedMeta } from "../metadata/getmeta";
import { MWMediaType } from "../metadata/types"; import { MWMediaType } from "../metadata/types/mw";
interface MWProgressData { interface MWProgressData {
type: "embed" | "provider"; type: "embed" | "provider";

View File

@@ -3,9 +3,16 @@ export enum MWStreamType {
HLS = "hls", HLS = "hls",
} }
// subsrt-ts supported types
export enum MWCaptionType { export enum MWCaptionType {
VTT = "vtt", VTT = "vtt",
SRT = "srt", SRT = "srt",
LRC = "lrc",
SBV = "sbv",
SUB = "sub",
SSA = "ssa",
ASS = "ass",
JSON = "json",
UNKNOWN = "unknown", UNKNOWN = "unknown",
} }

View File

@@ -7,9 +7,18 @@ import "./providers/superstream";
import "./providers/netfilm"; import "./providers/netfilm";
import "./providers/m4ufree"; import "./providers/m4ufree";
import "./providers/hdwatched"; import "./providers/hdwatched";
import "./providers/2embed";
import "./providers/sflix";
import "./providers/gomovies";
import "./providers/kissasian";
import "./providers/streamflix";
import "./providers/remotestream";
// embeds // embeds
import "./embeds/streamm4u"; import "./embeds/streamm4u";
import "./embeds/playm4u"; import "./embeds/playm4u";
import "./embeds/upcloud";
import "./embeds/streamsb";
import "./embeds/mp4upload";
initializeScraperStore(); initializeScraperStore();

View File

@@ -1,13 +1,29 @@
import { FetchError } from "ofetch"; import { FetchError } from "ofetch";
import { makeUrl, proxiedFetch } from "../helpers/fetch";
import { formatJWMeta, mediaTypeToJW } from "./justwatch";
import {
TMDBMediaToMediaType,
formatTMDBMeta,
getEpisodes,
getExternalIds,
getMediaDetails,
getMediaPoster,
getMovieFromExternalId,
mediaTypeToTMDB,
} from "./tmdb";
import { import {
formatJWMeta,
JWMediaResult, JWMediaResult,
JWSeasonMetaResult, JWSeasonMetaResult,
JW_API_BASE, JW_API_BASE,
mediaTypeToJW, } from "./types/justwatch";
} from "./justwatch"; import { MWMediaMeta, MWMediaType } from "./types/mw";
import { MWMediaMeta, MWMediaType } from "./types"; import {
TMDBMediaResult,
TMDBMovieData,
TMDBSeasonMetaResult,
TMDBShowData,
} from "./types/tmdb";
import { makeUrl, proxiedFetch } from "../helpers/fetch";
type JWExternalIdType = type JWExternalIdType =
| "eidr" | "eidr"
@@ -28,14 +44,96 @@ interface JWDetailedMeta extends JWMediaResult {
export interface DetailedMeta { export interface DetailedMeta {
meta: MWMediaMeta; meta: MWMediaMeta;
tmdbId: string; imdbId?: string;
imdbId: string; tmdbId?: string;
}
export function formatTMDBMetaResult(
details: TMDBShowData | TMDBMovieData,
type: MWMediaType
): TMDBMediaResult {
if (type === MWMediaType.MOVIE) {
const movie = details as TMDBMovieData;
return {
id: details.id,
title: movie.title,
object_type: mediaTypeToTMDB(type),
poster: getMediaPoster(movie.poster_path) ?? undefined,
original_release_year: new Date(movie.release_date).getFullYear(),
};
}
if (type === MWMediaType.SERIES) {
const show = details as TMDBShowData;
return {
id: details.id,
title: show.name,
object_type: mediaTypeToTMDB(type),
seasons: show.seasons.map((v) => ({
id: v.id,
season_number: v.season_number,
title: v.name,
})),
poster: getMediaPoster(show.poster_path) ?? undefined,
original_release_year: new Date(show.first_air_date).getFullYear(),
};
}
throw new Error("unsupported type");
} }
export async function getMetaFromId( export async function getMetaFromId(
type: MWMediaType, type: MWMediaType,
id: string, id: string,
seasonId?: string seasonId?: string
): Promise<DetailedMeta | null> {
const details = await getMediaDetails(id, mediaTypeToTMDB(type));
if (!details) return null;
const externalIds = await getExternalIds(id, mediaTypeToTMDB(type));
const imdbId = externalIds.imdb_id ?? undefined;
let seasonData: TMDBSeasonMetaResult | undefined;
if (type === MWMediaType.SERIES) {
const seasons = (details as TMDBShowData).seasons;
let selectedSeason = seasons.find((v) => v.id.toString() === seasonId);
if (!selectedSeason) {
selectedSeason = seasons.find((v) => v.season_number === 1);
}
if (selectedSeason) {
const episodes = await getEpisodes(
details.id.toString(),
selectedSeason.season_number
);
seasonData = {
id: selectedSeason.id.toString(),
season_number: selectedSeason.season_number,
title: selectedSeason.name,
episodes,
};
}
}
const tmdbmeta = formatTMDBMetaResult(details, type);
if (!tmdbmeta) return null;
const meta = formatTMDBMeta(tmdbmeta, seasonData);
if (!meta) return null;
return {
meta,
imdbId,
tmdbId: id,
};
}
export async function getLegacyMetaFromId(
type: MWMediaType,
id: string,
seasonId?: string
): Promise<DetailedMeta | null> { ): Promise<DetailedMeta | null> {
const queryType = mediaTypeToJW(type); const queryType = mediaTypeToJW(type);
@@ -66,8 +164,6 @@ export async function getMetaFromId(
if (!tmdbId) if (!tmdbId)
tmdbId = data.external_ids.find((v) => v.provider === "tmdb")?.external_id; tmdbId = data.external_ids.find((v) => v.provider === "tmdb")?.external_id;
if (!imdbId || !tmdbId) throw new Error("not enough info");
let seasonData: JWSeasonMetaResult | undefined; let seasonData: JWSeasonMetaResult | undefined;
if (data.object_type === "show") { if (data.object_type === "show") {
const seasonToScrape = seasonId ?? data.seasons?.[0].id.toString() ?? ""; const seasonToScrape = seasonId ?? data.seasons?.[0].id.toString() ?? "";
@@ -83,3 +179,55 @@ export async function getMetaFromId(
tmdbId, tmdbId,
}; };
} }
export function TMDBMediaToId(media: MWMediaMeta): string {
return ["tmdb", mediaTypeToTMDB(media.type), media.id].join("-");
}
export function decodeTMDBId(
paramId: string
): { id: string; type: MWMediaType } | null {
const [prefix, type, id] = paramId.split("-", 3);
if (prefix !== "tmdb") return null;
let mediaType;
try {
mediaType = TMDBMediaToMediaType(type);
} catch {
return null;
}
return {
type: mediaType,
id,
};
}
export function isLegacyUrl(url: string): boolean {
if (url.startsWith("/media/JW")) return true;
return false;
}
export async function convertLegacyUrl(
url: string
): Promise<string | undefined> {
if (!isLegacyUrl(url)) return undefined;
const urlParts = url.split("/").slice(2);
const [, type, id] = urlParts[0].split("-", 3);
const mediaType = TMDBMediaToMediaType(type);
const meta = await getLegacyMetaFromId(mediaType, id);
if (!meta) return undefined;
const { tmdbId, imdbId } = meta;
if (!tmdbId && !imdbId) return undefined;
// movies always have an imdb id on tmdb
if (imdbId && mediaType === MWMediaType.MOVIE) {
const movieId = await getMovieFromExternalId(imdbId);
if (movieId) return `/media/tmdb-movie-${movieId}`;
}
if (tmdbId) {
return `/media/tmdb-${type}-${tmdbId}`;
}
}

View File

@@ -1,38 +1,10 @@
import { MWMediaMeta, MWMediaType, MWSeasonMeta } from "./types"; import {
JWContentTypes,
export const JW_API_BASE = "https://apis.justwatch.com"; JWMediaResult,
export const JW_IMAGE_BASE = "https://images.justwatch.com"; JWSeasonMetaResult,
JW_IMAGE_BASE,
export type JWContentTypes = "movie" | "show"; } from "./types/justwatch";
import { MWMediaMeta, MWMediaType, MWSeasonMeta } from "./types/mw";
export type JWSeasonShort = {
title: string;
id: number;
season_number: number;
};
export type JWEpisodeShort = {
title: string;
id: number;
episode_number: number;
};
export type JWMediaResult = {
title: string;
poster?: string;
id: number;
original_release_year?: number;
jw_entity_id: string;
object_type: JWContentTypes;
seasons?: JWSeasonShort[];
};
export type JWSeasonMetaResult = {
title: string;
id: string;
season_number: number;
episodes: JWEpisodeShort[];
};
export function mediaTypeToJW(type: MWMediaType): JWContentTypes { export function mediaTypeToJW(type: MWMediaType): JWContentTypes {
if (type === MWMediaType.MOVIE) return "movie"; if (type === MWMediaType.MOVIE) return "movie";

View File

@@ -1,13 +1,12 @@
import { SimpleCache } from "@/utils/cache"; import { SimpleCache } from "@/utils/cache";
import { proxiedFetch } from "../helpers/fetch";
import { import {
formatJWMeta, formatTMDBMeta,
JWContentTypes, formatTMDBSearchResult,
JWMediaResult, mediaTypeToTMDB,
JW_API_BASE, searchMedia,
mediaTypeToJW, } from "./tmdb";
} from "./justwatch"; import { MWMediaMeta, MWQuery } from "./types/mw";
import { MWMediaMeta, MWQuery } from "./types";
const cache = new SimpleCache<MWQuery, MWMediaMeta[]>(); const cache = new SimpleCache<MWQuery, MWMediaMeta[]>();
cache.setCompare((a, b) => { cache.setCompare((a, b) => {
@@ -15,44 +14,16 @@ cache.setCompare((a, b) => {
}); });
cache.initialize(); cache.initialize();
type JWSearchQuery = {
content_types: JWContentTypes[];
page: number;
page_size: number;
query: string;
};
type JWPage<T> = {
items: T[];
page: number;
page_size: number;
total_pages: number;
total_results: number;
};
export async function searchForMedia(query: MWQuery): Promise<MWMediaMeta[]> { export async function searchForMedia(query: MWQuery): Promise<MWMediaMeta[]> {
if (cache.has(query)) return cache.get(query) as MWMediaMeta[]; if (cache.has(query)) return cache.get(query) as MWMediaMeta[];
const { searchQuery, type } = query; const { searchQuery, type } = query;
const contentType = mediaTypeToJW(type); const data = await searchMedia(searchQuery, mediaTypeToTMDB(type));
const body: JWSearchQuery = { const results = data.results.map((v) => {
content_types: [contentType], const formattedResult = formatTMDBSearchResult(v, mediaTypeToTMDB(type));
page: 1, return formatTMDBMeta(formattedResult);
query: searchQuery, });
page_size: 40,
};
const data = await proxiedFetch<JWPage<JWMediaResult>>( cache.set(query, results, 3600); // cache results for 1 hour
"/content/titles/en_US/popular", return results;
{
baseURL: JW_API_BASE,
params: {
body: JSON.stringify(body),
},
}
);
const returnData = data.items.map<MWMediaMeta>((v) => formatJWMeta(v));
cache.set(query, returnData, 3600); // cache for an hour
return returnData;
} }

View File

@@ -0,0 +1,239 @@
import { conf } from "@/setup/config";
import { MWMediaMeta, MWMediaType, MWSeasonMeta } from "./types/mw";
import {
ExternalIdMovieSearchResult,
TMDBContentTypes,
TMDBEpisodeShort,
TMDBExternalIds,
TMDBMediaResult,
TMDBMovieData,
TMDBMovieExternalIds,
TMDBMovieResponse,
TMDBMovieResult,
TMDBSeason,
TMDBSeasonMetaResult,
TMDBShowData,
TMDBShowExternalIds,
TMDBShowResponse,
TMDBShowResult,
} from "./types/tmdb";
import { mwFetch } from "../helpers/fetch";
export function mediaTypeToTMDB(type: MWMediaType): TMDBContentTypes {
if (type === MWMediaType.MOVIE) return "movie";
if (type === MWMediaType.SERIES) return "show";
throw new Error("unsupported type");
}
export function TMDBMediaToMediaType(type: string): MWMediaType {
if (type === "movie") return MWMediaType.MOVIE;
if (type === "show") return MWMediaType.SERIES;
throw new Error("unsupported type");
}
export function formatTMDBMeta(
media: TMDBMediaResult,
season?: TMDBSeasonMetaResult
): MWMediaMeta {
const type = TMDBMediaToMediaType(media.object_type);
let seasons: undefined | MWSeasonMeta[];
if (type === MWMediaType.SERIES) {
seasons = media.seasons
?.sort((a, b) => a.season_number - b.season_number)
.map(
(v): MWSeasonMeta => ({
title: v.title,
id: v.id.toString(),
number: v.season_number,
})
);
}
return {
title: media.title,
id: media.id.toString(),
year: media.original_release_year?.toString(),
poster: media.poster,
type,
seasons: seasons as any,
seasonData: season
? ({
id: season.id.toString(),
number: season.season_number,
title: season.title,
episodes: season.episodes
.sort((a, b) => a.episode_number - b.episode_number)
.map((v) => ({
id: v.id.toString(),
number: v.episode_number,
title: v.title,
})),
} as any)
: (undefined as any),
};
}
export function TMDBMediaToId(media: MWMediaMeta): string {
return ["tmdb", mediaTypeToTMDB(media.type), media.id].join("-");
}
export function decodeTMDBId(
paramId: string
): { id: string; type: MWMediaType } | null {
const [prefix, type, id] = paramId.split("-", 3);
if (prefix !== "tmdb") return null;
let mediaType;
try {
mediaType = TMDBMediaToMediaType(type);
} catch {
return null;
}
return {
type: mediaType,
id,
};
}
const baseURL = "https://api.themoviedb.org/3";
const headers = {
accept: "application/json",
Authorization: `Bearer ${conf().TMDB_READ_API_KEY}`,
};
async function get<T>(url: string, params?: object): Promise<T> {
const res = await mwFetch<any>(encodeURI(url), {
headers,
baseURL,
params: {
...params,
},
});
return res;
}
export async function searchMedia(
query: string,
type: TMDBContentTypes
): Promise<TMDBMovieResponse | TMDBShowResponse> {
let data;
switch (type) {
case "movie":
data = await get<TMDBMovieResponse>("search/movie", {
query,
include_adult: false,
language: "en-US",
page: 1,
});
break;
case "show":
data = await get<TMDBShowResponse>("search/tv", {
query,
include_adult: false,
language: "en-US",
page: 1,
});
break;
default:
throw new Error("Invalid media type");
}
return data;
}
// Conditional type which for inferring the return type based on the content type
type MediaDetailReturn<T extends TMDBContentTypes> = T extends "movie"
? TMDBMovieData
: T extends "show"
? TMDBShowData
: never;
export function getMediaDetails<
T extends TMDBContentTypes,
TReturn = MediaDetailReturn<T>
>(id: string, type: T): Promise<TReturn> {
if (type === "movie") {
return get<TReturn>(`/movie/${id}`);
}
if (type === "show") {
return get<TReturn>(`/tv/${id}`);
}
throw new Error("Invalid media type");
}
export function getMediaPoster(posterPath: string | null): string | undefined {
if (posterPath) return `https://image.tmdb.org/t/p/w185/${posterPath}`;
}
export async function getEpisodes(
id: string,
season: number
): Promise<TMDBEpisodeShort[]> {
const data = await get<TMDBSeason>(`/tv/${id}/season/${season}`);
return data.episodes.map((e) => ({
id: e.id,
episode_number: e.episode_number,
title: e.name,
}));
}
export async function getExternalIds(
id: string,
type: TMDBContentTypes
): Promise<TMDBExternalIds> {
let data;
switch (type) {
case "movie":
data = await get<TMDBMovieExternalIds>(`/movie/${id}/external_ids`);
break;
case "show":
data = await get<TMDBShowExternalIds>(`/tv/${id}/external_ids`);
break;
default:
throw new Error("Invalid media type");
}
return data;
}
export async function getMovieFromExternalId(
imdbId: string
): Promise<string | undefined> {
const data = await get<ExternalIdMovieSearchResult>(`/find/${imdbId}`, {
external_source: "imdb_id",
});
const movie = data.movie_results[0];
if (!movie) return undefined;
return movie.id.toString();
}
export function formatTMDBSearchResult(
result: TMDBShowResult | TMDBMovieResult,
mediatype: TMDBContentTypes
): TMDBMediaResult {
const type = TMDBMediaToMediaType(mediatype);
if (type === MWMediaType.SERIES) {
const show = result as TMDBShowResult;
return {
title: show.name,
poster: getMediaPoster(show.poster_path),
id: show.id,
original_release_year: new Date(show.first_air_date).getFullYear(),
object_type: mediatype,
};
}
const movie = result as TMDBMovieResult;
return {
title: movie.title,
poster: getMediaPoster(movie.poster_path),
id: movie.id,
original_release_year: new Date(movie.release_date).getFullYear(),
object_type: mediatype,
};
}

View File

@@ -0,0 +1,48 @@
export type JWContentTypes = "movie" | "show";
export type JWSearchQuery = {
content_types: JWContentTypes[];
page: number;
page_size: number;
query: string;
};
export type JWPage<T> = {
items: T[];
page: number;
page_size: number;
total_pages: number;
total_results: number;
};
export const JW_API_BASE = "https://apis.justwatch.com";
export const JW_IMAGE_BASE = "https://images.justwatch.com";
export type JWSeasonShort = {
title: string;
id: number;
season_number: number;
};
export type JWEpisodeShort = {
title: string;
id: number;
episode_number: number;
};
export type JWMediaResult = {
title: string;
poster?: string;
id: number;
original_release_year?: number;
jw_entity_id: string;
object_type: JWContentTypes;
seasons?: JWSeasonShort[];
};
export type JWSeasonMetaResult = {
title: string;
id: string;
season_number: number;
episodes: JWEpisodeShort[];
};

View File

@@ -45,3 +45,9 @@ export interface MWQuery {
searchQuery: string; searchQuery: string;
type: MWMediaType; type: MWMediaType;
} }
export interface DetailedMeta {
meta: MWMediaMeta;
imdbId?: string;
tmdbId?: string;
}

View File

@@ -0,0 +1,308 @@
export type TMDBContentTypes = "movie" | "show";
export type TMDBSeasonShort = {
title: string;
id: number;
season_number: number;
};
export type TMDBEpisodeShort = {
title: string;
id: number;
episode_number: number;
};
export type TMDBMediaResult = {
title: string;
poster?: string;
id: number;
original_release_year?: number;
object_type: TMDBContentTypes;
seasons?: TMDBSeasonShort[];
};
export type TMDBSeasonMetaResult = {
title: string;
id: string;
season_number: number;
episodes: TMDBEpisodeShort[];
};
export interface TMDBShowData {
adult: boolean;
backdrop_path: string | null;
created_by: {
id: number;
credit_id: string;
name: string;
gender: number;
profile_path: string | null;
}[];
episode_run_time: number[];
first_air_date: string;
genres: {
id: number;
name: string;
}[];
homepage: string;
id: number;
in_production: boolean;
languages: string[];
last_air_date: string;
last_episode_to_air: {
id: number;
name: string;
overview: string;
vote_average: number;
vote_count: number;
air_date: string;
episode_number: number;
production_code: string;
runtime: number | null;
season_number: number;
show_id: number;
still_path: string | null;
} | null;
name: string;
next_episode_to_air: {
id: number;
name: string;
overview: string;
vote_average: number;
vote_count: number;
air_date: string;
episode_number: number;
production_code: string;
runtime: number | null;
season_number: number;
show_id: number;
still_path: string | null;
} | null;
networks: {
id: number;
logo_path: string;
name: string;
origin_country: string;
}[];
number_of_episodes: number;
number_of_seasons: number;
origin_country: string[];
original_language: string;
original_name: string;
overview: string;
popularity: number;
poster_path: string | null;
production_companies: {
id: number;
logo_path: string | null;
name: string;
origin_country: string;
}[];
production_countries: {
iso_3166_1: string;
name: string;
}[];
seasons: {
air_date: string;
episode_count: number;
id: number;
name: string;
overview: string;
poster_path: string | null;
season_number: number;
}[];
spoken_languages: {
english_name: string;
iso_639_1: string;
name: string;
}[];
status: string;
tagline: string;
type: string;
vote_average: number;
vote_count: number;
}
export interface TMDBMovieData {
adult: boolean;
backdrop_path: string | null;
belongs_to_collection: {
id: number;
name: string;
poster_path: string | null;
backdrop_path: string | null;
} | null;
budget: number;
genres: {
id: number;
name: string;
}[];
homepage: string | null;
id: number;
imdb_id: string | null;
original_language: string;
original_title: string;
overview: string | null;
popularity: number;
poster_path: string | null;
production_companies: {
id: number;
logo_path: string | null;
name: string;
origin_country: string;
}[];
production_countries: {
iso_3166_1: string;
name: string;
}[];
release_date: string;
revenue: number;
runtime: number | null;
spoken_languages: {
english_name: string;
iso_639_1: string;
name: string;
}[];
status: string;
tagline: string | null;
title: string;
video: boolean;
vote_average: number;
vote_count: number;
}
export interface TMDBEpisodeResult {
season: number;
number: number;
title: string;
ids: {
trakt: number;
tvdb: number;
imdb: string;
tmdb: number;
};
}
export interface TMDBShowResult {
adult: boolean;
backdrop_path: string | null;
genre_ids: number[];
id: number;
origin_country: string[];
original_language: string;
original_name: string;
overview: string;
popularity: number;
poster_path: string | null;
first_air_date: string;
name: string;
vote_average: number;
vote_count: number;
}
export interface TMDBShowResponse {
page: number;
results: TMDBShowResult[];
total_pages: number;
total_results: number;
}
export interface TMDBMovieResult {
adult: boolean;
backdrop_path: string | null;
genre_ids: number[];
id: number;
original_language: string;
original_title: string;
overview: string;
popularity: number;
poster_path: string | null;
release_date: string;
title: string;
video: boolean;
vote_average: number;
vote_count: number;
}
export interface TMDBMovieResponse {
page: number;
results: TMDBMovieResult[];
total_pages: number;
total_results: number;
}
export interface TMDBEpisode {
air_date: string;
episode_number: number;
id: number;
name: string;
overview: string;
production_code: string;
runtime: number;
season_number: number;
show_id: number;
still_path: string | null;
vote_average: number;
vote_count: number;
crew: any[];
guest_stars: any[];
}
export interface TMDBSeason {
_id: string;
air_date: string;
episodes: TMDBEpisode[];
name: string;
overview: string;
id: number;
poster_path: string | null;
season_number: number;
}
export interface TMDBShowExternalIds {
id: number;
imdb_id: null | string;
freebase_mid: null | string;
freebase_id: null | string;
tvdb_id: number;
tvrage_id: null | string;
wikidata_id: null | string;
facebook_id: null | string;
instagram_id: null | string;
twitter_id: null | string;
}
export interface TMDBMovieExternalIds {
id: number;
imdb_id: null | string;
wikidata_id: null | string;
facebook_id: null | string;
instagram_id: null | string;
twitter_id: null | string;
}
export type TMDBExternalIds = TMDBShowExternalIds | TMDBMovieExternalIds;
export interface ExternalIdMovieSearchResult {
movie_results: {
adult: boolean;
backdrop_path: string;
id: number;
title: string;
original_language: string;
original_title: string;
overview: string;
poster_path: string;
media_type: string;
genre_ids: number[];
popularity: number;
release_date: string;
video: boolean;
vote_average: number;
vote_count: number;
}[];
person_results: any[];
tv_results: any[];
tv_episode_results: any[];
tv_season_results: any[];
}

View File

@@ -0,0 +1,252 @@
import Base64 from "crypto-js/enc-base64";
import Utf8 from "crypto-js/enc-utf8";
import { proxiedFetch, rawProxiedFetch } from "../helpers/fetch";
import { registerProvider } from "../helpers/register";
import {
MWCaptionType,
MWStreamQuality,
MWStreamType,
} from "../helpers/streams";
import { MWMediaType } from "../metadata/types/mw";
const twoEmbedBase = "https://www.2embed.to";
async function fetchCaptchaToken(recaptchaKey: string) {
const domainHash = Base64.stringify(Utf8.parse(twoEmbedBase)).replace(
/=/g,
"."
);
const recaptchaRender = await proxiedFetch<any>(
`https://www.google.com/recaptcha/api.js?render=${recaptchaKey}`
);
const vToken = recaptchaRender.substring(
recaptchaRender.indexOf("/releases/") + 10,
recaptchaRender.indexOf("/recaptcha__en.js")
);
const recaptchaAnchor = await proxiedFetch<any>(
`https://www.google.com/recaptcha/api2/anchor?ar=1&hl=en&size=invisible&cb=flicklax&k=${recaptchaKey}&co=${domainHash}&v=${vToken}`
);
const cToken = new DOMParser()
.parseFromString(recaptchaAnchor, "text/html")
.getElementById("recaptcha-token")
?.getAttribute("value");
if (!cToken) throw new Error("Unable to find cToken");
const payload = {
v: vToken,
reason: "q",
k: recaptchaKey,
c: cToken,
sa: "",
co: twoEmbedBase,
};
const tokenData = await proxiedFetch<string>(
`https://www.google.com/recaptcha/api2/reload?${new URLSearchParams(
payload
).toString()}`,
{
headers: { referer: "https://www.google.com/recaptcha/api2/" },
method: "POST",
}
);
const token = tokenData.match('rresp","(.+?)"');
return token ? token[1] : null;
}
interface IEmbedRes {
link: string;
sources: [];
tracks: [];
type: string;
}
interface IStreamData {
status: string;
message: string;
type: string;
token: string;
result:
| {
Original: {
label: string;
file: string;
url: string;
};
}
| {
label: string;
size: number;
url: string;
}[];
}
interface ISubtitles {
url: string;
lang: string;
}
async function fetchStream(sourceId: string, captchaToken: string) {
const embedRes = await proxiedFetch<IEmbedRes>(
`${twoEmbedBase}/ajax/embed/play?id=${sourceId}&_token=${captchaToken}`,
{
headers: {
Referer: twoEmbedBase,
},
}
);
// Link format: https://rabbitstream.net/embed-4/{data-id}?z=
const rabbitStreamUrl = new URL(embedRes.link);
const dataPath = rabbitStreamUrl.pathname.split("/");
const dataId = dataPath[dataPath.length - 1];
// https://rabbitstream.net/embed/m-download/{data-id}
const download = await proxiedFetch<any>(
`${rabbitStreamUrl.origin}/embed/m-download/${dataId}`,
{
headers: {
referer: twoEmbedBase,
},
}
);
const downloadPage = new DOMParser().parseFromString(download, "text/html");
const streamlareEl = Array.from(
downloadPage.querySelectorAll(".dls-brand")
).find((el) => el.textContent?.trim() === "Streamlare");
if (!streamlareEl) throw new Error("Unable to find streamlare element");
const streamlareUrl =
streamlareEl.nextElementSibling?.querySelector("a")?.href;
if (!streamlareUrl) throw new Error("Unable to parse streamlare url");
const subtitles: ISubtitles[] = [];
const subtitlesDropdown = downloadPage.querySelectorAll(
"#user_menu .dropdown-item"
);
subtitlesDropdown.forEach((item) => {
const url = item.getAttribute("href");
const lang = item.textContent?.trim().replace("Download", "").trim();
if (url && lang) subtitles.push({ url, lang });
});
const streamlare = await proxiedFetch<any>(streamlareUrl);
const streamlarePage = new DOMParser().parseFromString(
streamlare,
"text/html"
);
const csrfToken = streamlarePage
.querySelector("head > meta:nth-child(3)")
?.getAttribute("content");
if (!csrfToken) throw new Error("Unable to find CSRF token");
const videoId = streamlareUrl.match("/[ve]/([^?#&/]+)")?.[1];
if (!videoId) throw new Error("Unable to get streamlare video id");
const streamRes = await proxiedFetch<IStreamData>(
`${new URL(streamlareUrl).origin}/api/video/download/get`,
{
method: "POST",
body: JSON.stringify({
id: videoId,
}),
headers: {
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-Token": csrfToken,
},
}
);
if (streamRes.message !== "OK") throw new Error("Unable to fetch stream");
const streamData = Array.isArray(streamRes.result)
? streamRes.result[0]
: streamRes.result.Original;
if (!streamData) throw new Error("Unable to get stream data");
const followStream = await rawProxiedFetch(streamData.url, {
method: "HEAD",
referrer: new URL(streamlareUrl).origin,
});
const finalStreamUrl = followStream.headers.get("X-Final-Destination");
return { url: finalStreamUrl, subtitles };
}
registerProvider({
id: "2embed",
displayName: "2Embed",
rank: 125,
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
disabled: true, // Disabled, not working
async scrape({ media, episode, progress }) {
let embedUrl = `${twoEmbedBase}/embed/tmdb/movie?id=${media.tmdbId}`;
if (media.meta.type === MWMediaType.SERIES) {
const seasonNumber = media.meta.seasonData.number;
const episodeNumber = media.meta.seasonData.episodes.find(
(e) => e.id === episode
)?.number;
embedUrl = `${twoEmbedBase}/embed/tmdb/tv?id=${media.tmdbId}&s=${seasonNumber}&e=${episodeNumber}`;
}
const embed = await proxiedFetch<any>(embedUrl);
progress(20);
const embedPage = new DOMParser().parseFromString(embed, "text/html");
const pageServerItems = Array.from(
embedPage.querySelectorAll(".item-server")
);
const pageStreamItem = pageServerItems.find((item) =>
item.textContent?.includes("Vidcloud")
);
const sourceId = pageStreamItem
? pageStreamItem.getAttribute("data-id")
: null;
if (!sourceId) throw new Error("Unable to get source id");
const siteKey = embedPage
.querySelector("body")
?.getAttribute("data-recaptcha-key");
if (!siteKey) throw new Error("Unable to get site key");
const captchaToken = await fetchCaptchaToken(siteKey);
if (!captchaToken) throw new Error("Unable to fetch captcha token");
progress(35);
const stream = await fetchStream(sourceId, captchaToken);
if (!stream.url) throw new Error("Unable to find stream url");
return {
embeds: [],
stream: {
streamUrl: stream.url,
quality: MWStreamQuality.QUNKNOWN,
type: MWStreamType.MP4,
captions: stream.subtitles.map((sub) => {
return {
langIso: sub.lang,
url: `https://cc.2cdns.com${new URL(sub.url).pathname}`,
type: MWCaptionType.VTT,
};
}),
},
};
},
});

View File

@@ -1,127 +0,0 @@
import { compareTitle } from "@/utils/titleMatch";
import { proxiedFetch } from "../helpers/fetch";
import { registerProvider } from "../helpers/register";
import {
MWCaptionType,
MWStreamQuality,
MWStreamType,
} from "../helpers/streams";
import { MWMediaType } from "../metadata/types";
const flixHqBase = "https://api.consumet.org/meta/tmdb";
type FlixHQMediaType = "Movie" | "TV Series";
interface FLIXMediaBase {
id: number;
title: string;
url: string;
image: string;
type: FlixHQMediaType;
releaseDate: string;
}
function castSubtitles({ url, lang }: { url: string; lang: string }) {
return {
url,
langIso: lang,
type:
url.substring(url.length - 3) === "vtt"
? MWCaptionType.VTT
: MWCaptionType.SRT,
};
}
const qualityMap: Record<string, MWStreamQuality> = {
"360": MWStreamQuality.Q360P,
"540": MWStreamQuality.Q540P,
"480": MWStreamQuality.Q480P,
"720": MWStreamQuality.Q720P,
"1080": MWStreamQuality.Q1080P,
};
function flixTypeToMWType(type: FlixHQMediaType) {
if (type === "Movie") return MWMediaType.MOVIE;
return MWMediaType.SERIES;
}
registerProvider({
id: "flixhq",
displayName: "FlixHQ",
rank: 100,
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape({ media, episode, progress }) {
if (!this.type.includes(media.meta.type)) {
throw new Error("Unsupported type");
}
// search for relevant item
const searchResults = await proxiedFetch<any>(
`/${encodeURIComponent(media.meta.title)}`,
{
baseURL: flixHqBase,
}
);
const foundItem = searchResults.results.find((v: FLIXMediaBase) => {
if (v.type !== "Movie" && v.type !== "TV Series") return false;
return (
compareTitle(v.title, media.meta.title) &&
flixTypeToMWType(v.type) === media.meta.type &&
v.releaseDate === media.meta.year
);
});
if (!foundItem) throw new Error("No watchable item found");
// get media info
progress(25);
const mediaInfo = await proxiedFetch<any>(`/info/${foundItem.id}`, {
baseURL: flixHqBase,
params: {
type: flixTypeToMWType(foundItem.type),
},
});
if (!mediaInfo.id) throw new Error("No watchable item found");
// get stream info from media
progress(50);
let episodeId: string | undefined;
if (media.meta.type === MWMediaType.MOVIE) {
episodeId = mediaInfo.episodeId;
} else if (media.meta.type === MWMediaType.SERIES) {
const seasonNo = media.meta.seasonData.number;
const episodeNo = media.meta.seasonData.episodes.find(
(e) => e.id === episode
)?.number;
const season = mediaInfo.seasons.find((o: any) => o.season === seasonNo);
episodeId = season.episodes.find((o: any) => o.episode === episodeNo).id;
}
if (!episodeId) throw new Error("No watchable item found");
progress(75);
const watchInfo = await proxiedFetch<any>(`/watch/${episodeId}`, {
baseURL: flixHqBase,
params: {
id: mediaInfo.id,
},
});
if (!watchInfo.sources) throw new Error("No watchable item found");
// get best quality source
// comes sorted by quality in descending order
const source = watchInfo.sources[0];
return {
embeds: [],
stream: {
streamUrl: source.url,
quality: qualityMap[source.quality],
type: source.isM3U8 ? MWStreamType.HLS : MWStreamType.MP4,
captions: watchInfo.subtitles
.filter(
(x: { url: string; lang: string }) => !x.lang.includes("(maybe)")
)
.map(castSubtitles),
},
};
},
});

View File

@@ -0,0 +1 @@
export const flixHqBase = "https://flixhq.to";

View File

@@ -0,0 +1,36 @@
import { MWEmbedType } from "@/backend/helpers/embed";
import { registerProvider } from "@/backend/helpers/register";
import { MWMediaType } from "@/backend/metadata/types/mw";
import {
getFlixhqSourceDetails,
getFlixhqSources,
} from "@/backend/providers/flixhq/scrape";
import { getFlixhqId } from "@/backend/providers/flixhq/search";
registerProvider({
id: "flixhq",
displayName: "FlixHQ",
rank: 100,
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape({ media }) {
const id = await getFlixhqId(media.meta);
if (!id) throw new Error("flixhq no matching item found");
// TODO tv shows not supported. just need to scrape the specific episode sources
const sources = await getFlixhqSources(id);
const upcloudStream = sources.find(
(v) => v.embed.toLowerCase() === "upcloud"
);
if (!upcloudStream) throw new Error("upcloud stream not found for flixhq");
return {
embeds: [
{
type: MWEmbedType.UPCLOUD,
url: await getFlixhqSourceDetails(upcloudStream.episodeId),
},
],
};
},
});

View File

@@ -0,0 +1,41 @@
import { proxiedFetch } from "@/backend/helpers/fetch";
import { flixHqBase } from "@/backend/providers/flixhq/common";
export async function getFlixhqSources(id: string) {
const type = id.split("/")[0];
const episodeParts = id.split("-");
const episodeId = episodeParts[episodeParts.length - 1];
const data = await proxiedFetch<string>(
`/ajax/${type}/episodes/${episodeId}`,
{
baseURL: flixHqBase,
}
);
const doc = new DOMParser().parseFromString(data, "text/html");
const sourceLinks = [...doc.querySelectorAll(".nav-item > a")].map((el) => {
const embedTitle = el.getAttribute("title");
const linkId = el.getAttribute("data-linkid");
if (!embedTitle || !linkId) throw new Error("invalid sources");
return {
embed: embedTitle,
episodeId: linkId,
};
});
return sourceLinks;
}
export async function getFlixhqSourceDetails(
sourceId: string
): Promise<string> {
const jsonData = await proxiedFetch<Record<string, any>>(
`/ajax/sources/${sourceId}`,
{
baseURL: flixHqBase,
}
);
return jsonData.link;
}

View File

@@ -0,0 +1,43 @@
import { proxiedFetch } from "@/backend/helpers/fetch";
import { MWMediaMeta } from "@/backend/metadata/types/mw";
import { flixHqBase } from "@/backend/providers/flixhq/common";
import { compareTitle } from "@/utils/titleMatch";
export async function getFlixhqId(meta: MWMediaMeta): Promise<string | null> {
const searchResults = await proxiedFetch<string>(
`/search/${meta.title.replaceAll(/[^a-z0-9A-Z]/g, "-")}`,
{
baseURL: flixHqBase,
}
);
const doc = new DOMParser().parseFromString(searchResults, "text/html");
const items = [...doc.querySelectorAll(".film_list-wrap > div.flw-item")].map(
(el) => {
const id = el
.querySelector("div.film-poster > a")
?.getAttribute("href")
?.slice(1);
const title = el
.querySelector("div.film-detail > h2 > a")
?.getAttribute("title");
const year = el.querySelector(
"div.film-detail > div.fd-infor > span:nth-child(1)"
)?.textContent;
if (!id || !title || !year) return null;
return {
id,
title,
year,
};
}
);
const matchingItem = items.find(
(v) => v && compareTitle(meta.title, v.title) && meta.year === v.year
);
if (!matchingItem) return null;
return matchingItem.id;
}

View File

@@ -1,9 +1,10 @@
import { unpack } from "unpacker";
import CryptoJS from "crypto-js"; import CryptoJS from "crypto-js";
import { unpack } from "unpacker";
import { registerProvider } from "@/backend/helpers/register"; import { registerProvider } from "@/backend/helpers/register";
import { MWMediaType } from "@/backend/metadata/types";
import { MWStreamQuality } from "@/backend/helpers/streams"; import { MWStreamQuality } from "@/backend/helpers/streams";
import { MWMediaType } from "@/backend/metadata/types/mw";
import { proxiedFetch } from "../helpers/fetch"; import { proxiedFetch } from "../helpers/fetch";
const format = { const format = {
@@ -40,6 +41,7 @@ registerProvider({
type: [MWMediaType.MOVIE], type: [MWMediaType.MOVIE],
async scrape({ progress, media: { imdbId } }) { async scrape({ progress, media: { imdbId } }) {
if (!imdbId) throw new Error("not enough info");
progress(10); progress(10);
const streamRes = await proxiedFetch<string>( const streamRes = await proxiedFetch<string>(
"https://database.gdriveplayer.us/player.php", "https://database.gdriveplayer.us/player.php",

View File

@@ -0,0 +1,162 @@
import { MWEmbedType } from "../helpers/embed";
import { proxiedFetch } from "../helpers/fetch";
import { registerProvider } from "../helpers/register";
import { MWMediaType } from "../metadata/types/mw";
const gomoviesBase = "https://gomovies.sx";
registerProvider({
id: "gomovies",
displayName: "GOmovies",
rank: 200,
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape({ media, episode }) {
const search = await proxiedFetch<any>("/ajax/search", {
baseURL: gomoviesBase,
method: "POST",
body: JSON.stringify({
keyword: media.meta.title,
}),
headers: {
"X-Requested-With": "XMLHttpRequest",
},
});
const searchPage = new DOMParser().parseFromString(search, "text/html");
const mediaElements = searchPage.querySelectorAll("a.nav-item");
const mediaData = Array.from(mediaElements).map((movieEl) => {
const name = movieEl?.querySelector("h3.film-name")?.textContent;
const year = movieEl?.querySelector(
"div.film-infor span:first-of-type"
)?.textContent;
const path = movieEl.getAttribute("href");
return { name, year, path };
});
const targetMedia = mediaData.find(
(m) =>
m.name === media.meta.title &&
(media.meta.type === MWMediaType.MOVIE
? m.year === media.meta.year
: true)
);
if (!targetMedia?.path) throw new Error("Media not found");
// Example movie path: /movie/watch-{slug}-{id}
// Example series path: /tv/watch-{slug}-{id}
let mediaId = targetMedia.path.split("-").pop()?.replace("/", "");
let sources = null;
if (media.meta.type === MWMediaType.SERIES) {
const seasons = await proxiedFetch<any>(
`/ajax/v2/tv/seasons/${mediaId}`,
{
baseURL: gomoviesBase,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
}
);
const seasonsEl = new DOMParser()
.parseFromString(seasons, "text/html")
.querySelectorAll(".ss-item");
const seasonsData = [...seasonsEl].map((season) => ({
number: season.innerHTML.replace("Season ", ""),
dataId: season.getAttribute("data-id"),
}));
const seasonNumber = media.meta.seasonData.number;
const targetSeason = seasonsData.find(
(season) => +season.number === seasonNumber
);
if (!targetSeason) throw new Error("Season not found");
const episodes = await proxiedFetch<any>(
`/ajax/v2/season/episodes/${targetSeason.dataId}`,
{
baseURL: gomoviesBase,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
}
);
const episodesEl = new DOMParser()
.parseFromString(episodes, "text/html")
.querySelectorAll(".eps-item");
const episodesData = Array.from(episodesEl).map((ep) => ({
dataId: ep.getAttribute("data-id"),
number: ep
.querySelector("strong")
?.textContent?.replace("Eps", "")
.replace(":", "")
.trim(),
}));
const episodeNumber = media.meta.seasonData.episodes.find(
(e) => e.id === episode
)?.number;
const targetEpisode = episodesData.find((ep) =>
ep.number ? +ep.number === episodeNumber : false
);
if (!targetEpisode?.dataId) throw new Error("Episode not found");
mediaId = targetEpisode.dataId;
sources = await proxiedFetch<any>(`/ajax/v2/episode/servers/${mediaId}`, {
baseURL: gomoviesBase,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
});
} else {
sources = await proxiedFetch<any>(`/ajax/movie/episodes/${mediaId}`, {
baseURL: gomoviesBase,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
});
}
const upcloud = new DOMParser()
.parseFromString(sources, "text/html")
.querySelector('a[title*="upcloud" i]');
const upcloudDataId =
upcloud?.getAttribute("data-id") ?? upcloud?.getAttribute("data-linkid");
if (!upcloudDataId) throw new Error("Upcloud source not available");
const upcloudSource = await proxiedFetch<{
type: "iframe" | string;
link: string;
sources: [];
title: string;
tracks: [];
}>(`/ajax/sources/${upcloudDataId}`, {
baseURL: gomoviesBase,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
});
if (!upcloudSource.link || upcloudSource.type !== "iframe")
throw new Error("No upcloud stream found");
return {
embeds: [
{
type: MWEmbedType.UPCLOUD,
url: upcloudSource.link,
},
],
};
},
});

View File

@@ -2,7 +2,7 @@ import { proxiedFetch } from "../helpers/fetch";
import { MWProviderContext } from "../helpers/provider"; import { MWProviderContext } from "../helpers/provider";
import { registerProvider } from "../helpers/register"; import { registerProvider } from "../helpers/register";
import { MWStreamQuality, MWStreamType } from "../helpers/streams"; import { MWStreamQuality, MWStreamType } from "../helpers/streams";
import { MWMediaType } from "../metadata/types"; import { MWMediaType } from "../metadata/types/mw";
const hdwatchedBase = "https://www.hdwatched.xyz"; const hdwatchedBase = "https://www.hdwatched.xyz";
@@ -120,9 +120,11 @@ registerProvider({
id: "hdwatched", id: "hdwatched",
displayName: "HDwatched", displayName: "HDwatched",
rank: 150, rank: 150,
disabled: true, // very slow, haven't seen it work for a while
type: [MWMediaType.MOVIE, MWMediaType.SERIES], type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape(options) { async scrape(options) {
const { media, progress } = options; const { media, progress } = options;
if (!media.imdbId) throw new Error("not enough info");
if (!this.type.includes(media.meta.type)) { if (!this.type.includes(media.meta.type)) {
throw new Error("Unsupported type"); throw new Error("Unsupported type");
} }

View File

@@ -0,0 +1,119 @@
import { MWEmbedType } from "../helpers/embed";
import { proxiedFetch } from "../helpers/fetch";
import { registerProvider } from "../helpers/register";
import { MWMediaType } from "../metadata/types/mw";
const kissasianBase = "https://kissasian.li";
const embedProviders = [
{
type: MWEmbedType.MP4UPLOAD,
id: "mp",
},
{
type: MWEmbedType.STREAMSB,
id: "sb",
},
];
registerProvider({
id: "kissasian",
displayName: "KissAsian",
rank: 130,
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape({ media, episode, progress }) {
let seasonNumber = "";
let episodeNumber = "";
if (media.meta.type === MWMediaType.SERIES) {
seasonNumber =
media.meta.seasonData.number === 1
? ""
: `${media.meta.seasonData.number}`;
episodeNumber = `${
media.meta.seasonData.episodes.find((e) => e.id === episode)?.number ??
""
}`;
}
const searchForm = new FormData();
searchForm.append("keyword", `${media.meta.title} ${seasonNumber}`.trim());
searchForm.append("type", "Drama");
const search = await proxiedFetch<any>("/Search/SearchSuggest", {
baseURL: kissasianBase,
method: "POST",
body: searchForm,
});
const searchPage = new DOMParser().parseFromString(search, "text/html");
const dramas = Array.from(searchPage.querySelectorAll("a")).map((drama) => {
return {
name: drama.textContent,
url: drama.href,
};
});
const targetDrama =
dramas.find(
(d) => d.name?.toLowerCase() === media.meta.title.toLowerCase()
) ?? dramas[0];
if (!targetDrama) throw new Error("Drama not found");
progress(30);
const drama = await proxiedFetch<any>(targetDrama.url);
const dramaPage = new DOMParser().parseFromString(drama, "text/html");
const episodesEl = dramaPage.querySelectorAll("tbody tr:not(:first-child)");
const episodes = Array.from(episodesEl)
.map((ep) => {
const number = ep
?.querySelector("td.episodeSub a")
?.textContent?.split("Episode")[1]
?.trim();
const url = ep?.querySelector("td.episodeSub a")?.getAttribute("href");
return { number, url };
})
.filter((e) => !!e.url);
const targetEpisode =
media.meta.type === MWMediaType.MOVIE
? episodes[0]
: episodes.find((e) => e.number === `${episodeNumber}`);
if (!targetEpisode?.url) throw new Error("Episode not found");
progress(70);
let embeds = await Promise.all(
embedProviders.map(async (provider) => {
const watch = await proxiedFetch<any>(
`${targetEpisode.url}&s=${provider.id}`,
{
baseURL: kissasianBase,
}
);
const watchPage = new DOMParser().parseFromString(watch, "text/html");
const embedUrl = watchPage
.querySelector("iframe[id=my_video_1]")
?.getAttribute("src");
return {
type: provider.type,
url: embedUrl ?? "",
};
})
);
embeds = embeds.filter((e) => e.url !== "");
return {
embeds,
};
},
});

View File

@@ -1,7 +1,8 @@
import { MWEmbed, MWEmbedType } from "@/backend/helpers/embed"; import { MWEmbed, MWEmbedType } from "@/backend/helpers/embed";
import { proxiedFetch } from "../helpers/fetch"; import { proxiedFetch } from "../helpers/fetch";
import { registerProvider } from "../helpers/register"; import { registerProvider } from "../helpers/register";
import { MWMediaType } from "../metadata/types"; import { MWMediaType } from "../metadata/types/mw";
const HOST = "m4ufree.com"; const HOST = "m4ufree.com";
const URL_BASE = `https://${HOST}`; const URL_BASE = `https://${HOST}`;

View File

@@ -5,7 +5,7 @@ import {
MWStreamQuality, MWStreamQuality,
MWStreamType, MWStreamType,
} from "../helpers/streams"; } from "../helpers/streams";
import { MWMediaType } from "../metadata/types"; import { MWMediaType } from "../metadata/types/mw";
const netfilmBase = "https://net-film.vercel.app"; const netfilmBase = "https://net-film.vercel.app";

View File

@@ -0,0 +1,49 @@
import { mwFetch } from "@/backend/helpers/fetch";
import { registerProvider } from "@/backend/helpers/register";
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
import { MWMediaType } from "@/backend/metadata/types/mw";
const remotestreamBase = `https://fsa.remotestre.am`;
registerProvider({
id: "remotestream",
displayName: "Remote Stream",
disabled: false,
rank: 55,
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape({ media, episode, progress }) {
if (!this.type.includes(media.meta.type)) {
throw new Error("Unsupported type");
}
progress(30);
const type = media.meta.type === MWMediaType.MOVIE ? "Movies" : "Shows";
let playlistLink = `${remotestreamBase}/${type}/${media.tmdbId}`;
if (media.meta.type === MWMediaType.SERIES) {
const seasonNumber = media.meta.seasonData.number;
const episodeNumber = media.meta.seasonData.episodes.find(
(e) => e.id === episode
)?.number;
playlistLink += `/${seasonNumber}/${episodeNumber}/${episodeNumber}.m3u8`;
} else {
playlistLink += `/${media.tmdbId}.m3u8`;
}
const streamRes = await mwFetch<Blob>(playlistLink);
if (streamRes.type !== "application/x-mpegurl")
throw new Error("No watchable item found");
progress(90);
return {
embeds: [],
stream: {
streamUrl: playlistLink,
quality: MWStreamQuality.QUNKNOWN,
type: MWStreamType.HLS,
captions: [],
},
};
},
});

View File

@@ -0,0 +1,100 @@
import { proxiedFetch } from "../helpers/fetch";
import { registerProvider } from "../helpers/register";
import { MWStreamQuality, MWStreamType } from "../helpers/streams";
import { MWMediaType } from "../metadata/types/mw";
const sflixBase = "https://sflix.video";
registerProvider({
id: "sflix",
displayName: "Sflix",
rank: 50,
disabled: true, // domain dead
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape({ media, episode, progress }) {
let searchQuery = `${media.meta.title} `;
if (media.meta.type === MWMediaType.MOVIE)
searchQuery += media.meta.year ?? "";
if (media.meta.type === MWMediaType.SERIES)
searchQuery += `S${String(media.meta.seasonData.number).padStart(
2,
"0"
)}`;
const search = await proxiedFetch<any>(
`/?s=${encodeURIComponent(searchQuery)}`,
{
baseURL: sflixBase,
}
);
const searchPage = new DOMParser().parseFromString(search, "text/html");
const moviePageUrl = searchPage
.querySelector(".movies-list .ml-item:first-child a")
?.getAttribute("href");
if (!moviePageUrl) throw new Error("Movie does not exist");
progress(25);
const movie = await proxiedFetch<any>(moviePageUrl);
const moviePage = new DOMParser().parseFromString(movie, "text/html");
progress(45);
let outerEmbedSrc = null;
if (media.meta.type === MWMediaType.MOVIE) {
outerEmbedSrc = moviePage
.querySelector("iframe")
?.getAttribute("data-lazy-src");
} else if (media.meta.type === MWMediaType.SERIES) {
const series = Array.from(moviePage.querySelectorAll(".desc p a")).map(
(a) => ({
title: a.getAttribute("title"),
link: a.getAttribute("href"),
})
);
const episodeNumber = media.meta.seasonData.episodes.find(
(e) => e.id === episode
)?.number;
const targetSeries = series.find((s) =>
s.title?.endsWith(String(episodeNumber).padStart(2, "0"))
);
if (!targetSeries) throw new Error("Episode does not exist");
outerEmbedSrc = targetSeries.link;
}
if (!outerEmbedSrc) throw new Error("Outer embed source not found");
progress(65);
const outerEmbed = await proxiedFetch<any>(outerEmbedSrc);
const outerEmbedPage = new DOMParser().parseFromString(
outerEmbed,
"text/html"
);
const embedSrc = outerEmbedPage
.querySelector("iframe")
?.getAttribute("src");
if (!embedSrc) throw new Error("Embed source not found");
const embed = await proxiedFetch<string>(embedSrc);
const streamUrl = embed.match(/file\s*:\s*"([^"]+\.mp4)"/)?.[1];
if (!streamUrl) throw new Error("Unable to get stream");
return {
embeds: [],
stream: {
streamUrl,
quality: MWStreamQuality.Q1080P,
type: MWStreamType.MP4,
captions: [],
},
};
},
});

View File

@@ -0,0 +1,70 @@
import { proxiedFetch } from "@/backend/helpers/fetch";
import { registerProvider } from "@/backend/helpers/register";
import {
MWCaptionType,
MWStreamQuality,
MWStreamType,
} from "@/backend/helpers/streams";
import { MWMediaType } from "@/backend/metadata/types/mw";
const streamflixBase = "https://us-west2-compute-proxied.streamflix.one";
const qualityMap: Record<number, MWStreamQuality> = {
360: MWStreamQuality.Q360P,
540: MWStreamQuality.Q540P,
480: MWStreamQuality.Q480P,
720: MWStreamQuality.Q720P,
1080: MWStreamQuality.Q1080P,
};
registerProvider({
id: "streamflix",
displayName: "StreamFlix",
disabled: false,
rank: 69,
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape({ media, episode, progress }) {
if (!this.type.includes(media.meta.type)) {
throw new Error("Unsupported type");
}
progress(30);
const type = media.meta.type === MWMediaType.MOVIE ? "movies" : "tv";
let seasonNumber: number | undefined;
let episodeNumber: number | undefined;
if (media.meta.type === MWMediaType.SERIES) {
// can't do type === "tv" here :(
seasonNumber = media.meta.seasonData.number;
episodeNumber = media.meta.seasonData.episodes.find(
(e: any) => e.id === episode
)?.number;
}
const streamRes = await proxiedFetch<any>(`/api/player/${type}`, {
baseURL: streamflixBase,
params: {
id: media.tmdbId,
s: seasonNumber,
e: episodeNumber,
},
});
if (!streamRes.headers.Referer) throw new Error("No watchable item found");
progress(90);
return {
embeds: [],
stream: {
streamUrl: streamRes.sources[0].url,
quality: qualityMap[streamRes.sources[0].quality],
type: MWStreamType.HLS,
captions: streamRes.subtitles.map((s: Record<string, any>) => ({
needsProxy: true,
url: s.url,
type: MWCaptionType.VTT,
langIso: s.lang,
})),
},
};
},
});

View File

@@ -1,15 +1,19 @@
import { registerProvider } from "@/backend/helpers/register";
import { MWMediaType } from "@/backend/metadata/types";
import { customAlphabet } from "nanoid";
import CryptoJS from "crypto-js"; import CryptoJS from "crypto-js";
import { customAlphabet } from "nanoid";
import {
getMWCaptionTypeFromUrl,
isSupportedSubtitle,
} from "@/backend/helpers/captions";
import { proxiedFetch } from "@/backend/helpers/fetch"; import { proxiedFetch } from "@/backend/helpers/fetch";
import { registerProvider } from "@/backend/helpers/register";
import { import {
MWCaption, MWCaption,
MWCaptionType, MWCaptionType,
MWStreamQuality, MWStreamQuality,
MWStreamType, MWStreamType,
} from "@/backend/helpers/streams"; } from "@/backend/helpers/streams";
import { MWMediaType } from "@/backend/metadata/types/mw";
import { compareTitle } from "@/utils/titleMatch"; import { compareTitle } from "@/utils/titleMatch";
const nanoid = customAlphabet("0123456789abcdef", 32); const nanoid = customAlphabet("0123456789abcdef", 32);
@@ -111,22 +115,46 @@ const getBestQuality = (list: any[]) => {
); );
}; };
const convertSubtitles = (subtitleGroup: any): MWCaption | null => {
let subtitles = subtitleGroup.subtitles;
subtitles = subtitles
.map((subFile: any) => {
const supported = isSupportedSubtitle(subFile.file_path);
if (!supported) return null;
const type = getMWCaptionTypeFromUrl(subFile.file_path);
return {
...subFile,
type: type as MWCaptionType,
};
})
.filter(Boolean);
if (subtitles.length === 0) return null;
const subFile = subtitles[0];
return {
needsProxy: true,
langIso: subtitleGroup.language,
url: subFile.file_path,
type: subFile.type,
};
};
registerProvider({ registerProvider({
id: "superstream", id: "superstream",
displayName: "Superstream", displayName: "Superstream",
rank: 200, rank: 300,
type: [MWMediaType.MOVIE, MWMediaType.SERIES], type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape({ media, episode, progress }) { async scrape({ media, episode, progress }) {
// Find Superstream ID for show // Find Superstream ID for show
const searchQuery = { const searchQuery = {
module: "Search3", module: "Search4",
page: "1", page: "1",
type: "all", type: "all",
keyword: media.meta.title, keyword: media.meta.title,
pagelimit: "20", pagelimit: "20",
}; };
const searchRes = (await get(searchQuery, true)).data; const searchRes = (await get(searchQuery, true)).data.list;
progress(33); progress(33);
const superstreamEntry = searchRes.find( const superstreamEntry = searchRes.find(
@@ -164,16 +192,9 @@ registerProvider({
const subtitleRes = (await get(subtitleApiQuery)).data; const subtitleRes = (await get(subtitleApiQuery)).data;
const mappedCaptions = subtitleRes.list.map( const mappedCaptions = subtitleRes.list
(subtitle: any): MWCaption => { .map(convertSubtitles)
return { .filter(Boolean);
needsProxy: true,
langIso: subtitle.language,
url: subtitle.subtitles[0].file_path,
type: MWCaptionType.SRT,
};
}
);
return { return {
embeds: [], embeds: [],
@@ -224,22 +245,10 @@ registerProvider({
}; };
const subtitleRes = (await get(subtitleApiQuery)).data; const subtitleRes = (await get(subtitleApiQuery)).data;
const mappedCaptions = subtitleRes.list
.map(convertSubtitles)
.filter(Boolean);
const mappedCaptions = subtitleRes.list.map(
(subtitle: any): MWCaption | null => {
const sub = subtitle;
sub.subtitles = subtitle.subtitles.filter((subFile: any) => {
const extension = subFile.file_path.slice(-3);
return [MWCaptionType.SRT, MWCaptionType.VTT].includes(extension);
});
return {
needsProxy: true,
langIso: subtitle.language,
url: sub.subtitles[0].file_path,
type: MWCaptionType.SRT,
};
}
);
return { return {
embeds: [], embeds: [],
stream: { stream: {

View File

@@ -1,6 +1,7 @@
import { Icon, Icons } from "@/components/Icon";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { Icon, Icons } from "@/components/Icon";
interface Props { interface Props {
icon?: Icons; icon?: Icons;
onClick?: () => void; onClick?: () => void;

View File

@@ -1,4 +1,5 @@
import { useSettings } from "@/state/settings"; import { useSettings } from "@/state/settings";
import { Icon, Icons } from "./Icon"; import { Icon, Icons } from "./Icon";
export const colors = ["#ffffff", "#00ffff", "#ffff00"]; export const colors = ["#ffffff", "#00ffff", "#ffff00"];

View File

@@ -1,6 +1,6 @@
import { Listbox, Transition } from "@headlessui/react";
import React, { Fragment } from "react"; import React, { Fragment } from "react";
import { Listbox, Transition } from "@headlessui/react";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
export interface OptionItem { export interface OptionItem {
@@ -37,7 +37,7 @@ export function Dropdown(props: DropdownProps) {
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<Listbox.Options className="absolute top-10 left-0 right-0 z-10 mt-1 max-h-60 overflow-auto rounded-md bg-denim-500 py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-denim-400 scrollbar-thumb-denim-200 focus:outline-none sm:top-10 sm:text-sm"> <Listbox.Options className="absolute left-0 right-0 top-10 z-10 mt-1 max-h-60 overflow-auto rounded-md bg-denim-500 py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-denim-400 scrollbar-thumb-denim-200 focus:outline-none sm:top-10 sm:text-sm">
{props.options.map((opt) => ( {props.options.map((opt) => (
<Listbox.Option <Listbox.Option
className={({ active }) => className={({ active }) =>

View File

@@ -1,6 +1,7 @@
import { Transition } from "@/components/Transition";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { Transition } from "@/components/Transition";
export function Overlay(props: { children: React.ReactNode }) { export function Overlay(props: { children: React.ReactNode }) {
return ( return (
<> <>

View File

@@ -1,6 +1,8 @@
import { MWMediaType, MWQuery } from "@/backend/metadata/types";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { MWMediaType, MWQuery } from "@/backend/metadata/types/mw";
import { DropdownButton } from "./buttons/DropdownButton"; import { DropdownButton } from "./buttons/DropdownButton";
import { Icon, Icons } from "./Icon"; import { Icon, Icons } from "./Icon";
import { TextInputControl } from "./text-inputs/TextInputControl"; import { TextInputControl } from "./text-inputs/TextInputControl";
@@ -38,7 +40,7 @@ export function SearchBarInput(props: SearchBarProps) {
return ( return (
<div className="relative flex flex-col rounded-[28px] bg-denim-400 transition-colors focus-within:bg-denim-400 hover:bg-denim-500 sm:flex-row sm:items-center"> <div className="relative flex flex-col rounded-[28px] bg-denim-400 transition-colors focus-within:bg-denim-400 hover:bg-denim-500 sm:flex-row sm:items-center">
<div className="pointer-events-none absolute left-5 top-0 bottom-0 flex max-h-14 items-center"> <div className="pointer-events-none absolute bottom-0 left-5 top-0 flex max-h-14 items-center">
<Icon icon={Icons.SEARCH} /> <Icon icon={Icons.SEARCH} />
</div> </div>
@@ -50,7 +52,7 @@ export function SearchBarInput(props: SearchBarProps) {
placeholder={props.placeholder} placeholder={props.placeholder}
/> />
<div className="px-4 py-4 pt-0 sm:py-2 sm:px-2"> <div className="px-4 py-4 pt-0 sm:px-2 sm:py-2">
<DropdownButton <DropdownButton
icon={Icons.SEARCH} icon={Icons.SEARCH}
open={dropdownOpen} open={dropdownOpen}

View File

@@ -1,8 +1,8 @@
import { Fragment, ReactNode } from "react";
import { import {
Transition as HeadlessTransition, Transition as HeadlessTransition,
TransitionClasses, TransitionClasses,
} from "@headlessui/react"; } from "@headlessui/react";
import { Fragment, ReactNode } from "react";
type TransitionAnimations = type TransitionAnimations =
| "slide-down" | "slide-down"

View File

@@ -4,10 +4,11 @@ import React, {
useEffect, useEffect,
useState, useState,
} from "react"; } from "react";
import { Icon, Icons } from "@/components/Icon";
import { Icon, Icons } from "@/components/Icon";
import { BackdropContainer, useBackdrop } from "@/components/layout/Backdrop"; import { BackdropContainer, useBackdrop } from "@/components/layout/Backdrop";
import { ButtonControlProps, ButtonControl } from "./ButtonControl";
import { ButtonControl, ButtonControlProps } from "./ButtonControl";
export interface OptionItem { export interface OptionItem {
id: string; id: string;

View File

@@ -1,7 +1,9 @@
import { Icon, Icons } from "@/components/Icon";
import { useAutoAnimate } from "@formkit/auto-animate/react"; import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useCallback } from "react"; import { useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Icon, Icons } from "@/components/Icon";
import { ButtonControl } from "./ButtonControl"; import { ButtonControl } from "./ButtonControl";
export interface EditButtonProps { export interface EditButtonProps {

View File

@@ -1,5 +1,6 @@
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
import { ButtonControlProps, ButtonControl } from "./ButtonControl";
import { ButtonControl, ButtonControlProps } from "./ButtonControl";
export interface IconButtonProps extends ButtonControlProps { export interface IconButtonProps extends ButtonControlProps {
icon: Icons; icon: Icons;

View File

@@ -1,5 +1,6 @@
import React, { createRef, useEffect, useState } from "react"; import React, { createRef, useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useFade } from "@/hooks/useFade"; import { useFade } from "@/hooks/useFade";
interface BackdropProps { interface BackdropProps {
@@ -99,7 +100,7 @@ export function BackdropContainer(
return ( return (
<div ref={root}> <div ref={root}>
{createPortal( {createPortal(
<div className="pointer-events-none fixed top-0 left-0 z-[999]"> <div className="pointer-events-none fixed left-0 top-0 z-[999]">
<Backdrop active={props.active} {...props} /> <Backdrop active={props.active} {...props} />
<div ref={copy} className="pointer-events-auto absolute"> <div ref={copy} className="pointer-events-auto absolute">
{props.children} {props.children}

View File

@@ -1,4 +1,5 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
export function BrandPill(props: { export function BrandPill(props: {

View File

@@ -1,10 +1,11 @@
import { Component } from "react"; import { Component } from "react";
import { Trans, useTranslation } from "react-i18next";
import { IconPatch } from "@/components/buttons/IconPatch"; import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon"; import { Icons } from "@/components/Icon";
import { Link } from "@/components/text/Link"; import { Link } from "@/components/text/Link";
import { Title } from "@/components/text/Title"; import { Title } from "@/components/text/Title";
import { conf } from "@/setup/config"; import { conf } from "@/setup/config";
import { Trans, useTranslation } from "react-i18next";
interface ErrorShowcaseProps { interface ErrorShowcaseProps {
error: { error: {

View File

@@ -1,8 +1,9 @@
import { Overlay } from "@/components/Overlay";
import { Transition } from "@/components/Transition";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Overlay } from "@/components/Overlay";
import { Transition } from "@/components/Transition";
interface Props { interface Props {
show: boolean; show: boolean;
children?: ReactNode; children?: ReactNode;

View File

@@ -1,10 +1,12 @@
import { ReactNode, useState } from "react"; import { ReactNode, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { IconPatch } from "@/components/buttons/IconPatch"; import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon"; import { Icons } from "@/components/Icon";
import { conf } from "@/setup/config";
import { useBannerSize } from "@/hooks/useBanner"; import { useBannerSize } from "@/hooks/useBanner";
import { conf } from "@/setup/config";
import SettingsModal from "@/views/SettingsModal"; import SettingsModal from "@/views/SettingsModal";
import { BrandPill } from "./BrandPill"; import { BrandPill } from "./BrandPill";
export interface NavigationProps { export interface NavigationProps {
@@ -22,7 +24,7 @@ export function Navigation(props: NavigationProps) {
top: `${bannerHeight}px`, top: `${bannerHeight}px`,
}} }}
> >
<div className="fixed left-0 right-0 flex items-center justify-between py-5 px-7"> <div className="fixed left-0 right-0 flex items-center justify-between px-7 py-5">
<div <div
className={`${ className={`${
props.bg ? "opacity-100" : "opacity-0" props.bg ? "opacity-100" : "opacity-0"

View File

@@ -1,4 +1,5 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
interface SectionHeadingProps { interface SectionHeadingProps {

View File

@@ -9,12 +9,12 @@ export function Episode(props: EpisodeProps) {
return ( return (
<div <div
onClick={props.onClick} onClick={props.onClick}
className={`transition-[background-color, transform, box-shadow] relative mr-3 mb-3 inline-flex h-10 w-10 cursor-pointer select-none items-center justify-center overflow-hidden rounded bg-denim-500 font-bold text-white hover:bg-denim-400 active:scale-110 ${ className={`transition-[background-color, transform, box-shadow] relative mb-3 mr-3 inline-flex h-10 w-10 cursor-pointer select-none items-center justify-center overflow-hidden rounded bg-denim-500 font-bold text-white hover:bg-denim-400 active:scale-110 ${
props.active ? "shadow-[inset_0_0_0_2px] shadow-bink-500" : "" props.active ? "shadow-[inset_0_0_0_2px] shadow-bink-500" : ""
}`} }`}
> >
<div <div
className="absolute bottom-0 top-0 left-0 bg-bink-500 bg-opacity-50" className="absolute bottom-0 left-0 top-0 bg-bink-500 bg-opacity-50"
style={{ style={{
width: `${props.progress || 0}%`, width: `${props.progress || 0}%`,
}} }}

View File

@@ -1,17 +1,19 @@
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { TMDBMediaToId } from "@/backend/metadata/getmeta";
import { MWMediaMeta } from "@/backend/metadata/types/mw";
import { DotList } from "@/components/text/DotList"; import { DotList } from "@/components/text/DotList";
import { MWMediaMeta } from "@/backend/metadata/types";
import { JWMediaToId } from "@/backend/metadata/justwatch";
import { Icons } from "../Icon";
import { IconPatch } from "../buttons/IconPatch"; import { IconPatch } from "../buttons/IconPatch";
import { Icons } from "../Icon";
export interface MediaCardProps { export interface MediaCardProps {
media: MWMediaMeta; media: MWMediaMeta;
linkable?: boolean; linkable?: boolean;
series?: { series?: {
episode: number; episode: number;
season: number; season?: number;
episodeId: string; episodeId: string;
seasonId: string; seasonId: string;
}; };
@@ -49,7 +51,7 @@ function MediaCardContent({
> >
<div <div
className={[ className={[
"relative mb-4 aspect-[2/3] w-full overflow-hidden rounded-xl bg-denim-500 bg-cover bg-center transition-[border-radius] duration-100", "relative mb-4 w-full overflow-hidden rounded-xl bg-denim-500 bg-cover bg-center pb-[150%] transition-[border-radius] duration-100",
closable ? "" : "group-hover:rounded-lg", closable ? "" : "group-hover:rounded-lg",
].join(" ")} ].join(" ")}
style={{ style={{
@@ -59,7 +61,7 @@ function MediaCardContent({
{series ? ( {series ? (
<div <div
className={[ className={[
"absolute right-2 top-2 rounded-md bg-denim-200 py-1 px-2 transition-colors", "absolute right-2 top-2 rounded-md bg-denim-200 px-2 py-1 transition-colors",
closable ? "" : "group-hover:bg-denim-500", closable ? "" : "group-hover:bg-denim-500",
].join(" ")} ].join(" ")}
> >
@@ -70,7 +72,7 @@ function MediaCardContent({
].join(" ")} ].join(" ")}
> >
{t("seasons.seasonAndEpisode", { {t("seasons.seasonAndEpisode", {
season: series.season, season: series.season || 1,
episode: series.episode, episode: series.episode,
})} })}
</p> </p>
@@ -115,7 +117,7 @@ function MediaCardContent({
/> />
</div> </div>
</div> </div>
<h1 className="mb-1 max-h-[4.5rem] text-ellipsis break-words font-bold text-white line-clamp-3"> <h1 className="mb-1 line-clamp-3 max-h-[4.5rem] text-ellipsis break-words font-bold text-white">
<span>{media.title}</span> <span>{media.title}</span>
</h1> </h1>
<DotList className="text-xs" content={dotListContent} /> <DotList className="text-xs" content={dotListContent} />
@@ -130,12 +132,17 @@ export function MediaCard(props: MediaCardProps) {
const canLink = props.linkable && !props.closable; const canLink = props.linkable && !props.closable;
let link = canLink let link = canLink
? `/media/${encodeURIComponent(JWMediaToId(props.media))}` ? `/media/${encodeURIComponent(TMDBMediaToId(props.media))}`
: "#"; : "#";
if (canLink && props.series) if (canLink && props.series) {
link += `/${encodeURIComponent(props.series.seasonId)}/${encodeURIComponent( if (props.series.season === 0 && !props.series.episodeId) {
props.series.episodeId link += `/${encodeURIComponent(props.series.seasonId)}`;
)}`; } else {
link += `/${encodeURIComponent(
props.series.seasonId
)}/${encodeURIComponent(props.series.episodeId)}`;
}
}
if (!props.linkable) return <span>{content}</span>; if (!props.linkable) return <span>{content}</span>;
return ( return (

View File

@@ -1,6 +1,8 @@
import { MWMediaMeta } from "@/backend/metadata/types";
import { useWatchedContext } from "@/state/watched";
import { useMemo } from "react"; import { useMemo } from "react";
import { MWMediaMeta } from "@/backend/metadata/types/mw";
import { useWatchedContext } from "@/state/watched";
import { MediaCard } from "./MediaCard"; import { MediaCard } from "./MediaCard";
export interface WatchedMediaCardProps { export interface WatchedMediaCardProps {

View File

@@ -1,12 +1,14 @@
import { animated, easings, useSpringValue } from "@react-spring/web";
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FloatingCardAnchorPosition } from "@/components/popout/positions/FloatingCardAnchorPosition"; import { FloatingCardAnchorPosition } from "@/components/popout/positions/FloatingCardAnchorPosition";
import { FloatingCardMobilePosition } from "@/components/popout/positions/FloatingCardMobilePosition"; import { FloatingCardMobilePosition } from "@/components/popout/positions/FloatingCardMobilePosition";
import { useIsMobile } from "@/hooks/useIsMobile"; import { useIsMobile } from "@/hooks/useIsMobile";
import { PopoutSection } from "@/video/components/popouts/PopoutUtils"; import { PopoutSection } from "@/video/components/popouts/PopoutUtils";
import { useSpringValue, animated, easings } from "@react-spring/web";
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { Icon, Icons } from "../Icon";
import { FloatingDragHandle, MobilePopoutSpacer } from "./FloatingDragHandle"; import { FloatingDragHandle, MobilePopoutSpacer } from "./FloatingDragHandle";
import { Icon, Icons } from "../Icon";
interface FloatingCardProps { interface FloatingCardProps {
children?: ReactNode; children?: ReactNode;
@@ -152,7 +154,7 @@ export const FloatingCardView = {
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white" className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
> >
<Icon icon={Icons.X} /> <Icon icon={Icons.X} />
<span>Close</span> <span>{t("videoPlayer.popouts.close")}</span>
</div> </div>
); );
@@ -165,7 +167,7 @@ export const FloatingCardView = {
<div>{props.action ?? null}</div> <div>{props.action ?? null}</div>
</div> </div>
<h2 className="mt-8 mb-2 text-3xl font-bold text-white"> <h2 className="mb-2 mt-8 text-3xl font-bold text-white">
{props.title} {props.title}
</h2> </h2>
<p>{props.description}</p> <p>{props.description}</p>

View File

@@ -1,4 +1,3 @@
import { Transition } from "@/components/Transition";
import React, { import React, {
ReactNode, ReactNode,
useCallback, useCallback,
@@ -8,6 +7,8 @@ import React, {
} from "react"; } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Transition } from "@/components/Transition";
interface Props { interface Props {
children?: ReactNode; children?: ReactNode;
onClose?: () => void; onClose?: () => void;

View File

@@ -1,6 +1,7 @@
import { ReactNode } from "react";
import { Transition } from "@/components/Transition"; import { Transition } from "@/components/Transition";
import { useIsMobile } from "@/hooks/useIsMobile"; import { useIsMobile } from "@/hooks/useIsMobile";
import { ReactNode } from "react";
interface Props { interface Props {
children?: ReactNode; children?: ReactNode;

View File

@@ -1,6 +1,7 @@
import { createFloatingAnchorEvent } from "@/components/popout/FloatingAnchor";
import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { createFloatingAnchorEvent } from "@/components/popout/FloatingAnchor";
interface AnchorPositionProps { interface AnchorPositionProps {
children?: ReactNode; children?: ReactNode;
id: string; id: string;

View File

@@ -1,4 +1,4 @@
import { useSpring, animated, config } from "@react-spring/web"; import { animated, config, useSpring } from "@react-spring/web";
import { useDrag } from "@use-gesture/react"; import { useDrag } from "@use-gesture/react";
import { ReactNode, useEffect, useRef, useState } from "react"; import { ReactNode, useEffect, useRef, useState } from "react";

View File

@@ -1,4 +1,5 @@
import { Link as LinkRouter } from "react-router-dom"; import { Link as LinkRouter } from "react-router-dom";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
interface IArrowLinkPropsBase { interface IArrowLinkPropsBase {

View File

@@ -1,12 +1,12 @@
import { import {
ReactNode,
createContext,
useState,
useMemo,
Dispatch, Dispatch,
ReactNode,
SetStateAction, SetStateAction,
useEffect, createContext,
useContext, useContext,
useEffect,
useMemo,
useState,
} from "react"; } from "react";
import { useMeasure } from "react-use"; import { useMeasure } from "react-use";

View File

@@ -1,8 +1,9 @@
/// <reference types="chromecast-caf-sender"/> /// <reference types="chromecast-caf-sender"/>
import { isChromecastAvailable } from "@/setup/chromecast";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { isChromecastAvailable } from "@/setup/chromecast";
export function useChromecastAvailable() { export function useChromecastAvailable() {
const [available, setAvailable] = useState<boolean | null>(null); const [available, setAvailable] = useState<boolean | null>(null);

View File

@@ -0,0 +1,17 @@
import { useMemo } from "react";
import { useLocation } from "react-router-dom";
export function useQueryParams() {
const loc = useLocation();
const queryParams = useMemo(() => {
// Basic absolutely-not-fool-proof URL query param parser
const obj: Record<string, string> = Object.fromEntries(
new URLSearchParams(loc.search).entries()
);
return obj;
}, [loc]);
return queryParams;
}

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import { findBestStream } from "@/backend/helpers/scrape"; import { findBestStream } from "@/backend/helpers/scrape";
import { MWStream } from "@/backend/helpers/streams"; import { MWStream } from "@/backend/helpers/streams";
import { DetailedMeta } from "@/backend/metadata/getmeta"; import { DetailedMeta } from "@/backend/metadata/getmeta";
import { MWMediaType } from "@/backend/metadata/types"; import { MWMediaType } from "@/backend/metadata/types/mw";
import { useEffect, useState } from "react";
export interface ScrapeEventLog { export interface ScrapeEventLog {
type: "provider" | "embed"; type: "provider" | "embed";

View File

@@ -1,7 +1,8 @@
import { MWMediaType, MWQuery } from "@/backend/metadata/types";
import { useState } from "react"; import { useState } from "react";
import { generatePath, useHistory, useRouteMatch } from "react-router-dom"; import { generatePath, useHistory, useRouteMatch } from "react-router-dom";
import { MWMediaType, MWQuery } from "@/backend/metadata/types/mw";
function getInitialValue(params: { type: string; query: string }) { function getInitialValue(params: { type: string; query: string }) {
const type = const type =
Object.values(MWMediaType).find((v) => params.type === v) || Object.values(MWMediaType).find((v) => params.type === v) ||

View File

@@ -1,6 +1,7 @@
import { useState } from "react";
import { useControls } from "@/video/state/logic/controls"; import { useControls } from "@/video/state/logic/controls";
import { useMediaPlaying } from "@/video/state/logic/mediaplaying"; import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
import { useState } from "react";
export function useVolumeControl(descriptor: string) { export function useVolumeControl(descriptor: string) {
const [storedVolume, setStoredVolume] = useState(1); const [storedVolume, setStoredVolume] = useState(1);

View File

@@ -3,17 +3,19 @@ import React, { Suspense } from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { BrowserRouter, HashRouter } from "react-router-dom"; import { BrowserRouter, HashRouter } from "react-router-dom";
import type { ReactNode } from "react-router-dom/node_modules/@types/react/index"; import type { ReactNode } from "react-router-dom/node_modules/@types/react/index";
import { ErrorBoundary } from "@/components/layout/ErrorBoundary";
import { conf } from "@/setup/config";
import { registerSW } from "virtual:pwa-register"; import { registerSW } from "virtual:pwa-register";
import { ErrorBoundary } from "@/components/layout/ErrorBoundary";
import App from "@/setup/App"; import App from "@/setup/App";
import { assertConfig, conf } from "@/setup/config";
import i18n from "@/setup/i18n";
import "@/setup/ga"; import "@/setup/ga";
import "@/setup/sentry"; import "@/setup/sentry";
import "@/setup/i18n";
import "@/setup/index.css"; import "@/setup/index.css";
import "@/backend"; import "@/backend";
import { initializeChromecast } from "./setup/chromecast"; import { initializeChromecast } from "./setup/chromecast";
import { SettingsStore } from "./state/settings/store";
import { initializeStores } from "./utils/storage"; import { initializeStores } from "./utils/storage";
// initialize // initialize
@@ -28,7 +30,9 @@ registerSW({
}); });
const LazyLoadedApp = React.lazy(async () => { const LazyLoadedApp = React.lazy(async () => {
await assertConfig();
await initializeStores(); await initializeStores();
i18n.changeLanguage(SettingsStore.get().language ?? "en");
return { return {
default: App, default: App,
}; };

View File

@@ -1,16 +1,39 @@
import { lazy } from "react"; import { ReactElement, lazy, useEffect } from "react";
import { Redirect, Route, Switch } from "react-router-dom"; import {
import { BookmarkContextProvider } from "@/state/bookmark"; Redirect,
import { WatchedContextProvider } from "@/state/watched"; Route,
import { SettingsProvider } from "@/state/settings"; Switch,
useHistory,
useLocation,
} from "react-router-dom";
import { NotFoundPage } from "@/views/notfound/NotFoundView"; import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta";
import { MediaView } from "@/views/media/MediaView"; import { MWMediaType } from "@/backend/metadata/types/mw";
import { SearchView } from "@/views/search/SearchView";
import { MWMediaType } from "@/backend/metadata/types";
import { V2MigrationView } from "@/views/other/v2Migration";
import { BannerContextProvider } from "@/hooks/useBanner"; import { BannerContextProvider } from "@/hooks/useBanner";
import { Layout } from "@/setup/Layout"; import { Layout } from "@/setup/Layout";
import { BookmarkContextProvider } from "@/state/bookmark";
import { SettingsProvider } from "@/state/settings";
import { WatchedContextProvider } from "@/state/watched";
import { MediaView } from "@/views/media/MediaView";
import { NotFoundPage } from "@/views/notfound/NotFoundView";
import { V2MigrationView } from "@/views/other/v2Migration";
import { SearchView } from "@/views/search/SearchView";
function LegacyUrlView({ children }: { children: ReactElement }) {
const location = useLocation();
const { replace } = useHistory();
useEffect(() => {
const url = location.pathname;
if (!isLegacyUrl(url)) return;
convertLegacyUrl(location.pathname).then((convertedUrl) => {
replace(convertedUrl ?? "/");
});
}, [location.pathname, replace]);
if (isLegacyUrl(location.pathname)) return null;
return children;
}
function App() { function App() {
return ( return (
@@ -27,12 +50,16 @@ function App() {
</Route> </Route>
{/* pages */} {/* pages */}
<Route exact path="/media/:media" component={MediaView} /> <Route exact path="/media/:media">
<Route <LegacyUrlView>
exact <MediaView />
path="/media/:media/:season/:episode" </LegacyUrlView>
component={MediaView} </Route>
/> <Route exact path="/media/:media/:season/:episode">
<LegacyUrlView>
<MediaView />
</LegacyUrlView>
</Route>
<Route <Route
exact exact
path="/search/:type/:query?" path="/search/:type/:query?"
@@ -40,8 +67,6 @@ function App() {
/> />
{/* other */} {/* other */}
{process.env.NODE_ENV === "development" ? (
<>
<Route <Route
exact exact
path="/dev" path="/dev"
@@ -49,13 +74,6 @@ function App() {
() => import("@/views/developer/DeveloperView") () => import("@/views/developer/DeveloperView")
)} )}
/> />
<Route
exact
path="/dev/test"
component={lazy(
() => import("@/views/developer/TestView")
)}
/>
<Route <Route
exact exact
path="/dev/video" path="/dev/video"
@@ -63,6 +81,17 @@ function App() {
() => import("@/views/developer/VideoTesterView") () => import("@/views/developer/VideoTesterView")
)} )}
/> />
{/* developer routes that can abuse workers are disabled in production */}
{process.env.NODE_ENV === "development" ? (
<>
<Route
exact
path="/dev/test"
component={lazy(
() => import("@/views/developer/TestView")
)}
/>
<Route <Route
exact exact
path="/dev/providers" path="/dev/providers"

View File

@@ -1,8 +1,9 @@
import { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Banner } from "@/components/Banner"; import { Banner } from "@/components/Banner";
import { useBannerSize } from "@/hooks/useBanner"; import { useBannerSize } from "@/hooks/useBanner";
import { useIsOnline } from "@/hooks/usePing"; import { useIsOnline } from "@/hooks/usePing";
import { ReactNode } from "react";
import { useTranslation } from "react-i18next";
export function Layout(props: { children: ReactNode }) { export function Layout(props: { children: ReactNode }) {
const { t } = useTranslation(); const { t } = useTranslation();

View File

@@ -1,11 +1,10 @@
import { APP_VERSION, GITHUB_LINK, DISCORD_LINK } from "./constants"; import { APP_VERSION, DISCORD_LINK, GITHUB_LINK } from "./constants";
interface Config { interface Config {
APP_VERSION: string; APP_VERSION: string;
GITHUB_LINK: string; GITHUB_LINK: string;
DISCORD_LINK: string; DISCORD_LINK: string;
OMDB_API_KEY: string; TMDB_READ_API_KEY: string;
TMDB_API_KEY: string;
CORS_PROXY_URL: string; CORS_PROXY_URL: string;
NORMAL_ROUTER: boolean; NORMAL_ROUTER: boolean;
} }
@@ -14,15 +13,13 @@ export interface RuntimeConfig {
APP_VERSION: string; APP_VERSION: string;
GITHUB_LINK: string; GITHUB_LINK: string;
DISCORD_LINK: string; DISCORD_LINK: string;
OMDB_API_KEY: string; TMDB_READ_API_KEY: string;
TMDB_API_KEY: string;
NORMAL_ROUTER: boolean; NORMAL_ROUTER: boolean;
PROXY_URLS: string[]; PROXY_URLS: string[];
} }
const env: Record<keyof Config, undefined | string> = { const env: Record<keyof Config, undefined | string> = {
OMDB_API_KEY: import.meta.env.VITE_OMDB_API_KEY, TMDB_READ_API_KEY: import.meta.env.VITE_TMDB_READ_API_KEY,
TMDB_API_KEY: import.meta.env.VITE_TMDB_API_KEY,
APP_VERSION: undefined, APP_VERSION: undefined,
GITHUB_LINK: undefined, GITHUB_LINK: undefined,
DISCORD_LINK: undefined, DISCORD_LINK: undefined,
@@ -30,25 +27,28 @@ const env: Record<keyof Config, undefined | string> = {
NORMAL_ROUTER: import.meta.env.VITE_NORMAL_ROUTER, NORMAL_ROUTER: import.meta.env.VITE_NORMAL_ROUTER,
}; };
const alerts = [] as string[];
// loads from different locations, in order: environment (VITE_{KEY}), window (public/config.js) // loads from different locations, in order: environment (VITE_{KEY}), window (public/config.js)
function getKey(key: keyof Config, defaultString?: string): string { function getKeyValue(key: keyof Config): string | undefined {
let windowValue = (window as any)?.__CONFIG__?.[`VITE_${key}`]; let windowValue = (window as any)?.__CONFIG__?.[`VITE_${key}`];
if (windowValue !== undefined && windowValue.length === 0) if (windowValue !== undefined && windowValue.length === 0)
windowValue = undefined; windowValue = undefined;
const value = env[key] ?? windowValue ?? undefined; return env[key] ?? windowValue ?? undefined;
if (value === undefined) { }
if (defaultString) return defaultString;
if (!alerts.includes(key)) { function getKey(key: keyof Config, defaultString?: string): string {
return getKeyValue(key) ?? defaultString ?? "";
}
export function assertConfig() {
const keys: Array<keyof Config> = ["TMDB_READ_API_KEY", "CORS_PROXY_URL"];
const values = keys.map((key) => {
const val = getKeyValue(key);
if (val) return val;
// eslint-disable-next-line no-alert // eslint-disable-next-line no-alert
window.alert(`Misconfigured instance, missing key: ${key}`); window.alert(`Misconfigured instance, missing key: ${key}`);
alerts.push(key); return val;
} });
return ""; if (values.includes(undefined)) throw new Error("Misconfigured instance");
}
return value;
} }
export function conf(): RuntimeConfig { export function conf(): RuntimeConfig {
@@ -56,8 +56,7 @@ export function conf(): RuntimeConfig {
APP_VERSION, APP_VERSION,
GITHUB_LINK, GITHUB_LINK,
DISCORD_LINK, DISCORD_LINK,
OMDB_API_KEY: getKey("OMDB_API_KEY"), TMDB_READ_API_KEY: getKey("TMDB_READ_API_KEY"),
TMDB_API_KEY: getKey("TMDB_API_KEY"),
PROXY_URLS: getKey("CORS_PROXY_URL") PROXY_URLS: getKey("CORS_PROXY_URL")
.split(",") .split(",")
.map((v) => v.trim()), .map((v) => v.trim()),

View File

@@ -1,4 +1,5 @@
import ReactGA from "react-ga4"; import ReactGA from "react-ga4";
import { GA_ID } from "@/setup/constants"; import { GA_ID } from "@/setup/constants";
ReactGA.initialize([ ReactGA.initialize([

View File

@@ -1,19 +1,27 @@
import i18n from "i18next"; import i18n from "i18next";
import { initReactI18next } from "react-i18next"; import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
// Languages // Languages
import en from "./locales/en/translation.json";
import nl from "./locales/nl/translation.json";
import tr from "./locales/tr/translation.json";
import fr from "./locales/fr/translation.json";
import { captionLanguages } from "./iso6391"; import { captionLanguages } from "./iso6391";
import cs from "./locales/cs/translation.json";
import de from "./locales/de/translation.json";
import en from "./locales/en/translation.json";
import fr from "./locales/fr/translation.json";
import it from "./locales/it/translation.json";
import nl from "./locales/nl/translation.json";
import pirate from "./locales/pirate/translation.json";
import pl from "./locales/pl/translation.json";
import tr from "./locales/tr/translation.json";
import vi from "./locales/vi/translation.json";
import zh from "./locales/zh/translation.json";
const locales = { const locales = {
en: { en: {
translation: en, translation: en,
}, },
it: {
translation: it,
},
nl: { nl: {
translation: nl, translation: nl,
}, },
@@ -23,11 +31,26 @@ const locales = {
fr: { fr: {
translation: fr, translation: fr,
}, },
de: {
translation: de,
},
zh: {
translation: zh,
},
cs: {
translation: cs,
},
pirate: {
translation: pirate,
},
vi: {
translation: vi,
},
pl: {
translation: pl,
},
}; };
i18n i18n
// detect user language
// learn more: https://github.com/i18next/i18next-browser-languageDetector
.use(LanguageDetector)
// pass the i18n instance to react-i18next. // pass the i18n instance to react-i18next.
.use(initReactI18next) .use(initReactI18next)
// init i18next // init i18next

View File

@@ -179,3 +179,19 @@ input[type=range].styled-slider.slider-progress::-ms-fill-lower {
border: none; border: none;
border-right-width: 0; border-right-width: 0;
} }
::-webkit-scrollbar-track {
background-color: transparent;
}
::-webkit-scrollbar-thumb {
background-color: theme("colors.denim-500");
border: 5px solid transparent;
border-left: 0;
background-clip: content-box;
}
::-webkit-scrollbar {
/* For some reason the styles don't get applied without the width */
width: 13px;
}

View File

@@ -1,5 +1,6 @@
export type LangCode = export type LangCode =
| "none" | "none"
| "pirate"
| "aa" | "aa"
| "ab" | "ab"
| "ae" | "ae"
@@ -219,6 +220,12 @@ export const captionLanguages: CaptionLanguageOption[] = [
name: "None", name: "None",
nativeName: "Lorem ipsum", nativeName: "Lorem ipsum",
}, },
{
id: "pirate",
englishName: "Pirate",
name: "Pirate English",
nativeName: "Pirate English",
},
{ {
id: "aa", id: "aa",
englishName: "Afar", englishName: "Afar",

View File

@@ -0,0 +1,128 @@
{
"global": {
"name": "movie-web"
},
"search": {
"loading_series": "Načítání Vašich oblíbených seriálů...",
"loading_movie": "Načítání Vašich oblíbených filmů...",
"loading": "Načítání...",
"allResults": "To je vše co máme!",
"noResults": "Nemohli jsme nic najít!",
"allFailed": "Nepodařilo se najít média, zkuste to znovu!",
"headingTitle": "Výsledky vyhledávání",
"bookmarks": "Záložky",
"continueWatching": "Pokračujte ve sledování",
"title": "Co si přejete sledovat?",
"placeholder": "Co si přejete sledovat?"
},
"media": {
"movie": "Filmy",
"series": "Seriály",
"stopEditing": "Zastavit upravování",
"errors": {
"genericTitle": "Jejda, rozbilo se to!",
"failedMeta": "Nepovedlo se načíst meta",
"mediaFailed": "Nepodařilo se nám požádat o Vaše média, zkontrolujte své internetové připojení a zkuste to znovu.",
"videoFailed": "Při přehrávání požadovaného videa došlo k chybě. Pokud se tohle opakuje prosím nahlašte nám to na <0>Discord serveru</0> nebo na <1>GitHubu</1>."
}
},
"seasons": {
"seasonAndEpisode": "S{{season}} E{{episode}}"
},
"notFound": {
"genericTitle": "Nenalezeno",
"backArrow": "Zpátky domů",
"media": {
"title": "Nemohli jsme najít Vaše média.",
"description": "Nemohli jsme najít média o které jste požádali. Buďto jsme ho nemohli najít, nebo jste manipulovali s URL."
},
"provider": {
"title": "Tento poskytovatel byl zakázán",
"description": "Měli jsme s tímto poskytovatelem problémy, nebo byl moc nestabilní na používání, a tak jsme ho museli zakázat."
},
"page": {
"title": "Tuto stránku se nepodařilo najít",
"description": "Dívali jsme se všude: pod koši, ve skříni, za proxy, ale nakonec jsme nemohli najít stránku, kterou hledáte."
}
},
"searchBar": {
"movie": "Film",
"series": "Seriál",
"Search": "Hledání"
},
"videoPlayer": {
"findingBestVideo": "Hledáme pro Vás nejlepší video",
"noVideos": "Jejda, nemohli jsme žádné video najít",
"loading": "Načítání...",
"backToHome": "Zpátky domů",
"backToHomeShort": "Zpět",
"seasonAndEpisode": "S{{season}} E{{episode}}",
"timeLeft": "Zbývá {{timeLeft}}",
"finishAt": "Končí ve {{timeFinished, datetime}}",
"buttons": {
"episodes": "Epizody",
"source": "Zdroj",
"captions": "Titulky",
"download": "Stáhnout",
"settings": "Nastavení",
"pictureInPicture": "Obraz v obraze",
"playbackSpeed": "Rychlost přehrávání"
},
"popouts": {
"back": "Zpět",
"sources": "Zdroje",
"seasons": "Sezóny",
"captions": "Titulky",
"playbackSpeed": "Rychlost přehrávání",
"customPlaybackSpeed": "Vlastní rychlost přehrávání",
"captionPreferences": {
"title": "Upravit",
"delay": "Zpoždení",
"fontSize": "Velikost",
"opacity": "Průhlednost",
"color": "Barva"
},
"episode": "E{{index}} - {{title}}",
"noCaptions": "Žádné titulky",
"linkedCaptions": "Propojené titulky",
"customCaption": "Vlastní titulky",
"uploadCustomCaption": "Nahrát titulky",
"noEmbeds": "Nebyla nalezena žádná vložení pro tento zdroj",
"errors": {
"loadingWentWong": "Něco se nepovedlo při načítání epizod pro {{seasonTitle}}",
"embedsError": "Něco se povedlo při načítání vložení pro tuhle věc, kterou máte tak rádi"
},
"descriptions": {
"sources": "Jakého poskytovatele chcete použít?",
"embeds": "Vyberte video, které chcete sledovat",
"seasons": "Vyberte sérii, kterou chcete sledovat",
"episode": "Vyberte epizodu",
"captions": "Vyberte jazyk titulků",
"captionPreferences": "Upravte titulky tak, jak se Vám budou líbit",
"playbackSpeed": "Změňtě rychlost přehrávání"
}
},
"errors": {
"fatalError": "Došlo k závažné chybě v přehrávači videa, prosím nahlašte ji na <0>Discord serveru</0> nebo na <1>GitHubu</1>."
}
},
"settings": {
"title": "Nastavení",
"language": "Jazyk",
"captionLanguage": "Jazyk titulků"
},
"v3": {
"newSiteTitle": "Je dostupná nová verze!",
"newDomain": "https://movie-web.app",
"newDomainText": "movie-web se brzy přesune na novou doménu: <0>https://movie-web.app</0>. Nezapomeňte si aktualizovat záložky, protože <1>stará stránka přestane fungovat {{date}}.</1>",
"tireless": "Pracovali jsme neúnavně na této nové aktualizaci, a tak doufáme, že se Vám bude líbit co jsme v posledních měsících kuchtili.",
"leaveAnnouncement": "Vezměte mě tam!"
},
"casting": {
"casting": "Přehrávání do zařízení..."
},
"errors": {
"offline": "Zkontrolujte své internetové připojení"
}
}

View File

@@ -0,0 +1,127 @@
{
"global": {
"name": "movie-web"
},
"search": {
"loading_series": "Auf der Suche nach deiner Lieblingsserie...",
"loading_movie": "Auf der Suche nach deinen Lieblingsfilmen...",
"loading": "Wird geladen...",
"allResults": "Das ist alles, was wir haben!",
"noResults": "Wir haben nichts gefunden!",
"allFailed": "Das Medium wurde nicht gefunden, bitte versuchen Sie es erneut!",
"headingTitle": "Suchergebnisse",
"bookmarks": "Favoriten",
"continueWatching": "Weiter ansehen",
"title": "Was willst du gucken?",
"placeholder": "Was willst du gucken?"
},
"media": {
"movie": "Filme",
"series": "Serie",
"stopEditing": "Beenden die Bearbeitung",
"errors": {
"genericTitle": "Hoppla, etwas ist schiefgegangen!",
"failedMeta": "Metadaten konnten nicht geladen werden",
"mediaFailed": "Wir konnten die angeforderten Medien nicht abrufen.",
"videoFailed": "Beim Abspielen des angeforderten Videos ist ein Fehler aufgetreten. <0>Discord</0> Oder weiter <1>GitHub</1>."
}
},
"seasons": {
"seasonAndEpisode": "S{{season}} E{{episode}}"
},
"notFound": {
"genericTitle": "Nicht gefunden",
"backArrow": "Zurück zur Startseite",
"media": {
"title": "Das Medium konnte nicht gefunden werden",
"description": "Wir konnten die angeforderten Medien nicht finden."
},
"provider": {
"title": "Dieser Anbieter wurde deaktiviert",
"description": "Wir hatten Probleme mit dem Anbieter oder er war zu instabil, sodass wir ihn deaktivieren mussten."
},
"page": {
"title": "Diese Seite kann nicht gefunden werden",
"description": "Wir haben überall gesucht, aber am Ende konnten wir die gesuchte Seite nicht finden."
}
},
"searchBar": {
"movie": "Film",
"series": "Serie",
"Search": "Suchen"
},
"videoPlayer": {
"findingBestVideo": "Auf der Suche nach dem besten Video für Sie",
"noVideos": "Entschuldigung, wir konnten keine Videos finden",
"loading": "Wird geladen...",
"backToHome": "Zurück zur Startseite",
"backToHomeShort": "Rückmeldung",
"seasonAndEpisode": "S{{season}} E{{episode}}",
"timeLeft": "{{timeLeft}} verbleibend",
"finishAt": "Endet um {{timeFinished, datetime}}",
"buttons": {
"episodes": "Folgen",
"source": "Quelle",
"captions": "Untertitel",
"download": "Herunterladen",
"settings": "Einstellungen",
"pictureInPicture": "Bild-im-Bild",
"playbackSpeed": "Wiedergabegeschwindigkeit"
},
"popouts": {
"back": "Zurück",
"sources": "Quellen",
"seasons": "Staffel",
"captions": "Untertitel",
"playbackSpeed": "Lesegeschwindigkeit",
"customPlaybackSpeed": "Benutzerdefinierte Wiedergabegeschwindigkeit",
"captionPreferences": {
"title": "Bearbeiten",
"delay": "Verzögerung",
"fontSize": "Größe",
"opacity": "Opazität",
"color": "Farbe"
},
"episode": "E{{index}} - {{title}}",
"noCaptions": "Keine Untertitel",
"linkedCaptions": "Verbundene Untertitel",
"customCaption": "Benutzerdefinierte Untertitel",
"uploadCustomCaption": "Untertitel hochladen",
"noEmbeds": "Für diese Quelle wurde kein eingebetteter Inhalt gefunden",
"errors": {
"loadingWentWong": "Beim Laden der Folgen für {{seasonTitle}} ist ein Problem aufgetreten ",
"embedsError": "Beim Laden der eingebetteter Medien ist ein Problem aufgetreten"
},
"descriptions": {
"sources": "Welchen Anbieter möchtest du nutzen?",
"embeds": "Wähle das Video aus, das du ansehen möchten",
"seasons": "Wähle die Staffel aus, die du sehen möchten",
"episode": "Wähle eine Folge aus",
"captions": "Wähle eine Untertitelsprache",
"captionPreferences": "Passe das Erscheinungsbild von Untertiteln an",
"playbackSpeed": "Wiedergabegeschwindigkeit ändern"
}
},
"errors": {
"fatalError": "Der Videoplayer hat einen Fehler festgestellt, bitte melde ihn dem Server <0>Discord</0> Oder weiter <1>GitHub</1>."
}
},
"settings": {
"title": "Einstellungen",
"language": "Sprache",
"captionLanguage": "Untertitelsprache"
},
"v3": {
"newSiteTitle": "Neue Version verfügbar!",
"newDomain": "https://movie-web.app",
"newDomainText": "movie-web zieht in Kürze auf eine neue Domain um: <0>https://movie-web.app</0>. <1>Die alte Website funktioniert nicht mehr {{date}}.</1>",
"tireless": "Wir haben unermüdlich an diesem neuen Update gearbeitet und hoffen, dass dir gefällt, was wir in den letzten Monaten vorbereitet haben.",
"leaveAnnouncement": "Bring mich dahin!"
},
"casting": {
"casting": "An Gerät übertragen..."
},
"errors": {
"offline": "Internetverbindung ist instabil"
}
}

View File

@@ -58,7 +58,7 @@
"backToHomeShort": "Back", "backToHomeShort": "Back",
"seasonAndEpisode": "S{{season}} E{{episode}}", "seasonAndEpisode": "S{{season}} E{{episode}}",
"timeLeft": "{{timeLeft}} left", "timeLeft": "{{timeLeft}} left",
"finishAt": "Finish at {{timeFinished}}", "finishAt": "Finish at {{timeFinished, datetime}}",
"buttons": { "buttons": {
"episodes": "Episodes", "episodes": "Episodes",
"source": "Source", "source": "Source",
@@ -71,7 +71,16 @@
"popouts": { "popouts": {
"back": "Go back", "back": "Go back",
"sources": "Sources", "sources": "Sources",
"seasons": "Seasons", "close": "Close",
"seasons": {
"title":"Seasons",
"other": "Other seasons",
"noSeason": "No season"
},
"episodes": {
"unknown": "Unknown episode",
"noEpisode": "No episode"
},
"captions": "Captions", "captions": "Captions",
"playbackSpeed": "Playback speed", "playbackSpeed": "Playback speed",
"customPlaybackSpeed": "Custom playback speed", "customPlaybackSpeed": "Custom playback speed",

View File

@@ -27,7 +27,7 @@
} }
}, },
"seasons": { "seasons": {
"seasonAndEpisode": "S{{saison}} E{{épisode}}" "seasonAndEpisode": "S{{season}} E{{episode}}"
}, },
"notFound": { "notFound": {
"genericTitle": "Introuvable", "genericTitle": "Introuvable",
@@ -58,7 +58,7 @@
"backToHomeShort": "Retour", "backToHomeShort": "Retour",
"seasonAndEpisode": "S{{season}} E{{episode}}", "seasonAndEpisode": "S{{season}} E{{episode}}",
"timeLeft": "{{timeLeft}} restant", "timeLeft": "{{timeLeft}} restant",
"finishAt": "Terminer à {{timeFinished}}", "finishAt": "Terminer à {{timeFinished, datetime}}",
"buttons": { "buttons": {
"episodes": "Épisodes", "episodes": "Épisodes",
"source": "Source", "source": "Source",

View File

@@ -0,0 +1,128 @@
{
"global": {
"name": "movie-web"
},
"search": {
"loading_series": "Recupero delle tue serie preferite...",
"loading_movie": "Recupero dei tuoi film preferiti...",
"loading": "Caricamento...",
"allResults": "Ecco tutto ciò che abbiamo!",
"noResults": "Non abbiamo trovato nulla!",
"allFailed": "Impossibile trovare i media, riprova!",
"headingTitle": "Risultati della ricerca",
"bookmarks": "Segnalibri",
"continueWatching": "Continua a guardare",
"title": "Cosa vuoi guardare?",
"placeholder": "Cosa vuoi guardare?"
},
"media": {
"movie": "Film",
"series": "Serie",
"stopEditing": "Interrompi modifica",
"errors": {
"genericTitle": "Ops, qualcosa si è rotto!",
"failedMeta": "Caricamento dei metadati non riuscito",
"mediaFailed": "Impossibile richiedere il media che hai richiesto, controlla la tua connessione internet e riprova.",
"videoFailed": "Si è verificato un errore durante la riproduzione del video che hai richiesto. Se ciò continua a accadere, segnala il problema sul <0>server Discord</0> o su <1>GitHub</1>."
}
},
"seasons": {
"seasonAndEpisode": "S{{season}} E{{episode}}"
},
"notFound": {
"genericTitle": "Non trovato",
"backArrow": "Torna alla home",
"media": {
"title": "Impossibile trovare quel media",
"description": "Non siamo riusciti a trovare il media richiesto. È stato rimosso o hai manomesso l'URL."
},
"provider": {
"title": "Questo provider è stato disabilitato",
"description": "Abbiamo riscontrato problemi con il provider o era troppo instabile da utilizzare, quindi abbiamo dovuto disabilitarlo."
},
"page": {
"title": "Impossibile trovare quella pagina",
"description": "Abbiamo cercato ovunque: sotto i bidoni, nell'armadio, dietro il proxy, ma alla fine non siamo riusciti a trovare la pagina che stai cercando."
}
},
"searchBar": {
"movie": "Film",
"series": "Serie",
"Search": "Cerca"
},
"videoPlayer": {
"findingBestVideo": "Ricerca del miglior video per te",
"noVideos": "Ops, non è stato possibile trovare alcun video per te",
"loading": "Caricamento...",
"backToHome": "Torna alla home",
"backToHomeShort": "Indietro",
"seasonAndEpisode": "S{{season}} E{{episode}}",
"timeLeft": "{{timeLeft}} rimanente",
"finishAt": "Fine alle {{timeFinished, datetime}}",
"buttons": {
"episodes": "Episodi",
"source": "Fonte",
"captions": "Sottotitoli",
"download": "Download",
"settings": "Impostazioni",
"pictureInPicture": "Picture in Picture",
"playbackSpeed": "Velocità di riproduzione"
},
"popouts": {
"back": "Torna indietro",
"sources": "Fonti",
"seasons": "Stagioni",
"captions": "Sottotitoli",
"playbackSpeed": "Velocità di riproduzione",
"customPlaybackSpeed": "Velocità di riproduzione personalizzata",
"captionPreferences": {
"title": "Personalizza",
"delay": "Ritardo",
"fontSize": "Dimensione carattere",
"opacity": "Opacità",
"color": "Colore"
},
"episode": "E{{index}} - {{title}}",
"noCaptions": "Nessun sottotitolo",
"linkedCaptions": "Sottotitoli collegati",
"customCaption": "Sottotitolo personalizzato",
"uploadCustomCaption": "Carica sottotitolo",
"noEmbeds": "Nessun embed è stato trovato per questa fonte",
"errors": {
"loadingWentWong": "Si è verificato un problema durante il caricamento degli episodi per {{seasonTitle}}",
"embedsError": "Si è verificato un problema durante il caricamento degli embed per questa cosa che ti piace"
},
"descriptions": {
"sources": "Quale provider desideri utilizzare?",
"embeds": "Scegli quale video visualizzare",
"seasons": "Scegli quale stagione vuoi guardare",
"episode": "Scegli un episodio",
"captions": "Scegli una lingua per i sottotitoli",
"captionPreferences": "Personalizza l'aspetto dei sottotitoli",
"playbackSpeed": "Cambia la velocità di riproduzione"
}
},
"errors": {
"fatalError": "Il lettore video ha riscontrato un errore fatale, segnalalo sul <0>server Discord</0> o su <1>GitHub</1>."
}
},
"settings": {
"title": "Impostazioni",
"language": "Lingua",
"captionLanguage": "Lingua dei sottotitoli"
},
"v3": {
"newSiteTitle": "Nuova versione ora disponibile!",
"newDomain": "https://movie-web.app",
"newDomainText": "movie-web si sposterà presto su un nuovo dominio: <0>https://movie-web.app</0>. Assicurati di aggiornare tutti i tuoi segnalibri poiché <1>il vecchio sito smetterà di funzionare il {{date}}.</1>",
"tireless": "Abbiamo lavorato instancabilmente su questo nuovo aggiornamento, speriamo che ti piaccia quello su cui abbiamo lavorato negli ultimi mesi.",
"leaveAnnouncement": "Portami lì!"
},
"casting": {
"casting": "Trasmissione su dispositivo in corso..."
},
"errors": {
"offline": "Controlla la tua connessione internet"
}
}

View File

@@ -58,7 +58,7 @@
"backToHomeShort": "Terug", "backToHomeShort": "Terug",
"seasonAndEpisode": "S{{season}} A{{episode}}", "seasonAndEpisode": "S{{season}} A{{episode}}",
"timeLeft": "Nog {{timeLeft}}", "timeLeft": "Nog {{timeLeft}}",
"finishAt": "Afgelopen om {{timeFinished}}", "finishAt": "Afgelopen om {{timeFinished, datetime}}",
"buttons": { "buttons": {
"episodes": "Afleveringen", "episodes": "Afleveringen",
"source": "Bron", "source": "Bron",

View File

@@ -0,0 +1,124 @@
{
"global": {
"name": "movie-web"
},
"search": {
"loading_series": "Fetchin' yer favorite series...",
"loading_movie": "Fetchin' yer favorite movies...",
"loadin'": "Loadin'...",
"allResults": "That be all we 'ave, me hearty!",
"noResults": "We couldn't find anythin' that matches yer search!",
"allFailed": "Failed t' find media, walk the plank and try again!",
"headingTitle": "Search results",
"bookmarks": "Treasure Maps",
"continueWatchin'": "Continue Watchin'",
"title": "Wha' be ye wantin' to watch, me matey?",
"placeholder": "Wha' be ye searchin' for?"
},
"media": {
"movie": "Movie",
"series": "Series",
"stopEditin'": "Stop editin'",
"errors": {
"genericTitle": "Shiver me timbers! It broke!",
"failedMeta": "Ye can't trust the compass, failed to load meta",
"mediaFailed": "We failed t' request the media ye asked fer, check yer internet connection, or Davy Jones's locker awaits ye!",
"videoFailed": "Blimey! We encountered an error while playin' the video ye requested. If this keeps happening please report the issue to the <0>Discord server</0> or on <1>GitHub</1>."
}
},
"seasons": {
"seasonAndEpisode": "S{{season}} E{{episode}}"
},
"notFound": {
"genericTitle": "Ahoy! I see nothin' on the horizon.",
"backArrow": "Back to the port",
"media": {
"title": "Avast ye! Couldn't find that media",
"description": "We couldn't find the media ye requested. Either it's been scuttled or ye tampered with the URL, ye scallywag!"
},
"provider": {
"title": "Walk the plank! This provider has been disabled",
"description": "We had issues wit' the provider or 'twas too unstable t' use, so we had t' disable it. Try another one, arrr!"
},
"page": {
"title": "Avast ye! Couldn't find that page.",
"description": "Arrr! We searched every inch o' the vessel: from the bilge to the crow's nest, from the keel to the topmast, but avast! We couldn't find the page ye be lookin' fer, me heartie."
}
},
"searchBar": {
"movie": "Movie",
"series": "Series",
"Search": "Search"
},
"videoPlayer": {
"findingBestVideo": "Finding the best video fer ye, hoist the colors!",
"noVideos": "Blistering barnacles, couldn't find any videos fer ye. Ye need a better map!",
"loading": "Loading...",
"backToHome": "Back to the port, mates!",
"backToHomeShort": "Back",
"seasonAndEpisode": "S{{season}} E{{episode}}",
"timeLeft": "{{timeLeft}} left",
"finishAt": "Finish at {{timeFinished}}",
"buttons": {
"episodes": "Episodes",
"source": "Source",
"captions": "Captions",
"download": "Download",
"settings": "Settings",
"pictureInPicture": "Spyglass view",
"playbackSpeed": "Set sail!"
},
"popouts": {
"back": "Avast ye, go back!",
"sources": "Wha' provider do ye want to use?",
"seasons": "Choose which season you wants to watch!",
"captions": "Select a subtitle language, me hearty!",
"playbackSpeed": "Change the speed of Blackbeard's ship!",
"customPlaybackSpeed": "Set a custom playback speed",
"captionPreferences": {
"title": "Customize yer captions",
"delay": "Delay",
"fontSize": "Size",
"opacity": "Opacity",
"color": "Color"
},
"episode": "E{{index}} - {{title}}",
"noCaptions": "No captions, hoist the Jolly Roger!",
"linkedCaptions": "Linked captions, drop anchor!",
"customCaption": "Custom caption, arrr!",
"uploadCustomCaption": "Upload yer own caption!",
"noEmbeds": "No embeds we be found fer this source",
"errors": {
"loadingWentWong": "Shiver me timbers! Somethin' went wrong loadin' the episodes fer {{seasonTitle}}",
"embedsError": "Blimey! Somethin' went wrong loadin' the embeds fer this thin' that ye like"
},
"descriptions": {
"sources": "Wha' provider do ye wants to use?",
"embeds": "Choose which video to view",
"seasons": "Choose which season ye wants to watch",
"episode": "Pick an episode",
"captions": "Choose a subtitle language",
"captionPreferences": "Make subtitles look how ye wants it",
"playbackSpeed": "Change the playback speed"
}
},
"errors": {
"fatalError": "Blow me down! The video player encounted a fatal error, please report it to the <0>Discord server</0> or on <1>GitHub</1>."
}
},
"settings": {
"title": "Settings",
"language": "Language",
"captionLanguage": "Caption Language"
},
"v3": {
"newSiteTitle": "New version now released!",
"newDomain": "https://movie-web.app",
"newDomainText": "movie-web will soon be movin' to a new domain: <0>https://movie-web.app</0>. Make sure to update all yer bookmarks as <1>the ole website will stop workin' on {{date}}.</1>",
"tireless": "We've worked tirelessly on this new update, we hope ye will enjoy wha' we've been cookin' up fer the past months.",
"leaveAnnouncement": "Take me thar!"
},
"casting": { "casting": "Casting to device..." },
"errors": { "offline": "Avast! Check yer internet connection" }
}

View File

@@ -0,0 +1,137 @@
{
"global": {
"name": "movie-web"
},
"search": {
"loading_series": "Szukamy twoich ulubionych seriali...",
"loading_movie": "Szukamy twoich ulubionych filmów...",
"loading": "Wczytywanie...",
"allResults": "To wszystko co mamy!",
"noResults": "Nie mogliśmy niczego znaleźć!",
"allFailed": "Nie udało się znaleźć mediów, Spróbuj ponownie!",
"headingTitle": "Wyniki wyszukiwania",
"bookmarks": "Zakładki",
"continueWatching": "Kontynuuj oglądanie",
"title": "Co chciałbyś obejrzeć?",
"placeholder": "Co chciałbyś obejrzeć?"
},
"media": {
"movie": "Film",
"series": "Serial",
"stopEditing": "Zatrzymaj edycje",
"errors": {
"genericTitle": "Ups, popsuło się!",
"failedMeta": "Nie udało się wczytać metadanych",
"mediaFailed": "Nie udało nam się zarządać mediów, sprawdź połączenie sieciowe i spróbuj ponownie.",
"videoFailed": "Napotkaliśmy błąd podczas odtwarzania rządanego video. Jeśli problem będzie się powtarzać prosimy o zgłoszenie problemu na <0>Serwer Discord</0> lub na <1>GitHub</1>."
}
},
"seasons": {
"seasonAndEpisode": "S{{season}} E{{episode}}"
},
"notFound": {
"genericTitle": "Nie znaleziono",
"backArrow": "Wróć na stronę główną",
"media": {
"title": "Nie można znaleźć multimediów",
"description": "Nie mogliśmy znaleźć rządanych multimediów. Albo zostały usunięte, albo grzebałeś przy adresie URL."
},
"provider": {
"title": "Ten dostawca został wyłączony",
"description": "Mieliśmy problemy z tym dostawcą, albo był zbyt niestabilny, więc musieliśmy go wyłączyć."
},
"page": {
"title": "Nie można znaleźć tej strony",
"description": "Szukaliśmy wszędzie: w koszu, w szafie a nawet w piwnicy, ale nie byliśmy w stanie znaleźć strony której szukasz."
}
},
"searchBar": {
"movie": "Filmy",
"series": "Seriale",
"Search": "Szukaj"
},
"videoPlayer": {
"findingBestVideo": "Szukamy najlepszego video dla ciebie",
"noVideos": "Oj, Nie mogliśmy znaleźć żadnego video",
"loading": "Wczytywanie...",
"backToHome": "Wróć na stronę główną",
"backToHomeShort": "Wróć",
"seasonAndEpisode": "S{{season}} E{{episode}}",
"timeLeft": "Pozostało {{timeLeft}}",
"finishAt": "Zakończ na {{timeFinished, datetime}}",
"buttons": {
"episodes": "Odcinki",
"source": "Źródło",
"captions": "Napisy",
"download": "Pobierz",
"settings": "Ustawienia",
"pictureInPicture": "Obraz w obrazie (PIP)",
"playbackSpeed": "Prędkość odtwarzania"
},
"popouts": {
"close": "Zamknąć",
"seasons": {
"title":"Sezony",
"other": "Inne sezony",
"noSeason": "Brak sezonu"
},
"episodes": {
"unknown": "Nieznany odcinki",
"noEpisode": "Brak odcinki"
},
"back": "Wróć",
"sources": "Źródła",
"captions": "Napisy",
"playbackSpeed": "Prędkość odtwarzania",
"customPlaybackSpeed": "Niestandardowa prędkość odtwarzania",
"captionPreferences": {
"title": "Personalizuj",
"delay": "Opóźnienie",
"fontSize": "Rozmiar",
"opacity": "Przeźroczystość",
"color": "Kolor"
},
"episode": "E{{index}} - {{title}}",
"noCaptions": "Brak napisów",
"linkedCaptions": "Załączone napisy",
"customCaption": "Napisy niestandardowe",
"uploadCustomCaption": "Załącz",
"noEmbeds": "Nie znaleziono osadzonych mediów dla tego źródła",
"errors": {
"loadingWentWong": "Coś poszło nie tak {{seasonTitle}}",
"embedsError": "Coś poszło nie tak przy wczytywaniu osadzonych mediów"
},
"descriptions": {
"sources": "Którego dostawcy chciałbyś używać?",
"embeds": "Wybierz, które video chcesz zobaczyć",
"seasons": "Wybierz, który sezon chcesz obejrzeć",
"episode": "Wybierz odcinek",
"captions": "Zmień język napisów",
"captionPreferences": "Ustaw napisy, tak jak ci to odpowiada",
"playbackSpeed": "Zmień prędkość odtwarzania"
}
},
"errors": {
"fatalError": "Odtwarzacz napotkał poważny błąd, Prosimy o złoszenie tego na <0>Serwer Discord</0> lub na <1>GitHub</1>."
}
},
"settings": {
"title": "Ustawienia",
"language": "Język",
"captionLanguage": "Język napisów"
},
"v3": {
"newSiteTitle": "Nowa wersja została wydana!",
"newDomain": "https://movie-web.app",
"newDomainText": "movie-web przeniesie się wkrótce na nowy adres: <0>https://movie-web.app</0>. Prosimy zaaktualizować swoje zakładki ponieważ <1>stara strona przestanie działać {{date}}.</1>",
"tireless": "Pracowaliśmy niestrudzenie nad tą aktualizacją, Mamy nadzieję że będziecie zadowoleni z tego nad czym pracowaliśmy przez ostatnie parę miesięcy.",
"leaveAnnouncement": "Zabierz mnie tam!"
},
"casting": {
"casting": "Przesyłanie do urządzenia..."
},
"errors": {
"offline": "Sprawdź swoje połączenie sieciowe"
}
}

View File

@@ -71,7 +71,16 @@
"popouts": { "popouts": {
"back": "Geri git", "back": "Geri git",
"sources": "Kaynaklar", "sources": "Kaynaklar",
"seasons": "Sezonlar", "close":"Kapat",
"seasons": {
"title":"Sezonlar",
"other": "Diğer sezonlar",
"noSeason": "Sezon yok"
},
"episodes": {
"unknown": "Bilinmeyen bölüm",
"noEpisode": "Bölüm yok"
},
"captions": "Altyazılar", "captions": "Altyazılar",
"playbackSpeed": "Oynatma hızı", "playbackSpeed": "Oynatma hızı",
"customPlaybackSpeed": "Özel oynatma hızı", "customPlaybackSpeed": "Özel oynatma hızı",

View File

@@ -0,0 +1,128 @@
{
"global": {
"name": "movie-web"
},
"search": {
"loading_series": "Đang tìm chương trình yêu thích của bạn...",
"loading_movie": "Đang tìm bộ phim yêu thích của bạn...",
"loading": "Đang tải...",
"allResults": "Đó là tất cả chúng tôi có!",
"noResults": "Chúng tôi không thể tìm thấy gì!",
"allFailed": "Không thể tìm thấy nội dung, hãy thử lại!",
"headingTitle": "Kết quả tìm kiếm",
"bookmarks": "Đánh dấu",
"continueWatching": "Tiếp tục xem",
"title": "Bạn muốn xem gì?",
"placeholder": "Bạn muốn xem gì?"
},
"media": {
"movie": "Phim",
"series": "Chương trình truyền hình",
"stopEditing": "Hãy dừng chỉnh sửa",
"errors": {
"genericTitle": "Rất tiếc, đã hỏng!",
"failedMeta": "Không thể tải meta",
"mediaFailed": "Chúng tôi không thể tìm thấy nội dung mà bạn yêu cầu, hãy kiểm tra kết nối internet của bạn và thử lại.",
"videoFailed": "Chúng tôi đã gặp lỗi khi phát nội dung mà bạn yêu cầu. Nếu điều này tiếp tục xảy ra, vui lòng báo cáo sự cố trên <0>máy chủ Discord</0> hoặc trên <1>GitHub</1>."
}
},
"seasons": {
"seasonAndEpisode": "M{{season}} T{{episode}}"
},
"notFound": {
"genericTitle": "Không tìm thấy",
"backArrow": "Quay lại trang chính",
"media": {
"title": "Không thể tìm thấy nội dung",
"description": "Chúng tôi không thể tìm thấy nội dung mà bạn yêu cầu. Hoặc là nó đã bị xóa, hoặc bạn đã xáo trộn URL"
},
"provider": {
"title": "Nhà cung cấp này đã bị vô hiệu hóa",
"description": "Chúng tôi đã gặp vấn đề với nhà cung cấp hoặc nó quá bất ổn để sử dụng, cho nên chúng tôi đã phải vô hiệu hóa nó."
},
"page": {
"title": "Không thể tìm thấy trang",
"description": "Chúng tôi đã tìm kiếm khắp nơi: dưới thùng rác, trong tủ quần áo, đằng sau máy chủ proxy nhưng vẫn không thể tìm thấy trang bạn đang tìm kiếm."
}
},
"searchBar": {
"movie": "Phim",
"series": "Chương trình truyền hình",
"Search": "Tìm kiếm"
},
"videoPlayer": {
"findingBestVideo": "Đang tìm nội dung tốt nhất cho bạn",
"noVideos": "Rất tiếc, không tìm thấy nội dung nào cho bạn",
"loading": "Đang tải...",
"backToHome": "Quay lại trang chính",
"backToHomeShort": "Quay lại",
"seasonAndEpisode": "M{{season}} T{{episode}}",
"timeLeft": "Còn {{timeLeft}}",
"finishAt": "Kết thúc vào {{timeFinished, datetime}}",
"buttons": {
"episodes": "Tập",
"source": "Source",
"captions": "Phụ đề",
"download": "Tải xuống",
"settings": "Cài đặt",
"pictureInPicture": "Hình trong hình",
"playbackSpeed": "Tốc độ phát"
},
"popouts": {
"back": "Quay lại",
"sources": "Nguồn",
"seasons": "Mùa",
"captions": "Phụ đề",
"playbackSpeed": "Tốc độ phát",
"customPlaybackSpeed": "Tủy chỉnh tốc độ phát",
"captionPreferences": {
"title": "Tùy chỉnh",
"delay": "Trì hoãn",
"fontSize": "Kích cỡ",
"opacity": "Độ mờ",
"color": "Màu sắc"
},
"episode": "T{{index}} - {{title}}",
"noCaptions": "Không phụ đề",
"linkedCaptions": "Phụ đề được liên kết",
"customCaption": "Phụ đề tùy chỉnh",
"uploadCustomCaption": "Tải phụ đề lên",
"noEmbeds": "Không tìm thấy nội dung nhúng nào cho nguồn này",
"errors": {
"loadingWentWong": "Đã xảy ra lỗi khi tải các tập phim cho {{seasonTitle}}",
"embedsError": "Đã xảy ra lỗi khi tải nội dung nhúng cho nội dung bạn thích này"
},
"descriptions": {
"sources": "Bạn muốn sử dụng nhà cung cấp nào?",
"embeds": "Chọn video để xem",
"seasons": "Chọn mùa bạn muốn xem",
"episode": "Chọn một tập",
"captions": "Chọn ngôn ngữ của phụ đề",
"captionPreferences": "Làm cho phụ đề trông như thế nào bạn muốn",
"playbackSpeed": "Thay đổi tốc độ phát"
}
},
"errors": {
"fatalError": "Trình phát video đã gặp phải lỗi nghiêm trọng, vui lòng báo cáo sự cố trên <0>máy chủ Discord</0> hoặc trên <1>GitHub</1>."
}
},
"settings": {
"title": "Cài đặt",
"language": "Ngôn ngữ",
"captionLanguage": "Ngôn ngữ phụ đề"
},
"v3": {
"newSiteTitle": "Phiên bản mới đã được phát hành!",
"newDomain": "https://movie-web.app",
"newDomainText": "movie-web sẽ sớm chuyển sang trang mới: <0>https://movie-web.app</0>. Hãy đảm bảo rằng các đánh dấu đã được cập nhật vì <1>trang web cũ sẽ dừng hoạt động vào {{date}}.</1>",
"tireless": "Chúng tôi đã làm việc vất vả để tạo phiên bản mới này, chúng tôi hy vọng bạn sẽ thích những gì chúng tôi đã nung nấu trong những tháng qua.",
"leaveAnnouncement": "Hãy đưa tôi đến đó!"
},
"casting": {
"casting": "Đang truyền tới thiết bị..."
},
"errors": {
"offline": "Hãy kiểm tra kết nối Internet của bạn"
}
}

Some files were not shown because too many files have changed in this diff Show More