Compare commits

..

131 Commits

Author SHA1 Message Date
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
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
77 changed files with 2508 additions and 223 deletions

View File

@@ -29,10 +29,11 @@ Your proxy is now hosted on cloudflare. Note the url of your worker. you will ne
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.
3. Open `config.js` in notepad, VScode 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 inbetween the double qoutes 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",`
5. Save the file
Example (THIS IS MINE, IT WONT WORK FOR YOU): `VITE_CORS_PROXY_URL: "https://test-proxy.test.workers.dev"`
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.
It doesn't require php, its 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
VITE_CORS_PROXY_URL=...
# the keys below are optional - defaults are provided
VITE_TMDB_API_KEY=...
VITE_OMDB_API_KEY=...
VITE_TMDB_READ_API_KEY=...

View File

@@ -1,6 +1,6 @@
{
"name": "movie-web",
"version": "3.0.15",
"version": "3.1.2",
"private": true,
"homepage": "https://movie-web.app",
"dependencies": {

View File

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

View File

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

View File

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

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

@@ -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,93 @@
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;
}
}
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;
}
| string = streamRes.sources;
if (!isJSON(sources) || typeof sources === "string") {
const decryptionKey = await proxiedFetch<string>(
`https://raw.githubusercontent.com/enimax-anime/key/e4/key.txt`
);
const decryptedStream = AES.decrypt(sources, decryptionKey).toString(
enc.Utf8
);
const parsedStream = JSON.parse(decryptedStream)[0];
if (!parsedStream) throw new Error("No stream found");
sources = parsedStream as { file: string; type: string };
}
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

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,28 @@
import { FetchError } from "ofetch";
import { formatJWMeta, mediaTypeToJW } from "./justwatch";
import {
TMDBMediaToMediaType,
formatTMDBMeta,
getEpisodes,
getExternalIds,
getMediaDetails,
getMediaPoster,
getMovieFromExternalId,
mediaTypeToTMDB,
} from "./tmdb";
import {
JWMediaResult,
JWSeasonMetaResult,
JW_API_BASE,
formatJWMeta,
mediaTypeToJW,
} from "./justwatch";
import { MWMediaMeta, MWMediaType } from "./types";
} from "./types/justwatch";
import { MWMediaMeta, MWMediaType } from "./types/mw";
import {
TMDBMediaResult,
TMDBMovieData,
TMDBSeasonMetaResult,
TMDBShowData,
} from "./types/tmdb";
import { makeUrl, proxiedFetch } from "../helpers/fetch";
type JWExternalIdType =
@@ -33,10 +48,92 @@ export interface DetailedMeta {
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(
type: MWMediaType,
id: 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> {
const queryType = mediaTypeToJW(type);
@@ -82,3 +179,55 @@ export async function getMetaFromId(
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";
export const JW_API_BASE = "https://apis.justwatch.com";
export const JW_IMAGE_BASE = "https://images.justwatch.com";
export type JWContentTypes = "movie" | "show";
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[];
};
import {
JWContentTypes,
JWMediaResult,
JWSeasonMetaResult,
JW_IMAGE_BASE,
} from "./types/justwatch";
import { MWMediaMeta, MWMediaType, MWSeasonMeta } from "./types/mw";
export function mediaTypeToJW(type: MWMediaType): JWContentTypes {
if (type === MWMediaType.MOVIE) return "movie";

View File

@@ -1,14 +1,12 @@
import { SimpleCache } from "@/utils/cache";
import {
JWContentTypes,
JWMediaResult,
JW_API_BASE,
formatJWMeta,
mediaTypeToJW,
} from "./justwatch";
import { MWMediaMeta, MWQuery } from "./types";
import { proxiedFetch } from "../helpers/fetch";
formatTMDBMeta,
formatTMDBSearchResult,
mediaTypeToTMDB,
searchMedia,
} from "./tmdb";
import { MWMediaMeta, MWQuery } from "./types/mw";
const cache = new SimpleCache<MWQuery, MWMediaMeta[]>();
cache.setCompare((a, b) => {
@@ -16,44 +14,16 @@ cache.setCompare((a, b) => {
});
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[]> {
if (cache.has(query)) return cache.get(query) as MWMediaMeta[];
const { searchQuery, type } = query;
const contentType = mediaTypeToJW(type);
const body: JWSearchQuery = {
content_types: [contentType],
page: 1,
query: searchQuery,
page_size: 40,
};
const data = await searchMedia(searchQuery, mediaTypeToTMDB(type));
const results = data.results.map((v) => {
const formattedResult = formatTMDBSearchResult(v, mediaTypeToTMDB(type));
return formatTMDBMeta(formattedResult);
});
const data = await proxiedFetch<JWPage<JWMediaResult>>(
"/content/titles/en_US/popular",
{
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;
cache.set(query, results, 3600); // cache results for 1 hour
return results;
}

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;
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

@@ -8,7 +8,7 @@ import {
MWStreamQuality,
MWStreamType,
} from "../helpers/streams";
import { MWMediaType } from "../metadata/types";
import { MWMediaType } from "../metadata/types/mw";
const twoEmbedBase = "https://www.2embed.to";
@@ -191,6 +191,7 @@ registerProvider({
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}`;

View File

@@ -7,7 +7,7 @@ import {
import { mwFetch } from "../helpers/fetch";
import { registerProvider } from "../helpers/register";
import { MWCaption, MWStreamQuality, MWStreamType } from "../helpers/streams";
import { MWMediaType } from "../metadata/types";
import { MWMediaType } from "../metadata/types/mw";
const flixHqBase = "https://consumet-api-clone.vercel.app/meta/tmdb"; // instance stolen from streaminal :)

View File

@@ -3,7 +3,7 @@ import { unpack } from "unpacker";
import { registerProvider } from "@/backend/helpers/register";
import { MWStreamQuality } from "@/backend/helpers/streams";
import { MWMediaType } from "@/backend/metadata/types";
import { MWMediaType } from "@/backend/metadata/types/mw";
import { proxiedFetch } from "../helpers/fetch";

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 : ep.number === episodeNumber
);
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 { registerProvider } from "../helpers/register";
import { MWStreamQuality, MWStreamType } from "../helpers/streams";
import { MWMediaType } from "../metadata/types";
import { MWMediaType } from "../metadata/types/mw";
const hdwatchedBase = "https://www.hdwatched.xyz";

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

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

View File

@@ -5,7 +5,7 @@ import {
MWStreamQuality,
MWStreamType,
} from "../helpers/streams";
import { MWMediaType } from "../metadata/types";
import { MWMediaType } from "../metadata/types/mw";
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,99 @@
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,
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

@@ -13,7 +13,7 @@ import {
MWStreamQuality,
MWStreamType,
} from "@/backend/helpers/streams";
import { MWMediaType } from "@/backend/metadata/types";
import { MWMediaType } from "@/backend/metadata/types/mw";
import { compareTitle } from "@/utils/titleMatch";
const nanoid = customAlphabet("0123456789abcdef", 32);
@@ -142,7 +142,7 @@ const convertSubtitles = (subtitleGroup: any): MWCaption | null => {
registerProvider({
id: "superstream",
displayName: "Superstream",
rank: 200,
rank: 300,
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape({ media, episode, progress }) {

View File

@@ -1,7 +1,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { MWMediaType, MWQuery } from "@/backend/metadata/types";
import { MWMediaType, MWQuery } from "@/backend/metadata/types/mw";
import { DropdownButton } from "./buttons/DropdownButton";
import { Icon, Icons } from "./Icon";

View File

@@ -1,8 +1,8 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { JWMediaToId } from "@/backend/metadata/justwatch";
import { MWMediaMeta } from "@/backend/metadata/types";
import { TMDBMediaToId } from "@/backend/metadata/getmeta";
import { MWMediaMeta } from "@/backend/metadata/types/mw";
import { DotList } from "@/components/text/DotList";
import { IconPatch } from "../buttons/IconPatch";
@@ -13,7 +13,7 @@ export interface MediaCardProps {
linkable?: boolean;
series?: {
episode: number;
season: number;
season?: number;
episodeId: string;
seasonId: string;
};
@@ -72,7 +72,7 @@ function MediaCardContent({
].join(" ")}
>
{t("seasons.seasonAndEpisode", {
season: series.season,
season: series.season || 1,
episode: series.episode,
})}
</p>
@@ -132,12 +132,17 @@ export function MediaCard(props: MediaCardProps) {
const canLink = props.linkable && !props.closable;
let link = canLink
? `/media/${encodeURIComponent(JWMediaToId(props.media))}`
? `/media/${encodeURIComponent(TMDBMediaToId(props.media))}`
: "#";
if (canLink && props.series)
link += `/${encodeURIComponent(props.series.seasonId)}/${encodeURIComponent(
props.series.episodeId
)}`;
if (canLink && props.series) {
if (props.series.season === 0 && !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>;
return (

View File

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

View File

@@ -154,7 +154,7 @@ export const FloatingCardView = {
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
>
<Icon icon={Icons.X} />
<span>Close</span>
<span>{t("videoPlayer.popouts.close")}</span>
</div>
);

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

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

View File

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

View File

@@ -7,14 +7,15 @@ import { registerSW } from "virtual:pwa-register";
import { ErrorBoundary } from "@/components/layout/ErrorBoundary";
import App from "@/setup/App";
import { conf } from "@/setup/config";
import { assertConfig, conf } from "@/setup/config";
import i18n from "@/setup/i18n";
import "@/setup/ga";
import "@/setup/sentry";
import "@/setup/i18n";
import "@/setup/index.css";
import "@/backend";
import { initializeChromecast } from "./setup/chromecast";
import { SettingsStore } from "./state/settings/store";
import { initializeStores } from "./utils/storage";
// initialize
@@ -29,7 +30,9 @@ registerSW({
});
const LazyLoadedApp = React.lazy(async () => {
await assertConfig();
await initializeStores();
i18n.changeLanguage(SettingsStore.get().language ?? "en");
return {
default: App,
};

View File

@@ -1,7 +1,14 @@
import { lazy } from "react";
import { Redirect, Route, Switch } from "react-router-dom";
import { ReactElement, lazy, useEffect } from "react";
import {
Redirect,
Route,
Switch,
useHistory,
useLocation,
} from "react-router-dom";
import { MWMediaType } from "@/backend/metadata/types";
import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta";
import { MWMediaType } from "@/backend/metadata/types/mw";
import { BannerContextProvider } from "@/hooks/useBanner";
import { Layout } from "@/setup/Layout";
import { BookmarkContextProvider } from "@/state/bookmark";
@@ -12,6 +19,22 @@ 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() {
return (
<SettingsProvider>
@@ -27,12 +50,16 @@ function App() {
</Route>
{/* pages */}
<Route exact path="/media/:media" component={MediaView} />
<Route
exact
path="/media/:media/:season/:episode"
component={MediaView}
/>
<Route exact path="/media/:media">
<LegacyUrlView>
<MediaView />
</LegacyUrlView>
</Route>
<Route exact path="/media/:media/:season/:episode">
<LegacyUrlView>
<MediaView />
</LegacyUrlView>
</Route>
<Route
exact
path="/search/:type/:query?"

View File

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

View File

@@ -7,14 +7,21 @@ 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 = {
en: {
translation: en,
},
it: {
translation: it,
},
nl: {
translation: nl,
},
@@ -33,6 +40,15 @@ const locales = {
cs: {
translation: cs,
},
pirate: {
translation: pirate,
},
vi: {
translation: vi,
},
pl: {
translation: pl,
},
};
i18n
// pass the i18n instance to react-i18next.

View File

@@ -178,4 +178,20 @@ input[type=range].styled-slider.slider-progress::-ms-fill-lower {
background: var(--slider-progress-background);
border: none;
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 =
| "none"
| "pirate"
| "aa"
| "ab"
| "ae"
@@ -219,6 +220,12 @@ export const captionLanguages: CaptionLanguageOption[] = [
name: "None",
nativeName: "Lorem ipsum",
},
{
id: "pirate",
englishName: "Pirate",
name: "Pirate English",
nativeName: "Pirate English",
},
{
id: "aa",
englishName: "Afar",

View File

@@ -3,8 +3,8 @@
"name": "movie-web"
},
"search": {
"loading_series": "Auf der Suche nach Ihrer Lieblingsserie...",
"loading_movie": "Auf der Suche nach Ihren Lieblingsfilmen...",
"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!",
@@ -12,15 +12,15 @@
"headingTitle": "Suchergebnisse",
"bookmarks": "Favoriten",
"continueWatching": "Weiter ansehen",
"title": "Was willst du sehen?",
"placeholder": "Was willst du sehen?"
"title": "Was willst du gucken?",
"placeholder": "Was willst du gucken?"
},
"media": {
"movie": "Filme",
"series": "Serie",
"stopEditing": "Beenden Sie die Bearbeitung",
"stopEditing": "Beenden die Bearbeitung",
"errors": {
"genericTitle": "Hoppla, etwas ist falsch gegangen!",
"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>."
@@ -48,17 +48,17 @@
"searchBar": {
"movie": "Film",
"series": "Serie",
"Search": "Forschen"
"Search": "Suchen"
},
"videoPlayer": {
"findingBestVideo": "Auf der Suche nach dem besten Video für Sie",
"noVideos": "Entschuldigung, wir konnten keine Videos für Sie finden",
"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}} bleibt",
"finishAt": "Ende um {{timeFinished, datetime}}",
"timeLeft": "{{timeLeft}} verbleibend",
"finishAt": "Endet um {{timeFinished, datetime}}",
"buttons": {
"episodes": "Folgen",
"source": "Quelle",
@@ -71,13 +71,13 @@
"popouts": {
"back": "Zurück",
"sources": "Quellen",
"seasons": "Saison",
"seasons": "Staffel",
"captions": "Untertitel",
"playbackSpeed": "Lesegeschwindigkeit",
"customPlaybackSpeed": "Benutzerdefinierte Wiedergabegeschwindigkeit",
"captionPreferences": {
"title": "Personifizieren",
"delay": "Zeitlimit",
"title": "Bearbeiten",
"delay": "Verzögerung",
"fontSize": "Größe",
"opacity": "Opazität",
"color": "Farbe"
@@ -93,17 +93,17 @@
"embedsError": "Beim Laden der eingebetteter Medien ist ein Problem aufgetreten"
},
"descriptions": {
"sources": "Welchen Anbieter möchten Sie nutzen?",
"embeds": "Wählen Sie das Video aus, das Sie ansehen möchten",
"seasons": "Wählen Sie die Staffel aus, die Sie sehen möchten",
"episode": "Wählen Sie eine Folge aus",
"captions": "Wählen Sie eine Untertitelsprache",
"captionPreferences": "Passen Sie das Erscheinungsbild von Untertiteln an",
"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 melden Sie ihn dem Server <0>Discord</0> Oder weiter <1>GitHub</1>."
"fatalError": "Der Videoplayer hat einen Fehler festgestellt, bitte melde ihn dem Server <0>Discord</0> Oder weiter <1>GitHub</1>."
}
},
"settings": {
@@ -115,13 +115,13 @@
"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 Ihnen das gefällt, was wir in den letzten Monaten vorbereitet haben.",
"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": "Ihre Internetverbindung ist instabil"
"offline": "Internetverbindung ist instabil"
}
}

View File

@@ -71,7 +71,16 @@
"popouts": {
"back": "Go back",
"sources": "Sources",
"seasons": "Seasons",
"close": "Close",
"seasons": {
"title":"Seasons",
"other": "Other seasons",
"noSeason": "No season"
},
"episodes": {
"unknown": "Unknown episode",
"noEpisode": "No episode"
},
"captions": "Captions",
"playbackSpeed": "Playback speed",
"customPlaybackSpeed": "Custom playback speed",

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

@@ -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": {
"back": "Geri git",
"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",
"playbackSpeed": "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"
}
}

View File

@@ -1,6 +1,6 @@
import { ReactNode, createContext, useContext, useMemo } from "react";
import { MWMediaMeta } from "@/backend/metadata/types";
import { MWMediaMeta } from "@/backend/metadata/types/mw";
import { useStore } from "@/utils/storage";
import { BookmarkStore } from "./store";

View File

@@ -2,6 +2,7 @@ import { createVersionedStore } from "@/utils/storage";
import { BookmarkStoreData } from "./types";
import { OldBookmarks, migrateV1Bookmarks } from "../watched/migrations/v2";
import { migrateV2Bookmarks } from "../watched/migrations/v3";
export const BookmarkStore = createVersionedStore<BookmarkStoreData>()
.setKey("mw-bookmarks")
@@ -13,6 +14,12 @@ export const BookmarkStore = createVersionedStore<BookmarkStoreData>()
})
.addVersion({
version: 1,
migrate(old: BookmarkStoreData) {
return migrateV2Bookmarks(old);
},
})
.addVersion({
version: 2,
create() {
return {
bookmarks: [],

View File

@@ -1,4 +1,4 @@
import { MWMediaMeta } from "@/backend/metadata/types";
import { MWMediaMeta } from "@/backend/metadata/types/mw";
export interface BookmarkStoreData {
bookmarks: MWMediaMeta[];

View File

@@ -8,7 +8,7 @@ import {
} from "react";
import { DetailedMeta } from "@/backend/metadata/getmeta";
import { MWMediaType } from "@/backend/metadata/types";
import { MWMediaType } from "@/backend/metadata/types/mw";
import { useStore } from "@/utils/storage";
import { VideoProgressStore } from "./store";

View File

@@ -1,6 +1,6 @@
import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta";
import { searchForMedia } from "@/backend/metadata/search";
import { MWMediaMeta, MWMediaType } from "@/backend/metadata/types";
import { MWMediaMeta, MWMediaType } from "@/backend/metadata/types/mw";
import { compareTitle } from "@/utils/titleMatch";
import { WatchedStoreData, WatchedStoreItem } from "../types";

View File

@@ -0,0 +1,89 @@
import { getLegacyMetaFromId } from "@/backend/metadata/getmeta";
import {
getEpisodes,
getMediaDetails,
getMovieFromExternalId,
} from "@/backend/metadata/tmdb";
import { MWMediaType } from "@/backend/metadata/types/mw";
import { BookmarkStoreData } from "@/state/bookmark/types";
import { isNotNull } from "@/utils/typeguard";
import { WatchedStoreData } from "../types";
async function migrateId(
id: string,
type: MWMediaType
): Promise<string | undefined> {
const meta = await getLegacyMetaFromId(type, id);
if (!meta) return undefined;
const { tmdbId, imdbId } = meta;
if (!tmdbId && !imdbId) return undefined;
// movies always have an imdb id on tmdb
if (imdbId && type === MWMediaType.MOVIE) {
const movieId = await getMovieFromExternalId(imdbId);
if (movieId) return movieId;
}
if (tmdbId) {
return tmdbId;
}
}
export async function migrateV2Bookmarks(old: BookmarkStoreData) {
const updatedBookmarks = old.bookmarks.map(async (item) => ({
...item,
id: await migrateId(item.id, item.type).catch(() => undefined),
}));
return {
bookmarks: (await Promise.all(updatedBookmarks)).filter((item) => item.id),
};
}
export async function migrateV3Videos(
old: WatchedStoreData
): Promise<WatchedStoreData> {
const updatedItems = await Promise.all(
old.items.map(async (progress) => {
try {
const migratedId = await migrateId(
progress.item.meta.id,
progress.item.meta.type
);
if (!migratedId) return null;
const clone = structuredClone(progress);
clone.item.meta.id = migratedId;
if (clone.item.series) {
const series = clone.item.series;
const details = await getMediaDetails(migratedId, "show");
const season = details.seasons.find(
(v) => v.season_number === series.season
);
if (!season) return null;
const episodes = await getEpisodes(migratedId, season.season_number);
const episode = episodes.find(
(v) => v.episode_number === series.episode
);
if (!episode) return null;
clone.item.series.episodeId = episode.id.toString();
clone.item.series.seasonId = season.id.toString();
}
return clone;
} catch (err) {
return null;
}
})
);
return {
items: updatedItems.filter(isNotNull),
};
}

View File

@@ -1,6 +1,7 @@
import { createVersionedStore } from "@/utils/storage";
import { OldData, migrateV2Videos } from "./migrations/v2";
import { migrateV3Videos } from "./migrations/v3";
import { WatchedStoreData } from "./types";
export const VideoProgressStore = createVersionedStore<WatchedStoreData>()
@@ -21,6 +22,12 @@ export const VideoProgressStore = createVersionedStore<WatchedStoreData>()
})
.addVersion({
version: 2,
migrate(old: WatchedStoreData) {
return migrateV3Videos(old);
},
})
.addVersion({
version: 3,
create() {
return {
items: [],

View File

@@ -1,4 +1,4 @@
import { MWMediaMeta } from "@/backend/metadata/types";
import { MWMediaMeta } from "@/backend/metadata/types/mw";
export interface StoreMediaItem {
meta: MWMediaMeta;

View File

@@ -46,8 +46,13 @@ export async function initializeStores() {
let mostRecentData = data;
try {
for (const version of relevantVersions) {
if (version.migrate)
if (version.migrate) {
localStorage.setItem(
`BACKUP-v${version.version}-${internal.key}`,
JSON.stringify(mostRecentData)
);
mostRecentData = await version.migrate(mostRecentData);
}
}
} catch (err) {
console.error(`FAILED TO MIGRATE STORE ${internal.key}`, err);

3
src/utils/typeguard.ts Normal file
View File

@@ -0,0 +1,3 @@
export function isNotNull<T>(obj: T | null): obj is T {
return obj != null;
}

View File

@@ -1,4 +1,4 @@
import { MWMediaType } from "@/backend/metadata/types";
import { MWMediaType } from "@/backend/metadata/types/mw";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useMeta } from "@/video/state/logic/meta";

View File

@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import { MWMediaType } from "@/backend/metadata/types";
import { MWMediaType } from "@/backend/metadata/types/mw";
import { Icons } from "@/components/Icon";
import { FloatingAnchor } from "@/components/popout/FloatingAnchor";
import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton";

View File

@@ -1,7 +1,7 @@
import { useEffect } from "react";
import { MWCaption } from "@/backend/helpers/streams";
import { MWSeasonWithEpisodeMeta } from "@/backend/metadata/types";
import { MWSeasonWithEpisodeMeta } from "@/backend/metadata/types/mw";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { VideoPlayerMeta } from "@/video/state/types";

View File

@@ -1,6 +1,7 @@
import throttle from "lodash.throttle";
import { useEffect, useMemo, useRef } from "react";
import { useQueryParams } from "@/hooks/useQueryParams";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
@@ -20,6 +21,7 @@ export function ProgressListenerController(props: Props) {
const misc = useMisc(descriptor);
const didInitialize = useRef<true | null>(null);
const lastTime = useRef<number>(props.startAt ?? 0);
const queryParams = useQueryParams();
// time updates (throttled)
const updateTime = useMemo(
@@ -56,9 +58,26 @@ export function ProgressListenerController(props: Props) {
useEffect(() => {
if (lastStateProviderId.current === stateProviderId) return;
if (mediaPlaying.isFirstLoading) return;
lastStateProviderId.current = stateProviderId;
if ((queryParams.t ?? null) !== null) {
// Convert `t` param to time. Supports having only seconds (like `?t=192`), but also `3:30` or `1:30:02`
const timeArr = queryParams.t.toString().split(":").map(Number).reverse(); // This is an array of [seconds, ?minutes, ?hours] as ints.
const hours = timeArr[2] ?? 0;
const minutes = Math.min(timeArr[1] ?? 0, 59);
const seconds = Math.min(timeArr[0] ?? 0, minutes > 0 ? 59 : Infinity);
const timeInSeconds = hours * 60 * 60 + minutes * 60 + seconds;
controls.setTime(timeInSeconds);
return;
}
controls.setTime(lastTime.current);
}, [controls, mediaPlaying, stateProviderId]);
}, [controls, mediaPlaying, stateProviderId, queryParams]);
useEffect(() => {
// if it initialized, but media starts loading for the first time again.

View File

@@ -1,7 +1,7 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { MWMediaType } from "@/backend/metadata/types";
import { MWMediaType } from "@/backend/metadata/types/mw";
import { useMeta } from "@/video/state/logic/meta";
export function useCurrentSeriesEpisodeInfo(descriptor: string) {

View File

@@ -2,7 +2,7 @@ import { Component } from "react";
import { Trans } from "react-i18next";
import type { ReactNode } from "react-router-dom/node_modules/@types/react/index";
import { MWMediaMeta } from "@/backend/metadata/types";
import { MWMediaMeta } from "@/backend/metadata/types/mw";
import { ErrorMessage } from "@/components/layout/ErrorBoundary";
import { Link } from "@/components/text/Link";
import { conf } from "@/setup/config";

View File

@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import { MWMediaMeta } from "@/backend/metadata/types";
import { MWMediaMeta } from "@/backend/metadata/types/mw";
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icon, Icons } from "@/components/Icon";
import { BrandPill } from "@/components/layout/BrandPill";

View File

@@ -2,9 +2,11 @@ import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { getMetaFromId } from "@/backend/metadata/getmeta";
import { decodeJWId } from "@/backend/metadata/justwatch";
import { MWMediaType, MWSeasonWithEpisodeMeta } from "@/backend/metadata/types";
import { decodeTMDBId, getMetaFromId } from "@/backend/metadata/getmeta";
import {
MWMediaType,
MWSeasonWithEpisodeMeta,
} from "@/backend/metadata/types/mw";
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icon, Icons } from "@/components/Icon";
import { Loading } from "@/components/layout/Loading";
@@ -45,7 +47,7 @@ export function EpisodeSelectionPopout() {
seasonId: sId,
season: undefined,
});
reqSeasonMeta(decodeJWId(params.media)?.id as string, sId).then((v) => {
reqSeasonMeta(decodeTMDBId(params.media)?.id as string, sId).then((v) => {
if (v?.meta.type !== MWMediaType.SERIES) return;
setCurrentVisibleSeason({
seasonId: sId,
@@ -99,10 +101,10 @@ export function EpisodeSelectionPopout() {
<>
<FloatingView {...pageProps("seasons")} height={600} width={375}>
<FloatingCardView.Header
title={t("videoPlayer.popouts.seasons")}
title={t("videoPlayer.popouts.seasons.title")}
description={t("videoPlayer.popouts.descriptions.seasons")}
goBack={() => navigate("/episodes")}
backText={`To ${currentSeasonInfo?.title.toLowerCase()}`}
backText={currentSeasonInfo?.title}
/>
<FloatingCardView.Content>
{currentSeasonInfo
@@ -115,12 +117,15 @@ export function EpisodeSelectionPopout() {
{season.title}
</PopoutListEntry>
))
: "No season"}
: t("videoPlayer.popouts.seasons.noSeason")}
</FloatingCardView.Content>
</FloatingView>
<FloatingView {...pageProps("episodes")} height={600} width={375}>
<FloatingCardView.Header
title={currentSeasonInfo?.title ?? "Unknown season"}
title={
currentSeasonInfo?.title ??
t("videoPlayer.popouts.episodes.unknown")
}
description={t("videoPlayer.popouts.descriptions.episode")}
goBack={closePopout}
close
@@ -130,7 +135,7 @@ export function EpisodeSelectionPopout() {
onClick={() => navigate("/episodes/seasons")}
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
>
<span>Other seasons</span>
<span>{t("videoPlayer.popouts.seasons.other")}</span>
<Icon icon={Icons.CHEVRON_RIGHT} />
</button>
}
@@ -181,7 +186,7 @@ export function EpisodeSelectionPopout() {
})}
</PopoutListEntry>
))
: "No episodes"}
: t("videoPlayer.popouts.episodes.noEpisode")}
</div>
)}
</FloatingCardView.Content>

View File

@@ -9,8 +9,6 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { useInterface } from "@/video/state/logic/interface";
import "./Popouts.css";
function ShowPopout(props: { popoutId: string | null; onClose: () => void }) {
const popoutMap = {
settings: <SettingsPopout />,

View File

@@ -1,15 +0,0 @@
.popout-wrapper ::-webkit-scrollbar-track {
background-color: transparent;
}
.popout-wrapper ::-webkit-scrollbar-thumb {
background-color: theme("colors.denim-500");
border: 5px solid transparent;
border-left: 0;
background-clip: content-box;
}
.popout-wrapper ::-webkit-scrollbar {
/* For some reason the styles don't get applied without the width */
width: 13px;
}

View File

@@ -3,7 +3,7 @@ import { Helmet } from "react-helmet";
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
import { DetailedMeta } from "@/backend/metadata/getmeta";
import { MWMediaType } from "@/backend/metadata/types";
import { MWMediaType } from "@/backend/metadata/types/mw";
import { Button } from "@/components/Button";
import { Dropdown } from "@/components/Dropdown";
import { Navigation } from "@/components/layout/Navigation";

View File

@@ -4,9 +4,15 @@ import { useTranslation } from "react-i18next";
import { useHistory, useParams } from "react-router-dom";
import { MWStream } from "@/backend/helpers/streams";
import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta";
import { decodeJWId } from "@/backend/metadata/justwatch";
import { MWMediaType, MWSeasonWithEpisodeMeta } from "@/backend/metadata/types";
import {
DetailedMeta,
decodeTMDBId,
getMetaFromId,
} from "@/backend/metadata/getmeta";
import {
MWMediaType,
MWSeasonWithEpisodeMeta,
} from "@/backend/metadata/types/mw";
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon";
import { Loading } from "@/components/layout/Loading";
@@ -181,7 +187,7 @@ export function MediaView() {
const [selected, setSelected] = useState<SelectedMediaData | null>(null);
const [exec, loading, error] = useLoading(
async (mediaParams: string, seasonId?: string) => {
const data = decodeJWId(mediaParams);
const data = decodeTMDBId(mediaParams);
if (!data) return null;
return getMetaFromId(data.type, data.id, seasonId);
}

View File

@@ -1,7 +1,7 @@
import pako from "pako";
import { useEffect, useState } from "react";
import { MWMediaType } from "@/backend/metadata/types";
import { MWMediaType } from "@/backend/metadata/types/mw";
import { conf } from "@/setup/config";
function fromBinary(str: string): Uint8Array {

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { MWQuery } from "@/backend/metadata/types";
import { MWQuery } from "@/backend/metadata/types/mw";
import { useDebounce } from "@/hooks/useDebounce";
import { HomeView } from "./HomeView";

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { searchForMedia } from "@/backend/metadata/search";
import { MWMediaMeta, MWQuery } from "@/backend/metadata/types";
import { MWMediaMeta, MWQuery } from "@/backend/metadata/types/mw";
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon";
import { SectionHeading } from "@/components/layout/SectionHeading";