mirror of
https://github.com/movie-web/movie-web.git
synced 2025-09-13 13:33:25 +00:00
Compare commits
312 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
4563ea2c18 | ||
|
eea9c19b56 | ||
|
c4c7816543 | ||
|
545120d5cc | ||
|
4ff3e43c78 | ||
|
845fd93597 | ||
|
e0bf711a79 | ||
|
9fbba7ea55 | ||
|
f892a3037f | ||
|
394271857f | ||
|
f5f69ca7d4 | ||
|
1c17ef679d | ||
|
09f6a3125b | ||
|
436fb2707b | ||
|
a46cfa43d3 | ||
|
dccab9b0bf | ||
|
7c3d4aac27 | ||
|
1408fcde93 | ||
|
89cdf74b2f | ||
|
984d215312 | ||
|
430486a9b9 | ||
|
9495a3bf41 | ||
|
33b67f32b1 | ||
|
3f241c2d07 | ||
|
5661a7873a | ||
|
4f5a926c90 | ||
|
205248a376 | ||
|
0d249a3e27 | ||
|
4d51de3bd1 | ||
|
c08a6c7e54 | ||
|
c9bac3ed68 | ||
|
06eb8e6b6d | ||
|
0e9263b619 | ||
|
763de37e9e | ||
|
46bd20f718 | ||
|
8da155ba2b | ||
|
b5c330d4e3 | ||
|
879271c239 | ||
|
70f8355386 | ||
|
3af98373fb | ||
|
c17f8a15e8 | ||
|
63f26b81de | ||
|
70852773f9 | ||
|
7e5c2f9b88 | ||
|
a4bd9bb87a | ||
|
89af8156f4 | ||
|
443ab476d8 | ||
|
524c57d4fc | ||
|
ffa1ad3b8a | ||
|
d47acada58 | ||
|
682017977b | ||
|
ab1dd18d39 | ||
|
cffe5080f6 | ||
|
60142acbda | ||
|
688e1ff24a | ||
|
0066cff111 | ||
|
d06f379d1b | ||
|
a04cd37307 | ||
|
dd3c533349 | ||
|
ec5f1dfad9 | ||
|
bc0f9a6abf | ||
|
a0bb03790a | ||
|
7e948c60c1 | ||
|
9003bf6788 | ||
|
e912ea4715 | ||
|
58ca372a49 | ||
|
ad26391645 | ||
|
f6b830d06d | ||
|
d4c6dac9f2 | ||
|
2db7e0bef8 | ||
|
d198760f9c | ||
|
7e696d5c2c | ||
|
4bd00eb47a | ||
|
d961655186 | ||
|
330cbf2d9e | ||
|
28d2dd0e89 | ||
|
74cc50cfa2 | ||
|
07deb1897d | ||
|
be90b02043 | ||
|
61c3ed076f | ||
|
80dd2158df | ||
|
db75f2320d | ||
|
f9d756e0ef | ||
|
424ee6fe77 | ||
|
5d56b847c6 | ||
|
20c4b14799 | ||
|
c4afc37217 | ||
|
3ee9ee43a5 | ||
|
b22e3ff8c1 | ||
|
a7af045308 | ||
|
e889eaebaa | ||
|
baf744b5d6 | ||
|
e5ddb98162 | ||
|
1eac9f886e | ||
|
dfe67157d4 | ||
|
40e45ae103 | ||
|
1a613287f8 | ||
|
ef782974fe | ||
|
893a385f00 | ||
|
18bde24b3a | ||
|
b7033a31c4 | ||
|
cc4f64032a | ||
|
30e5ae7121 | ||
|
ce4721e1bb | ||
|
534edd5883 | ||
|
02135527c1 | ||
|
12ebee622a | ||
|
8c52371c6d | ||
|
3c096c069c | ||
|
f20cb5aad2 | ||
|
519e74480e | ||
|
be03a8eb42 | ||
d586899dbf | |||
|
525f9d0b74 | ||
|
01b019365d | ||
|
5e0e223851 | ||
|
a648f45694 | ||
|
ffc772727a | ||
|
77a0c36a58 | ||
|
766dc63bfa | ||
|
e3d6ec93c7 | ||
|
1fd458fa27 | ||
|
e4c15c624b | ||
|
b12649bd2e | ||
|
37e10fb40e | ||
|
61b75da402 | ||
|
73b2f57fdc | ||
|
0b8c6439d7 | ||
|
4ad0d53683 | ||
|
3958df8e29 | ||
|
fa36493c50 | ||
|
efd87ab96e | ||
|
f80d79070e | ||
|
be7b875666 | ||
|
bb869fd7e3 | ||
|
2b30bb0e2b | ||
|
b9448b5231 | ||
|
7a6af6c072 | ||
|
2657d1f856 | ||
|
21cc8c16d6 | ||
|
b04209d9b3 | ||
|
55bfa2be9d | ||
|
dd8b6c3f9e | ||
|
835e818ca0 | ||
942725d04c | |||
|
010f1d3987 | ||
|
7bad6eaff9 | ||
|
bcff5a8972 | ||
|
caba492ca2 | ||
|
f03145ee6d | ||
|
c0aebca4d9 | ||
|
c7651950ce | ||
|
cd3bd22a2c | ||
|
9773fcc7b5 | ||
|
c937acfb09 | ||
|
d1f3a7ad24 | ||
|
cd0e4522c9 | ||
f4be26d92d | |||
|
22f8d8a581 | ||
|
6cfd1235bc | ||
|
bdeaca3062 | ||
|
15e95923be | ||
|
571df9e0ad | ||
|
cce47fab5d | ||
|
6eb25fb49c | ||
|
e61937b5c4 | ||
|
2338b0d652 | ||
|
37463afc8d | ||
|
9c8e89a274 | ||
|
bf135a2bdf | ||
|
4dc6658e67 | ||
|
fffc119e88 | ||
|
5468a4677b | ||
|
85cfba1a7a | ||
|
fd6895c326 | ||
|
dfc3d9e50f | ||
|
fcdf45d3f5 | ||
|
592837e2a6 | ||
|
9b3c1ffa28 | ||
|
7cb9ccaf14 | ||
|
aa91bae418 | ||
|
7737bd1866 | ||
|
4c0c61b0b9 | ||
|
4880d46dc4 | ||
|
ef39d87b4b | ||
|
e2a4caa8aa | ||
|
b6a60cf5f8 | ||
|
f784f5f4b2 | ||
|
01348f2f9a | ||
|
8200079af7 | ||
|
dcb5d2f068 | ||
|
99e47f16ea | ||
|
6fb76908ae | ||
|
a718abdcdd | ||
|
0e77d63caf | ||
|
106290070a | ||
|
433d618096 | ||
|
af954af36c | ||
|
16841b8e69 | ||
|
41979712c3 | ||
|
9b62b55fbb | ||
|
6ef41bdf1c | ||
|
33ebd34808 | ||
|
52598599e7 | ||
|
cccc84624a | ||
|
d54921900b | ||
|
2a4bc7349c | ||
|
7b641c61cd | ||
|
3a7b05264d | ||
|
a1e3d98538 | ||
|
68e5742c25 | ||
|
283b9cc996 | ||
|
3ed5dcfc15 | ||
|
71235f5174 | ||
|
0d79a677a0 | ||
|
a34d245e2b | ||
|
8b8cbc8cc9 | ||
|
5ee4f013ff | ||
|
75ef831ddc | ||
|
99a3e6db69 | ||
|
7d3e1c0943 | ||
|
e2d1842946 | ||
|
2cfd7e64a2 | ||
|
d6def996bf | ||
|
8bba2961b4 | ||
|
f12f53d32c | ||
|
da05a2597e | ||
|
d40076e950 | ||
|
bb4a6d8a1e | ||
|
7007f030e1 | ||
|
24fa1c449f | ||
|
591b1d3bc5 | ||
|
c162f15496 | ||
|
2650707d2c | ||
|
a0a51c898a | ||
|
43c8da9003 | ||
|
1472b21600 | ||
|
2424cdfc9e | ||
|
2239c186a5 | ||
|
0c2df2cd3c | ||
|
b26b0715bd | ||
|
7b75c36d21 | ||
|
e52b29a1a1 | ||
|
a910c1c18c | ||
|
12c245b2da | ||
|
871780f95e | ||
|
fa985fc2c2 | ||
|
db9eec195a | ||
|
de1221235b | ||
|
b576a298e8 | ||
|
fcb24c783c | ||
c5251401e7 | |||
41fd23cf20 | |||
|
5dfeeadbb8 | ||
|
0794558338 | ||
|
d2ffa35f2c | ||
c330112dbc | |||
84b8a67cea | |||
|
546b008b2e | ||
|
b9b0380dfe | ||
|
c472e7f7b8 | ||
|
3decc9190c | ||
|
184af19498 | ||
|
2eab07b8b6 | ||
|
5d8f03b859 | ||
|
2178057633 | ||
|
9e961223f6 | ||
|
c2b52d3db8 | ||
|
42dee51570 | ||
|
9c13be37e8 | ||
|
06a44da9cc | ||
|
49d7dc9761 | ||
|
1585805d86 | ||
|
7dc76e993f | ||
|
661d995e3b | ||
|
156b693460 | ||
|
d82b32e8d9 | ||
|
8a8dbb2778 | ||
|
6d95f83c0b | ||
|
2fe53a05e8 | ||
|
495222eb10 | ||
|
119bafa516 | ||
|
ba1ee0267b | ||
|
92ef687ddc | ||
|
5e776f8655 | ||
|
c541d4212a | ||
2d17c8abaa | |||
|
4a52fc11ed | ||
|
54d1af0e0a | ||
|
48f54dd7cc | ||
|
3a44eb550d | ||
|
0fa3d3e430 | ||
|
a9849b40c2 | ||
|
80954514b6 | ||
|
e2dd74c0af | ||
|
2f10de415b | ||
|
efcb12f95a | ||
|
307f555b70 | ||
|
4d5f03337d | ||
|
9f008f02d1 | ||
|
e91f65dd91 | ||
|
3aab008f12 | ||
|
659b0168c3 | ||
|
e9e2129aa2 | ||
|
bed3318ebe | ||
|
436a2388b9 | ||
|
1ad1c69d3e | ||
|
fac2b50bfc | ||
|
4d08ecc694 | ||
|
5edc99cdfe | ||
|
603e42b907 | ||
|
d51603a382 |
53
.eslintrc.js
53
.eslintrc.js
@@ -8,27 +8,28 @@ const a11yOff = Object.keys(require("eslint-plugin-jsx-a11y").rules).reduce(
|
||||
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true
|
||||
browser: true,
|
||||
},
|
||||
extends: [
|
||||
"airbnb",
|
||||
"airbnb/hooks",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier",
|
||||
"plugin:prettier/recommended"
|
||||
"plugin:prettier/recommended",
|
||||
],
|
||||
ignorePatterns: ["public/*", "dist/*", "/*.js", "/*.ts"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
tsconfigRootDir: "./"
|
||||
tsconfigRootDir: "./",
|
||||
},
|
||||
settings: {
|
||||
"import/resolver": {
|
||||
typescript: {}
|
||||
}
|
||||
typescript: {
|
||||
project: "./tsconfig.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: ["@typescript-eslint", "import"],
|
||||
plugins: ["@typescript-eslint", "import", "prettier"],
|
||||
rules: {
|
||||
"react/jsx-uses-react": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
@@ -54,16 +55,44 @@ module.exports = {
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
||||
"react/jsx-filename-extension": [
|
||||
"error",
|
||||
{ extensions: [".js", ".tsx", ".jsx"] }
|
||||
{ extensions: [".js", ".tsx", ".jsx"] },
|
||||
],
|
||||
"import/extensions": [
|
||||
"error",
|
||||
"ignorePackages",
|
||||
{
|
||||
ts: "never",
|
||||
tsx: "never"
|
||||
}
|
||||
tsx: "never",
|
||||
},
|
||||
],
|
||||
...a11yOff
|
||||
}
|
||||
"import/order": [
|
||||
"error",
|
||||
{
|
||||
groups: [
|
||||
"builtin",
|
||||
"external",
|
||||
"internal",
|
||||
["sibling", "parent"],
|
||||
"index",
|
||||
"unknown",
|
||||
],
|
||||
"newlines-between": "always",
|
||||
alphabetize: {
|
||||
order: "asc",
|
||||
caseInsensitive: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
"sort-imports": [
|
||||
"error",
|
||||
{
|
||||
ignoreCase: false,
|
||||
ignoreDeclarationSort: true,
|
||||
ignoreMemberSort: false,
|
||||
memberSyntaxSortOrder: ["none", "all", "multiple", "single"],
|
||||
allowSeparatedGroups: true,
|
||||
},
|
||||
],
|
||||
...a11yOff,
|
||||
},
|
||||
};
|
||||
|
@@ -8,7 +8,7 @@
|
||||
<a href="https://discord.gg/vXsRvye8BS"><img src="https://discordapp.com/api/guilds/871713465100816424/widget.png?style=banner2" alt="Discord Server"></a>
|
||||
</p>
|
||||
|
||||
movie-web is a web app for watching movies easily. Check it out at **[movie.squeezebox.dev](https://movie.squeezebox.dev)**.
|
||||
movie-web is a web app for watching movies easily. Check it out at **[movie-web.app](https://movie-web.app)**.
|
||||
|
||||
This service works by displaying video files from third-party providers inside an intuitive and aesthetic user interface.
|
||||
|
||||
|
@@ -29,6 +29,9 @@
|
||||
<!-- prevent darkreader extension from messing with our already dark site -->
|
||||
<meta name="darkreader-lock" />
|
||||
|
||||
<!-- disabling referrer can fix some provider problems -->
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
|
||||
<title>movie-web</title>
|
||||
</head>
|
||||
<body>
|
||||
|
11
package.json
11
package.json
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"name": "movie-web",
|
||||
"version": "3.0.8",
|
||||
"version": "3.1.0",
|
||||
"private": true,
|
||||
"homepage": "https://movie.squeezebox.dev",
|
||||
"homepage": "https://movie-web.app",
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^1.0.0-beta.5",
|
||||
"@headlessui/react": "^1.5.0",
|
||||
"@react-spring/web": "^9.7.1",
|
||||
"@sentry/integrations": "^7.49.0",
|
||||
"@sentry/react": "^7.49.0",
|
||||
"@use-gesture/react": "^10.2.24",
|
||||
"core-js": "^3.29.1",
|
||||
"crypto-js": "^4.1.1",
|
||||
@@ -19,7 +21,6 @@
|
||||
"json5": "^2.2.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"nanoid": "^4.0.0",
|
||||
"node-webvtt": "^1.9.4",
|
||||
"ofetch": "^1.0.0",
|
||||
"pako": "^2.1.0",
|
||||
"react": "^17.0.2",
|
||||
@@ -31,7 +32,7 @@
|
||||
"react-stickynode": "^4.1.0",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-use": "^17.4.0",
|
||||
"srt-webvtt": "^2.0.0",
|
||||
"subsrt-ts": "^2.1.1",
|
||||
"unpacker": "^1.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -81,7 +82,7 @@
|
||||
"eslint-config-airbnb": "19.0.4",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
"eslint-import-resolver-typescript": "^2.5.0",
|
||||
"eslint-plugin-import": "^2.25.4",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "7.29.4",
|
||||
|
27
src/@types/node_webtt.d.ts
vendored
27
src/@types/node_webtt.d.ts
vendored
@@ -1,27 +0,0 @@
|
||||
declare module "node-webvtt" {
|
||||
interface Cue {
|
||||
identifier: string;
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
styles: string;
|
||||
}
|
||||
interface Options {
|
||||
meta?: boolean;
|
||||
strict?: boolean;
|
||||
}
|
||||
type ParserError = Error;
|
||||
interface ParseResult {
|
||||
valid: boolean;
|
||||
strict: boolean;
|
||||
cues: Cue[];
|
||||
errors: ParserError[];
|
||||
meta?: Map<string, string>;
|
||||
}
|
||||
interface Segment {
|
||||
duration: number;
|
||||
cues: Cue[];
|
||||
}
|
||||
function parse(text: string, options: Options): ParseResult;
|
||||
function segment(input: string, segmentLength?: number): Segment[];
|
||||
}
|
@@ -1,9 +1,10 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import "@/backend";
|
||||
import { getProviders } from "@/backend/helpers/register";
|
||||
import { MWMediaType } from "@/backend/metadata/types";
|
||||
import { runProvider } from "@/backend/helpers/run";
|
||||
import { testData } from "@/__tests__/providers/testdata";
|
||||
import { getProviders } from "@/backend/helpers/register";
|
||||
import { runProvider } from "@/backend/helpers/run";
|
||||
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||
|
||||
describe("providers", () => {
|
||||
const providers = getProviders();
|
||||
|
@@ -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[] = [
|
||||
{
|
||||
|
152
src/__tests__/subtitles/subtitles.test.ts
Normal file
152
src/__tests__/subtitles/subtitles.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import {
|
||||
getMWCaptionTypeFromUrl,
|
||||
isSupportedSubtitle,
|
||||
parseSubtitles,
|
||||
} from "@/backend/helpers/captions";
|
||||
import { MWCaptionType } from "@/backend/helpers/streams";
|
||||
|
||||
import {
|
||||
ass,
|
||||
multilineSubtitlesTestVtt,
|
||||
srt,
|
||||
visibleSubtitlesTestVtt,
|
||||
vtt,
|
||||
} from "./testdata";
|
||||
|
||||
describe("subtitles", () => {
|
||||
it("should return true if given url ends with a known subtitle type", ({
|
||||
expect,
|
||||
}) => {
|
||||
expect(isSupportedSubtitle("https://example.com/test.srt")).toBe(true);
|
||||
expect(isSupportedSubtitle("https://example.com/test.vtt")).toBe(true);
|
||||
expect(isSupportedSubtitle("https://example.com/test.txt")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return corresponding MWCaptionType", ({ expect }) => {
|
||||
expect(getMWCaptionTypeFromUrl("https://example.com/test.srt")).toBe(
|
||||
MWCaptionType.SRT
|
||||
);
|
||||
expect(getMWCaptionTypeFromUrl("https://example.com/test.vtt")).toBe(
|
||||
MWCaptionType.VTT
|
||||
);
|
||||
expect(getMWCaptionTypeFromUrl("https://example.com/test.txt")).toBe(
|
||||
MWCaptionType.UNKNOWN
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw when empty text is given", ({ expect }) => {
|
||||
expect(() => parseSubtitles("")).toThrow("Given text is empty");
|
||||
});
|
||||
|
||||
it("should parse srt", ({ expect }) => {
|
||||
const parsed = parseSubtitles(srt);
|
||||
const parsedSrt = [
|
||||
{
|
||||
type: "caption",
|
||||
index: 1,
|
||||
start: 0,
|
||||
end: 0,
|
||||
duration: 0,
|
||||
content: "Test",
|
||||
text: "Test",
|
||||
},
|
||||
{
|
||||
type: "caption",
|
||||
index: 2,
|
||||
start: 0,
|
||||
end: 0,
|
||||
duration: 0,
|
||||
content: "Test",
|
||||
text: "Test",
|
||||
},
|
||||
];
|
||||
expect(parsed).toHaveLength(2);
|
||||
expect(parsed).toEqual(parsedSrt);
|
||||
});
|
||||
|
||||
it("should parse vtt", ({ expect }) => {
|
||||
const parsed = parseSubtitles(vtt);
|
||||
const parsedVtt = [
|
||||
{
|
||||
type: "caption",
|
||||
index: 1,
|
||||
start: 0,
|
||||
end: 4000,
|
||||
duration: 4000,
|
||||
content: "Where did he go?",
|
||||
text: "Where did he go?",
|
||||
},
|
||||
{
|
||||
type: "caption",
|
||||
index: 2,
|
||||
start: 3000,
|
||||
end: 6500,
|
||||
duration: 3500,
|
||||
content: "I think he went down this lane.",
|
||||
text: "I think he went down this lane.",
|
||||
},
|
||||
{
|
||||
type: "caption",
|
||||
index: 3,
|
||||
start: 4000,
|
||||
end: 6500,
|
||||
duration: 2500,
|
||||
content: "What are you waiting for?",
|
||||
text: "What are you waiting for?",
|
||||
},
|
||||
];
|
||||
expect(parsed).toHaveLength(3);
|
||||
expect(parsed).toEqual(parsedVtt);
|
||||
});
|
||||
|
||||
it("should parse ass", ({ expect }) => {
|
||||
const parsed = parseSubtitles(ass);
|
||||
expect(parsed).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should delay subtitles when given a delay", ({ expect }) => {
|
||||
const videoTime = 11;
|
||||
let delayedSeconds = 0;
|
||||
const parsed = parseSubtitles(visibleSubtitlesTestVtt);
|
||||
const isVisible = (start: number, end: number, delay: number): boolean => {
|
||||
const delayedStart = start / 1000 + delay;
|
||||
const delayedEnd = end / 1000 + delay;
|
||||
return (
|
||||
Math.max(0, delayedStart) <= videoTime &&
|
||||
Math.max(0, delayedEnd) >= videoTime
|
||||
);
|
||||
};
|
||||
const visibleSubtitles = parsed.filter((c) =>
|
||||
isVisible(c.start, c.end, delayedSeconds)
|
||||
);
|
||||
expect(visibleSubtitles).toHaveLength(1);
|
||||
|
||||
delayedSeconds = 10;
|
||||
const delayedVisibleSubtitles = parsed.filter((c) =>
|
||||
isVisible(c.start, c.end, delayedSeconds)
|
||||
);
|
||||
expect(delayedVisibleSubtitles).toHaveLength(1);
|
||||
|
||||
delayedSeconds = -10;
|
||||
const delayedVisibleSubtitles2 = parsed.filter((c) =>
|
||||
isVisible(c.start, c.end, delayedSeconds)
|
||||
);
|
||||
expect(delayedVisibleSubtitles2).toHaveLength(1);
|
||||
|
||||
delayedSeconds = -20;
|
||||
const delayedVisibleSubtitles3 = parsed.filter((c) =>
|
||||
isVisible(c.start, c.end, delayedSeconds)
|
||||
);
|
||||
expect(delayedVisibleSubtitles3).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should parse multiline captions", ({ expect }) => {
|
||||
const parsed = parseSubtitles(multilineSubtitlesTestVtt);
|
||||
|
||||
expect(parsed[0].text).toBe(`- Test 1\n- Test 2\n- Test 3`);
|
||||
expect(parsed[1].text).toBe(`- Test 4`);
|
||||
expect(parsed[2].text).toBe(`- Test 6`);
|
||||
});
|
||||
});
|
68
src/__tests__/subtitles/testdata.ts
Normal file
68
src/__tests__/subtitles/testdata.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
const srt = `
|
||||
1
|
||||
00:00:00,000 --> 00:00:00,000
|
||||
Test
|
||||
|
||||
2
|
||||
00:00:00,000 --> 00:00:00,000
|
||||
Test
|
||||
`;
|
||||
const vtt = `
|
||||
WEBVTT
|
||||
|
||||
00:00:00.000 --> 00:00:04.000 position:10%,line-left align:left size:35%
|
||||
Where did he go?
|
||||
|
||||
00:00:03.000 --> 00:00:06.500 position:90% align:right size:35%
|
||||
I think he went down this lane.
|
||||
|
||||
00:00:04.000 --> 00:00:06.500 position:45%,line-right align:center size:35%
|
||||
What are you waiting for?
|
||||
`;
|
||||
const ass = `[Script Info]
|
||||
; Generated by Ebby.co
|
||||
Title:
|
||||
Original Script:
|
||||
ScriptType: v4.00+
|
||||
Collisions: Normal
|
||||
PlayResX: 384
|
||||
PlayResY: 288
|
||||
PlayDepth: 0
|
||||
Timer: 100.0
|
||||
WrapStyle: 0
|
||||
|
||||
[v4+ Styles]
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
Style: Default, Arial, 16, &H00FFFFFF, &H00000000, &H00000000, &H00000000, 0, 0, 0, 0, 100, 100, 0, 0, 1, 1, 0, 2, 15, 15, 15, 0
|
||||
|
||||
[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
Dialogue: 0,0:00:10.00,0:00:20.00,Default,,0000,0000,0000,,This is the first subtitle.
|
||||
Dialogue: 0,0:00:30.00,0:00:34.00,Default,,0000,0000,0000,,This is the second.
|
||||
Dialogue: 0,0:00:34.00,0:00:35.00,Default,,0000,0000,0000,,Third`;
|
||||
|
||||
const visibleSubtitlesTestVtt = `WEBVTT
|
||||
|
||||
00:00:00.000 --> 00:00:10.000 position:10%,line-left align:left size:35%
|
||||
Test 1
|
||||
|
||||
00:00:10.000 --> 00:00:20.000 position:90% align:right size:35%
|
||||
Test 2
|
||||
|
||||
00:00:20.000 --> 00:00:31.000 position:45%,line-right align:center size:35%
|
||||
Test 3
|
||||
`;
|
||||
|
||||
const multilineSubtitlesTestVtt = `WEBVTT
|
||||
|
||||
00:00:00.000 --> 00:00:10.000
|
||||
- Test 1\n- Test 2\n- Test 3
|
||||
|
||||
00:00:10.000 --> 00:00:20.000
|
||||
- Test 4
|
||||
|
||||
00:00:20.000 --> 00:00:31.000
|
||||
- Test 6
|
||||
`;
|
||||
|
||||
export { vtt, srt, ass, visibleSubtitlesTestVtt, multilineSubtitlesTestVtt };
|
32
src/backend/embeds/mp4upload.ts
Normal file
32
src/backend/embeds/mp4upload.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
});
|
@@ -1,11 +1,11 @@
|
||||
import { MWEmbedType } from "@/backend/helpers/embed";
|
||||
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||
import { registerEmbedScraper } from "@/backend/helpers/register";
|
||||
import {
|
||||
MWEmbedStream,
|
||||
MWStreamQuality,
|
||||
MWStreamType,
|
||||
MWEmbedStream,
|
||||
} from "@/backend/helpers/streams";
|
||||
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||
|
||||
const HOST = "streamm4u.club";
|
||||
const URL_BASE = `https://${HOST}`;
|
||||
|
211
src/backend/embeds/streamsb.ts
Normal file
211
src/backend/embeds/streamsb.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
});
|
93
src/backend/embeds/upcloud.ts
Normal file
93
src/backend/embeds/upcloud.ts
Normal 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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
@@ -1,48 +1,45 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { convert, detect, list, parse } from "subsrt-ts";
|
||||
import { ContentCaption } from "subsrt-ts/dist/types/handler";
|
||||
|
||||
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
|
||||
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
|
||||
import toWebVTT from "srt-webvtt";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
export const sanitize = DOMPurify.sanitize;
|
||||
export const CUSTOM_CAPTION_ID = "customCaption";
|
||||
export async function getCaptionUrl(caption: MWCaption): Promise<string> {
|
||||
if (caption.type === MWCaptionType.SRT) {
|
||||
let captionBlob: Blob;
|
||||
|
||||
if (caption.needsProxy) {
|
||||
captionBlob = await proxiedFetch<Blob>(caption.url, {
|
||||
responseType: "blob" as any,
|
||||
});
|
||||
} else {
|
||||
captionBlob = await mwFetch<Blob>(caption.url, {
|
||||
responseType: "blob" as any,
|
||||
});
|
||||
}
|
||||
|
||||
return toWebVTT(captionBlob);
|
||||
}
|
||||
|
||||
if (caption.type === MWCaptionType.VTT) {
|
||||
if (caption.needsProxy) {
|
||||
const blob = await proxiedFetch<Blob>(caption.url, {
|
||||
responseType: "blob" as any,
|
||||
});
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
return caption.url;
|
||||
}
|
||||
|
||||
throw new Error("invalid type");
|
||||
export const customCaption = "external-custom";
|
||||
export function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
|
||||
return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`;
|
||||
}
|
||||
export const subtitleTypeList = list().map((type) => `.${type}`);
|
||||
export function isSupportedSubtitle(url: string): boolean {
|
||||
return subtitleTypeList.some((type) => url.endsWith(type));
|
||||
}
|
||||
|
||||
export async function convertCustomCaptionFileToWebVTT(file: File) {
|
||||
const header = await file.slice(0, 6).text();
|
||||
const isWebVTT = header === "WEBVTT";
|
||||
if (!isWebVTT) {
|
||||
return toWebVTT(file);
|
||||
export function getMWCaptionTypeFromUrl(url: string): MWCaptionType {
|
||||
if (!isSupportedSubtitle(url)) return MWCaptionType.UNKNOWN;
|
||||
const type = subtitleTypeList.find((t) => url.endsWith(t));
|
||||
if (!type) return MWCaptionType.UNKNOWN;
|
||||
return type.slice(1) as MWCaptionType;
|
||||
}
|
||||
|
||||
export const sanitize = DOMPurify.sanitize;
|
||||
export async function getCaptionUrl(caption: MWCaption): Promise<string> {
|
||||
let captionBlob: Blob;
|
||||
if (caption.url.startsWith("blob:")) {
|
||||
// custom subtitle
|
||||
captionBlob = await (await fetch(caption.url)).blob();
|
||||
} else if (caption.needsProxy) {
|
||||
captionBlob = await proxiedFetch<Blob>(caption.url, {
|
||||
responseType: "blob" as any,
|
||||
});
|
||||
} else {
|
||||
captionBlob = await mwFetch<Blob>(caption.url, {
|
||||
responseType: "blob" as any,
|
||||
});
|
||||
}
|
||||
return URL.createObjectURL(file);
|
||||
// convert to vtt for track element source which will be used in PiP mode
|
||||
const text = await captionBlob.text();
|
||||
const vtt = convert(text, "vtt");
|
||||
return URL.createObjectURL(new Blob([vtt], { type: "text/vtt" }));
|
||||
}
|
||||
|
||||
export function revokeCaptionBlob(url: string | undefined) {
|
||||
@@ -50,3 +47,16 @@ export function revokeCaptionBlob(url: string | undefined) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSubtitles(text: string): ContentCaption[] {
|
||||
const textTrimmed = text.trim();
|
||||
if (textTrimmed === "") {
|
||||
throw new Error("Given text is empty");
|
||||
}
|
||||
if (detect(textTrimmed) === "") {
|
||||
throw new Error("Invalid subtitle format");
|
||||
}
|
||||
return parse(textTrimmed).filter(
|
||||
(cue) => cue.type === "caption"
|
||||
) as ContentCaption[];
|
||||
}
|
||||
|
@@ -4,6 +4,9 @@ export enum MWEmbedType {
|
||||
M4UFREE = "m4ufree",
|
||||
STREAMM4U = "streamm4u",
|
||||
PLAYM4U = "playm4u",
|
||||
UPCLOUD = "upcloud",
|
||||
STREAMSB = "streamsb",
|
||||
MP4UPLOAD = "mp4upload",
|
||||
}
|
||||
|
||||
export type MWEmbed = {
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { FetchOptions, FetchResponse, ofetch } from "ofetch";
|
||||
|
||||
import { conf } from "@/setup/config";
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
let proxyUrlIndex = Math.floor(Math.random() * conf().PROXY_URLS.length);
|
||||
|
||||
@@ -58,3 +59,36 @@ export function proxiedFetch<T>(url: string, ops: P<T>[1] = {}): R<T> {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function rawProxiedFetch<T>(
|
||||
url: string,
|
||||
ops: FetchOptions = {}
|
||||
): Promise<FetchResponse<T>> {
|
||||
let combinedUrl = ops?.baseURL ?? "";
|
||||
if (
|
||||
combinedUrl.length > 0 &&
|
||||
combinedUrl.endsWith("/") &&
|
||||
url.startsWith("/")
|
||||
)
|
||||
combinedUrl += url.slice(1);
|
||||
else if (
|
||||
combinedUrl.length > 0 &&
|
||||
!combinedUrl.endsWith("/") &&
|
||||
!url.startsWith("/")
|
||||
)
|
||||
combinedUrl += `/${url}`;
|
||||
else combinedUrl += url;
|
||||
|
||||
const parsedUrl = new URL(combinedUrl);
|
||||
Object.entries(ops?.params ?? {}).forEach(([k, v]) => {
|
||||
parsedUrl.searchParams.set(k, v);
|
||||
});
|
||||
|
||||
return baseFetch.raw(getProxyUrl(), {
|
||||
...ops,
|
||||
baseURL: undefined,
|
||||
params: {
|
||||
destination: parsedUrl.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { DetailedMeta } from "../metadata/getmeta";
|
||||
import { MWMediaType } from "../metadata/types";
|
||||
import { MWEmbed } from "./embed";
|
||||
import { MWStream } from "./streams";
|
||||
import { DetailedMeta } from "../metadata/getmeta";
|
||||
import { MWMediaType } from "../metadata/types/mw";
|
||||
|
||||
export type MWProviderScrapeResult = {
|
||||
stream?: MWStream;
|
||||
|
@@ -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";
|
||||
|
@@ -3,9 +3,17 @@ export enum MWStreamType {
|
||||
HLS = "hls",
|
||||
}
|
||||
|
||||
// subsrt-ts supported types
|
||||
export enum MWCaptionType {
|
||||
VTT = "vtt",
|
||||
SRT = "srt",
|
||||
LRC = "lrc",
|
||||
SBV = "sbv",
|
||||
SUB = "sub",
|
||||
SSA = "ssa",
|
||||
ASS = "ass",
|
||||
JSON = "json",
|
||||
UNKNOWN = "unknown",
|
||||
}
|
||||
|
||||
export enum MWStreamQuality {
|
||||
|
@@ -1,14 +1,24 @@
|
||||
import { initializeScraperStore } from "./helpers/register";
|
||||
|
||||
// providers
|
||||
import "./providers/gdriveplayer";
|
||||
// import "./providers/gdriveplayer";
|
||||
import "./providers/flixhq";
|
||||
import "./providers/superstream";
|
||||
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();
|
||||
|
@@ -1,13 +1,29 @@
|
||||
import { FetchError } from "ofetch";
|
||||
import { makeUrl, proxiedFetch } from "../helpers/fetch";
|
||||
|
||||
import { formatJWMeta, mediaTypeToJW } from "./justwatch";
|
||||
import {
|
||||
TMDBMediaToMediaType,
|
||||
formatTMDBMeta,
|
||||
getEpisodes,
|
||||
getExternalIds,
|
||||
getMediaDetails,
|
||||
getMediaPoster,
|
||||
getMovieFromExternalId,
|
||||
mediaTypeToTMDB,
|
||||
} from "./tmdb";
|
||||
import {
|
||||
formatJWMeta,
|
||||
JWMediaResult,
|
||||
JWSeasonMetaResult,
|
||||
JW_API_BASE,
|
||||
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 =
|
||||
| "eidr"
|
||||
@@ -28,14 +44,96 @@ interface JWDetailedMeta extends JWMediaResult {
|
||||
|
||||
export interface DetailedMeta {
|
||||
meta: MWMediaMeta;
|
||||
tmdbId: string;
|
||||
imdbId: string;
|
||||
imdbId?: string;
|
||||
tmdbId?: string;
|
||||
}
|
||||
|
||||
export function formatTMDBMetaResult(
|
||||
details: TMDBShowData | TMDBMovieData,
|
||||
type: MWMediaType
|
||||
): TMDBMediaResult {
|
||||
if (type === MWMediaType.MOVIE) {
|
||||
const movie = details as TMDBMovieData;
|
||||
return {
|
||||
id: details.id,
|
||||
title: movie.title,
|
||||
object_type: mediaTypeToTMDB(type),
|
||||
poster: getMediaPoster(movie.poster_path) ?? undefined,
|
||||
original_release_year: new Date(movie.release_date).getFullYear(),
|
||||
};
|
||||
}
|
||||
if (type === MWMediaType.SERIES) {
|
||||
const show = details as TMDBShowData;
|
||||
return {
|
||||
id: details.id,
|
||||
title: show.name,
|
||||
object_type: mediaTypeToTMDB(type),
|
||||
seasons: show.seasons.map((v) => ({
|
||||
id: v.id,
|
||||
season_number: v.season_number,
|
||||
title: v.name,
|
||||
})),
|
||||
poster: (details as TMDBMovieData).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);
|
||||
|
||||
@@ -66,8 +164,6 @@ export async function getMetaFromId(
|
||||
if (!tmdbId)
|
||||
tmdbId = data.external_ids.find((v) => v.provider === "tmdb")?.external_id;
|
||||
|
||||
if (!imdbId || !tmdbId) throw new Error("not enough info");
|
||||
|
||||
let seasonData: JWSeasonMetaResult | undefined;
|
||||
if (data.object_type === "show") {
|
||||
const seasonToScrape = seasonId ?? data.seasons?.[0].id.toString() ?? "";
|
||||
@@ -83,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}`;
|
||||
}
|
||||
}
|
||||
|
@@ -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";
|
||||
|
@@ -1,13 +1,12 @@
|
||||
import { SimpleCache } from "@/utils/cache";
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
|
||||
import {
|
||||
formatJWMeta,
|
||||
JWContentTypes,
|
||||
JWMediaResult,
|
||||
JW_API_BASE,
|
||||
mediaTypeToJW,
|
||||
} from "./justwatch";
|
||||
import { MWMediaMeta, MWQuery } from "./types";
|
||||
formatTMDBMeta,
|
||||
formatTMDBSearchResult,
|
||||
mediaTypeToTMDB,
|
||||
searchMedia,
|
||||
} from "./tmdb";
|
||||
import { MWMediaMeta, MWQuery } from "./types/mw";
|
||||
|
||||
const cache = new SimpleCache<MWQuery, MWMediaMeta[]>();
|
||||
cache.setCompare((a, b) => {
|
||||
@@ -15,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;
|
||||
}
|
||||
|
239
src/backend/metadata/tmdb.ts
Normal file
239
src/backend/metadata/tmdb.ts
Normal 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_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,
|
||||
};
|
||||
}
|
48
src/backend/metadata/types/justwatch.ts
Normal file
48
src/backend/metadata/types/justwatch.ts
Normal 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[];
|
||||
};
|
@@ -45,3 +45,9 @@ export interface MWQuery {
|
||||
searchQuery: string;
|
||||
type: MWMediaType;
|
||||
}
|
||||
|
||||
export interface DetailedMeta {
|
||||
meta: MWMediaMeta;
|
||||
imdbId?: string;
|
||||
tmdbId?: string;
|
||||
}
|
308
src/backend/metadata/types/tmdb.ts
Normal file
308
src/backend/metadata/types/tmdb.ts
Normal 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[];
|
||||
}
|
252
src/backend/providers/2embed.ts
Normal file
252
src/backend/providers/2embed.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import Base64 from "crypto-js/enc-base64";
|
||||
import Utf8 from "crypto-js/enc-utf8";
|
||||
|
||||
import { proxiedFetch, rawProxiedFetch } from "../helpers/fetch";
|
||||
import { registerProvider } from "../helpers/register";
|
||||
import {
|
||||
MWCaptionType,
|
||||
MWStreamQuality,
|
||||
MWStreamType,
|
||||
} from "../helpers/streams";
|
||||
import { MWMediaType } from "../metadata/types/mw";
|
||||
|
||||
const twoEmbedBase = "https://www.2embed.to";
|
||||
|
||||
async function fetchCaptchaToken(recaptchaKey: string) {
|
||||
const domainHash = Base64.stringify(Utf8.parse(twoEmbedBase)).replace(
|
||||
/=/g,
|
||||
"."
|
||||
);
|
||||
|
||||
const recaptchaRender = await proxiedFetch<any>(
|
||||
`https://www.google.com/recaptcha/api.js?render=${recaptchaKey}`
|
||||
);
|
||||
|
||||
const vToken = recaptchaRender.substring(
|
||||
recaptchaRender.indexOf("/releases/") + 10,
|
||||
recaptchaRender.indexOf("/recaptcha__en.js")
|
||||
);
|
||||
|
||||
const recaptchaAnchor = await proxiedFetch<any>(
|
||||
`https://www.google.com/recaptcha/api2/anchor?ar=1&hl=en&size=invisible&cb=flicklax&k=${recaptchaKey}&co=${domainHash}&v=${vToken}`
|
||||
);
|
||||
|
||||
const cToken = new DOMParser()
|
||||
.parseFromString(recaptchaAnchor, "text/html")
|
||||
.getElementById("recaptcha-token")
|
||||
?.getAttribute("value");
|
||||
|
||||
if (!cToken) throw new Error("Unable to find cToken");
|
||||
|
||||
const payload = {
|
||||
v: vToken,
|
||||
reason: "q",
|
||||
k: recaptchaKey,
|
||||
c: cToken,
|
||||
sa: "",
|
||||
co: twoEmbedBase,
|
||||
};
|
||||
|
||||
const tokenData = await proxiedFetch<string>(
|
||||
`https://www.google.com/recaptcha/api2/reload?${new URLSearchParams(
|
||||
payload
|
||||
).toString()}`,
|
||||
{
|
||||
headers: { referer: "https://www.google.com/recaptcha/api2/" },
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
|
||||
const token = tokenData.match('rresp","(.+?)"');
|
||||
return token ? token[1] : null;
|
||||
}
|
||||
|
||||
interface IEmbedRes {
|
||||
link: string;
|
||||
sources: [];
|
||||
tracks: [];
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface IStreamData {
|
||||
status: string;
|
||||
message: string;
|
||||
type: string;
|
||||
token: string;
|
||||
result:
|
||||
| {
|
||||
Original: {
|
||||
label: string;
|
||||
file: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
label: string;
|
||||
size: number;
|
||||
url: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface ISubtitles {
|
||||
url: string;
|
||||
lang: string;
|
||||
}
|
||||
|
||||
async function fetchStream(sourceId: string, captchaToken: string) {
|
||||
const embedRes = await proxiedFetch<IEmbedRes>(
|
||||
`${twoEmbedBase}/ajax/embed/play?id=${sourceId}&_token=${captchaToken}`,
|
||||
{
|
||||
headers: {
|
||||
Referer: twoEmbedBase,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Link format: https://rabbitstream.net/embed-4/{data-id}?z=
|
||||
const rabbitStreamUrl = new URL(embedRes.link);
|
||||
|
||||
const dataPath = rabbitStreamUrl.pathname.split("/");
|
||||
const dataId = dataPath[dataPath.length - 1];
|
||||
|
||||
// https://rabbitstream.net/embed/m-download/{data-id}
|
||||
const download = await proxiedFetch<any>(
|
||||
`${rabbitStreamUrl.origin}/embed/m-download/${dataId}`,
|
||||
{
|
||||
headers: {
|
||||
referer: twoEmbedBase,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const downloadPage = new DOMParser().parseFromString(download, "text/html");
|
||||
|
||||
const streamlareEl = Array.from(
|
||||
downloadPage.querySelectorAll(".dls-brand")
|
||||
).find((el) => el.textContent?.trim() === "Streamlare");
|
||||
if (!streamlareEl) throw new Error("Unable to find streamlare element");
|
||||
|
||||
const streamlareUrl =
|
||||
streamlareEl.nextElementSibling?.querySelector("a")?.href;
|
||||
if (!streamlareUrl) throw new Error("Unable to parse streamlare url");
|
||||
|
||||
const subtitles: ISubtitles[] = [];
|
||||
const subtitlesDropdown = downloadPage.querySelectorAll(
|
||||
"#user_menu .dropdown-item"
|
||||
);
|
||||
subtitlesDropdown.forEach((item) => {
|
||||
const url = item.getAttribute("href");
|
||||
const lang = item.textContent?.trim().replace("Download", "").trim();
|
||||
if (url && lang) subtitles.push({ url, lang });
|
||||
});
|
||||
|
||||
const streamlare = await proxiedFetch<any>(streamlareUrl);
|
||||
|
||||
const streamlarePage = new DOMParser().parseFromString(
|
||||
streamlare,
|
||||
"text/html"
|
||||
);
|
||||
|
||||
const csrfToken = streamlarePage
|
||||
.querySelector("head > meta:nth-child(3)")
|
||||
?.getAttribute("content");
|
||||
|
||||
if (!csrfToken) throw new Error("Unable to find CSRF token");
|
||||
|
||||
const videoId = streamlareUrl.match("/[ve]/([^?#&/]+)")?.[1];
|
||||
if (!videoId) throw new Error("Unable to get streamlare video id");
|
||||
|
||||
const streamRes = await proxiedFetch<IStreamData>(
|
||||
`${new URL(streamlareUrl).origin}/api/video/download/get`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
id: videoId,
|
||||
}),
|
||||
headers: {
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"X-CSRF-Token": csrfToken,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (streamRes.message !== "OK") throw new Error("Unable to fetch stream");
|
||||
|
||||
const streamData = Array.isArray(streamRes.result)
|
||||
? streamRes.result[0]
|
||||
: streamRes.result.Original;
|
||||
if (!streamData) throw new Error("Unable to get stream data");
|
||||
|
||||
const followStream = await rawProxiedFetch(streamData.url, {
|
||||
method: "HEAD",
|
||||
referrer: new URL(streamlareUrl).origin,
|
||||
});
|
||||
|
||||
const finalStreamUrl = followStream.headers.get("X-Final-Destination");
|
||||
return { url: finalStreamUrl, subtitles };
|
||||
}
|
||||
|
||||
registerProvider({
|
||||
id: "2embed",
|
||||
displayName: "2Embed",
|
||||
rank: 125,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
disabled: true, // Disabled, not working
|
||||
async scrape({ media, episode, progress }) {
|
||||
let embedUrl = `${twoEmbedBase}/embed/tmdb/movie?id=${media.tmdbId}`;
|
||||
|
||||
if (media.meta.type === MWMediaType.SERIES) {
|
||||
const seasonNumber = media.meta.seasonData.number;
|
||||
const episodeNumber = media.meta.seasonData.episodes.find(
|
||||
(e) => e.id === episode
|
||||
)?.number;
|
||||
|
||||
embedUrl = `${twoEmbedBase}/embed/tmdb/tv?id=${media.tmdbId}&s=${seasonNumber}&e=${episodeNumber}`;
|
||||
}
|
||||
|
||||
const embed = await proxiedFetch<any>(embedUrl);
|
||||
progress(20);
|
||||
|
||||
const embedPage = new DOMParser().parseFromString(embed, "text/html");
|
||||
|
||||
const pageServerItems = Array.from(
|
||||
embedPage.querySelectorAll(".item-server")
|
||||
);
|
||||
const pageStreamItem = pageServerItems.find((item) =>
|
||||
item.textContent?.includes("Vidcloud")
|
||||
);
|
||||
|
||||
const sourceId = pageStreamItem
|
||||
? pageStreamItem.getAttribute("data-id")
|
||||
: null;
|
||||
if (!sourceId) throw new Error("Unable to get source id");
|
||||
|
||||
const siteKey = embedPage
|
||||
.querySelector("body")
|
||||
?.getAttribute("data-recaptcha-key");
|
||||
if (!siteKey) throw new Error("Unable to get site key");
|
||||
|
||||
const captchaToken = await fetchCaptchaToken(siteKey);
|
||||
if (!captchaToken) throw new Error("Unable to fetch captcha token");
|
||||
progress(35);
|
||||
|
||||
const stream = await fetchStream(sourceId, captchaToken);
|
||||
if (!stream.url) throw new Error("Unable to find stream url");
|
||||
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: stream.url,
|
||||
quality: MWStreamQuality.QUNKNOWN,
|
||||
type: MWStreamType.MP4,
|
||||
captions: stream.subtitles.map((sub) => {
|
||||
return {
|
||||
langIso: sub.lang,
|
||||
url: `https://cc.2cdns.com${new URL(sub.url).pathname}`,
|
||||
type: MWCaptionType.VTT,
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
@@ -1,42 +1,38 @@
|
||||
import { compareTitle } from "@/utils/titleMatch";
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
import { registerProvider } from "../helpers/register";
|
||||
|
||||
import {
|
||||
MWCaptionType,
|
||||
MWStreamQuality,
|
||||
MWStreamType,
|
||||
} from "../helpers/streams";
|
||||
import { MWMediaType } from "../metadata/types";
|
||||
getMWCaptionTypeFromUrl,
|
||||
isSupportedSubtitle,
|
||||
} from "../helpers/captions";
|
||||
import { mwFetch } from "../helpers/fetch";
|
||||
import { registerProvider } from "../helpers/register";
|
||||
import { MWCaption, MWStreamQuality, MWStreamType } from "../helpers/streams";
|
||||
import { MWMediaType } from "../metadata/types/mw";
|
||||
|
||||
// const flixHqBase = "https://api.consumet.org/movies/flixhq";
|
||||
// *** TEMPORARY FIX - use other instance
|
||||
// SEE ISSUE: https://github.com/consumet/api.consumet.org/issues/326
|
||||
const flixHqBase = "https://c.delusionz.xyz/movies/flixhq";
|
||||
const flixHqBase = "https://consumet-api-clone.vercel.app/meta/tmdb"; // instance stolen from streaminal :)
|
||||
|
||||
type FlixHQMediaType = "Movie" | "TV Series";
|
||||
interface FLIXMediaBase {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
image: string;
|
||||
type: "Movie" | "TV Series";
|
||||
}
|
||||
|
||||
interface FLIXTVSerie extends FLIXMediaBase {
|
||||
seasons: number | null;
|
||||
}
|
||||
|
||||
interface FLIXMovie extends FLIXMediaBase {
|
||||
type: FlixHQMediaType;
|
||||
releaseDate: string;
|
||||
}
|
||||
|
||||
function castSubtitles({ url, lang }: { url: string; lang: string }) {
|
||||
interface FLIXSubType {
|
||||
url: string;
|
||||
lang: string;
|
||||
}
|
||||
function convertSubtitles({ url, lang }: FLIXSubType): MWCaption | null {
|
||||
if (lang.includes("(maybe)")) return null;
|
||||
const supported = isSupportedSubtitle(url);
|
||||
if (!supported) return null;
|
||||
const type = getMWCaptionTypeFromUrl(url);
|
||||
return {
|
||||
url,
|
||||
langIso: lang,
|
||||
type:
|
||||
url.substring(url.length - 3) === "vtt"
|
||||
? MWCaptionType.VTT
|
||||
: MWCaptionType.SRT,
|
||||
type,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,18 +44,22 @@ const qualityMap: Record<string, MWStreamQuality> = {
|
||||
"1080": MWStreamQuality.Q1080P,
|
||||
};
|
||||
|
||||
function flixTypeToMWType(type: FlixHQMediaType) {
|
||||
if (type === "Movie") return MWMediaType.MOVIE;
|
||||
return MWMediaType.SERIES;
|
||||
}
|
||||
|
||||
registerProvider({
|
||||
id: "flixhq",
|
||||
displayName: "FlixHQ",
|
||||
rank: 100,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
|
||||
async scrape({ media, episode, progress }) {
|
||||
if (!this.type.includes(media.meta.type)) {
|
||||
throw new Error("Unsupported type");
|
||||
}
|
||||
// search for relevant item
|
||||
const searchResults = await proxiedFetch<any>(
|
||||
const searchResults = await mwFetch<any>(
|
||||
`/${encodeURIComponent(media.meta.title)}`,
|
||||
{
|
||||
baseURL: flixHqBase,
|
||||
@@ -67,60 +67,46 @@ registerProvider({
|
||||
);
|
||||
|
||||
const foundItem = searchResults.results.find((v: FLIXMediaBase) => {
|
||||
if (media.meta.type === MWMediaType.MOVIE) {
|
||||
if (v.type !== "Movie") return false;
|
||||
const movie = v as FLIXMovie;
|
||||
return (
|
||||
compareTitle(movie.title, media.meta.title) &&
|
||||
movie.releaseDate === media.meta.year
|
||||
);
|
||||
}
|
||||
if (media.meta.type === MWMediaType.SERIES) {
|
||||
if (v.type !== "TV Series") return false;
|
||||
const serie = v as FLIXTVSerie;
|
||||
if (serie.seasons && media.meta.seasons) {
|
||||
return (
|
||||
compareTitle(serie.title, media.meta.title) &&
|
||||
serie.seasons === media.meta.seasons.length
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
if (v.type !== "Movie" && v.type !== "TV Series") return false;
|
||||
return (
|
||||
compareTitle(v.title, media.meta.title) &&
|
||||
flixTypeToMWType(v.type) === media.meta.type &&
|
||||
v.releaseDate === media.meta.year
|
||||
);
|
||||
});
|
||||
|
||||
if (!foundItem) throw new Error("No watchable item found");
|
||||
const flixId = foundItem.id;
|
||||
|
||||
// get media info
|
||||
progress(25);
|
||||
const mediaInfo = await proxiedFetch<any>("/info", {
|
||||
const mediaInfo = await mwFetch<any>(`/info/${foundItem.id}`, {
|
||||
baseURL: flixHqBase,
|
||||
params: {
|
||||
id: flixId,
|
||||
type: flixTypeToMWType(foundItem.type),
|
||||
},
|
||||
});
|
||||
if (!mediaInfo.episodes) throw new Error("No watchable item found");
|
||||
if (!mediaInfo.id) throw new Error("No watchable item found");
|
||||
// get stream info from media
|
||||
progress(75);
|
||||
progress(50);
|
||||
|
||||
// By default we assume it is a movie
|
||||
let episodeId: string | undefined = mediaInfo.episodes[0].id;
|
||||
if (media.meta.type === MWMediaType.SERIES) {
|
||||
let episodeId: string | undefined;
|
||||
if (media.meta.type === MWMediaType.MOVIE) {
|
||||
episodeId = mediaInfo.episodeId;
|
||||
} else if (media.meta.type === MWMediaType.SERIES) {
|
||||
const seasonNo = media.meta.seasonData.number;
|
||||
const episodeNo = media.meta.seasonData.episodes.find(
|
||||
(e) => e.id === episode
|
||||
)?.number;
|
||||
episodeId = mediaInfo.episodes.find(
|
||||
(e: any) => e.season === seasonNo && e.number === episodeNo
|
||||
)?.id;
|
||||
|
||||
const season = mediaInfo.seasons.find((o: any) => o.season === seasonNo);
|
||||
episodeId = season.episodes.find((o: any) => o.episode === episodeNo).id;
|
||||
}
|
||||
if (!episodeId) throw new Error("No watchable item found");
|
||||
|
||||
const watchInfo = await proxiedFetch<any>("/watch", {
|
||||
progress(75);
|
||||
const watchInfo = await mwFetch<any>(`/watch/${episodeId}`, {
|
||||
baseURL: flixHqBase,
|
||||
params: {
|
||||
episodeId,
|
||||
mediaId: flixId,
|
||||
id: mediaInfo.id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -135,11 +121,7 @@ registerProvider({
|
||||
streamUrl: source.url,
|
||||
quality: qualityMap[source.quality],
|
||||
type: source.isM3U8 ? MWStreamType.HLS : MWStreamType.MP4,
|
||||
captions: watchInfo.subtitles
|
||||
.filter(
|
||||
(x: { url: string; lang: string }) => !x.lang.includes("(maybe)")
|
||||
)
|
||||
.map(castSubtitles),
|
||||
captions: watchInfo.subtitles.map(convertSubtitles).filter(Boolean),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
@@ -1,9 +1,10 @@
|
||||
import { unpack } from "unpacker";
|
||||
import CryptoJS from "crypto-js";
|
||||
import { unpack } from "unpacker";
|
||||
|
||||
import { registerProvider } from "@/backend/helpers/register";
|
||||
import { MWMediaType } from "@/backend/metadata/types";
|
||||
import { MWStreamQuality } from "@/backend/helpers/streams";
|
||||
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
|
||||
const format = {
|
||||
@@ -40,6 +41,7 @@ registerProvider({
|
||||
type: [MWMediaType.MOVIE],
|
||||
|
||||
async scrape({ progress, media: { imdbId } }) {
|
||||
if (!imdbId) throw new Error("not enough info");
|
||||
progress(10);
|
||||
const streamRes = await proxiedFetch<string>(
|
||||
"https://database.gdriveplayer.us/player.php",
|
||||
|
162
src/backend/providers/gomovies.ts
Normal file
162
src/backend/providers/gomovies.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
197
src/backend/providers/hdwatched.ts
Normal file
197
src/backend/providers/hdwatched.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
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/mw";
|
||||
|
||||
const hdwatchedBase = "https://www.hdwatched.xyz";
|
||||
|
||||
const qualityMap: Record<number, MWStreamQuality> = {
|
||||
360: MWStreamQuality.Q360P,
|
||||
540: MWStreamQuality.Q540P,
|
||||
480: MWStreamQuality.Q480P,
|
||||
720: MWStreamQuality.Q720P,
|
||||
1080: MWStreamQuality.Q1080P,
|
||||
};
|
||||
|
||||
interface SearchRes {
|
||||
title: string;
|
||||
year?: number;
|
||||
href: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
function getStreamFromEmbed(stream: string) {
|
||||
const embedPage = new DOMParser().parseFromString(stream, "text/html");
|
||||
const source = embedPage.querySelector("#vjsplayer > source");
|
||||
if (!source) {
|
||||
throw new Error("Unable to fetch stream");
|
||||
}
|
||||
|
||||
const streamSrc = source.getAttribute("src");
|
||||
const streamRes = source.getAttribute("res");
|
||||
|
||||
if (!streamSrc || !streamRes) throw new Error("Unable to find stream");
|
||||
|
||||
return {
|
||||
streamUrl: streamSrc,
|
||||
quality:
|
||||
streamRes && typeof +streamRes === "number"
|
||||
? qualityMap[+streamRes]
|
||||
: MWStreamQuality.QUNKNOWN,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchMovie(targetSource: SearchRes) {
|
||||
const stream = await proxiedFetch<any>(`/embed/${targetSource.id}`, {
|
||||
baseURL: hdwatchedBase,
|
||||
});
|
||||
|
||||
const embedPage = new DOMParser().parseFromString(stream, "text/html");
|
||||
const source = embedPage.querySelector("#vjsplayer > source");
|
||||
if (!source) {
|
||||
throw new Error("Unable to fetch movie stream");
|
||||
}
|
||||
|
||||
return getStreamFromEmbed(stream);
|
||||
}
|
||||
|
||||
async function fetchSeries(
|
||||
targetSource: SearchRes,
|
||||
{ media, episode, progress }: MWProviderContext
|
||||
) {
|
||||
if (media.meta.type !== MWMediaType.SERIES)
|
||||
throw new Error("Media type mismatch");
|
||||
|
||||
const seasonNumber = media.meta.seasonData.number;
|
||||
const episodeNumber = media.meta.seasonData.episodes.find(
|
||||
(e) => e.id === episode
|
||||
)?.number;
|
||||
|
||||
if (!seasonNumber || !episodeNumber)
|
||||
throw new Error("Unable to get season or episode number");
|
||||
|
||||
const seriesPage = await proxiedFetch<any>(
|
||||
`${targetSource.href}?season=${media.meta.seasonData.number}`,
|
||||
{
|
||||
baseURL: hdwatchedBase,
|
||||
}
|
||||
);
|
||||
|
||||
const seasonPage = new DOMParser().parseFromString(seriesPage, "text/html");
|
||||
const pageElements = seasonPage.querySelectorAll("div.i-container");
|
||||
|
||||
const seriesList: SearchRes[] = [];
|
||||
pageElements.forEach((pageElement) => {
|
||||
const href = pageElement.querySelector("a")?.getAttribute("href") || "";
|
||||
const title =
|
||||
pageElement?.querySelector("span.content-title")?.textContent || "";
|
||||
|
||||
seriesList.push({
|
||||
title,
|
||||
href,
|
||||
id: href.split("/")[2], // Format: /free/{id}/{series-slug}-season-{season-number}-episode-{episode-number}
|
||||
});
|
||||
});
|
||||
|
||||
const targetEpisode = seriesList.find(
|
||||
(episodeEl) =>
|
||||
episodeEl.title.trim().toLowerCase() === `episode ${episodeNumber}`
|
||||
);
|
||||
|
||||
if (!targetEpisode) throw new Error("Unable to find episode");
|
||||
|
||||
progress(70);
|
||||
|
||||
const stream = await proxiedFetch<any>(`/embed/${targetEpisode.id}`, {
|
||||
baseURL: hdwatchedBase,
|
||||
});
|
||||
|
||||
const embedPage = new DOMParser().parseFromString(stream, "text/html");
|
||||
const source = embedPage.querySelector("#vjsplayer > source");
|
||||
if (!source) {
|
||||
throw new Error("Unable to fetch movie stream");
|
||||
}
|
||||
|
||||
return getStreamFromEmbed(stream);
|
||||
}
|
||||
|
||||
registerProvider({
|
||||
id: "hdwatched",
|
||||
displayName: "HDwatched",
|
||||
rank: 150,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
async scrape(options) {
|
||||
const { media, progress } = options;
|
||||
if (!media.imdbId) throw new Error("not enough info");
|
||||
if (!this.type.includes(media.meta.type)) {
|
||||
throw new Error("Unsupported type");
|
||||
}
|
||||
|
||||
const search = await proxiedFetch<any>(`/search/${media.imdbId}`, {
|
||||
baseURL: hdwatchedBase,
|
||||
});
|
||||
|
||||
const searchPage = new DOMParser().parseFromString(search, "text/html");
|
||||
const pageElements = searchPage.querySelectorAll("div.i-container");
|
||||
|
||||
const searchList: SearchRes[] = [];
|
||||
pageElements.forEach((pageElement) => {
|
||||
const href = pageElement.querySelector("a")?.getAttribute("href") || "";
|
||||
const title =
|
||||
pageElement?.querySelector("span.content-title")?.textContent || "";
|
||||
const year =
|
||||
parseInt(
|
||||
pageElement
|
||||
?.querySelector("div.duration")
|
||||
?.textContent?.trim()
|
||||
?.split(" ")
|
||||
?.pop() || "",
|
||||
10
|
||||
) || 0;
|
||||
|
||||
searchList.push({
|
||||
title,
|
||||
year,
|
||||
href,
|
||||
id: href.split("/")[2], // Format: /free/{id}/{movie-slug} or /series/{id}/{series-slug}
|
||||
});
|
||||
});
|
||||
|
||||
progress(20);
|
||||
|
||||
const targetSource = searchList.find(
|
||||
(source) => source.year === (media.meta.year ? +media.meta.year : 0) // Compare year to make the search more robust
|
||||
);
|
||||
|
||||
if (!targetSource) {
|
||||
throw new Error("Could not find stream");
|
||||
}
|
||||
|
||||
progress(40);
|
||||
|
||||
if (media.meta.type === MWMediaType.SERIES) {
|
||||
const series = await fetchSeries(targetSource, options);
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: series.streamUrl,
|
||||
quality: series.quality,
|
||||
type: MWStreamType.MP4,
|
||||
captions: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const movie = await fetchMovie(targetSource);
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: movie.streamUrl,
|
||||
quality: movie.quality,
|
||||
type: MWStreamType.MP4,
|
||||
captions: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
119
src/backend/providers/kissasian.ts
Normal file
119
src/backend/providers/kissasian.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
});
|
@@ -1,7 +1,8 @@
|
||||
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}`;
|
||||
|
@@ -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";
|
||||
|
||||
@@ -22,6 +22,7 @@ registerProvider({
|
||||
displayName: "NetFilm",
|
||||
rank: 15,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
disabled: true, // The creator has asked us (very nicely) to leave him alone. Until (if) we self-host, netfilm should remain disabled
|
||||
|
||||
async scrape({ media, episode, progress }) {
|
||||
if (!this.type.includes(media.meta.type)) {
|
||||
|
49
src/backend/providers/remotestream.ts
Normal file
49
src/backend/providers/remotestream.ts
Normal 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: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
99
src/backend/providers/sflix.ts
Normal file
99
src/backend/providers/sflix.ts
Normal 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: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
70
src/backend/providers/streamflix.ts
Normal file
70
src/backend/providers/streamflix.ts
Normal 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,
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
@@ -1,15 +1,19 @@
|
||||
import { registerProvider } from "@/backend/helpers/register";
|
||||
import { MWMediaType } from "@/backend/metadata/types";
|
||||
|
||||
import { customAlphabet } from "nanoid";
|
||||
import CryptoJS from "crypto-js";
|
||||
import { customAlphabet } from "nanoid";
|
||||
|
||||
import {
|
||||
getMWCaptionTypeFromUrl,
|
||||
isSupportedSubtitle,
|
||||
} from "@/backend/helpers/captions";
|
||||
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||
import { registerProvider } from "@/backend/helpers/register";
|
||||
import {
|
||||
MWCaption,
|
||||
MWCaptionType,
|
||||
MWStreamQuality,
|
||||
MWStreamType,
|
||||
} from "@/backend/helpers/streams";
|
||||
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||
import { compareTitle } from "@/utils/titleMatch";
|
||||
|
||||
const nanoid = customAlphabet("0123456789abcdef", 32);
|
||||
@@ -111,10 +115,34 @@ const getBestQuality = (list: any[]) => {
|
||||
);
|
||||
};
|
||||
|
||||
const convertSubtitles = (subtitleGroup: any): MWCaption | null => {
|
||||
let subtitles = subtitleGroup.subtitles;
|
||||
subtitles = subtitles
|
||||
.map((subFile: any) => {
|
||||
const supported = isSupportedSubtitle(subFile.file_path);
|
||||
if (!supported) return null;
|
||||
const type = getMWCaptionTypeFromUrl(subFile.file_path);
|
||||
return {
|
||||
...subFile,
|
||||
type: type as MWCaptionType,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (subtitles.length === 0) return null;
|
||||
const subFile = subtitles[0];
|
||||
return {
|
||||
needsProxy: true,
|
||||
langIso: subtitleGroup.language,
|
||||
url: subFile.file_path,
|
||||
type: subFile.type,
|
||||
};
|
||||
};
|
||||
|
||||
registerProvider({
|
||||
id: "superstream",
|
||||
displayName: "Superstream",
|
||||
rank: 200,
|
||||
rank: 300,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
|
||||
async scrape({ media, episode, progress }) {
|
||||
@@ -164,16 +192,9 @@ registerProvider({
|
||||
|
||||
const subtitleRes = (await get(subtitleApiQuery)).data;
|
||||
|
||||
const mappedCaptions = subtitleRes.list.map(
|
||||
(subtitle: any): MWCaption => {
|
||||
return {
|
||||
needsProxy: true,
|
||||
langIso: subtitle.language,
|
||||
url: subtitle.subtitles[0].file_path,
|
||||
type: MWCaptionType.SRT,
|
||||
};
|
||||
}
|
||||
);
|
||||
const mappedCaptions = subtitleRes.list
|
||||
.map(convertSubtitles)
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
embeds: [],
|
||||
@@ -224,16 +245,9 @@ registerProvider({
|
||||
};
|
||||
|
||||
const subtitleRes = (await get(subtitleApiQuery)).data;
|
||||
|
||||
const mappedCaptions = subtitleRes.list.map((subtitle: any): MWCaption => {
|
||||
return {
|
||||
needsProxy: true,
|
||||
langIso: subtitle.language,
|
||||
url: subtitle.subtitles[0].file_path,
|
||||
type: MWCaptionType.SRT,
|
||||
};
|
||||
});
|
||||
|
||||
const mappedCaptions = subtitleRes.list
|
||||
.map(convertSubtitles)
|
||||
.filter(Boolean);
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
interface Props {
|
||||
icon?: Icons;
|
||||
onClick?: () => void;
|
||||
|
30
src/components/CaptionColorSelector.tsx
Normal file
30
src/components/CaptionColorSelector.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useSettings } from "@/state/settings";
|
||||
|
||||
import { Icon, Icons } from "./Icon";
|
||||
|
||||
export const colors = ["#ffffff", "#00ffff", "#ffff00"];
|
||||
export default function CaptionColorSelector({ color }: { color: string }) {
|
||||
const { captionSettings, setCaptionColor } = useSettings();
|
||||
return (
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded transition-[background-color,transform] duration-100 hover:bg-[#1c161b79] active:scale-110 ${
|
||||
color === captionSettings.style.color ? "bg-[#1C161B]" : ""
|
||||
}`}
|
||||
onClick={() => setCaptionColor(color)}
|
||||
>
|
||||
<div
|
||||
className="h-4 w-4 cursor-pointer appearance-none rounded-full"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
<Icon
|
||||
className={[
|
||||
"absolute text-xs text-[#1C161B]",
|
||||
color === captionSettings.style.color ? "" : "hidden",
|
||||
].join(" ")}
|
||||
icon={Icons.CHECKMARK}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,6 +1,6 @@
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import React, { Fragment } from "react";
|
||||
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
export interface OptionItem {
|
||||
@@ -37,7 +37,7 @@ export function Dropdown(props: DropdownProps) {
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute bottom-11 left-0 right-0 z-10 mt-1 max-h-60 overflow-auto rounded-md bg-denim-500 py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-denim-400 scrollbar-thumb-denim-200 focus:outline-none sm:bottom-10 sm:text-sm">
|
||||
<Listbox.Options className="absolute left-0 right-0 top-10 z-10 mt-1 max-h-60 overflow-auto rounded-md bg-denim-500 py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-denim-400 scrollbar-thumb-denim-200 focus:outline-none sm:top-10 sm:text-sm">
|
||||
{props.options.map((opt) => (
|
||||
<Listbox.Option
|
||||
className={({ active }) =>
|
||||
|
@@ -40,6 +40,7 @@ export enum Icons {
|
||||
WATCH_PARTY = "watch_party",
|
||||
PICTURE_IN_PICTURE = "pictureInPicture",
|
||||
CHECKMARK = "checkmark",
|
||||
TACHOMETER = "tachometer",
|
||||
}
|
||||
|
||||
export interface IconProps {
|
||||
@@ -87,6 +88,7 @@ const iconList: Record<Icons, string> = {
|
||||
watch_party: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M319.4 372c48.5-31.3 80.6-85.9 80.6-148c0-97.2-78.8-176-176-176S48 126.8 48 224c0 62.1 32.1 116.6 80.6 148c1.2 17.3 4 38 7.2 57.1l.2 1C56 395.8 0 316.5 0 224C0 100.3 100.3 0 224 0S448 100.3 448 224c0 92.5-56 171.9-136 206.1l.2-1.1c3.1-19.2 6-39.8 7.2-57zm-2.3-38.1c-1.6-5.7-3.9-11.1-7-16.2c-5.8-9.7-13.5-17-21.9-22.4c19.5-17.6 31.8-43 31.8-71.3c0-53-43-96-96-96s-96 43-96 96c0 28.3 12.3 53.8 31.8 71.3c-8.4 5.4-16.1 12.7-21.9 22.4c-3.1 5.1-5.4 10.5-7 16.2C99.8 307.5 80 268 80 224c0-79.5 64.5-144 144-144s144 64.5 144 144c0 44-19.8 83.5-50.9 109.9zM224 312c32.9 0 64 8.6 64 43.8c0 33-12.9 104.1-20.6 132.9c-5.1 19-24.5 23.4-43.4 23.4s-38.2-4.4-43.4-23.4c-7.8-28.5-20.6-99.7-20.6-132.8c0-35.1 31.1-43.8 64-43.8zm0-144a56 56 0 1 1 0 112 56 56 0 1 1 0-112z"/></svg>`,
|
||||
pictureInPicture: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 1.98 2 1.98h18c1.1 0 2-.88 2-1.98V5c0-1.1-.9-2-2-2zm0 16.01H3V4.98h18v14.03z"/></svg>`,
|
||||
checkmark: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 24 24"><path d="M9 22l-10-10.598 2.798-2.859 7.149 7.473 13.144-14.016 2.909 2.806z" /></svg>`,
|
||||
tachometer: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 576 512"><!-- Font Awesome Pro 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M128 288c-17.67 0-32 14.33-32 32s14.33 32 32 32 32-14.33 32-32-14.33-32-32-32zm154.65-97.08l16.24-48.71c1.16-3.45 3.18-6.35 4.92-9.43-4.73-2.76-9.94-4.78-15.81-4.78-17.67 0-32 14.33-32 32 0 15.78 11.63 28.29 26.65 30.92zM176 176c-17.67 0-32 14.33-32 32s14.33 32 32 32 32-14.33 32-32-14.33-32-32-32zM288 32C128.94 32 0 160.94 0 320c0 52.8 14.25 102.26 39.06 144.8 5.61 9.62 16.3 15.2 27.44 15.2h443c11.14 0 21.83-5.58 27.44-15.2C561.75 422.26 576 372.8 576 320c0-159.06-128.94-288-288-288zm212.27 400H75.73C57.56 397.63 48 359.12 48 320 48 187.66 155.66 80 288 80s240 107.66 240 240c0 39.12-9.56 77.63-27.73 112zM416 320c0 17.67 14.33 32 32 32s32-14.33 32-32-14.33-32-32-32-32 14.33-32 32zm-56.41-182.77c-12.72-4.23-26.16 2.62-30.38 15.17l-45.34 136.01C250.49 290.58 224 318.06 224 352c0 11.72 3.38 22.55 8.88 32h110.25c5.5-9.45 8.88-20.28 8.88-32 0-19.45-8.86-36.66-22.55-48.4l45.34-136.01c4.17-12.57-2.64-26.17-15.21-30.36zM432 208c0-15.8-11.66-28.33-26.72-30.93-.07.21-.07.43-.14.65l-19.5 58.49c4.37 2.24 9.11 3.8 14.36 3.8 17.67-.01 32-14.34 32-32.01z"/></svg>`,
|
||||
};
|
||||
|
||||
function ChromeCastButton() {
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { Transition } from "@/components/Transition";
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
import { Transition } from "@/components/Transition";
|
||||
|
||||
export function Overlay(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
|
@@ -1,6 +1,8 @@
|
||||
import { MWMediaType, MWQuery } from "@/backend/metadata/types";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { MWMediaType, MWQuery } from "@/backend/metadata/types/mw";
|
||||
|
||||
import { DropdownButton } from "./buttons/DropdownButton";
|
||||
import { Icon, Icons } from "./Icon";
|
||||
import { TextInputControl } from "./text-inputs/TextInputControl";
|
||||
@@ -38,7 +40,7 @@ export function SearchBarInput(props: SearchBarProps) {
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col rounded-[28px] bg-denim-400 transition-colors focus-within:bg-denim-400 hover:bg-denim-500 sm:flex-row sm:items-center">
|
||||
<div className="pointer-events-none absolute left-5 top-0 bottom-0 flex max-h-14 items-center">
|
||||
<div className="pointer-events-none absolute bottom-0 left-5 top-0 flex max-h-14 items-center">
|
||||
<Icon icon={Icons.SEARCH} />
|
||||
</div>
|
||||
|
||||
@@ -50,7 +52,7 @@ export function SearchBarInput(props: SearchBarProps) {
|
||||
placeholder={props.placeholder}
|
||||
/>
|
||||
|
||||
<div className="px-4 py-4 pt-0 sm:py-2 sm:px-2">
|
||||
<div className="px-4 py-4 pt-0 sm:px-2 sm:py-2">
|
||||
<DropdownButton
|
||||
icon={Icons.SEARCH}
|
||||
open={dropdownOpen}
|
||||
|
47
src/components/Slider.tsx
Normal file
47
src/components/Slider.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ChangeEventHandler, useEffect, useRef } from "react";
|
||||
|
||||
export type SliderProps = {
|
||||
label?: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
value?: number;
|
||||
valueDisplay?: string;
|
||||
onChange: ChangeEventHandler<HTMLInputElement>;
|
||||
};
|
||||
|
||||
export function Slider(props: SliderProps) {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
const e = ref.current as HTMLInputElement;
|
||||
e.style.setProperty("--value", e.value);
|
||||
e.style.setProperty("--min", e.min === "" ? "0" : e.min);
|
||||
e.style.setProperty("--max", e.max === "" ? "100" : e.max);
|
||||
e.addEventListener("input", () => e.style.setProperty("--value", e.value));
|
||||
}, [ref]);
|
||||
|
||||
return (
|
||||
<div className="mb-6 flex flex-row gap-4">
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{props.label ? (
|
||||
<label className="font-bold">{props.label}</label>
|
||||
) : null}
|
||||
<input
|
||||
type="range"
|
||||
ref={ref}
|
||||
className="styled-slider slider-progress mt-[20px]"
|
||||
onChange={props.onChange}
|
||||
value={props.value}
|
||||
max={props.max}
|
||||
min={props.min}
|
||||
step={props.step}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 aspect-[2/1] h-8 rounded-sm bg-[#1C161B] pt-1">
|
||||
<div className="text-center font-bold text-white">
|
||||
{props.valueDisplay ?? props.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,8 +1,8 @@
|
||||
import { Fragment, ReactNode } from "react";
|
||||
import {
|
||||
Transition as HeadlessTransition,
|
||||
TransitionClasses,
|
||||
} from "@headlessui/react";
|
||||
import { Fragment, ReactNode } from "react";
|
||||
|
||||
type TransitionAnimations =
|
||||
| "slide-down"
|
||||
|
@@ -4,10 +4,11 @@ import React, {
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { BackdropContainer, useBackdrop } from "@/components/layout/Backdrop";
|
||||
import { ButtonControlProps, ButtonControl } from "./ButtonControl";
|
||||
|
||||
import { ButtonControl, ButtonControlProps } from "./ButtonControl";
|
||||
|
||||
export interface OptionItem {
|
||||
id: string;
|
||||
|
@@ -1,7 +1,9 @@
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
import { ButtonControl } from "./ButtonControl";
|
||||
|
||||
export interface EditButtonProps {
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { ButtonControlProps, ButtonControl } from "./ButtonControl";
|
||||
|
||||
import { ButtonControl, ButtonControlProps } from "./ButtonControl";
|
||||
|
||||
export interface IconButtonProps extends ButtonControlProps {
|
||||
icon: Icons;
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import React, { createRef, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { useFade } from "@/hooks/useFade";
|
||||
|
||||
interface BackdropProps {
|
||||
@@ -99,7 +100,7 @@ export function BackdropContainer(
|
||||
return (
|
||||
<div ref={root}>
|
||||
{createPortal(
|
||||
<div className="pointer-events-none fixed top-0 left-0 z-[999]">
|
||||
<div className="pointer-events-none fixed left-0 top-0 z-[999]">
|
||||
<Backdrop active={props.active} {...props} />
|
||||
<div ref={copy} className="pointer-events-auto absolute">
|
||||
{props.children}
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
export function BrandPill(props: {
|
||||
|
@@ -1,10 +1,11 @@
|
||||
import { Component } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { Link } from "@/components/text/Link";
|
||||
import { Title } from "@/components/text/Title";
|
||||
import { conf } from "@/setup/config";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
interface ErrorShowcaseProps {
|
||||
error: {
|
||||
|
@@ -1,8 +1,9 @@
|
||||
import { Overlay } from "@/components/Overlay";
|
||||
import { Transition } from "@/components/Transition";
|
||||
import { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { Overlay } from "@/components/Overlay";
|
||||
import { Transition } from "@/components/Transition";
|
||||
|
||||
interface Props {
|
||||
show: boolean;
|
||||
children?: ReactNode;
|
||||
@@ -35,9 +36,14 @@ export function Modal(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalCard(props: { children?: ReactNode }) {
|
||||
export function ModalCard(props: { className?: string; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="relative mx-2 max-w-[600px] overflow-hidden rounded-lg bg-denim-200 px-10 py-10">
|
||||
<div
|
||||
className={[
|
||||
"relative mx-2 w-[500px] overflow-hidden rounded-lg bg-denim-300 px-10 py-10 sm:w-[500px] md:w-[500px] lg:w-[1000px]",
|
||||
props.className ?? "",
|
||||
].join(" ")}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
|
@@ -1,9 +1,12 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { conf } from "@/setup/config";
|
||||
import { useBannerSize } from "@/hooks/useBanner";
|
||||
import { conf } from "@/setup/config";
|
||||
import SettingsModal from "@/views/SettingsModal";
|
||||
|
||||
import { BrandPill } from "./BrandPill";
|
||||
|
||||
export interface NavigationProps {
|
||||
@@ -13,7 +16,7 @@ export interface NavigationProps {
|
||||
|
||||
export function Navigation(props: NavigationProps) {
|
||||
const bannerHeight = useBannerSize();
|
||||
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
return (
|
||||
<div
|
||||
className="fixed left-0 right-0 top-0 z-20 min-h-[150px] bg-gradient-to-b from-denim-300 via-denim-300 to-transparent sm:from-transparent"
|
||||
@@ -21,7 +24,7 @@ export function Navigation(props: NavigationProps) {
|
||||
top: `${bannerHeight}px`,
|
||||
}}
|
||||
>
|
||||
<div className="fixed left-0 right-0 flex items-center justify-between py-5 px-7">
|
||||
<div className="fixed left-0 right-0 flex items-center justify-between px-7 py-5">
|
||||
<div
|
||||
className={`${
|
||||
props.bg ? "opacity-100" : "opacity-0"
|
||||
@@ -42,6 +45,14 @@ export function Navigation(props: NavigationProps) {
|
||||
props.children ? "hidden sm:flex" : "flex"
|
||||
} relative flex-row gap-4`}
|
||||
>
|
||||
<IconPatch
|
||||
className="text-2xl text-white"
|
||||
icon={Icons.GEAR}
|
||||
clickable
|
||||
onClick={() => {
|
||||
setShowModal(true);
|
||||
}}
|
||||
/>
|
||||
<a
|
||||
href={conf().DISCORD_LINK}
|
||||
target="_blank"
|
||||
@@ -60,6 +71,7 @@ export function Navigation(props: NavigationProps) {
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsModal show={showModal} onClose={() => setShowModal(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
interface SectionHeadingProps {
|
||||
|
@@ -9,12 +9,12 @@ export function Episode(props: EpisodeProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={props.onClick}
|
||||
className={`transition-[background-color, transform, box-shadow] relative mr-3 mb-3 inline-flex h-10 w-10 cursor-pointer select-none items-center justify-center overflow-hidden rounded bg-denim-500 font-bold text-white hover:bg-denim-400 active:scale-110 ${
|
||||
className={`transition-[background-color, transform, box-shadow] relative mb-3 mr-3 inline-flex h-10 w-10 cursor-pointer select-none items-center justify-center overflow-hidden rounded bg-denim-500 font-bold text-white hover:bg-denim-400 active:scale-110 ${
|
||||
props.active ? "shadow-[inset_0_0_0_2px] shadow-bink-500" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="absolute bottom-0 top-0 left-0 bg-bink-500 bg-opacity-50"
|
||||
className="absolute bottom-0 left-0 top-0 bg-bink-500 bg-opacity-50"
|
||||
style={{
|
||||
width: `${props.progress || 0}%`,
|
||||
}}
|
||||
|
@@ -1,17 +1,19 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { TMDBMediaToId } from "@/backend/metadata/getmeta";
|
||||
import { MWMediaMeta } from "@/backend/metadata/types/mw";
|
||||
import { DotList } from "@/components/text/DotList";
|
||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||
import { JWMediaToId } from "@/backend/metadata/justwatch";
|
||||
import { Icons } from "../Icon";
|
||||
|
||||
import { IconPatch } from "../buttons/IconPatch";
|
||||
import { Icons } from "../Icon";
|
||||
|
||||
export interface MediaCardProps {
|
||||
media: MWMediaMeta;
|
||||
linkable?: boolean;
|
||||
series?: {
|
||||
episode: number;
|
||||
season: number;
|
||||
season?: number;
|
||||
episodeId: string;
|
||||
seasonId: string;
|
||||
};
|
||||
@@ -59,7 +61,7 @@ function MediaCardContent({
|
||||
{series ? (
|
||||
<div
|
||||
className={[
|
||||
"absolute right-2 top-2 rounded-md bg-denim-200 py-1 px-2 transition-colors",
|
||||
"absolute right-2 top-2 rounded-md bg-denim-200 px-2 py-1 transition-colors",
|
||||
closable ? "" : "group-hover:bg-denim-500",
|
||||
].join(" ")}
|
||||
>
|
||||
@@ -70,7 +72,7 @@ function MediaCardContent({
|
||||
].join(" ")}
|
||||
>
|
||||
{t("seasons.seasonAndEpisode", {
|
||||
season: series.season,
|
||||
season: series.season || 1,
|
||||
episode: series.episode,
|
||||
})}
|
||||
</p>
|
||||
@@ -130,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 (
|
||||
|
@@ -1,6 +1,8 @@
|
||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||
import { useWatchedContext } from "@/state/watched";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { MWMediaMeta } from "@/backend/metadata/types/mw";
|
||||
import { useWatchedContext } from "@/state/watched";
|
||||
|
||||
import { MediaCard } from "./MediaCard";
|
||||
|
||||
export interface WatchedMediaCardProps {
|
||||
|
@@ -1,11 +1,14 @@
|
||||
import { animated, easings, useSpringValue } from "@react-spring/web";
|
||||
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FloatingCardAnchorPosition } from "@/components/popout/positions/FloatingCardAnchorPosition";
|
||||
import { FloatingCardMobilePosition } from "@/components/popout/positions/FloatingCardMobilePosition";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { PopoutSection } from "@/video/components/popouts/PopoutUtils";
|
||||
import { useSpringValue, animated, easings } from "@react-spring/web";
|
||||
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Icon, Icons } from "../Icon";
|
||||
|
||||
import { FloatingDragHandle, MobilePopoutSpacer } from "./FloatingDragHandle";
|
||||
import { Icon, Icons } from "../Icon";
|
||||
|
||||
interface FloatingCardProps {
|
||||
children?: ReactNode;
|
||||
@@ -133,13 +136,15 @@ export const FloatingCardView = {
|
||||
action?: React.ReactNode;
|
||||
backText?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
let left = (
|
||||
<div
|
||||
onClick={props.goBack}
|
||||
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
|
||||
>
|
||||
<Icon icon={Icons.ARROW_LEFT} />
|
||||
<span>{props.backText || "Go back"}</span>
|
||||
<span>{props.backText || t("videoPlayer.popouts.back")}</span>
|
||||
</div>
|
||||
);
|
||||
if (props.close)
|
||||
@@ -149,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>
|
||||
);
|
||||
|
||||
@@ -162,7 +167,7 @@ export const FloatingCardView = {
|
||||
<div>{props.action ?? null}</div>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-8 mb-2 text-3xl font-bold text-white">
|
||||
<h2 className="mb-2 mt-8 text-3xl font-bold text-white">
|
||||
{props.title}
|
||||
</h2>
|
||||
<p>{props.description}</p>
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import { Transition } from "@/components/Transition";
|
||||
import React, {
|
||||
ReactNode,
|
||||
useCallback,
|
||||
@@ -8,6 +7,8 @@ import React, {
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { Transition } from "@/components/Transition";
|
||||
|
||||
interface Props {
|
||||
children?: ReactNode;
|
||||
onClose?: () => void;
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { Transition } from "@/components/Transition";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children?: ReactNode;
|
||||
@@ -29,6 +30,7 @@ export function FloatingView(props: Props) {
|
||||
data-floating-page={props.show ? "true" : undefined}
|
||||
style={{
|
||||
height: props.height ? `${props.height}px` : undefined,
|
||||
maxHeight: "70vh",
|
||||
width: props.width ? width : undefined,
|
||||
}}
|
||||
>
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { createFloatingAnchorEvent } from "@/components/popout/FloatingAnchor";
|
||||
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { createFloatingAnchorEvent } from "@/components/popout/FloatingAnchor";
|
||||
|
||||
interface AnchorPositionProps {
|
||||
children?: ReactNode;
|
||||
id: string;
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { useSpring, animated, config } from "@react-spring/web";
|
||||
import { animated, config, useSpring } from "@react-spring/web";
|
||||
import { useDrag } from "@use-gesture/react";
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
|
||||
@@ -21,8 +21,20 @@ export function FloatingCardMobilePosition(props: MobilePositionProps) {
|
||||
}));
|
||||
|
||||
const bind = useDrag(
|
||||
({ last, velocity: [, vy], direction: [, dy], movement: [, my] }) => {
|
||||
({
|
||||
last,
|
||||
velocity: [, vy],
|
||||
direction: [, dy],
|
||||
movement: [, my],
|
||||
...event
|
||||
}) => {
|
||||
if (closing.current) return;
|
||||
|
||||
const isInScrollable = (event.target as HTMLDivElement).closest(
|
||||
".overflow-y-auto"
|
||||
);
|
||||
if (isInScrollable) return; // Don't attempt to swipe the thing away if it's a scroll area unless the scroll area is at the top and the user is swiping down
|
||||
|
||||
const height = cardRect?.height ?? 0;
|
||||
if (last) {
|
||||
// if past half height downwards
|
||||
@@ -69,7 +81,7 @@ export function FloatingCardMobilePosition(props: MobilePositionProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-x-0 mx-auto max-w-[400px] origin-bottom-left touch-none"
|
||||
className="is-mobile-view absolute inset-x-0 mx-auto max-w-[400px] origin-bottom-left touch-none"
|
||||
style={{
|
||||
transform: `translateY(${
|
||||
window.innerHeight - (cardRect?.height ?? 0) + 200
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { Link as LinkRouter } from "react-router-dom";
|
||||
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
interface IArrowLinkPropsBase {
|
||||
|
@@ -1,12 +1,12 @@
|
||||
import {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useState,
|
||||
useMemo,
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useMeasure } from "react-use";
|
||||
|
||||
|
@@ -1,8 +1,9 @@
|
||||
/// <reference types="chromecast-caf-sender"/>
|
||||
|
||||
import { isChromecastAvailable } from "@/setup/chromecast";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { isChromecastAvailable } from "@/setup/chromecast";
|
||||
|
||||
export function useChromecastAvailable() {
|
||||
const [available, setAvailable] = useState<boolean | null>(null);
|
||||
|
||||
|
17
src/hooks/useQueryParams.ts
Normal file
17
src/hooks/useQueryParams.ts
Normal 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;
|
||||
}
|
@@ -1,8 +1,9 @@
|
||||
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 { useEffect, useState } from "react";
|
||||
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||
|
||||
export interface ScrapeEventLog {
|
||||
type: "provider" | "embed";
|
||||
|
@@ -1,12 +1,13 @@
|
||||
import { MWMediaType, MWQuery } from "@/backend/metadata/types";
|
||||
import { useState } from "react";
|
||||
import { generatePath, useHistory, useRouteMatch } from "react-router-dom";
|
||||
|
||||
import { MWMediaType, MWQuery } from "@/backend/metadata/types/mw";
|
||||
|
||||
function getInitialValue(params: { type: string; query: string }) {
|
||||
const type =
|
||||
Object.values(MWMediaType).find((v) => params.type === v) ||
|
||||
MWMediaType.MOVIE;
|
||||
const searchQuery = params.query || "";
|
||||
const searchQuery = decodeURIComponent(params.query || "");
|
||||
return { type, searchQuery };
|
||||
}
|
||||
|
||||
|
@@ -1,18 +1,19 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
||||
import { useState } from "react";
|
||||
|
||||
export function useVolumeControl(descriptor: string) {
|
||||
const [storedVolume, setStoredVolume] = useState(1);
|
||||
const controls = useControls(descriptor);
|
||||
const mediaPlaying = useMediaPlaying(descriptor);
|
||||
|
||||
const toggleVolume = () => {
|
||||
const toggleVolume = (isKeyboardEvent = false) => {
|
||||
if (mediaPlaying.volume > 0) {
|
||||
setStoredVolume(mediaPlaying.volume);
|
||||
controls.setVolume(0);
|
||||
controls.setVolume(0, isKeyboardEvent);
|
||||
} else {
|
||||
controls.setVolume(storedVolume > 0 ? storedVolume : 1);
|
||||
controls.setVolume(storedVolume > 0 ? storedVolume : 1, isKeyboardEvent);
|
||||
}
|
||||
};
|
||||
|
||||
|
@@ -3,16 +3,19 @@ import React, { Suspense } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { BrowserRouter, HashRouter } from "react-router-dom";
|
||||
import type { ReactNode } from "react-router-dom/node_modules/@types/react/index";
|
||||
import { ErrorBoundary } from "@/components/layout/ErrorBoundary";
|
||||
import { conf } from "@/setup/config";
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
|
||||
import { ErrorBoundary } from "@/components/layout/ErrorBoundary";
|
||||
import App from "@/setup/App";
|
||||
import { conf } from "@/setup/config";
|
||||
import i18n from "@/setup/i18n";
|
||||
|
||||
import "@/setup/ga";
|
||||
import "@/setup/i18n";
|
||||
import "@/setup/sentry";
|
||||
import "@/setup/index.css";
|
||||
import "@/backend";
|
||||
import { initializeChromecast } from "./setup/chromecast";
|
||||
import { SettingsStore } from "./state/settings/store";
|
||||
import { initializeStores } from "./utils/storage";
|
||||
|
||||
// initialize
|
||||
@@ -28,6 +31,7 @@ registerSW({
|
||||
|
||||
const LazyLoadedApp = React.lazy(async () => {
|
||||
await initializeStores();
|
||||
i18n.changeLanguage(SettingsStore.get().language ?? "en");
|
||||
return {
|
||||
default: App,
|
||||
};
|
||||
|
@@ -1,20 +1,39 @@
|
||||
import { Redirect, Route, Switch } from "react-router-dom";
|
||||
import { BookmarkContextProvider } from "@/state/bookmark";
|
||||
import { WatchedContextProvider } from "@/state/watched";
|
||||
import { SettingsProvider } from "@/state/settings";
|
||||
import { ReactElement, lazy, useEffect } from "react";
|
||||
import {
|
||||
Redirect,
|
||||
Route,
|
||||
Switch,
|
||||
useHistory,
|
||||
useLocation,
|
||||
} from "react-router-dom";
|
||||
|
||||
import { NotFoundPage } from "@/views/notfound/NotFoundView";
|
||||
import { MediaView } from "@/views/media/MediaView";
|
||||
import { SearchView } from "@/views/search/SearchView";
|
||||
import { MWMediaType } from "@/backend/metadata/types";
|
||||
import { V2MigrationView } from "@/views/other/v2Migration";
|
||||
import { DeveloperView } from "@/views/developer/DeveloperView";
|
||||
import { VideoTesterView } from "@/views/developer/VideoTesterView";
|
||||
import { ProviderTesterView } from "@/views/developer/ProviderTesterView";
|
||||
import { EmbedTesterView } from "@/views/developer/EmbedTesterView";
|
||||
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 { TestView } from "@/views/developer/TestView";
|
||||
import { BookmarkContextProvider } from "@/state/bookmark";
|
||||
import { SettingsProvider } from "@/state/settings";
|
||||
import { WatchedContextProvider } from "@/state/watched";
|
||||
import { MediaView } from "@/views/media/MediaView";
|
||||
import { NotFoundPage } from "@/views/notfound/NotFoundView";
|
||||
import { V2MigrationView } from "@/views/other/v2Migration";
|
||||
import { SearchView } from "@/views/search/SearchView";
|
||||
|
||||
function LegacyUrlView({ children }: { children: ReactElement }) {
|
||||
const location = useLocation();
|
||||
const { replace } = useHistory();
|
||||
|
||||
useEffect(() => {
|
||||
const url = location.pathname;
|
||||
if (!isLegacyUrl(url)) return;
|
||||
convertLegacyUrl(location.pathname).then((convertedUrl) => {
|
||||
replace(convertedUrl ?? "/");
|
||||
});
|
||||
}, [location.pathname, replace]);
|
||||
|
||||
if (isLegacyUrl(location.pathname)) return null;
|
||||
return children;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -31,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?"
|
||||
@@ -44,15 +67,47 @@ function App() {
|
||||
/>
|
||||
|
||||
{/* other */}
|
||||
<Route exact path="/dev" component={DeveloperView} />
|
||||
<Route exact path="/dev/test" component={TestView} />
|
||||
<Route exact path="/dev/video" component={VideoTesterView} />
|
||||
<Route
|
||||
exact
|
||||
path="/dev/providers"
|
||||
component={ProviderTesterView}
|
||||
path="/dev"
|
||||
component={lazy(
|
||||
() => import("@/views/developer/DeveloperView")
|
||||
)}
|
||||
/>
|
||||
<Route exact path="/dev/embeds" component={EmbedTesterView} />
|
||||
<Route
|
||||
exact
|
||||
path="/dev/video"
|
||||
component={lazy(
|
||||
() => import("@/views/developer/VideoTesterView")
|
||||
)}
|
||||
/>
|
||||
{/* developer routes that can abuse workers are disabled in production */}
|
||||
{process.env.NODE_ENV === "development" ? (
|
||||
<>
|
||||
<Route
|
||||
exact
|
||||
path="/dev/test"
|
||||
component={lazy(
|
||||
() => import("@/views/developer/TestView")
|
||||
)}
|
||||
/>
|
||||
|
||||
<Route
|
||||
exact
|
||||
path="/dev/providers"
|
||||
component={lazy(
|
||||
() => import("@/views/developer/ProviderTesterView")
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/dev/embeds"
|
||||
component={lazy(
|
||||
() => import("@/views/developer/EmbedTesterView")
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Route path="*" component={NotFoundPage} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
|
@@ -1,8 +1,9 @@
|
||||
import { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Banner } from "@/components/Banner";
|
||||
import { useBannerSize } from "@/hooks/useBanner";
|
||||
import { useIsOnline } from "@/hooks/usePing";
|
||||
import { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function Layout(props: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { APP_VERSION, GITHUB_LINK, DISCORD_LINK } from "./constants";
|
||||
import { APP_VERSION, DISCORD_LINK, GITHUB_LINK } from "./constants";
|
||||
|
||||
interface Config {
|
||||
APP_VERSION: string;
|
||||
|
@@ -2,3 +2,5 @@ export const APP_VERSION = import.meta.env.PACKAGE_VERSION;
|
||||
export const DISCORD_LINK = "https://discord.gg/Jhqt4Xzpfb";
|
||||
export const GITHUB_LINK = "https://github.com/movie-web/movie-web";
|
||||
export const GA_ID = "G-44YVXRL61C";
|
||||
export const SENTRY_DSN =
|
||||
"https://b267ab7d52674c23af4e4e6cf2956251@o4505053491167232.ingest.sentry.io/4505053495296000";
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import ReactGA from "react-ga4";
|
||||
|
||||
import { GA_ID } from "@/setup/constants";
|
||||
|
||||
ReactGA.initialize([
|
||||
|
@@ -1,30 +1,70 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
|
||||
// Languages
|
||||
import { captionLanguages } from "./iso6391";
|
||||
import cs from "./locales/cs/translation.json";
|
||||
import de from "./locales/de/translation.json";
|
||||
import en from "./locales/en/translation.json";
|
||||
import fr from "./locales/fr/translation.json";
|
||||
import it from "./locales/it/translation.json";
|
||||
import nl from "./locales/nl/translation.json";
|
||||
import pirate from "./locales/pirate/translation.json";
|
||||
import pl from "./locales/pl/translation.json";
|
||||
import tr from "./locales/tr/translation.json";
|
||||
import vi from "./locales/vi/translation.json";
|
||||
import zh from "./locales/zh/translation.json";
|
||||
|
||||
const locales = {
|
||||
en: {
|
||||
translation: en,
|
||||
},
|
||||
it: {
|
||||
translation: it,
|
||||
},
|
||||
nl: {
|
||||
translation: nl,
|
||||
},
|
||||
tr: {
|
||||
translation: tr,
|
||||
},
|
||||
fr: {
|
||||
translation: fr,
|
||||
},
|
||||
de: {
|
||||
translation: de,
|
||||
},
|
||||
zh: {
|
||||
translation: zh,
|
||||
},
|
||||
cs: {
|
||||
translation: cs,
|
||||
},
|
||||
pirate: {
|
||||
translation: pirate,
|
||||
},
|
||||
vi: {
|
||||
translation: vi,
|
||||
},
|
||||
pl: {
|
||||
translation: pl,
|
||||
},
|
||||
};
|
||||
i18n
|
||||
// detect user language
|
||||
// learn more: https://github.com/i18next/i18next-browser-languageDetector
|
||||
.use(LanguageDetector)
|
||||
// pass the i18n instance to react-i18next.
|
||||
.use(initReactI18next)
|
||||
// init i18next
|
||||
// for all options read: https://www.i18next.com/overview/configuration-options
|
||||
.init({
|
||||
fallbackLng: "en",
|
||||
|
||||
resources: {
|
||||
en: {
|
||||
translation: en,
|
||||
},
|
||||
},
|
||||
|
||||
resources: locales,
|
||||
interpolation: {
|
||||
escapeValue: false, // not needed for react as it escapes by default
|
||||
},
|
||||
});
|
||||
|
||||
export const appLanguageOptions = captionLanguages.filter((x) => {
|
||||
return Object.keys(locales).includes(x.id);
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
|
@@ -38,6 +38,7 @@ body[data-no-select] {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
@@ -55,6 +56,10 @@ body[data-no-select] {
|
||||
@apply brightness-[500];
|
||||
}
|
||||
|
||||
.is-mobile-view .overflow-y-auto {
|
||||
height: 60vh;
|
||||
}
|
||||
|
||||
/*generated with Input range slider CSS style generator (version 20211225)
|
||||
https://toughengineer.github.io/demo/slider-styler*/
|
||||
:root {
|
||||
@@ -62,6 +67,7 @@ https://toughengineer.github.io/demo/slider-styler*/
|
||||
--slider-border-radius: 1em;
|
||||
--slider-progress-background: #8652bb;
|
||||
}
|
||||
|
||||
input[type=range].styled-slider {
|
||||
height: var(--slider-height);
|
||||
-webkit-appearance: none;
|
||||
@@ -101,7 +107,7 @@ input[type=range].styled-slider::-webkit-slider-thumb:hover {
|
||||
}
|
||||
|
||||
input[type=range].styled-slider.slider-progress::-webkit-slider-runnable-track {
|
||||
background: linear-gradient(var(--slider-progress-background),var(--slider-progress-background)) 0/var(--sx) 100% no-repeat, #1C161B;
|
||||
background: linear-gradient(var(--slider-progress-background), var(--slider-progress-background)) 0/var(--sx) 100% no-repeat, #1C161B;
|
||||
}
|
||||
|
||||
/*mozilla*/
|
||||
@@ -127,7 +133,7 @@ input[type=range].styled-slider::-moz-range-thumb:hover {
|
||||
}
|
||||
|
||||
input[type=range].styled-slider.slider-progress::-moz-range-track {
|
||||
background: linear-gradient(var(--slider-progress-background),var(--slider-progress-background)) 0/var(--sx) 100% no-repeat, #1C161B;
|
||||
background: linear-gradient(var(--slider-progress-background), var(--slider-progress-background)) 0/var(--sx) 100% no-repeat, #1C161B;
|
||||
}
|
||||
|
||||
/*ms*/
|
||||
@@ -173,3 +179,19 @@ input[type=range].styled-slider.slider-progress::-ms-fill-lower {
|
||||
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;
|
||||
}
|
1333
src/setup/iso6391.ts
Normal file
1333
src/setup/iso6391.ts
Normal file
File diff suppressed because it is too large
Load Diff
128
src/setup/locales/cs/translation.json
Normal file
128
src/setup/locales/cs/translation.json
Normal file
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"global": {
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading_series": "Načítání Vašich oblíbených seriálů...",
|
||||
"loading_movie": "Načítání Vašich oblíbených filmů...",
|
||||
"loading": "Načítání...",
|
||||
"allResults": "To je vše co máme!",
|
||||
"noResults": "Nemohli jsme nic najít!",
|
||||
"allFailed": "Nepodařilo se najít média, zkuste to znovu!",
|
||||
"headingTitle": "Výsledky vyhledávání",
|
||||
"bookmarks": "Záložky",
|
||||
"continueWatching": "Pokračujte ve sledování",
|
||||
"title": "Co si přejete sledovat?",
|
||||
"placeholder": "Co si přejete sledovat?"
|
||||
},
|
||||
"media": {
|
||||
"movie": "Filmy",
|
||||
"series": "Seriály",
|
||||
"stopEditing": "Zastavit upravování",
|
||||
"errors": {
|
||||
"genericTitle": "Jejda, rozbilo se to!",
|
||||
"failedMeta": "Nepovedlo se načíst meta",
|
||||
"mediaFailed": "Nepodařilo se nám požádat o Vaše média, zkontrolujte své internetové připojení a zkuste to znovu.",
|
||||
"videoFailed": "Při přehrávání požadovaného videa došlo k chybě. Pokud se tohle opakuje prosím nahlašte nám to na <0>Discord serveru</0> nebo na <1>GitHubu</1>."
|
||||
}
|
||||
},
|
||||
"seasons": {
|
||||
"seasonAndEpisode": "S{{season}} E{{episode}}"
|
||||
},
|
||||
"notFound": {
|
||||
"genericTitle": "Nenalezeno",
|
||||
"backArrow": "Zpátky domů",
|
||||
"media": {
|
||||
"title": "Nemohli jsme najít Vaše média.",
|
||||
"description": "Nemohli jsme najít média o které jste požádali. Buďto jsme ho nemohli najít, nebo jste manipulovali s URL."
|
||||
},
|
||||
"provider": {
|
||||
"title": "Tento poskytovatel byl zakázán",
|
||||
"description": "Měli jsme s tímto poskytovatelem problémy, nebo byl moc nestabilní na používání, a tak jsme ho museli zakázat."
|
||||
},
|
||||
"page": {
|
||||
"title": "Tuto stránku se nepodařilo najít",
|
||||
"description": "Dívali jsme se všude: pod koši, ve skříni, za proxy, ale nakonec jsme nemohli najít stránku, kterou hledáte."
|
||||
}
|
||||
},
|
||||
"searchBar": {
|
||||
"movie": "Film",
|
||||
"series": "Seriál",
|
||||
"Search": "Hledání"
|
||||
},
|
||||
"videoPlayer": {
|
||||
"findingBestVideo": "Hledáme pro Vás nejlepší video",
|
||||
"noVideos": "Jejda, nemohli jsme žádné video najít",
|
||||
"loading": "Načítání...",
|
||||
"backToHome": "Zpátky domů",
|
||||
"backToHomeShort": "Zpět",
|
||||
"seasonAndEpisode": "S{{season}} E{{episode}}",
|
||||
"timeLeft": "Zbývá {{timeLeft}}",
|
||||
"finishAt": "Končí ve {{timeFinished, datetime}}",
|
||||
"buttons": {
|
||||
"episodes": "Epizody",
|
||||
"source": "Zdroj",
|
||||
"captions": "Titulky",
|
||||
"download": "Stáhnout",
|
||||
"settings": "Nastavení",
|
||||
"pictureInPicture": "Obraz v obraze",
|
||||
"playbackSpeed": "Rychlost přehrávání"
|
||||
},
|
||||
"popouts": {
|
||||
"back": "Zpět",
|
||||
"sources": "Zdroje",
|
||||
"seasons": "Sezóny",
|
||||
"captions": "Titulky",
|
||||
"playbackSpeed": "Rychlost přehrávání",
|
||||
"customPlaybackSpeed": "Vlastní rychlost přehrávání",
|
||||
"captionPreferences": {
|
||||
"title": "Upravit",
|
||||
"delay": "Zpoždení",
|
||||
"fontSize": "Velikost",
|
||||
"opacity": "Průhlednost",
|
||||
"color": "Barva"
|
||||
},
|
||||
"episode": "E{{index}} - {{title}}",
|
||||
"noCaptions": "Žádné titulky",
|
||||
"linkedCaptions": "Propojené titulky",
|
||||
"customCaption": "Vlastní titulky",
|
||||
"uploadCustomCaption": "Nahrát titulky",
|
||||
"noEmbeds": "Nebyla nalezena žádná vložení pro tento zdroj",
|
||||
|
||||
"errors": {
|
||||
"loadingWentWong": "Něco se nepovedlo při načítání epizod pro {{seasonTitle}}",
|
||||
"embedsError": "Něco se povedlo při načítání vložení pro tuhle věc, kterou máte tak rádi"
|
||||
},
|
||||
"descriptions": {
|
||||
"sources": "Jakého poskytovatele chcete použít?",
|
||||
"embeds": "Vyberte video, které chcete sledovat",
|
||||
"seasons": "Vyberte sérii, kterou chcete sledovat",
|
||||
"episode": "Vyberte epizodu",
|
||||
"captions": "Vyberte jazyk titulků",
|
||||
"captionPreferences": "Upravte titulky tak, jak se Vám budou líbit",
|
||||
"playbackSpeed": "Změňtě rychlost přehrávání"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"fatalError": "Došlo k závažné chybě v přehrávači videa, prosím nahlašte ji na <0>Discord serveru</0> nebo na <1>GitHubu</1>."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Nastavení",
|
||||
"language": "Jazyk",
|
||||
"captionLanguage": "Jazyk titulků"
|
||||
},
|
||||
"v3": {
|
||||
"newSiteTitle": "Je dostupná nová verze!",
|
||||
"newDomain": "https://movie-web.app",
|
||||
"newDomainText": "movie-web se brzy přesune na novou doménu: <0>https://movie-web.app</0>. Nezapomeňte si aktualizovat záložky, protože <1>stará stránka přestane fungovat {{date}}.</1>",
|
||||
"tireless": "Pracovali jsme neúnavně na této nové aktualizaci, a tak doufáme, že se Vám bude líbit co jsme v posledních měsících kuchtili.",
|
||||
"leaveAnnouncement": "Vezměte mě tam!"
|
||||
},
|
||||
"casting": {
|
||||
"casting": "Přehrávání do zařízení..."
|
||||
},
|
||||
"errors": {
|
||||
"offline": "Zkontrolujte své internetové připojení"
|
||||
}
|
||||
}
|
127
src/setup/locales/de/translation.json
Normal file
127
src/setup/locales/de/translation.json
Normal file
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"global": {
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading_series": "Auf der Suche nach deiner Lieblingsserie...",
|
||||
"loading_movie": "Auf der Suche nach deinen Lieblingsfilmen...",
|
||||
"loading": "Wird geladen...",
|
||||
"allResults": "Das ist alles, was wir haben!",
|
||||
"noResults": "Wir haben nichts gefunden!",
|
||||
"allFailed": "Das Medium wurde nicht gefunden, bitte versuchen Sie es erneut!",
|
||||
"headingTitle": "Suchergebnisse",
|
||||
"bookmarks": "Favoriten",
|
||||
"continueWatching": "Weiter ansehen",
|
||||
"title": "Was willst du gucken?",
|
||||
"placeholder": "Was willst du gucken?"
|
||||
},
|
||||
"media": {
|
||||
"movie": "Filme",
|
||||
"series": "Serie",
|
||||
"stopEditing": "Beenden die Bearbeitung",
|
||||
"errors": {
|
||||
"genericTitle": "Hoppla, etwas ist schiefgegangen!",
|
||||
"failedMeta": "Metadaten konnten nicht geladen werden",
|
||||
"mediaFailed": "Wir konnten die angeforderten Medien nicht abrufen.",
|
||||
"videoFailed": "Beim Abspielen des angeforderten Videos ist ein Fehler aufgetreten. <0>Discord</0> Oder weiter <1>GitHub</1>."
|
||||
}
|
||||
},
|
||||
"seasons": {
|
||||
"seasonAndEpisode": "S{{season}} E{{episode}}"
|
||||
},
|
||||
"notFound": {
|
||||
"genericTitle": "Nicht gefunden",
|
||||
"backArrow": "Zurück zur Startseite",
|
||||
"media": {
|
||||
"title": "Das Medium konnte nicht gefunden werden",
|
||||
"description": "Wir konnten die angeforderten Medien nicht finden."
|
||||
},
|
||||
"provider": {
|
||||
"title": "Dieser Anbieter wurde deaktiviert",
|
||||
"description": "Wir hatten Probleme mit dem Anbieter oder er war zu instabil, sodass wir ihn deaktivieren mussten."
|
||||
},
|
||||
"page": {
|
||||
"title": "Diese Seite kann nicht gefunden werden",
|
||||
"description": "Wir haben überall gesucht, aber am Ende konnten wir die gesuchte Seite nicht finden."
|
||||
}
|
||||
},
|
||||
"searchBar": {
|
||||
"movie": "Film",
|
||||
"series": "Serie",
|
||||
"Search": "Suchen"
|
||||
},
|
||||
"videoPlayer": {
|
||||
"findingBestVideo": "Auf der Suche nach dem besten Video für Sie",
|
||||
"noVideos": "Entschuldigung, wir konnten keine Videos finden",
|
||||
"loading": "Wird geladen...",
|
||||
"backToHome": "Zurück zur Startseite",
|
||||
"backToHomeShort": "Rückmeldung",
|
||||
"seasonAndEpisode": "S{{season}} E{{episode}}",
|
||||
"timeLeft": "{{timeLeft}} verbleibend",
|
||||
"finishAt": "Endet um {{timeFinished, datetime}}",
|
||||
"buttons": {
|
||||
"episodes": "Folgen",
|
||||
"source": "Quelle",
|
||||
"captions": "Untertitel",
|
||||
"download": "Herunterladen",
|
||||
"settings": "Einstellungen",
|
||||
"pictureInPicture": "Bild-im-Bild",
|
||||
"playbackSpeed": "Wiedergabegeschwindigkeit"
|
||||
},
|
||||
"popouts": {
|
||||
"back": "Zurück",
|
||||
"sources": "Quellen",
|
||||
"seasons": "Staffel",
|
||||
"captions": "Untertitel",
|
||||
"playbackSpeed": "Lesegeschwindigkeit",
|
||||
"customPlaybackSpeed": "Benutzerdefinierte Wiedergabegeschwindigkeit",
|
||||
"captionPreferences": {
|
||||
"title": "Bearbeiten",
|
||||
"delay": "Verzögerung",
|
||||
"fontSize": "Größe",
|
||||
"opacity": "Opazität",
|
||||
"color": "Farbe"
|
||||
},
|
||||
"episode": "E{{index}} - {{title}}",
|
||||
"noCaptions": "Keine Untertitel",
|
||||
"linkedCaptions": "Verbundene Untertitel",
|
||||
"customCaption": "Benutzerdefinierte Untertitel",
|
||||
"uploadCustomCaption": "Untertitel hochladen",
|
||||
"noEmbeds": "Für diese Quelle wurde kein eingebetteter Inhalt gefunden",
|
||||
"errors": {
|
||||
"loadingWentWong": "Beim Laden der Folgen für {{seasonTitle}} ist ein Problem aufgetreten ",
|
||||
"embedsError": "Beim Laden der eingebetteter Medien ist ein Problem aufgetreten"
|
||||
},
|
||||
"descriptions": {
|
||||
"sources": "Welchen Anbieter möchtest du nutzen?",
|
||||
"embeds": "Wähle das Video aus, das du ansehen möchten",
|
||||
"seasons": "Wähle die Staffel aus, die du sehen möchten",
|
||||
"episode": "Wähle eine Folge aus",
|
||||
"captions": "Wähle eine Untertitelsprache",
|
||||
"captionPreferences": "Passe das Erscheinungsbild von Untertiteln an",
|
||||
"playbackSpeed": "Wiedergabegeschwindigkeit ändern"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"fatalError": "Der Videoplayer hat einen Fehler festgestellt, bitte melde ihn dem Server <0>Discord</0> Oder weiter <1>GitHub</1>."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"language": "Sprache",
|
||||
"captionLanguage": "Untertitelsprache"
|
||||
},
|
||||
"v3": {
|
||||
"newSiteTitle": "Neue Version verfügbar!",
|
||||
"newDomain": "https://movie-web.app",
|
||||
"newDomainText": "movie-web zieht in Kürze auf eine neue Domain um: <0>https://movie-web.app</0>. <1>Die alte Website funktioniert nicht mehr {{date}}.</1>",
|
||||
"tireless": "Wir haben unermüdlich an diesem neuen Update gearbeitet und hoffen, dass dir gefällt, was wir in den letzten Monaten vorbereitet haben.",
|
||||
"leaveAnnouncement": "Bring mich dahin!"
|
||||
},
|
||||
"casting": {
|
||||
"casting": "An Gerät übertragen..."
|
||||
},
|
||||
"errors": {
|
||||
"offline": "Internetverbindung ist instabil"
|
||||
}
|
||||
}
|
@@ -57,18 +57,33 @@
|
||||
"backToHome": "Back to home",
|
||||
"backToHomeShort": "Back",
|
||||
"seasonAndEpisode": "S{{season}} E{{episode}}",
|
||||
"timeLeft": "{{timeLeft}} left",
|
||||
"finishAt": "Finish at {{timeFinished, datetime}}",
|
||||
"buttons": {
|
||||
"episodes": "Episodes",
|
||||
"source": "Source",
|
||||
"captions": "Captions",
|
||||
"download": "Download",
|
||||
"settings": "Settings",
|
||||
"pictureInPicture": "Picture in Picture"
|
||||
"pictureInPicture": "Picture in Picture",
|
||||
"playbackSpeed": "Playback speed"
|
||||
},
|
||||
"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",
|
||||
"captionPreferences": {
|
||||
"title": "Customize",
|
||||
"delay": "Delay",
|
||||
@@ -80,8 +95,9 @@
|
||||
"noCaptions": "No captions",
|
||||
"linkedCaptions": "Linked captions",
|
||||
"customCaption": "Custom caption",
|
||||
"uploadCustomCaption": "Upload caption (SRT, VTT)",
|
||||
"uploadCustomCaption": "Upload caption",
|
||||
"noEmbeds": "No embeds were found for this source",
|
||||
|
||||
"errors": {
|
||||
"loadingWentWong": "Something went wrong loading the episodes for {{seasonTitle}}",
|
||||
"embedsError": "Something went wrong loading the embeds for this thing that you like"
|
||||
@@ -92,13 +108,19 @@
|
||||
"seasons": "Choose which season you want to watch",
|
||||
"episode": "Pick an episode",
|
||||
"captions": "Choose a subtitle language",
|
||||
"captionPreferences": "Make subtitles look how you want it"
|
||||
"captionPreferences": "Make subtitles look how you want it",
|
||||
"playbackSpeed": "Change the playback speed"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"fatalError": "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",
|
||||
|
127
src/setup/locales/fr/translation.json
Normal file
127
src/setup/locales/fr/translation.json
Normal file
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"global": {
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading_series": "Recherche de votre série préférée...",
|
||||
"loading_movie": "Recherche de vos films préférés...",
|
||||
"loading": "Chargement...",
|
||||
"allResults": "C'est tout ce que nous avons!",
|
||||
"noResults": "Nous n'avons rien trouvé!",
|
||||
"allFailed": "Le média n'a pas été trouvé, veuillez réessayez!",
|
||||
"headingTitle": "Résultats de la recherche",
|
||||
"bookmarks": "Favoris",
|
||||
"continueWatching": "Continuer le visionnage",
|
||||
"title": "Que voulez-vous voir?",
|
||||
"placeholder": "Que voulez-vous voir?"
|
||||
},
|
||||
"media": {
|
||||
"movie": "Films",
|
||||
"series": "Séries",
|
||||
"stopEditing": "Arrêter l'édition",
|
||||
"errors": {
|
||||
"genericTitle": "Oups, c'est coupé !",
|
||||
"failedMeta": "Impossible de charger les métadonnées",
|
||||
"mediaFailed": "Nous n'avons pas réussi à récupérer le média que vous avez demandé. Veuillez vérifier votre connexion Internet et réessayer.",
|
||||
"videoFailed": "Nous avons rencontré une erreur lors de la lecture de la vidéo que vous avez demandée. Si cela se reproduit, veuillez signaler le problème au serveur <0>Discord</0> ou sur <1>GitHub</1>."
|
||||
}
|
||||
},
|
||||
"seasons": {
|
||||
"seasonAndEpisode": "S{{season}} E{{episode}}"
|
||||
},
|
||||
"notFound": {
|
||||
"genericTitle": "Introuvable",
|
||||
"backArrow": "Retour à l'accueil",
|
||||
"media": {
|
||||
"title": "Impossible de trouver ce média",
|
||||
"description": "Nous n'avons pas trouvé le média que vous avez demandé. Soit il a été supprimé, soit vous avez modifié l'URL."
|
||||
},
|
||||
"provider": {
|
||||
"title": "Ce fournisseur a été désactivé",
|
||||
"description": "Nous avons eu des problèmes avec le fournisseur ou il était trop instable pour être utilisé, nous avons donc dû le désactiver."
|
||||
},
|
||||
"page": {
|
||||
"title": "Impossible de trouver cette page",
|
||||
"description": "Nous avons cherché partout : sous les poubelles, dans le placard, derrière le proxy, mais nous n'avons finalement pas trouvé la page que vous cherchez."
|
||||
}
|
||||
},
|
||||
"searchBar": {
|
||||
"movie": "Film",
|
||||
"series": "Série",
|
||||
"Search": "Rechercher"
|
||||
},
|
||||
"videoPlayer": {
|
||||
"findingBestVideo": "Recherche de la meilleure vidéo pour vous",
|
||||
"noVideos": "Désolé, nous n'avons pas pu trouver de vidéos pour vous",
|
||||
"loading": "Chargement...",
|
||||
"backToHome": "Retour à la page d'accueil",
|
||||
"backToHomeShort": "Retour",
|
||||
"seasonAndEpisode": "S{{season}} E{{episode}}",
|
||||
"timeLeft": "{{timeLeft}} restant",
|
||||
"finishAt": "Terminer à {{timeFinished, datetime}}",
|
||||
"buttons": {
|
||||
"episodes": "Épisodes",
|
||||
"source": "Source",
|
||||
"captions": "Sous-titres",
|
||||
"download": "Télécharger",
|
||||
"settings": "Paramètres",
|
||||
"pictureInPicture": "Image dans l'image",
|
||||
"playbackSpeed": "Vitesse"
|
||||
},
|
||||
"popouts": {
|
||||
"back": "Retourner",
|
||||
"sources": "Sources",
|
||||
"seasons": "Saisons",
|
||||
"captions": "Sous-titres",
|
||||
"playbackSpeed": "Vitesse de lecture",
|
||||
"customPlaybackSpeed": "Vitesse de lecture personnalisée",
|
||||
"captionPreferences": {
|
||||
"title": "Personnaliser",
|
||||
"delay": "Délai",
|
||||
"fontSize": "Taille",
|
||||
"opacity": "Opacité",
|
||||
"color": "Couleur"
|
||||
},
|
||||
"episode": "E{{index}} - {{title}}",
|
||||
"noCaptions": "Pas de sous-titres",
|
||||
"linkedCaptions": "Sous-titres liés",
|
||||
"customCaption": "Sous-titres personnalisés",
|
||||
"uploadCustomCaption": "Télécharger des sous-titres",
|
||||
"noEmbeds": "Aucun contenu intégré n'a été trouvé pour cette source",
|
||||
"errors": {
|
||||
"loadingWentWong": "Un problème est survenu lors du chargement des épisodes pour {{seasonTitle}}",
|
||||
"embedsError": "Un problème est survenu lors du chargement des contenus intégrés pour cet élément que vous aimez"
|
||||
},
|
||||
"descriptions": {
|
||||
"sources": "Quel fournisseur voulez-vous utiliser ?",
|
||||
"embeds": "Choisissez quelle vidéo regarder",
|
||||
"seasons": "Choisissez la saison que vous voulez regarder",
|
||||
"episode": "Sélectionnez un épisode",
|
||||
"captions": "Choisissez une langue de sous-titres",
|
||||
"captionPreferences": "Personnalisez l'apparence des sous-titres",
|
||||
"playbackSpeed": "Changer la vitesse de lecture"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"fatalError": "Le lecteur vidéo a rencontré une erreur fatale, veuillez la signaler au serveur <0>Discord</0> ou sur <1>GitHub</1>."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
"language": "Language",
|
||||
"captionLanguage": "Langue des sous-titres"
|
||||
},
|
||||
"v3": {
|
||||
"newSiteTitle": "Nouvelle version disponible!",
|
||||
"newDomain": "https://movie-web.app",
|
||||
"newDomainText": "movie-web déménagera bientôt vers un nouveau domaine : <0>https://movie-web.app</0>. Veillez à mettre à jour tous vos favoris car <1>l'ancien site web cessera de fonctionner le {{date}}.</1>",
|
||||
"tireless": "Nous avons travaillé sans relâche sur cette nouvelle mise à jour et nous espérons que vous apprécierez ce que nous avons préparé ces derniers mois.",
|
||||
"leaveAnnouncement": "Emmenez-moi là!"
|
||||
},
|
||||
"casting": {
|
||||
"casting": "Transmission à l'appareil..."
|
||||
},
|
||||
"errors": {
|
||||
"offline": "Vérifiez votre connexion internet"
|
||||
}
|
||||
}
|
128
src/setup/locales/it/translation.json
Normal file
128
src/setup/locales/it/translation.json
Normal 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"
|
||||
}
|
||||
}
|
128
src/setup/locales/nl/translation.json
Normal file
128
src/setup/locales/nl/translation.json
Normal file
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"global": {
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading_series": "We zoeken je favoriete series...",
|
||||
"loading_movie": "We zoeken je favoriete films...",
|
||||
"loading": "Aan het zoeken...",
|
||||
"allResults": "Dat is het!",
|
||||
"noResults": "We konden helaas niets vinden.",
|
||||
"allFailed": "Het is niet gelukt de media te laden, probeer het nog eens.",
|
||||
"headingTitle": "Zoekresultaten",
|
||||
"bookmarks": "Opgeslagen",
|
||||
"continueWatching": "Kijk verder",
|
||||
"title": "Wat wil je graag kijken?",
|
||||
"placeholder": "Wat wil je graag kijken?"
|
||||
},
|
||||
"media": {
|
||||
"movie": "Film",
|
||||
"series": "Serie",
|
||||
"stopEditing": "Stop met bewerken",
|
||||
"errors": {
|
||||
"genericTitle": "Oeps, hier ging iets mis!",
|
||||
"failedMeta": "Het is niet gelukt de meta-informatie op te halen/",
|
||||
"mediaFailed": "Het is niet gelukt deze media op te halen. Controleer of je een internetverbinding hebt en probeer het nog een keer.",
|
||||
"videoFailed": "Er ging iets mis tijdens het spelen van deze video. Als dit blijft gebeuren, deel het dan in de <0>Discord server</0> of maak een <1>GitHub issue</1>."
|
||||
}
|
||||
},
|
||||
"seasons": {
|
||||
"seasonAndEpisode": "S{{season}} A{{episode}}"
|
||||
},
|
||||
"notFound": {
|
||||
"genericTitle": "Pagina niet gevonden",
|
||||
"backArrow": "Naar de home-pagina",
|
||||
"media": {
|
||||
"title": "We konden deze media niet vinden.",
|
||||
"description": "We konden dit stukje media niet vinden. Het is mogelijk verwijderd, of jij hebt zelf de URL aangepast."
|
||||
},
|
||||
"provider": {
|
||||
"title": "Deze bron is niet langer beschikbaar",
|
||||
"description": "Deze bron was helaas te instabiel, we hebben hem jammer genoeg uit moeten zetten."
|
||||
},
|
||||
"page": {
|
||||
"title": "Pagina niet gevonden",
|
||||
"description": "We hebben echt alles geprobeerd, zelfs tijdrijzen; echter hebben we deze pagina helaas niet kunnen vinden."
|
||||
}
|
||||
},
|
||||
"searchBar": {
|
||||
"movie": "Films",
|
||||
"series": "Series",
|
||||
"Search": "Zoeken"
|
||||
},
|
||||
"videoPlayer": {
|
||||
"findingBestVideo": "De beste video voor jou aan het zoeken...",
|
||||
"noVideos": "Helaas konden we dat filmpje niet vinden",
|
||||
"loading": "Aan het laden...",
|
||||
"backToHome": "Naar de home-pagina",
|
||||
"backToHomeShort": "Terug",
|
||||
"seasonAndEpisode": "S{{season}} A{{episode}}",
|
||||
"timeLeft": "Nog {{timeLeft}}",
|
||||
"finishAt": "Afgelopen om {{timeFinished, datetime}}",
|
||||
"buttons": {
|
||||
"episodes": "Afleveringen",
|
||||
"source": "Bron",
|
||||
"captions": "Ondertiteling",
|
||||
"download": "Download",
|
||||
"settings": "Instellingen",
|
||||
"pictureInPicture": "Beeld-in-beeld",
|
||||
"playbackSpeed": "Afspeelsnelheid"
|
||||
},
|
||||
"popouts": {
|
||||
"back": "Terug",
|
||||
"sources": "Bronnen",
|
||||
"seasons": "Seizoenen",
|
||||
"captions": "Ondertiteling",
|
||||
"playbackSpeed": "Afspeelsnelheid",
|
||||
"customPlaybackSpeed": "Andere snelheden",
|
||||
"captionPreferences": {
|
||||
"title": "Instellingen",
|
||||
"delay": "Vertraging",
|
||||
"fontSize": "Lettergrootte",
|
||||
"opacity": "Doorzichtbaarheid",
|
||||
"color": "Kleur"
|
||||
},
|
||||
"episode": "A{{index}} - {{title}}",
|
||||
"noCaptions": "Geen ondertiteling",
|
||||
"linkedCaptions": "Gelinkte ondertiteling",
|
||||
"customCaption": "Eigen ondertiteling",
|
||||
"uploadCustomCaption": "Ondertiteling uploaden",
|
||||
"noEmbeds": "We hebben geen filmpjes kunnen vinden voor deze bron.",
|
||||
|
||||
"errors": {
|
||||
"loadingWentWong": "Er ging iets mis tijdens het laden van de afleveringen voor {{seasonTitle}}",
|
||||
"embedsError": "Er ging iets mis tijdens het laden van de embeds voor dit dingetje dat je waarschijnlijk leuk vindt"
|
||||
},
|
||||
"descriptions": {
|
||||
"sources": "Welke bron wil je graag gebruiken",
|
||||
"embeds": "Welk filmpje wil je gebruiken?",
|
||||
"seasons": "Welk seizoen wil je kijken?",
|
||||
"episode": "Kies een aflevering",
|
||||
"captions": "Kies een taal voor de ondertiteling",
|
||||
"captionPreferences": "Pas de ondertiteling aan aan je voorkeuren",
|
||||
"playbackSpeed": "Pas de afspeelsnelhijd aan"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"fatalError": "De videospeler is helaas ontploft, rapporteer deze fout op de <0>Discord server</0> of op <1>GitHub</1>."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Instellingen",
|
||||
"language": "Taal",
|
||||
"captionLanguage": "Taal voor de Ondertiteling"
|
||||
},
|
||||
"v3": {
|
||||
"newSiteTitle": "De nieuwe versie is uit!",
|
||||
"newDomain": "https://movie-web.app",
|
||||
"newDomainText": "We gaan binnenkort verhuizen naar een nieuw domein: <0>https://movie-web.app</0>. Pas je bladwijzers aan naar het nieuwe domein, want </b>het oude domein gaat stoppen met werken op {{date}}.</b>",
|
||||
"tireless": "We hebben mega hard gewerkt aan deze nieuwe versie, dus we hopen dat je er van gaat genieten.",
|
||||
"leaveAnnouncement": "Let's go!"
|
||||
},
|
||||
"casting": {
|
||||
"casting": "Aan het casten..."
|
||||
},
|
||||
"errors": {
|
||||
"offline": "Controleer je internetverbinding"
|
||||
}
|
||||
}
|
124
src/setup/locales/pirate/translation.json
Normal file
124
src/setup/locales/pirate/translation.json
Normal 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" }
|
||||
}
|
137
src/setup/locales/pl/translation.json
Normal file
137
src/setup/locales/pl/translation.json
Normal 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"
|
||||
}
|
||||
}
|
137
src/setup/locales/tr/translation.json
Normal file
137
src/setup/locales/tr/translation.json
Normal file
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"global": {
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading_series": "Favori dizileriniz aranıyor...",
|
||||
"loading_movie": "Favori filmleriniz aranıyor...",
|
||||
"loading": "Yükleniyor...",
|
||||
"allResults": "Bu kadarını bulabildik!",
|
||||
"noResults": "Hiçbir şey bulamadık!",
|
||||
"allFailed": "Medya bulunamadı, tekrar deneyin!",
|
||||
"headingTitle": "Arama sonuçları",
|
||||
"bookmarks": "Yerimleri",
|
||||
"continueWatching": "İzlemeye devam edin",
|
||||
"title": "Ne izlemek istersiniz?",
|
||||
"placeholder": "Ne izlemek istersiniz?"
|
||||
},
|
||||
"media": {
|
||||
"movie": "Film",
|
||||
"series": "Dizi",
|
||||
"stopEditing": "Düzenlemeyi durdur",
|
||||
"errors": {
|
||||
"genericTitle": "Hay aksi, bozuldu!",
|
||||
"failedMeta": "Önbilgi yüklenemedi",
|
||||
"mediaFailed": "İstediğiniz medyaya istek atarken hata oluştu, internet bağlantınızı kontrol edin ve tekrar deneyin.",
|
||||
"videoFailed": "İstediğiniz videoyu oynatırken bir sorunla karşılaştık. Bu durum devam ederse lütfen bunu <0>Discord sunucumuza</0> veya <1>GitHub</1> üzerinden bildiriniz."
|
||||
}
|
||||
},
|
||||
"seasons": {
|
||||
"seasonAndEpisode": "S{{season}} B{{episode}}"
|
||||
},
|
||||
"notFound": {
|
||||
"genericTitle": "Bulunamadı",
|
||||
"backArrow": "Geri",
|
||||
"media": {
|
||||
"title": "Medya bulunamadı",
|
||||
"description": "İstediğiniz medyayı bulamadık. URL'i yanlış girdiniz ya da medya kaldırıldı."
|
||||
},
|
||||
"provider": {
|
||||
"title": "Bu sağlayıcı devre dışı bırakıldı",
|
||||
"description": "Sağlayıcı ile ilgili bir sorun oluştu ya da kullanılacak kadar stabil değildi bu yüzden devre dışı bırakmak zorunda kaldık."
|
||||
},
|
||||
"page": {
|
||||
"title": "Sayfa bulunamadı",
|
||||
"description": "Her yere baktık: bazanın altına, dolabın içine hatta ara sunucuya ama maalesef aradığınız sayfayı bulamadık."
|
||||
}
|
||||
},
|
||||
"searchBar": {
|
||||
"movie": "Film",
|
||||
"series": "Dizi",
|
||||
"Search": "Ara"
|
||||
},
|
||||
"videoPlayer": {
|
||||
"findingBestVideo": "Sizin için en iyi videoyu buluyoruz...",
|
||||
"noVideos": "Hay aksi, hiçbir video bulamadık",
|
||||
"loading": "Yükleniyor...",
|
||||
"backToHome": "Ana sayfaya dön",
|
||||
"backToHomeShort": "Geri",
|
||||
"seasonAndEpisode": "S{{season}} B{{episode}}",
|
||||
"timeLeft": "{{timeLeft}} kaldı",
|
||||
"finishAt": "{{timeFinished, datetime}}'de/da bitiyor",
|
||||
"buttons": {
|
||||
"episodes": "Bölümler",
|
||||
"source": "Kaynak",
|
||||
"captions": "Altyazılar",
|
||||
"download": "İndir",
|
||||
"settings": "Ayarlar",
|
||||
"pictureInPicture": "Resim içinde Resim",
|
||||
"playbackSpeed": "Oynatma Hızı"
|
||||
},
|
||||
"popouts": {
|
||||
"back": "Geri git",
|
||||
"sources": "Kaynaklar",
|
||||
"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ı",
|
||||
"captionPreferences": {
|
||||
"title": "Kişiselleştirme",
|
||||
"delay": "Gecikme",
|
||||
"fontSize": "Boyut",
|
||||
"opacity": "Opaklık",
|
||||
"color": "Renk"
|
||||
},
|
||||
"episode": "B{{index}} - {{title}}",
|
||||
"noCaptions": "Altyazı yok",
|
||||
"linkedCaptions": "Kaynak Altyazıları",
|
||||
"customCaption": "Özel altyazı",
|
||||
"uploadCustomCaption": "Altyazı yükle",
|
||||
"noEmbeds": "Bu kaynak için gömülü video bulunamadı",
|
||||
|
||||
"errors": {
|
||||
"loadingWentWong": "{{seasonTitle}} için bölümler yüklenirken bir hata oluştu",
|
||||
"embedsError": "İstediğiniz şey için gömülü video bulunurken bir hata oluştu"
|
||||
},
|
||||
"descriptions": {
|
||||
"sources": "Hangi sağlayıcıyı kullanmak istersiniz?",
|
||||
"embeds": "Görüntülemek istediğiniz videoyu seçiniz",
|
||||
"seasons": "İzlemek istediğiniz sezonu seçiniz",
|
||||
"episode": "Bir bölüm seçiniz",
|
||||
"captions": "Altyazı dili seçiniz",
|
||||
"captionPreferences": "Altyazıları istediğiniz gibi ayarlayın",
|
||||
"playbackSpeed": "Oynatma hızınızı değiştirin"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"fatalError": "Video oynatıcıda bir hata oluştu, lütfen bunu <0>Discord sunucumuzda</0> ya da <1>GitHub</1> üzeriden bildiriniz."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ayarlar",
|
||||
"language": "Dil",
|
||||
"captionLanguage": "Altyazı Dili"
|
||||
},
|
||||
"v3": {
|
||||
"newSiteTitle": "Yeni sürüm yayınlandı!",
|
||||
"newDomain": "https://movie-web.app",
|
||||
"newDomainText": "movie-web yakında yeni bir alan adına taşınacak: <0>https://movie-web.app</0>. <1>{{date}} tarihinde eski site çalışmayacağı için</1> yerimlerinizi güncellemeyi unutmayın.",
|
||||
"tireless": "Bu yeni güncelleme için gece gündüz çalıştık, umarız aylardan beri hazırladığımız bu güncellemeyi beğenirsiniz.",
|
||||
"leaveAnnouncement": "Götür beni!"
|
||||
},
|
||||
"casting": {
|
||||
"casting": "Cihaza aktarılıyor..."
|
||||
},
|
||||
"errors": {
|
||||
"offline": "İnternet bağlantınızı kontrol ediniz"
|
||||
}
|
||||
}
|
128
src/setup/locales/vi/translation.json
Normal file
128
src/setup/locales/vi/translation.json
Normal 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"
|
||||
}
|
||||
}
|
127
src/setup/locales/zh/translation.json
Normal file
127
src/setup/locales/zh/translation.json
Normal file
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"global": {
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading_series": "正在获取您最喜欢的连续剧……",
|
||||
"loading_movie": "正在获取您最喜欢的影片……",
|
||||
"loading": "载入中……",
|
||||
"allResults": "以上是我们能找到的所有结果!",
|
||||
"noResults": "我们找不到任何结果!",
|
||||
"allFailed": "查找媒体失败,请重试!",
|
||||
"headingTitle": "搜索结果",
|
||||
"bookmarks": "书签",
|
||||
"continueWatching": "继续观看",
|
||||
"title": "您想看些什么?",
|
||||
"placeholder": "您想看些什么?"
|
||||
},
|
||||
"media": {
|
||||
"movie": "电影",
|
||||
"series": "连续剧",
|
||||
"stopEditing": "退出编辑",
|
||||
"errors": {
|
||||
"genericTitle": "哎呀,出问题了!",
|
||||
"failedMeta": "加载元数据失败",
|
||||
"mediaFailed": "我们未能请求到您要求的媒体,检查互联网连接并重试。",
|
||||
"videoFailed": "我们在播放您要求的视频时遇到了错误。如果错误持续发生,请向 <0>Discord 服务器</0>或 <1>GitHub</1> 提交问题报告。"
|
||||
}
|
||||
},
|
||||
"seasons": {
|
||||
"seasonAndEpisode": "第{{season}}季 第{{episode}}集"
|
||||
},
|
||||
"notFound": {
|
||||
"genericTitle": "未找到",
|
||||
"backArrow": "返回首页",
|
||||
"media": {
|
||||
"title": "无法找到媒体",
|
||||
"description": "我们无法找到您请求的媒体。它可能已被删除,或您篡改了 URL"
|
||||
},
|
||||
"provider": {
|
||||
"title": "该内容提供者已被停用",
|
||||
"description": "我们的提供者出现问题,或是太不稳定,导致无法使用,所以我们不得不将其停用。"
|
||||
},
|
||||
"page": {
|
||||
"title": "无法找到页面",
|
||||
"description": "我们已经到处找过了:不管是垃圾桶下、橱柜里或是代理之后。但最终并没有发现您查找的页面。"
|
||||
}
|
||||
},
|
||||
"searchBar": {
|
||||
"movie": "电影",
|
||||
"series": "连续剧",
|
||||
"Search": "搜索"
|
||||
},
|
||||
"videoPlayer": {
|
||||
"findingBestVideo": "正在为您探测最佳视频",
|
||||
"noVideos": "哎呀,无法为您找到任何视频",
|
||||
"loading": "载入中……",
|
||||
"backToHome": "返回首页",
|
||||
"backToHomeShort": "返回",
|
||||
"seasonAndEpisode": "第{{season}}季 第{{episode}}集",
|
||||
"timeLeft": "还剩余 {{timeLeft}}",
|
||||
"finishAt": "在 {{timeFinished, datetime}} 结束",
|
||||
"buttons": {
|
||||
"episodes": "分集",
|
||||
"source": "视频源",
|
||||
"captions": "字幕",
|
||||
"download": "下载",
|
||||
"settings": "设置",
|
||||
"pictureInPicture": "画中画",
|
||||
"playbackSpeed": "播放速度"
|
||||
},
|
||||
"popouts": {
|
||||
"back": "返回",
|
||||
"sources": "视频源",
|
||||
"seasons": "分季",
|
||||
"captions": "字幕",
|
||||
"playbackSpeed": "播放速度",
|
||||
"customPlaybackSpeed": "自定义播放速度",
|
||||
"captionPreferences": {
|
||||
"title": "自定义",
|
||||
"delay": "延迟",
|
||||
"fontSize": "尺寸",
|
||||
"opacity": "透明度",
|
||||
"color": "颜色"
|
||||
},
|
||||
"episode": "第{{index}}集 - {{title}}",
|
||||
"noCaptions": "没有字幕",
|
||||
"linkedCaptions": "已链接字幕",
|
||||
"customCaption": "自定义字幕",
|
||||
"uploadCustomCaption": "上传字幕",
|
||||
"noEmbeds": "未发现该视频源的嵌入内容",
|
||||
"errors": {
|
||||
"loadingWentWong": "加载 {{seasonTitle}} 的分集时出现了一些问题",
|
||||
"embedsError": "为您喜欢的这一东西加载嵌入内容时出现了一些问题"
|
||||
},
|
||||
"descriptions": {
|
||||
"sources": "您想使用哪个内容提供者?",
|
||||
"embeds": "选择要观看的视频",
|
||||
"seasons": "选择您要观看的季",
|
||||
"episode": "选择一个分集",
|
||||
"captions": "选择字幕语言",
|
||||
"captionPreferences": "让字幕看起来如您所想",
|
||||
"playbackSpeed": "改变播放速度"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"fatalError": "视频播放器遇到致命错误,请向 <0>Discord 服务器</0>或 <1>GitHub</1> 报告。"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"language": "语言",
|
||||
"captionLanguage": "字幕语言"
|
||||
},
|
||||
"v3": {
|
||||
"newSiteTitle": "新的版本现已发布!",
|
||||
"newDomain": "https://movie-web.app",
|
||||
"newDomainText": "movie-web 将很快转移到新的域名:<0>https://movie-web.app</0>。请确保已经更新全部书签链接,<1>旧网站将于 {{date}} 停止工作。</1>",
|
||||
"tireless": "为了这一新版本,我们不懈努力,希望您会喜欢我们在过去几个月中所做的一切。",
|
||||
"leaveAnnouncement": "请带我去!"
|
||||
},
|
||||
"casting": {
|
||||
"casting": "正在投射到设备……"
|
||||
},
|
||||
"errors": {
|
||||
"offline": "检查您的互联网连接"
|
||||
}
|
||||
}
|
16
src/setup/sentry.tsx
Normal file
16
src/setup/sentry.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { CaptureConsole, HttpClient } from "@sentry/integrations";
|
||||
import * as Sentry from "@sentry/react";
|
||||
|
||||
import { conf } from "@/setup/config";
|
||||
import { SENTRY_DSN } from "@/setup/constants";
|
||||
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
release: `movie-web@${conf().APP_VERSION}`,
|
||||
sampleRate: 0.5,
|
||||
integrations: [
|
||||
new Sentry.BrowserTracing(),
|
||||
new CaptureConsole(),
|
||||
new HttpClient(),
|
||||
],
|
||||
});
|
@@ -1,6 +1,8 @@
|
||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||
import { ReactNode, createContext, useContext, useMemo } from "react";
|
||||
|
||||
import { MWMediaMeta } from "@/backend/metadata/types/mw";
|
||||
import { useStore } from "@/utils/storage";
|
||||
import { createContext, ReactNode, useContext, useMemo } from "react";
|
||||
|
||||
import { BookmarkStore } from "./store";
|
||||
import { BookmarkStoreData } from "./types";
|
||||
|
||||
|
@@ -1,6 +1,8 @@
|
||||
import { createVersionedStore } from "@/utils/storage";
|
||||
import { migrateV1Bookmarks, OldBookmarks } from "../watched/migrations/v2";
|
||||
|
||||
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")
|
||||
@@ -12,6 +14,12 @@ export const BookmarkStore = createVersionedStore<BookmarkStoreData>()
|
||||
})
|
||||
.addVersion({
|
||||
version: 1,
|
||||
migrate(old: BookmarkStoreData) {
|
||||
return migrateV2Bookmarks(old);
|
||||
},
|
||||
})
|
||||
.addVersion({
|
||||
version: 2,
|
||||
create() {
|
||||
return {
|
||||
bookmarks: [],
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||
import { MWMediaMeta } from "@/backend/metadata/types/mw";
|
||||
|
||||
export interface BookmarkStoreData {
|
||||
bookmarks: MWMediaMeta[];
|
||||
|
@@ -1,14 +1,18 @@
|
||||
import { ReactNode, createContext, useContext, useMemo } from "react";
|
||||
|
||||
import { LangCode } from "@/setup/iso6391";
|
||||
import { useStore } from "@/utils/storage";
|
||||
import { createContext, ReactNode, useContext, useMemo } from "react";
|
||||
|
||||
import { SettingsStore } from "./store";
|
||||
import { MWSettingsData } from "./types";
|
||||
|
||||
interface MWSettingsDataSetters {
|
||||
setLanguage(language: string): void;
|
||||
setLanguage(language: LangCode): void;
|
||||
setCaptionLanguage(language: LangCode): void;
|
||||
setCaptionDelay(delay: number): void;
|
||||
setCaptionColor(color: string): void;
|
||||
setCaptionFontSize(size: number): void;
|
||||
setCaptionBackgroundColor(backgroundColor: string): void;
|
||||
setCaptionBackgroundColor(backgroundColor: number): void;
|
||||
}
|
||||
type MWSettingsDataWrapper = MWSettingsData & MWSettingsDataSetters;
|
||||
const SettingsContext = createContext<MWSettingsDataWrapper>(null as any);
|
||||
@@ -17,7 +21,6 @@ export function SettingsProvider(props: { children: ReactNode }) {
|
||||
return Math.max(min, Math.min(value, max));
|
||||
}
|
||||
const [settings, setSettings] = useStore(SettingsStore);
|
||||
|
||||
const context: MWSettingsDataWrapper = useMemo(() => {
|
||||
const settingsContext: MWSettingsDataWrapper = {
|
||||
...settings,
|
||||
@@ -29,6 +32,14 @@ export function SettingsProvider(props: { children: ReactNode }) {
|
||||
};
|
||||
});
|
||||
},
|
||||
setCaptionLanguage(language) {
|
||||
setSettings((oldSettings) => {
|
||||
const captionSettings = oldSettings.captionSettings;
|
||||
captionSettings.language = language;
|
||||
const newSettings = oldSettings;
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setCaptionDelay(delay: number) {
|
||||
setSettings((oldSettings) => {
|
||||
const captionSettings = oldSettings.captionSettings;
|
||||
@@ -56,7 +67,10 @@ export function SettingsProvider(props: { children: ReactNode }) {
|
||||
setCaptionBackgroundColor(backgroundColor) {
|
||||
setSettings((oldSettings) => {
|
||||
const style = oldSettings.captionSettings.style;
|
||||
style.backgroundColor = backgroundColor;
|
||||
style.backgroundColor = `${style.backgroundColor.substring(
|
||||
0,
|
||||
7
|
||||
)}${backgroundColor.toString(16).padStart(2, "0")}`;
|
||||
const newSettings = oldSettings;
|
||||
return newSettings;
|
||||
});
|
||||
|
@@ -1,11 +1,12 @@
|
||||
import { createVersionedStore } from "@/utils/storage";
|
||||
import { MWSettingsData } from "./types";
|
||||
|
||||
import { MWSettingsData, MWSettingsDataV1 } from "./types";
|
||||
|
||||
export const SettingsStore = createVersionedStore<MWSettingsData>()
|
||||
.setKey("mw-settings")
|
||||
.addVersion({
|
||||
version: 0,
|
||||
create(): MWSettingsData {
|
||||
create(): MWSettingsDataV1 {
|
||||
return {
|
||||
language: "en",
|
||||
captionSettings: {
|
||||
@@ -18,5 +19,31 @@ export const SettingsStore = createVersionedStore<MWSettingsData>()
|
||||
},
|
||||
};
|
||||
},
|
||||
migrate(data: MWSettingsDataV1): MWSettingsData {
|
||||
return {
|
||||
language: data.language,
|
||||
captionSettings: {
|
||||
language: "none",
|
||||
...data.captionSettings,
|
||||
},
|
||||
};
|
||||
},
|
||||
})
|
||||
.addVersion({
|
||||
version: 1,
|
||||
create(): MWSettingsData {
|
||||
return {
|
||||
language: "en",
|
||||
captionSettings: {
|
||||
delay: 0,
|
||||
language: "none",
|
||||
style: {
|
||||
color: "#ffffff",
|
||||
fontSize: 25,
|
||||
backgroundColor: "#00000096",
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
})
|
||||
.build();
|
||||
|
@@ -1,3 +1,5 @@
|
||||
import { LangCode } from "@/setup/iso6391";
|
||||
|
||||
export interface CaptionStyleSettings {
|
||||
color: string;
|
||||
/**
|
||||
@@ -7,7 +9,7 @@ export interface CaptionStyleSettings {
|
||||
backgroundColor: string;
|
||||
}
|
||||
|
||||
export interface CaptionSettings {
|
||||
export interface CaptionSettingsV1 {
|
||||
/**
|
||||
* Range is [-10, 10]s
|
||||
*/
|
||||
@@ -15,7 +17,20 @@ export interface CaptionSettings {
|
||||
style: CaptionStyleSettings;
|
||||
}
|
||||
|
||||
export interface CaptionSettings {
|
||||
language: LangCode;
|
||||
/**
|
||||
* Range is [-10, 10]s
|
||||
*/
|
||||
delay: number;
|
||||
style: CaptionStyleSettings;
|
||||
}
|
||||
export interface MWSettingsDataV1 {
|
||||
language: LangCode;
|
||||
captionSettings: CaptionSettingsV1;
|
||||
}
|
||||
|
||||
export interface MWSettingsData {
|
||||
language: string;
|
||||
language: LangCode;
|
||||
captionSettings: CaptionSettings;
|
||||
}
|
||||
|
@@ -1,16 +1,18 @@
|
||||
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||
import { MWMediaType } from "@/backend/metadata/types";
|
||||
import { useStore } from "@/utils/storage";
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||
import { useStore } from "@/utils/storage";
|
||||
|
||||
import { VideoProgressStore } from "./store";
|
||||
import { StoreMediaItem, WatchedStoreItem, WatchedStoreData } from "./types";
|
||||
import { StoreMediaItem, WatchedStoreData, WatchedStoreItem } from "./types";
|
||||
|
||||
const FIVETEEN_MINUTES = 15 * 60;
|
||||
const FIVE_MINUTES = 5 * 60;
|
||||
|
@@ -1,7 +1,8 @@
|
||||
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";
|
||||
|
||||
interface OldMediaBase {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user