mirror of
https://github.com/movie-web/movie-web.git
synced 2025-09-13 13:33:25 +00:00
Compare commits
204 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
173f1f2f90 | ||
|
3accf133f1 | ||
|
6152e1881d | ||
|
47eba8caa4 | ||
|
1dc957b56a | ||
|
e653c72d87 | ||
|
c39d61cf53 | ||
|
b14a73378f | ||
|
43d1e290fc | ||
|
1f6318360e | ||
|
791299dd43 | ||
|
2c92bbf94e | ||
|
e3569c7ed7 | ||
|
196a805d32 | ||
|
94d6d7b37e | ||
|
fde5f0c82e | ||
|
bb449d6dfb | ||
|
bb8b21324b | ||
|
53fe6031d1 | ||
|
ee9400373d | ||
|
6c8cc63cbc | ||
|
8c105e78b5 | ||
|
28f253c542 | ||
|
38fa25da2c | ||
|
efb9a7a076 | ||
|
5eab635f19 | ||
|
c1dceab8eb | ||
|
e202229766 | ||
|
2e3684eaad | ||
|
31fcd22822 | ||
|
1524a3af39 | ||
|
072b2d134b | ||
|
606e55d552 | ||
|
0b8aeb1832 | ||
|
3bd2bb4b2c | ||
|
6e8e323417 | ||
|
50fdf230a1 | ||
|
765cf2a17a | ||
|
2d431595cd | ||
|
3bceb2a905 | ||
|
e9c0e64cf0 | ||
|
d9c944a8fa | ||
|
bd40165bc2 | ||
|
65c7a461d7 | ||
|
3103ecd004 | ||
|
c744d3bc7d | ||
|
e0009c8f29 | ||
|
f6af13f7a6 | ||
|
f7ebb6ed89 | ||
|
468ee4dcf6 | ||
|
bc21fa4749 | ||
|
3cdb056d43 | ||
|
6a926ec7fe | ||
|
0c18d8f04b | ||
|
6705683c19 | ||
|
91f9f56174 | ||
|
ce71a2d638 | ||
|
d2d710ad37 | ||
|
8d82ee5f88 | ||
|
b1663a919f | ||
|
bc3848fae4 | ||
|
76a12b8f7a | ||
|
8d0cd59d85 | ||
|
8b1a5bce4a | ||
|
9b852f12cf | ||
|
81f0425755 | ||
|
a9a3eac4ea | ||
|
06e54886e5 | ||
|
ce00f1c5c2 | ||
|
244c603ad7 | ||
|
ea52156bb8 | ||
|
1c6b0ae3e8 | ||
|
00e25f1ae4 | ||
|
6aa0c86e42 | ||
|
fcf8a9e755 | ||
|
e5e45c4fa0 | ||
|
f68c8148d8 | ||
|
4563ea2c18 | ||
|
eea9c19b56 | ||
|
c4c7816543 | ||
|
545120d5cc | ||
|
4ff3e43c78 | ||
|
845fd93597 | ||
|
e0bf711a79 | ||
|
9fbba7ea55 | ||
|
f892a3037f | ||
|
394271857f | ||
|
f5f69ca7d4 | ||
|
1c17ef679d | ||
|
09f6a3125b | ||
|
436fb2707b | ||
|
a46cfa43d3 | ||
|
dccab9b0bf | ||
|
7c3d4aac27 | ||
|
1408fcde93 | ||
|
89cdf74b2f | ||
|
984d215312 | ||
|
430486a9b9 | ||
|
9495a3bf41 | ||
|
33b67f32b1 | ||
|
03d414a200 | ||
|
81b22b0473 | ||
|
5c50155718 | ||
|
102d252f82 | ||
|
969aa6156e | ||
|
d19f0cf305 | ||
|
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 |
3
.github/CODEOWNERS
vendored
Normal file
3
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
* @movie-web/core
|
||||||
|
|
||||||
|
.github @binaryoverload
|
128
.github/CODE_OF_CONDUCT.md
vendored
Normal file
128
.github/CODE_OF_CONDUCT.md
vendored
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, religion, or sexual identity
|
||||||
|
and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
|
diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the
|
||||||
|
overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or
|
||||||
|
advances of any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email
|
||||||
|
address, without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official e-mail address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement at
|
||||||
|
codeofconduct@movie-web.app.
|
||||||
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series
|
||||||
|
of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or
|
||||||
|
permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within
|
||||||
|
the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.0, available at
|
||||||
|
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||||
|
enforcement ladder](https://github.com/mozilla/diversity).
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
https://www.contributor-covenant.org/faq. Translations are available at
|
||||||
|
https://www.contributor-covenant.org/translations.
|
112
.github/CONTRIBUTING.md
vendored
Normal file
112
.github/CONTRIBUTING.md
vendored
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# Contributing Guidelines for movie-web
|
||||||
|
|
||||||
|
Thank you for investing your time in contributing to our project! Your contribution will be reflected on [movie-web.app](https://movie-web.app).
|
||||||
|
|
||||||
|
Please read our [Code of Conduct](./CODE_OF_CONDUCT.md) to keep our community approachable and respectable.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
- [New Contributor Guide](#new-contributor-guide)
|
||||||
|
- [Requesting a feature or reporting a bug](#requesting-a-feature-or-reporting-a-bug)
|
||||||
|
- [Discord Server](#discord-server)
|
||||||
|
- [GitHub Issues](#github-issues)
|
||||||
|
- [Before you start](#before-you-start)
|
||||||
|
- [Contributing](#before-you-start)
|
||||||
|
- [Recommended Development Environment](#recommended-development-environment)
|
||||||
|
- [Tips](#tips)
|
||||||
|
- [Language Contributions](#language-contributions)
|
||||||
|
|
||||||
|
## New contributor guide
|
||||||
|
|
||||||
|
To get an overview of the project, read the [README](README.md). Here are some resources to help you get started with open-source contributions:
|
||||||
|
|
||||||
|
- [Finding ways to contribute to open-source on GitHub](https://docs.github.com/en/get-started/exploring-projects-on-github/finding-ways-to-contribute-to-open-source-on-github)
|
||||||
|
- [Set up Git](https://docs.github.com/en/get-started/quickstart/set-up-git)
|
||||||
|
- [GitHub flow](https://docs.github.com/en/get-started/quickstart/github-flow)
|
||||||
|
- [Collaborating with pull requests](https://docs.github.com/en/github/collaborating-with-pull-requests)
|
||||||
|
|
||||||
|
|
||||||
|
## Requesting a feature or reporting a bug
|
||||||
|
There are two places where to request features or report bugs:
|
||||||
|
- GitHub Issues
|
||||||
|
- The movie-web Discord server
|
||||||
|
|
||||||
|
### Discord Server
|
||||||
|
If you do not have a GitHub account or want to discuss a feature or bug with us before making an issue, you can join our Discord server.
|
||||||
|
|
||||||
|
<a href="https://discord.movie-web.app"><img src="https://discord.com/api/guilds/871713465100816424/widget.png?style=banner2" alt="Discord Server"></a>
|
||||||
|
|
||||||
|
### GitHub Issues
|
||||||
|
To make a GitHub issue for movie-web, please visit the [new issue page](https://github.com/movie-web/movie-web/issues/new/choose) where you can pick either the "Bug Report" or "Feature Request" template.
|
||||||
|
|
||||||
|
When filling out an issue template, please include as much detail as possible and any screenshots or console logs as appropriate.
|
||||||
|
|
||||||
|
After an issue is created, it will be assigned either the https://github.com/movie-web/movie-web/labels/bug or https://github.com/movie-web/movie-web/labels/feature label, along with https://github.com/movie-web/movie-web/labels/awaiting-approval. One of our maintainers will review your issue and, if it's accepted, will set the https://github.com/movie-web/movie-web/labels/approved label.
|
||||||
|
|
||||||
|
## Before you start!
|
||||||
|
Before starting a contribution, please check your contribution is part of an open issue on [our issues page](https://github.com/movie-web/movie-web/issues?q=is%3Aopen+is%3Aissue+label%3Aapproved).
|
||||||
|
|
||||||
|
GitHub issues are how we track our bugs and feature requests that will be implemented into movie-web - all contributions **must** have an issue and be approved by a maintainer before a pull request can be worked on.
|
||||||
|
|
||||||
|
If a pull request is opened before an issue is created and accepted, you may risk having your pull request rejected! Always check with us before starting work on a feature - we don't want to waste your time!
|
||||||
|
|
||||||
|
> **Note**
|
||||||
|
> The exception to this are language contributions, which are discussed in [this section](#language-contributions)
|
||||||
|
|
||||||
|
Also, make sure that the issue you would like to work on has been given the https://github.com/movie-web/movie-web/labels/approved label by a maintainer. Otherwise, if we reject the issue, it means your work will have gone to waste!
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
If you're here because you'd like to work on an issue, amazing! Thank you for even considering contributing to movie-web; it means a lot :heart:
|
||||||
|
|
||||||
|
Firstly, make sure you've read the [Before you start!](#before-you-start) section!
|
||||||
|
|
||||||
|
When you have found a GitHub issue you would like to work on, you can request to be assigned to the issue by commenting on the GitHub issue.
|
||||||
|
|
||||||
|
If you are assigned to an issue but can't complete it for whatever reason, no problem! Just let us know, and we will open up the issue to have someone else assigned.
|
||||||
|
|
||||||
|
### Recommended Development Environment
|
||||||
|
Our recommended development environment to work on movie-web is:
|
||||||
|
- [Visual Studio Code](https://code.visualstudio.com/)
|
||||||
|
- [ESLint Extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint)
|
||||||
|
- [EditorConfig Extension](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig)
|
||||||
|
|
||||||
|
When opening Visual Studio Code, you will be prompted to install our recommended extensions if they are not installed for you.
|
||||||
|
|
||||||
|
Our project is set up to enforce formatting and code style standards using ESLint.
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
Here are some tips to make sure that your pull requests are :pinched_fingers: first time:
|
||||||
|
|
||||||
|
- KISS - Keep It Simple Soldier! - Simple code makes readable and efficient code!
|
||||||
|
- Follow standard best practices for TypeScript and React.
|
||||||
|
- Keep as much as possible to the style of movie-web. Look around our codebase to familiarise yourself with how we do things!
|
||||||
|
- Ensure to take note of the ESLint errors and warnings! **Do not ignore them!** They are there for a reason.
|
||||||
|
- Test, test, test! Make sure you thoroughly test the features you are contributing.
|
||||||
|
|
||||||
|
### Language Contributions
|
||||||
|
Language contributions help movie-web massively, allowing people worldwide to use our app!
|
||||||
|
|
||||||
|
Like most apps, our translations are stored in `.json` files. Each language string has a unique key (For example, `notFound.genericTitle`) that references a language string in the appropriate language file.
|
||||||
|
|
||||||
|
Each language file is called `translation.json` and is stored in the `src/setup/languages/<language code>/` folder. For example, the English language file is located at `src/setup/languages/en/translation.json`.
|
||||||
|
|
||||||
|
> **Warning**
|
||||||
|
>
|
||||||
|
> Before you start a translation, please:
|
||||||
|
> - Check there isn't an existing GitHub [issue](https://github.com/movie-web/movie-web/issues) or [pull request](https://github.com/movie-web/movie-web/pulls) open for the language.
|
||||||
|
> - Make sure we aren't in the middle of a new feature update. When releasing major versions, we only accept changes to translations once the new version is complete. Otherwise, the language files would need to be updated.
|
||||||
|
>
|
||||||
|
> Please speak to us before starting a language PR. We want to use your time effectively.
|
||||||
|
|
||||||
|
To make a translation:
|
||||||
|
- Copy the `en` folder inside the `src/setup/languages` folder
|
||||||
|
- Rename the copied folder to the 2-letter code for the country/language which is being translated.
|
||||||
|
- [Click this link to see a list of codes](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes). Use the codes in the `639-1` column!
|
||||||
|
- For example, Arabic is `ar`
|
||||||
|
- Edit the language strings inside the `translation.json` file
|
||||||
|
- **Do not** edit the keys. Only edit the values.
|
||||||
|
- e.g. in `"stopEditing": "Stop editing",` - only change the `Stop editing` part, not the `stopEditing` part.
|
||||||
|
- In the `src/setup/i18n.ts` file:
|
||||||
|
- Import your new translation file, e.g. `import ar from "./locales/ar/translation.json";`
|
||||||
|
- Add your translation to the `locales` object (Look at other languages for an example)
|
||||||
|
|
||||||
|
Once you have completed your translation, please open a pull request. We do not accept partial translations, so please ensure every language string is translated to the intended language.
|
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -1,7 +1,7 @@
|
|||||||
name: Bug Report
|
name: Bug Report
|
||||||
description: File a bug report
|
description: File a bug report
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["bug"]
|
labels: ["bug", "awaiting-approval"]
|
||||||
assignees: []
|
assignees: []
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
|
4
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
4
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@@ -1,7 +1,7 @@
|
|||||||
name: Feature request
|
name: Feature Request
|
||||||
description: Suggest a new feature
|
description: Suggest a new feature
|
||||||
title: "[Feature]: "
|
title: "[Feature]: "
|
||||||
labels: ["enhancement"]
|
labels: ["feature", "awaiting-approval"]
|
||||||
assignees: []
|
assignees: []
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
13
.github/SECURITY.md
vendored
Normal file
13
.github/SECURITY.md
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
The movie-web maintainers only support the latest version of movie-web published at https://movie-web.app.
|
||||||
|
|
||||||
|
Support is not provided for any forks or mirrors of movie-web.
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
There are two ways you can contact the movie-web maintainers to report a vulnerability:
|
||||||
|
- Email [security@movie-web.app](mailto:security@movie-web.app)
|
||||||
|
- Report the vulnerability in the [movie-web Discord server](https://discord.movie-web.app)
|
6
.github/pull_request_template.md
vendored
Normal file
6
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
This pull request resolves #XXX
|
||||||
|
|
||||||
|
- [ ] I have read and agreed to the [code of conduct](https://github.com/movie-web/movie-web/blob/dev/.github/CODE_OF_CONDUCT.md).
|
||||||
|
- [ ] I have read and complied with the [contributing guidelines](https://github.com/movie-web/movie-web/blob/dev/.github/CONTRIBUTING.md).
|
||||||
|
- [ ] What I'm implementing was assigned to me and is an [approved issue](https://github.com/movie-web/movie-web/issues?q=is%3Aopen+is%3Aissue+label%3Aapproved). For reference, please take a look at our [GitHub projects](https://github.com/movie-web/movie-web/projects).
|
||||||
|
- [ ] I have tested all of my changes.
|
@@ -5,7 +5,7 @@
|
|||||||
<a href="https://github.com/movie-web/movie-web/blob/master/LICENSE.md"><img alt="GitHub license" src="https://img.shields.io/github/license/movie-web/movie-web?style=flat-square"></a>
|
<a href="https://github.com/movie-web/movie-web/blob/master/LICENSE.md"><img alt="GitHub license" src="https://img.shields.io/github/license/movie-web/movie-web?style=flat-square"></a>
|
||||||
<a href="https://github.com/movie-web/movie-web/network"><img alt="GitHub forks" src="https://img.shields.io/github/forks/movie-web/movie-web?style=flat-square"></a>
|
<a href="https://github.com/movie-web/movie-web/network"><img alt="GitHub forks" src="https://img.shields.io/github/forks/movie-web/movie-web?style=flat-square"></a>
|
||||||
<a href="https://github.com/movie-web/movie-web/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/movie-web/movie-web?style=flat-square"></a><br/>
|
<a href="https://github.com/movie-web/movie-web/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/movie-web/movie-web?style=flat-square"></a><br/>
|
||||||
<a href="https://discord.gg/vXsRvye8BS"><img src="https://discordapp.com/api/guilds/871713465100816424/widget.png?style=banner2" alt="Discord Server"></a>
|
<a href="https://discord.movie-web.app"><img src="https://discord.com/api/guilds/871713465100816424/widget.png?style=banner2" alt="Discord Server"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
movie-web is a web app for watching movies easily. Check it out at **[movie-web.app](https://movie-web.app)**.
|
movie-web is a web app for watching movies easily. Check it out at **[movie-web.app](https://movie-web.app)**.
|
||||||
|
@@ -1,38 +1,41 @@
|
|||||||
# Selfhosting tutorial
|
# Self-hosting tutorial
|
||||||
|
|
||||||
> **Note:** We do not provide support on how to selfhost, if you cant figure it out then tough luck. Please do not make Github issues or ask in our Discord server for support on how to selfhost.
|
> **Note**
|
||||||
|
> We **do not** provide support on how to self-host. If you can't figure it out then tough luck. Please do not make GitHub issues or ask in our Discord server for support on how to self-host.
|
||||||
|
|
||||||
So you wanna selfhost. This app is made of two parts:
|
So you would like to self-host. This app is made of two parts:
|
||||||
- The proxy
|
- The proxy
|
||||||
- The client
|
- The client
|
||||||
|
|
||||||
## Hosting the proxy
|
## Hosting the proxy
|
||||||
|
|
||||||
The proxy is made as a cloudflare worker, cloudflare has a generous free plan, so you don't need to pay anything unless you get hundreds of users.
|
The proxy is made as a Cloudflare worker. Cloudflare has a generous free plan, so you don't need to pay anything unless you get hundreds of users.
|
||||||
|
|
||||||
1. Create a cloudflare account at [https://dash.cloudflare.com](https://dash.cloudflare.com)
|
1. Create a Cloudflare account at [https://dash.cloudflare.com](https://dash.cloudflare.com).
|
||||||
2. Navigate to `Workers`.
|
2. Navigate to `Workers`.
|
||||||
3. If it asks you, choose a subdomain
|
3. If it asks you, choose a subdomain.
|
||||||
4. If it asks for a workers plan, press "Continue with free"
|
4. If it asks for a workers plan, press "Continue with free".
|
||||||
5. Create a new service with a name of your choice. Must be type `HTTP handler`
|
5. Create a new service with a name of your choice. Must be type `HTTP handler`.
|
||||||
6. On the service page, Click `Quick edit`
|
6. On the service page, Click `Quick edit`.
|
||||||
7. Download the `worker.js` file from the latest release of the proxy: [https://github.com/movie-web/simple-proxy/releases/latest](https://github.com/movie-web/simple-proxy/releases/latest)
|
7. Remove the template code in the quick edit window.
|
||||||
8. Open the downloaded `worker.js` file in notepad, VScode or similar.
|
7. Download the `worker.js` file from the latest release of the proxy: [https://github.com/movie-web/simple-proxy/releases/latest](https://github.com/movie-web/simple-proxy/releases/latest).
|
||||||
|
8. Open the downloaded `worker.js` file in Notepad, Visual Studio Code or similar.
|
||||||
9. Copy the text contents of the `worker.js` file.
|
9. Copy the text contents of the `worker.js` file.
|
||||||
10. Paste the text contents into the edit screen of the cloudflare service worker.
|
10. Paste the text contents into the edit screen of the Cloudflare service worker.
|
||||||
11. Click `Save and deploy` and confirm.
|
11. Click `Save and deploy` and confirm.
|
||||||
|
|
||||||
Your proxy is now hosted on cloudflare. Note the url of your worker. you will need it later.
|
Your proxy is now hosted on Cloudflare. Note the url of your worker as you will need it later.
|
||||||
|
|
||||||
## Hosting the client
|
## Hosting the client
|
||||||
|
|
||||||
1. Download the file `movie-web.zip` from the latest release: [https://github.com/movie-web/movie-web/releases/latest](https://github.com/movie-web/movie-web/releases/latest)
|
1. Download the file `movie-web.zip` from the latest release: [https://github.com/movie-web/movie-web/releases/latest](https://github.com/movie-web/movie-web/releases/latest).
|
||||||
2. Extract the zip file so you can edit the files.
|
2. Extract the zip file so you can edit the files.
|
||||||
3. Open `config.js` in notepad, VScode or similar.
|
3. Open `config.js` in Notepad, Visual Studio Code or similar.
|
||||||
4. Put your cloudflare proxy URL inbetween the double qoutes of `VITE_CORS_PROXY_URL: "",`. Make sure to not have a slash at the end of your URL.
|
4. Put your Cloudflare proxy URL in-between the double quotes of `VITE_CORS_PROXY_URL: ""`. Make sure to not have a slash at the end of your URL.
|
||||||
|
|
||||||
Example (THIS IS MINE, IT WONT WORK FOR YOU): `VITE_CORS_PROXY_URL: "https://test-proxy.test.workers.dev",`
|
Example (THIS IS AN EXAMPLE, IT WON'T WORK FOR YOU): `VITE_CORS_PROXY_URL: "https://test-proxy.test.workers.dev"`
|
||||||
5. Save the file
|
5. Put your TMDB read access token inside the quotes of `VITE_TMDB_READ_API_KEY: ""`. You can generate it for free at [https://www.themoviedb.org/settings/api](https://www.themoviedb.org/settings/api).
|
||||||
|
6. Save the file
|
||||||
|
|
||||||
Your client has been prepared, you can now host it on any webhost.
|
Your client has now been prepared, you can now host it with any static website hosting (Common ones include [GitHub Pages](https://pages.github.com/), [Netlify](https://www.netlify.com/) and [Vercel](https://vercel.com/) but any will work!).
|
||||||
It doesn't require php, its just a standard static page.
|
It doesn't require PHP, it's just a standard static page.
|
||||||
|
@@ -1,6 +1,3 @@
|
|||||||
# make sure the cors proxy url does NOT have a slash at the end
|
# make sure the cors proxy url does NOT have a slash at the end
|
||||||
VITE_CORS_PROXY_URL=...
|
VITE_CORS_PROXY_URL=...
|
||||||
|
VITE_TMDB_READ_API_KEY=...
|
||||||
# the keys below are optional - defaults are provided
|
|
||||||
VITE_TMDB_API_KEY=...
|
|
||||||
VITE_OMDB_API_KEY=...
|
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "movie-web",
|
"name": "movie-web",
|
||||||
"version": "3.0.15",
|
"version": "3.2.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"homepage": "https://movie-web.app",
|
"homepage": "https://movie-web.app",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
window.__CONFIG__ = {
|
window.__CONFIG__ = {
|
||||||
// url must NOT end with a slash
|
// url must NOT end with a slash
|
||||||
VITE_CORS_PROXY_URL: "",
|
VITE_CORS_PROXY_URL: "",
|
||||||
VITE_TMDB_API_KEY: "b030404650f279792a8d3287232358e3",
|
VITE_TMDB_READ_API_KEY: ""
|
||||||
VITE_OMDB_API_KEY: "aa0937c0",
|
|
||||||
};
|
};
|
||||||
|
@@ -4,7 +4,7 @@ import "@/backend";
|
|||||||
import { testData } from "@/__tests__/providers/testdata";
|
import { testData } from "@/__tests__/providers/testdata";
|
||||||
import { getProviders } from "@/backend/helpers/register";
|
import { getProviders } from "@/backend/helpers/register";
|
||||||
import { runProvider } from "@/backend/helpers/run";
|
import { runProvider } from "@/backend/helpers/run";
|
||||||
import { MWMediaType } from "@/backend/metadata/types";
|
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||||
|
|
||||||
describe("providers", () => {
|
describe("providers", () => {
|
||||||
const providers = getProviders();
|
const providers = getProviders();
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||||
import { MWMediaType } from "@/backend/metadata/types";
|
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||||
|
|
||||||
export const testData: DetailedMeta[] = [
|
export const testData: DetailedMeta[] = [
|
||||||
{
|
{
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
135
src/backend/embeds/upcloud.ts
Normal file
135
src/backend/embeds/upcloud.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { AES, enc } from "crypto-js";
|
||||||
|
|
||||||
|
import { MWEmbedType } from "@/backend/helpers/embed";
|
||||||
|
import { registerEmbedScraper } from "@/backend/helpers/register";
|
||||||
|
import {
|
||||||
|
MWCaptionType,
|
||||||
|
MWStreamQuality,
|
||||||
|
MWStreamType,
|
||||||
|
} from "@/backend/helpers/streams";
|
||||||
|
|
||||||
|
import { proxiedFetch } from "../helpers/fetch";
|
||||||
|
|
||||||
|
interface StreamRes {
|
||||||
|
server: number;
|
||||||
|
sources: string;
|
||||||
|
tracks: {
|
||||||
|
file: string;
|
||||||
|
kind: "captions" | "thumbnails";
|
||||||
|
label: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJSON(json: string) {
|
||||||
|
try {
|
||||||
|
JSON.parse(json);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractKey(script: string): [number, number][] | null {
|
||||||
|
const startOfSwitch = script.lastIndexOf("switch");
|
||||||
|
const endOfCases = script.indexOf("partKeyStartPosition");
|
||||||
|
const switchBody = script.slice(startOfSwitch, endOfCases);
|
||||||
|
|
||||||
|
const nums: [number, number][] = [];
|
||||||
|
const matches = switchBody.matchAll(
|
||||||
|
/:[a-zA-Z0-9]+=([a-zA-Z0-9]+),[a-zA-Z0-9]+=([a-zA-Z0-9]+);/g
|
||||||
|
);
|
||||||
|
for (const match of matches) {
|
||||||
|
const innerNumbers: number[] = [];
|
||||||
|
for (const varMatch of [match[1], match[2]]) {
|
||||||
|
const regex = new RegExp(`${varMatch}=0x([a-zA-Z0-9]+)`, "g");
|
||||||
|
const varMatches = [...script.matchAll(regex)];
|
||||||
|
const lastMatch = varMatches[varMatches.length - 1];
|
||||||
|
if (!lastMatch) return null;
|
||||||
|
const number = parseInt(lastMatch[1], 16);
|
||||||
|
innerNumbers.push(number);
|
||||||
|
}
|
||||||
|
|
||||||
|
nums.push([innerNumbers[0], innerNumbers[1]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerEmbedScraper({
|
||||||
|
id: "upcloud",
|
||||||
|
displayName: "UpCloud",
|
||||||
|
for: MWEmbedType.UPCLOUD,
|
||||||
|
rank: 200,
|
||||||
|
async getStream({ url }) {
|
||||||
|
// Example url: https://dokicloud.one/embed-4/{id}?z=
|
||||||
|
const parsedUrl = new URL(url.replace("embed-5", "embed-4"));
|
||||||
|
|
||||||
|
const dataPath = parsedUrl.pathname.split("/");
|
||||||
|
const dataId = dataPath[dataPath.length - 1];
|
||||||
|
|
||||||
|
const streamRes = await proxiedFetch<StreamRes>(
|
||||||
|
`${parsedUrl.origin}/ajax/embed-4/getSources?id=${dataId}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Referer: parsedUrl.origin,
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let sources: { file: string; type: string } | null = null;
|
||||||
|
|
||||||
|
if (!isJSON(streamRes.sources)) {
|
||||||
|
const scriptJs = await proxiedFetch<string>(
|
||||||
|
`https://rabbitstream.net/js/player/prod/e4-player.min.js`,
|
||||||
|
{
|
||||||
|
responseType: "text" as any,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const decryptionKey = extractKey(scriptJs);
|
||||||
|
if (!decryptionKey) throw new Error("Key extraction failed");
|
||||||
|
|
||||||
|
let extractedKey = "";
|
||||||
|
let strippedSources = streamRes.sources;
|
||||||
|
let totalledOffset = 0;
|
||||||
|
decryptionKey.forEach(([a, b]) => {
|
||||||
|
const start = a + totalledOffset;
|
||||||
|
const end = start + b;
|
||||||
|
extractedKey += streamRes.sources.slice(start, end);
|
||||||
|
strippedSources = strippedSources.replace(
|
||||||
|
streamRes.sources.substring(start, end),
|
||||||
|
""
|
||||||
|
);
|
||||||
|
totalledOffset += b;
|
||||||
|
});
|
||||||
|
|
||||||
|
const decryptedStream = AES.decrypt(
|
||||||
|
strippedSources,
|
||||||
|
extractedKey
|
||||||
|
).toString(enc.Utf8);
|
||||||
|
const parsedStream = JSON.parse(decryptedStream)[0];
|
||||||
|
if (!parsedStream) throw new Error("No stream found");
|
||||||
|
sources = parsedStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sources) throw new Error("upcloud source not found");
|
||||||
|
|
||||||
|
return {
|
||||||
|
embedId: MWEmbedType.UPCLOUD,
|
||||||
|
streamUrl: sources.file,
|
||||||
|
quality: MWStreamQuality.Q1080P,
|
||||||
|
type: MWStreamType.HLS,
|
||||||
|
captions: streamRes.tracks
|
||||||
|
.filter((sub) => sub.kind === "captions")
|
||||||
|
.map((sub) => {
|
||||||
|
return {
|
||||||
|
langIso: sub.label,
|
||||||
|
url: sub.file,
|
||||||
|
type: sub.file.endsWith("vtt")
|
||||||
|
? MWCaptionType.VTT
|
||||||
|
: MWCaptionType.UNKNOWN,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
@@ -4,6 +4,9 @@ export enum MWEmbedType {
|
|||||||
M4UFREE = "m4ufree",
|
M4UFREE = "m4ufree",
|
||||||
STREAMM4U = "streamm4u",
|
STREAMM4U = "streamm4u",
|
||||||
PLAYM4U = "playm4u",
|
PLAYM4U = "playm4u",
|
||||||
|
UPCLOUD = "upcloud",
|
||||||
|
STREAMSB = "streamsb",
|
||||||
|
MP4UPLOAD = "mp4upload",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MWEmbed = {
|
export type MWEmbed = {
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { MWEmbed } from "./embed";
|
import { MWEmbed } from "./embed";
|
||||||
import { MWStream } from "./streams";
|
import { MWStream } from "./streams";
|
||||||
import { DetailedMeta } from "../metadata/getmeta";
|
import { DetailedMeta } from "../metadata/getmeta";
|
||||||
import { MWMediaType } from "../metadata/types";
|
import { MWMediaType } from "../metadata/types/mw";
|
||||||
|
|
||||||
export type MWProviderScrapeResult = {
|
export type MWProviderScrapeResult = {
|
||||||
stream?: MWStream;
|
stream?: MWStream;
|
||||||
|
@@ -3,7 +3,7 @@ import { getEmbedScraperByType, getProviders } from "./register";
|
|||||||
import { runEmbedScraper, runProvider } from "./run";
|
import { runEmbedScraper, runProvider } from "./run";
|
||||||
import { MWStream } from "./streams";
|
import { MWStream } from "./streams";
|
||||||
import { DetailedMeta } from "../metadata/getmeta";
|
import { DetailedMeta } from "../metadata/getmeta";
|
||||||
import { MWMediaType } from "../metadata/types";
|
import { MWMediaType } from "../metadata/types/mw";
|
||||||
|
|
||||||
interface MWProgressData {
|
interface MWProgressData {
|
||||||
type: "embed" | "provider";
|
type: "embed" | "provider";
|
||||||
|
@@ -8,9 +8,17 @@ import "./providers/netfilm";
|
|||||||
import "./providers/m4ufree";
|
import "./providers/m4ufree";
|
||||||
import "./providers/hdwatched";
|
import "./providers/hdwatched";
|
||||||
import "./providers/2embed";
|
import "./providers/2embed";
|
||||||
|
import "./providers/sflix";
|
||||||
|
import "./providers/gomovies";
|
||||||
|
import "./providers/kissasian";
|
||||||
|
import "./providers/streamflix";
|
||||||
|
import "./providers/remotestream";
|
||||||
|
|
||||||
// embeds
|
// embeds
|
||||||
import "./embeds/streamm4u";
|
import "./embeds/streamm4u";
|
||||||
import "./embeds/playm4u";
|
import "./embeds/playm4u";
|
||||||
|
import "./embeds/upcloud";
|
||||||
|
import "./embeds/streamsb";
|
||||||
|
import "./embeds/mp4upload";
|
||||||
|
|
||||||
initializeScraperStore();
|
initializeScraperStore();
|
||||||
|
@@ -1,13 +1,28 @@
|
|||||||
import { FetchError } from "ofetch";
|
import { FetchError } from "ofetch";
|
||||||
|
|
||||||
|
import { formatJWMeta, mediaTypeToJW } from "./justwatch";
|
||||||
|
import {
|
||||||
|
TMDBMediaToMediaType,
|
||||||
|
formatTMDBMeta,
|
||||||
|
getEpisodes,
|
||||||
|
getExternalIds,
|
||||||
|
getMediaDetails,
|
||||||
|
getMediaPoster,
|
||||||
|
getMovieFromExternalId,
|
||||||
|
mediaTypeToTMDB,
|
||||||
|
} from "./tmdb";
|
||||||
import {
|
import {
|
||||||
JWMediaResult,
|
JWMediaResult,
|
||||||
JWSeasonMetaResult,
|
JWSeasonMetaResult,
|
||||||
JW_API_BASE,
|
JW_API_BASE,
|
||||||
formatJWMeta,
|
} from "./types/justwatch";
|
||||||
mediaTypeToJW,
|
import { MWMediaMeta, MWMediaType } from "./types/mw";
|
||||||
} from "./justwatch";
|
import {
|
||||||
import { MWMediaMeta, MWMediaType } from "./types";
|
TMDBMediaResult,
|
||||||
|
TMDBMovieData,
|
||||||
|
TMDBSeasonMetaResult,
|
||||||
|
TMDBShowData,
|
||||||
|
} from "./types/tmdb";
|
||||||
import { makeUrl, proxiedFetch } from "../helpers/fetch";
|
import { makeUrl, proxiedFetch } from "../helpers/fetch";
|
||||||
|
|
||||||
type JWExternalIdType =
|
type JWExternalIdType =
|
||||||
@@ -33,10 +48,92 @@ export interface DetailedMeta {
|
|||||||
tmdbId?: string;
|
tmdbId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatTMDBMetaResult(
|
||||||
|
details: TMDBShowData | TMDBMovieData,
|
||||||
|
type: MWMediaType
|
||||||
|
): TMDBMediaResult {
|
||||||
|
if (type === MWMediaType.MOVIE) {
|
||||||
|
const movie = details as TMDBMovieData;
|
||||||
|
return {
|
||||||
|
id: details.id,
|
||||||
|
title: movie.title,
|
||||||
|
object_type: mediaTypeToTMDB(type),
|
||||||
|
poster: getMediaPoster(movie.poster_path) ?? undefined,
|
||||||
|
original_release_year: new Date(movie.release_date).getFullYear(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === MWMediaType.SERIES) {
|
||||||
|
const show = details as TMDBShowData;
|
||||||
|
return {
|
||||||
|
id: details.id,
|
||||||
|
title: show.name,
|
||||||
|
object_type: mediaTypeToTMDB(type),
|
||||||
|
seasons: show.seasons.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
season_number: v.season_number,
|
||||||
|
title: v.name,
|
||||||
|
})),
|
||||||
|
poster: getMediaPoster(show.poster_path) ?? undefined,
|
||||||
|
original_release_year: new Date(show.first_air_date).getFullYear(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("unsupported type");
|
||||||
|
}
|
||||||
|
|
||||||
export async function getMetaFromId(
|
export async function getMetaFromId(
|
||||||
type: MWMediaType,
|
type: MWMediaType,
|
||||||
id: string,
|
id: string,
|
||||||
seasonId?: string
|
seasonId?: string
|
||||||
|
): Promise<DetailedMeta | null> {
|
||||||
|
const details = await getMediaDetails(id, mediaTypeToTMDB(type));
|
||||||
|
|
||||||
|
if (!details) return null;
|
||||||
|
|
||||||
|
const externalIds = await getExternalIds(id, mediaTypeToTMDB(type));
|
||||||
|
const imdbId = externalIds.imdb_id ?? undefined;
|
||||||
|
|
||||||
|
let seasonData: TMDBSeasonMetaResult | undefined;
|
||||||
|
|
||||||
|
if (type === MWMediaType.SERIES) {
|
||||||
|
const seasons = (details as TMDBShowData).seasons;
|
||||||
|
|
||||||
|
let selectedSeason = seasons.find((v) => v.id.toString() === seasonId);
|
||||||
|
if (!selectedSeason) {
|
||||||
|
selectedSeason = seasons.find((v) => v.season_number === 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedSeason) {
|
||||||
|
const episodes = await getEpisodes(
|
||||||
|
details.id.toString(),
|
||||||
|
selectedSeason.season_number
|
||||||
|
);
|
||||||
|
|
||||||
|
seasonData = {
|
||||||
|
id: selectedSeason.id.toString(),
|
||||||
|
season_number: selectedSeason.season_number,
|
||||||
|
title: selectedSeason.name,
|
||||||
|
episodes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmdbmeta = formatTMDBMetaResult(details, type);
|
||||||
|
if (!tmdbmeta) return null;
|
||||||
|
const meta = formatTMDBMeta(tmdbmeta, seasonData);
|
||||||
|
if (!meta) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta,
|
||||||
|
imdbId,
|
||||||
|
tmdbId: id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLegacyMetaFromId(
|
||||||
|
type: MWMediaType,
|
||||||
|
id: string,
|
||||||
|
seasonId?: string
|
||||||
): Promise<DetailedMeta | null> {
|
): Promise<DetailedMeta | null> {
|
||||||
const queryType = mediaTypeToJW(type);
|
const queryType = mediaTypeToJW(type);
|
||||||
|
|
||||||
@@ -82,3 +179,55 @@ export async function getMetaFromId(
|
|||||||
tmdbId,
|
tmdbId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TMDBMediaToId(media: MWMediaMeta): string {
|
||||||
|
return ["tmdb", mediaTypeToTMDB(media.type), media.id].join("-");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeTMDBId(
|
||||||
|
paramId: string
|
||||||
|
): { id: string; type: MWMediaType } | null {
|
||||||
|
const [prefix, type, id] = paramId.split("-", 3);
|
||||||
|
if (prefix !== "tmdb") return null;
|
||||||
|
let mediaType;
|
||||||
|
try {
|
||||||
|
mediaType = TMDBMediaToMediaType(type);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: mediaType,
|
||||||
|
id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLegacyUrl(url: string): boolean {
|
||||||
|
if (url.startsWith("/media/JW")) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function convertLegacyUrl(
|
||||||
|
url: string
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (!isLegacyUrl(url)) return undefined;
|
||||||
|
|
||||||
|
const urlParts = url.split("/").slice(2);
|
||||||
|
const [, type, id] = urlParts[0].split("-", 3);
|
||||||
|
|
||||||
|
const mediaType = TMDBMediaToMediaType(type);
|
||||||
|
const meta = await getLegacyMetaFromId(mediaType, id);
|
||||||
|
|
||||||
|
if (!meta) return undefined;
|
||||||
|
const { tmdbId, imdbId } = meta;
|
||||||
|
if (!tmdbId && !imdbId) return undefined;
|
||||||
|
|
||||||
|
// movies always have an imdb id on tmdb
|
||||||
|
if (imdbId && mediaType === MWMediaType.MOVIE) {
|
||||||
|
const movieId = await getMovieFromExternalId(imdbId);
|
||||||
|
if (movieId) return `/media/tmdb-movie-${movieId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tmdbId) {
|
||||||
|
return `/media/tmdb-${type}-${tmdbId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -1,38 +1,10 @@
|
|||||||
import { MWMediaMeta, MWMediaType, MWSeasonMeta } from "./types";
|
import {
|
||||||
|
JWContentTypes,
|
||||||
export const JW_API_BASE = "https://apis.justwatch.com";
|
JWMediaResult,
|
||||||
export const JW_IMAGE_BASE = "https://images.justwatch.com";
|
JWSeasonMetaResult,
|
||||||
|
JW_IMAGE_BASE,
|
||||||
export type JWContentTypes = "movie" | "show";
|
} from "./types/justwatch";
|
||||||
|
import { MWMediaMeta, MWMediaType, MWSeasonMeta } from "./types/mw";
|
||||||
export type JWSeasonShort = {
|
|
||||||
title: string;
|
|
||||||
id: number;
|
|
||||||
season_number: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type JWEpisodeShort = {
|
|
||||||
title: string;
|
|
||||||
id: number;
|
|
||||||
episode_number: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type JWMediaResult = {
|
|
||||||
title: string;
|
|
||||||
poster?: string;
|
|
||||||
id: number;
|
|
||||||
original_release_year?: number;
|
|
||||||
jw_entity_id: string;
|
|
||||||
object_type: JWContentTypes;
|
|
||||||
seasons?: JWSeasonShort[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type JWSeasonMetaResult = {
|
|
||||||
title: string;
|
|
||||||
id: string;
|
|
||||||
season_number: number;
|
|
||||||
episodes: JWEpisodeShort[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function mediaTypeToJW(type: MWMediaType): JWContentTypes {
|
export function mediaTypeToJW(type: MWMediaType): JWContentTypes {
|
||||||
if (type === MWMediaType.MOVIE) return "movie";
|
if (type === MWMediaType.MOVIE) return "movie";
|
||||||
|
@@ -1,14 +1,12 @@
|
|||||||
import { SimpleCache } from "@/utils/cache";
|
import { SimpleCache } from "@/utils/cache";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
JWContentTypes,
|
formatTMDBMeta,
|
||||||
JWMediaResult,
|
formatTMDBSearchResult,
|
||||||
JW_API_BASE,
|
mediaTypeToTMDB,
|
||||||
formatJWMeta,
|
searchMedia,
|
||||||
mediaTypeToJW,
|
} from "./tmdb";
|
||||||
} from "./justwatch";
|
import { MWMediaMeta, MWQuery } from "./types/mw";
|
||||||
import { MWMediaMeta, MWQuery } from "./types";
|
|
||||||
import { proxiedFetch } from "../helpers/fetch";
|
|
||||||
|
|
||||||
const cache = new SimpleCache<MWQuery, MWMediaMeta[]>();
|
const cache = new SimpleCache<MWQuery, MWMediaMeta[]>();
|
||||||
cache.setCompare((a, b) => {
|
cache.setCompare((a, b) => {
|
||||||
@@ -16,44 +14,16 @@ cache.setCompare((a, b) => {
|
|||||||
});
|
});
|
||||||
cache.initialize();
|
cache.initialize();
|
||||||
|
|
||||||
type JWSearchQuery = {
|
|
||||||
content_types: JWContentTypes[];
|
|
||||||
page: number;
|
|
||||||
page_size: number;
|
|
||||||
query: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type JWPage<T> = {
|
|
||||||
items: T[];
|
|
||||||
page: number;
|
|
||||||
page_size: number;
|
|
||||||
total_pages: number;
|
|
||||||
total_results: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function searchForMedia(query: MWQuery): Promise<MWMediaMeta[]> {
|
export async function searchForMedia(query: MWQuery): Promise<MWMediaMeta[]> {
|
||||||
if (cache.has(query)) return cache.get(query) as MWMediaMeta[];
|
if (cache.has(query)) return cache.get(query) as MWMediaMeta[];
|
||||||
const { searchQuery, type } = query;
|
const { searchQuery, type } = query;
|
||||||
|
|
||||||
const contentType = mediaTypeToJW(type);
|
const data = await searchMedia(searchQuery, mediaTypeToTMDB(type));
|
||||||
const body: JWSearchQuery = {
|
const results = data.results.map((v) => {
|
||||||
content_types: [contentType],
|
const formattedResult = formatTMDBSearchResult(v, mediaTypeToTMDB(type));
|
||||||
page: 1,
|
return formatTMDBMeta(formattedResult);
|
||||||
query: searchQuery,
|
});
|
||||||
page_size: 40,
|
|
||||||
};
|
|
||||||
|
|
||||||
const data = await proxiedFetch<JWPage<JWMediaResult>>(
|
cache.set(query, results, 3600); // cache results for 1 hour
|
||||||
"/content/titles/en_US/popular",
|
return results;
|
||||||
{
|
|
||||||
baseURL: JW_API_BASE,
|
|
||||||
params: {
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const returnData = data.items.map<MWMediaMeta>((v) => formatJWMeta(v));
|
|
||||||
cache.set(query, returnData, 3600); // cache for an hour
|
|
||||||
return returnData;
|
|
||||||
}
|
}
|
||||||
|
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_READ_API_KEY}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function get<T>(url: string, params?: object): Promise<T> {
|
||||||
|
const res = await mwFetch<any>(encodeURI(url), {
|
||||||
|
headers,
|
||||||
|
baseURL,
|
||||||
|
params: {
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchMedia(
|
||||||
|
query: string,
|
||||||
|
type: TMDBContentTypes
|
||||||
|
): Promise<TMDBMovieResponse | TMDBShowResponse> {
|
||||||
|
let data;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "movie":
|
||||||
|
data = await get<TMDBMovieResponse>("search/movie", {
|
||||||
|
query,
|
||||||
|
include_adult: false,
|
||||||
|
language: "en-US",
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "show":
|
||||||
|
data = await get<TMDBShowResponse>("search/tv", {
|
||||||
|
query,
|
||||||
|
include_adult: false,
|
||||||
|
language: "en-US",
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error("Invalid media type");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditional type which for inferring the return type based on the content type
|
||||||
|
type MediaDetailReturn<T extends TMDBContentTypes> = T extends "movie"
|
||||||
|
? TMDBMovieData
|
||||||
|
: T extends "show"
|
||||||
|
? TMDBShowData
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export function getMediaDetails<
|
||||||
|
T extends TMDBContentTypes,
|
||||||
|
TReturn = MediaDetailReturn<T>
|
||||||
|
>(id: string, type: T): Promise<TReturn> {
|
||||||
|
if (type === "movie") {
|
||||||
|
return get<TReturn>(`/movie/${id}`);
|
||||||
|
}
|
||||||
|
if (type === "show") {
|
||||||
|
return get<TReturn>(`/tv/${id}`);
|
||||||
|
}
|
||||||
|
throw new Error("Invalid media type");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMediaPoster(posterPath: string | null): string | undefined {
|
||||||
|
if (posterPath) return `https://image.tmdb.org/t/p/w185/${posterPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEpisodes(
|
||||||
|
id: string,
|
||||||
|
season: number
|
||||||
|
): Promise<TMDBEpisodeShort[]> {
|
||||||
|
const data = await get<TMDBSeason>(`/tv/${id}/season/${season}`);
|
||||||
|
return data.episodes.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
episode_number: e.episode_number,
|
||||||
|
title: e.name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExternalIds(
|
||||||
|
id: string,
|
||||||
|
type: TMDBContentTypes
|
||||||
|
): Promise<TMDBExternalIds> {
|
||||||
|
let data;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "movie":
|
||||||
|
data = await get<TMDBMovieExternalIds>(`/movie/${id}/external_ids`);
|
||||||
|
break;
|
||||||
|
case "show":
|
||||||
|
data = await get<TMDBShowExternalIds>(`/tv/${id}/external_ids`);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error("Invalid media type");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMovieFromExternalId(
|
||||||
|
imdbId: string
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const data = await get<ExternalIdMovieSearchResult>(`/find/${imdbId}`, {
|
||||||
|
external_source: "imdb_id",
|
||||||
|
});
|
||||||
|
|
||||||
|
const movie = data.movie_results[0];
|
||||||
|
if (!movie) return undefined;
|
||||||
|
|
||||||
|
return movie.id.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTMDBSearchResult(
|
||||||
|
result: TMDBShowResult | TMDBMovieResult,
|
||||||
|
mediatype: TMDBContentTypes
|
||||||
|
): TMDBMediaResult {
|
||||||
|
const type = TMDBMediaToMediaType(mediatype);
|
||||||
|
if (type === MWMediaType.SERIES) {
|
||||||
|
const show = result as TMDBShowResult;
|
||||||
|
return {
|
||||||
|
title: show.name,
|
||||||
|
poster: getMediaPoster(show.poster_path),
|
||||||
|
id: show.id,
|
||||||
|
original_release_year: new Date(show.first_air_date).getFullYear(),
|
||||||
|
object_type: mediatype,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const movie = result as TMDBMovieResult;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: movie.title,
|
||||||
|
poster: getMediaPoster(movie.poster_path),
|
||||||
|
id: movie.id,
|
||||||
|
original_release_year: new Date(movie.release_date).getFullYear(),
|
||||||
|
object_type: mediatype,
|
||||||
|
};
|
||||||
|
}
|
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;
|
searchQuery: string;
|
||||||
type: MWMediaType;
|
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[];
|
||||||
|
}
|
@@ -8,7 +8,7 @@ import {
|
|||||||
MWStreamQuality,
|
MWStreamQuality,
|
||||||
MWStreamType,
|
MWStreamType,
|
||||||
} from "../helpers/streams";
|
} from "../helpers/streams";
|
||||||
import { MWMediaType } from "../metadata/types";
|
import { MWMediaType } from "../metadata/types/mw";
|
||||||
|
|
||||||
const twoEmbedBase = "https://www.2embed.to";
|
const twoEmbedBase = "https://www.2embed.to";
|
||||||
|
|
||||||
@@ -191,6 +191,7 @@ registerProvider({
|
|||||||
displayName: "2Embed",
|
displayName: "2Embed",
|
||||||
rank: 125,
|
rank: 125,
|
||||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||||
|
disabled: true, // Disabled, not working
|
||||||
async scrape({ media, episode, progress }) {
|
async scrape({ media, episode, progress }) {
|
||||||
let embedUrl = `${twoEmbedBase}/embed/tmdb/movie?id=${media.tmdbId}`;
|
let embedUrl = `${twoEmbedBase}/embed/tmdb/movie?id=${media.tmdbId}`;
|
||||||
|
|
||||||
|
@@ -1,128 +0,0 @@
|
|||||||
import { compareTitle } from "@/utils/titleMatch";
|
|
||||||
|
|
||||||
import {
|
|
||||||
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";
|
|
||||||
|
|
||||||
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: FlixHQMediaType;
|
|
||||||
releaseDate: 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const qualityMap: Record<string, MWStreamQuality> = {
|
|
||||||
"360": MWStreamQuality.Q360P,
|
|
||||||
"540": MWStreamQuality.Q540P,
|
|
||||||
"480": MWStreamQuality.Q480P,
|
|
||||||
"720": MWStreamQuality.Q720P,
|
|
||||||
"1080": MWStreamQuality.Q1080P,
|
|
||||||
};
|
|
||||||
|
|
||||||
function flixTypeToMWType(type: FlixHQMediaType) {
|
|
||||||
if (type === "Movie") return MWMediaType.MOVIE;
|
|
||||||
return MWMediaType.SERIES;
|
|
||||||
}
|
|
||||||
|
|
||||||
registerProvider({
|
|
||||||
id: "flixhq",
|
|
||||||
displayName: "FlixHQ",
|
|
||||||
rank: 100,
|
|
||||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
|
||||||
async scrape({ media, episode, progress }) {
|
|
||||||
if (!this.type.includes(media.meta.type)) {
|
|
||||||
throw new Error("Unsupported type");
|
|
||||||
}
|
|
||||||
// search for relevant item
|
|
||||||
const searchResults = await mwFetch<any>(
|
|
||||||
`/${encodeURIComponent(media.meta.title)}`,
|
|
||||||
{
|
|
||||||
baseURL: flixHqBase,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const foundItem = searchResults.results.find((v: FLIXMediaBase) => {
|
|
||||||
if (v.type !== "Movie" && v.type !== "TV Series") return false;
|
|
||||||
return (
|
|
||||||
compareTitle(v.title, media.meta.title) &&
|
|
||||||
flixTypeToMWType(v.type) === media.meta.type &&
|
|
||||||
v.releaseDate === media.meta.year
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!foundItem) throw new Error("No watchable item found");
|
|
||||||
|
|
||||||
// get media info
|
|
||||||
progress(25);
|
|
||||||
const mediaInfo = await mwFetch<any>(`/info/${foundItem.id}`, {
|
|
||||||
baseURL: flixHqBase,
|
|
||||||
params: {
|
|
||||||
type: flixTypeToMWType(foundItem.type),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!mediaInfo.id) throw new Error("No watchable item found");
|
|
||||||
// get stream info from media
|
|
||||||
progress(50);
|
|
||||||
|
|
||||||
let episodeId: string | undefined;
|
|
||||||
if (media.meta.type === MWMediaType.MOVIE) {
|
|
||||||
episodeId = mediaInfo.episodeId;
|
|
||||||
} else if (media.meta.type === MWMediaType.SERIES) {
|
|
||||||
const seasonNo = media.meta.seasonData.number;
|
|
||||||
const episodeNo = media.meta.seasonData.episodes.find(
|
|
||||||
(e) => e.id === episode
|
|
||||||
)?.number;
|
|
||||||
|
|
||||||
const season = mediaInfo.seasons.find((o: any) => o.season === seasonNo);
|
|
||||||
episodeId = season.episodes.find((o: any) => o.episode === episodeNo).id;
|
|
||||||
}
|
|
||||||
if (!episodeId) throw new Error("No watchable item found");
|
|
||||||
progress(75);
|
|
||||||
const watchInfo = await mwFetch<any>(`/watch/${episodeId}`, {
|
|
||||||
baseURL: flixHqBase,
|
|
||||||
params: {
|
|
||||||
id: mediaInfo.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!watchInfo.sources) throw new Error("No watchable item found");
|
|
||||||
|
|
||||||
// get best quality source
|
|
||||||
// comes sorted by quality in descending order
|
|
||||||
const source = watchInfo.sources[0];
|
|
||||||
return {
|
|
||||||
embeds: [],
|
|
||||||
stream: {
|
|
||||||
streamUrl: source.url,
|
|
||||||
quality: qualityMap[source.quality],
|
|
||||||
type: source.isM3U8 ? MWStreamType.HLS : MWStreamType.MP4,
|
|
||||||
captions: watchInfo.subtitles.map(convertSubtitles).filter(Boolean),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
1
src/backend/providers/flixhq/common.ts
Normal file
1
src/backend/providers/flixhq/common.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const flixHqBase = "https://flixhq.to";
|
36
src/backend/providers/flixhq/index.ts
Normal file
36
src/backend/providers/flixhq/index.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { MWEmbedType } from "@/backend/helpers/embed";
|
||||||
|
import { registerProvider } from "@/backend/helpers/register";
|
||||||
|
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||||
|
import {
|
||||||
|
getFlixhqSourceDetails,
|
||||||
|
getFlixhqSources,
|
||||||
|
} from "@/backend/providers/flixhq/scrape";
|
||||||
|
import { getFlixhqId } from "@/backend/providers/flixhq/search";
|
||||||
|
|
||||||
|
registerProvider({
|
||||||
|
id: "flixhq",
|
||||||
|
displayName: "FlixHQ",
|
||||||
|
rank: 100,
|
||||||
|
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||||
|
async scrape({ media }) {
|
||||||
|
const id = await getFlixhqId(media.meta);
|
||||||
|
if (!id) throw new Error("flixhq no matching item found");
|
||||||
|
|
||||||
|
// TODO tv shows not supported. just need to scrape the specific episode sources
|
||||||
|
|
||||||
|
const sources = await getFlixhqSources(id);
|
||||||
|
const upcloudStream = sources.find(
|
||||||
|
(v) => v.embed.toLowerCase() === "upcloud"
|
||||||
|
);
|
||||||
|
if (!upcloudStream) throw new Error("upcloud stream not found for flixhq");
|
||||||
|
|
||||||
|
return {
|
||||||
|
embeds: [
|
||||||
|
{
|
||||||
|
type: MWEmbedType.UPCLOUD,
|
||||||
|
url: await getFlixhqSourceDetails(upcloudStream.episodeId),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
41
src/backend/providers/flixhq/scrape.ts
Normal file
41
src/backend/providers/flixhq/scrape.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||||
|
import { flixHqBase } from "@/backend/providers/flixhq/common";
|
||||||
|
|
||||||
|
export async function getFlixhqSources(id: string) {
|
||||||
|
const type = id.split("/")[0];
|
||||||
|
const episodeParts = id.split("-");
|
||||||
|
const episodeId = episodeParts[episodeParts.length - 1];
|
||||||
|
|
||||||
|
const data = await proxiedFetch<string>(
|
||||||
|
`/ajax/${type}/episodes/${episodeId}`,
|
||||||
|
{
|
||||||
|
baseURL: flixHqBase,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const doc = new DOMParser().parseFromString(data, "text/html");
|
||||||
|
|
||||||
|
const sourceLinks = [...doc.querySelectorAll(".nav-item > a")].map((el) => {
|
||||||
|
const embedTitle = el.getAttribute("title");
|
||||||
|
const linkId = el.getAttribute("data-linkid");
|
||||||
|
if (!embedTitle || !linkId) throw new Error("invalid sources");
|
||||||
|
return {
|
||||||
|
embed: embedTitle,
|
||||||
|
episodeId: linkId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return sourceLinks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFlixhqSourceDetails(
|
||||||
|
sourceId: string
|
||||||
|
): Promise<string> {
|
||||||
|
const jsonData = await proxiedFetch<Record<string, any>>(
|
||||||
|
`/ajax/sources/${sourceId}`,
|
||||||
|
{
|
||||||
|
baseURL: flixHqBase,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return jsonData.link;
|
||||||
|
}
|
43
src/backend/providers/flixhq/search.ts
Normal file
43
src/backend/providers/flixhq/search.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||||
|
import { MWMediaMeta } from "@/backend/metadata/types/mw";
|
||||||
|
import { flixHqBase } from "@/backend/providers/flixhq/common";
|
||||||
|
import { compareTitle } from "@/utils/titleMatch";
|
||||||
|
|
||||||
|
export async function getFlixhqId(meta: MWMediaMeta): Promise<string | null> {
|
||||||
|
const searchResults = await proxiedFetch<string>(
|
||||||
|
`/search/${meta.title.replaceAll(/[^a-z0-9A-Z]/g, "-")}`,
|
||||||
|
{
|
||||||
|
baseURL: flixHqBase,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const doc = new DOMParser().parseFromString(searchResults, "text/html");
|
||||||
|
const items = [...doc.querySelectorAll(".film_list-wrap > div.flw-item")].map(
|
||||||
|
(el) => {
|
||||||
|
const id = el
|
||||||
|
.querySelector("div.film-poster > a")
|
||||||
|
?.getAttribute("href")
|
||||||
|
?.slice(1);
|
||||||
|
const title = el
|
||||||
|
.querySelector("div.film-detail > h2 > a")
|
||||||
|
?.getAttribute("title");
|
||||||
|
const year = el.querySelector(
|
||||||
|
"div.film-detail > div.fd-infor > span:nth-child(1)"
|
||||||
|
)?.textContent;
|
||||||
|
|
||||||
|
if (!id || !title || !year) return null;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
year,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const matchingItem = items.find(
|
||||||
|
(v) => v && compareTitle(meta.title, v.title) && meta.year === v.year
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!matchingItem) return null;
|
||||||
|
return matchingItem.id;
|
||||||
|
}
|
@@ -3,7 +3,7 @@ import { unpack } from "unpacker";
|
|||||||
|
|
||||||
import { registerProvider } from "@/backend/helpers/register";
|
import { registerProvider } from "@/backend/helpers/register";
|
||||||
import { MWStreamQuality } from "@/backend/helpers/streams";
|
import { MWStreamQuality } from "@/backend/helpers/streams";
|
||||||
import { MWMediaType } from "@/backend/metadata/types";
|
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||||
|
|
||||||
import { proxiedFetch } from "../helpers/fetch";
|
import { proxiedFetch } from "../helpers/fetch";
|
||||||
|
|
||||||
|
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 === episodeNumber : false
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!targetEpisode?.dataId) throw new Error("Episode not found");
|
||||||
|
|
||||||
|
mediaId = targetEpisode.dataId;
|
||||||
|
|
||||||
|
sources = await proxiedFetch<any>(`/ajax/v2/episode/servers/${mediaId}`, {
|
||||||
|
baseURL: gomoviesBase,
|
||||||
|
headers: {
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
sources = await proxiedFetch<any>(`/ajax/movie/episodes/${mediaId}`, {
|
||||||
|
baseURL: gomoviesBase,
|
||||||
|
headers: {
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const upcloud = new DOMParser()
|
||||||
|
.parseFromString(sources, "text/html")
|
||||||
|
.querySelector('a[title*="upcloud" i]');
|
||||||
|
|
||||||
|
const upcloudDataId =
|
||||||
|
upcloud?.getAttribute("data-id") ?? upcloud?.getAttribute("data-linkid");
|
||||||
|
|
||||||
|
if (!upcloudDataId) throw new Error("Upcloud source not available");
|
||||||
|
|
||||||
|
const upcloudSource = await proxiedFetch<{
|
||||||
|
type: "iframe" | string;
|
||||||
|
link: string;
|
||||||
|
sources: [];
|
||||||
|
title: string;
|
||||||
|
tracks: [];
|
||||||
|
}>(`/ajax/sources/${upcloudDataId}`, {
|
||||||
|
baseURL: gomoviesBase,
|
||||||
|
headers: {
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!upcloudSource.link || upcloudSource.type !== "iframe")
|
||||||
|
throw new Error("No upcloud stream found");
|
||||||
|
|
||||||
|
return {
|
||||||
|
embeds: [
|
||||||
|
{
|
||||||
|
type: MWEmbedType.UPCLOUD,
|
||||||
|
url: upcloudSource.link,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
@@ -2,7 +2,7 @@ import { proxiedFetch } from "../helpers/fetch";
|
|||||||
import { MWProviderContext } from "../helpers/provider";
|
import { MWProviderContext } from "../helpers/provider";
|
||||||
import { registerProvider } from "../helpers/register";
|
import { registerProvider } from "../helpers/register";
|
||||||
import { MWStreamQuality, MWStreamType } from "../helpers/streams";
|
import { MWStreamQuality, MWStreamType } from "../helpers/streams";
|
||||||
import { MWMediaType } from "../metadata/types";
|
import { MWMediaType } from "../metadata/types/mw";
|
||||||
|
|
||||||
const hdwatchedBase = "https://www.hdwatched.xyz";
|
const hdwatchedBase = "https://www.hdwatched.xyz";
|
||||||
|
|
||||||
@@ -120,6 +120,7 @@ registerProvider({
|
|||||||
id: "hdwatched",
|
id: "hdwatched",
|
||||||
displayName: "HDwatched",
|
displayName: "HDwatched",
|
||||||
rank: 150,
|
rank: 150,
|
||||||
|
disabled: true, // very slow, haven't seen it work for a while
|
||||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||||
async scrape(options) {
|
async scrape(options) {
|
||||||
const { media, progress } = options;
|
const { media, progress } = options;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
@@ -2,7 +2,7 @@ import { MWEmbed, MWEmbedType } from "@/backend/helpers/embed";
|
|||||||
|
|
||||||
import { proxiedFetch } from "../helpers/fetch";
|
import { proxiedFetch } from "../helpers/fetch";
|
||||||
import { registerProvider } from "../helpers/register";
|
import { registerProvider } from "../helpers/register";
|
||||||
import { MWMediaType } from "../metadata/types";
|
import { MWMediaType } from "../metadata/types/mw";
|
||||||
|
|
||||||
const HOST = "m4ufree.com";
|
const HOST = "m4ufree.com";
|
||||||
const URL_BASE = `https://${HOST}`;
|
const URL_BASE = `https://${HOST}`;
|
||||||
|
@@ -5,7 +5,7 @@ import {
|
|||||||
MWStreamQuality,
|
MWStreamQuality,
|
||||||
MWStreamType,
|
MWStreamType,
|
||||||
} from "../helpers/streams";
|
} from "../helpers/streams";
|
||||||
import { MWMediaType } from "../metadata/types";
|
import { MWMediaType } from "../metadata/types/mw";
|
||||||
|
|
||||||
const netfilmBase = "https://net-film.vercel.app";
|
const netfilmBase = "https://net-film.vercel.app";
|
||||||
|
|
||||||
|
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: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
100
src/backend/providers/sflix.ts
Normal file
100
src/backend/providers/sflix.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { proxiedFetch } from "../helpers/fetch";
|
||||||
|
import { registerProvider } from "../helpers/register";
|
||||||
|
import { MWStreamQuality, MWStreamType } from "../helpers/streams";
|
||||||
|
import { MWMediaType } from "../metadata/types/mw";
|
||||||
|
|
||||||
|
const sflixBase = "https://sflix.video";
|
||||||
|
|
||||||
|
registerProvider({
|
||||||
|
id: "sflix",
|
||||||
|
displayName: "Sflix",
|
||||||
|
rank: 50,
|
||||||
|
disabled: true, // domain dead
|
||||||
|
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||||
|
async scrape({ media, episode, progress }) {
|
||||||
|
let searchQuery = `${media.meta.title} `;
|
||||||
|
|
||||||
|
if (media.meta.type === MWMediaType.MOVIE)
|
||||||
|
searchQuery += media.meta.year ?? "";
|
||||||
|
|
||||||
|
if (media.meta.type === MWMediaType.SERIES)
|
||||||
|
searchQuery += `S${String(media.meta.seasonData.number).padStart(
|
||||||
|
2,
|
||||||
|
"0"
|
||||||
|
)}`;
|
||||||
|
|
||||||
|
const search = await proxiedFetch<any>(
|
||||||
|
`/?s=${encodeURIComponent(searchQuery)}`,
|
||||||
|
{
|
||||||
|
baseURL: sflixBase,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const searchPage = new DOMParser().parseFromString(search, "text/html");
|
||||||
|
|
||||||
|
const moviePageUrl = searchPage
|
||||||
|
.querySelector(".movies-list .ml-item:first-child a")
|
||||||
|
?.getAttribute("href");
|
||||||
|
if (!moviePageUrl) throw new Error("Movie does not exist");
|
||||||
|
|
||||||
|
progress(25);
|
||||||
|
|
||||||
|
const movie = await proxiedFetch<any>(moviePageUrl);
|
||||||
|
const moviePage = new DOMParser().parseFromString(movie, "text/html");
|
||||||
|
|
||||||
|
progress(45);
|
||||||
|
|
||||||
|
let outerEmbedSrc = null;
|
||||||
|
if (media.meta.type === MWMediaType.MOVIE) {
|
||||||
|
outerEmbedSrc = moviePage
|
||||||
|
.querySelector("iframe")
|
||||||
|
?.getAttribute("data-lazy-src");
|
||||||
|
} else if (media.meta.type === MWMediaType.SERIES) {
|
||||||
|
const series = Array.from(moviePage.querySelectorAll(".desc p a")).map(
|
||||||
|
(a) => ({
|
||||||
|
title: a.getAttribute("title"),
|
||||||
|
link: a.getAttribute("href"),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const episodeNumber = media.meta.seasonData.episodes.find(
|
||||||
|
(e) => e.id === episode
|
||||||
|
)?.number;
|
||||||
|
|
||||||
|
const targetSeries = series.find((s) =>
|
||||||
|
s.title?.endsWith(String(episodeNumber).padStart(2, "0"))
|
||||||
|
);
|
||||||
|
if (!targetSeries) throw new Error("Episode does not exist");
|
||||||
|
|
||||||
|
outerEmbedSrc = targetSeries.link;
|
||||||
|
}
|
||||||
|
if (!outerEmbedSrc) throw new Error("Outer embed source not found");
|
||||||
|
|
||||||
|
progress(65);
|
||||||
|
|
||||||
|
const outerEmbed = await proxiedFetch<any>(outerEmbedSrc);
|
||||||
|
const outerEmbedPage = new DOMParser().parseFromString(
|
||||||
|
outerEmbed,
|
||||||
|
"text/html"
|
||||||
|
);
|
||||||
|
|
||||||
|
const embedSrc = outerEmbedPage
|
||||||
|
.querySelector("iframe")
|
||||||
|
?.getAttribute("src");
|
||||||
|
if (!embedSrc) throw new Error("Embed source not found");
|
||||||
|
|
||||||
|
const embed = await proxiedFetch<string>(embedSrc);
|
||||||
|
|
||||||
|
const streamUrl = embed.match(/file\s*:\s*"([^"]+\.mp4)"/)?.[1];
|
||||||
|
if (!streamUrl) throw new Error("Unable to get stream");
|
||||||
|
|
||||||
|
return {
|
||||||
|
embeds: [],
|
||||||
|
stream: {
|
||||||
|
streamUrl,
|
||||||
|
quality: MWStreamQuality.Q1080P,
|
||||||
|
type: MWStreamType.MP4,
|
||||||
|
captions: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
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,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
@@ -13,7 +13,7 @@ import {
|
|||||||
MWStreamQuality,
|
MWStreamQuality,
|
||||||
MWStreamType,
|
MWStreamType,
|
||||||
} from "@/backend/helpers/streams";
|
} from "@/backend/helpers/streams";
|
||||||
import { MWMediaType } from "@/backend/metadata/types";
|
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||||
import { compareTitle } from "@/utils/titleMatch";
|
import { compareTitle } from "@/utils/titleMatch";
|
||||||
|
|
||||||
const nanoid = customAlphabet("0123456789abcdef", 32);
|
const nanoid = customAlphabet("0123456789abcdef", 32);
|
||||||
@@ -39,6 +39,10 @@ const apiUrls = [
|
|||||||
];
|
];
|
||||||
const appKey = atob("bW92aWVib3g=");
|
const appKey = atob("bW92aWVib3g=");
|
||||||
const appId = atob("Y29tLnRkby5zaG93Ym94");
|
const appId = atob("Y29tLnRkby5zaG93Ym94");
|
||||||
|
const captionsDomains = [
|
||||||
|
atob("bWJwaW1hZ2VzLmNodWF4aW4uY29t"),
|
||||||
|
atob("aW1hZ2VzLnNoZWd1Lm5ldA=="),
|
||||||
|
];
|
||||||
|
|
||||||
// cryptography stuff
|
// cryptography stuff
|
||||||
const crypto = {
|
const crypto = {
|
||||||
@@ -119,11 +123,18 @@ const convertSubtitles = (subtitleGroup: any): MWCaption | null => {
|
|||||||
let subtitles = subtitleGroup.subtitles;
|
let subtitles = subtitleGroup.subtitles;
|
||||||
subtitles = subtitles
|
subtitles = subtitles
|
||||||
.map((subFile: any) => {
|
.map((subFile: any) => {
|
||||||
const supported = isSupportedSubtitle(subFile.file_path);
|
const filePath = subFile.file_path
|
||||||
|
.replace(captionsDomains[0], captionsDomains[1])
|
||||||
|
.replace(/\s/g, "+")
|
||||||
|
.replace(/[()]/g, (c: string) => {
|
||||||
|
return `%${c.charCodeAt(0).toString(16)}`;
|
||||||
|
});
|
||||||
|
const supported = isSupportedSubtitle(filePath);
|
||||||
if (!supported) return null;
|
if (!supported) return null;
|
||||||
const type = getMWCaptionTypeFromUrl(subFile.file_path);
|
const type = getMWCaptionTypeFromUrl(filePath);
|
||||||
return {
|
return {
|
||||||
...subFile,
|
...subFile,
|
||||||
|
file_path: filePath,
|
||||||
type: type as MWCaptionType,
|
type: type as MWCaptionType,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -142,19 +153,19 @@ const convertSubtitles = (subtitleGroup: any): MWCaption | null => {
|
|||||||
registerProvider({
|
registerProvider({
|
||||||
id: "superstream",
|
id: "superstream",
|
||||||
displayName: "Superstream",
|
displayName: "Superstream",
|
||||||
rank: 200,
|
rank: 300,
|
||||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||||
|
|
||||||
async scrape({ media, episode, progress }) {
|
async scrape({ media, episode, progress }) {
|
||||||
// Find Superstream ID for show
|
// Find Superstream ID for show
|
||||||
const searchQuery = {
|
const searchQuery = {
|
||||||
module: "Search3",
|
module: "Search4",
|
||||||
page: "1",
|
page: "1",
|
||||||
type: "all",
|
type: "all",
|
||||||
keyword: media.meta.title,
|
keyword: media.meta.title,
|
||||||
pagelimit: "20",
|
pagelimit: "20",
|
||||||
};
|
};
|
||||||
const searchRes = (await get(searchQuery, true)).data;
|
const searchRes = (await get(searchQuery, true)).data.list;
|
||||||
progress(33);
|
progress(33);
|
||||||
|
|
||||||
const superstreamEntry = searchRes.find(
|
const superstreamEntry = searchRes.find(
|
||||||
@@ -248,6 +259,7 @@ registerProvider({
|
|||||||
const mappedCaptions = subtitleRes.list
|
const mappedCaptions = subtitleRes.list
|
||||||
.map(convertSubtitles)
|
.map(convertSubtitles)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
embeds: [],
|
embeds: [],
|
||||||
stream: {
|
stream: {
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { MWMediaType, MWQuery } from "@/backend/metadata/types";
|
import { MWMediaType, MWQuery } from "@/backend/metadata/types/mw";
|
||||||
|
|
||||||
import { DropdownButton } from "./buttons/DropdownButton";
|
import { DropdownButton } from "./buttons/DropdownButton";
|
||||||
import { Icon, Icons } from "./Icon";
|
import { Icon, Icons } from "./Icon";
|
||||||
|
@@ -1,8 +1,8 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { JWMediaToId } from "@/backend/metadata/justwatch";
|
import { TMDBMediaToId } from "@/backend/metadata/getmeta";
|
||||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
import { MWMediaMeta } from "@/backend/metadata/types/mw";
|
||||||
import { DotList } from "@/components/text/DotList";
|
import { DotList } from "@/components/text/DotList";
|
||||||
|
|
||||||
import { IconPatch } from "../buttons/IconPatch";
|
import { IconPatch } from "../buttons/IconPatch";
|
||||||
@@ -13,7 +13,7 @@ export interface MediaCardProps {
|
|||||||
linkable?: boolean;
|
linkable?: boolean;
|
||||||
series?: {
|
series?: {
|
||||||
episode: number;
|
episode: number;
|
||||||
season: number;
|
season?: number;
|
||||||
episodeId: string;
|
episodeId: string;
|
||||||
seasonId: string;
|
seasonId: string;
|
||||||
};
|
};
|
||||||
@@ -51,7 +51,7 @@ function MediaCardContent({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
"relative mb-4 aspect-[2/3] w-full overflow-hidden rounded-xl bg-denim-500 bg-cover bg-center transition-[border-radius] duration-100",
|
"relative mb-4 w-full overflow-hidden rounded-xl bg-denim-500 bg-cover bg-center pb-[150%] transition-[border-radius] duration-100",
|
||||||
closable ? "" : "group-hover:rounded-lg",
|
closable ? "" : "group-hover:rounded-lg",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
style={{
|
style={{
|
||||||
@@ -72,7 +72,7 @@ function MediaCardContent({
|
|||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{t("seasons.seasonAndEpisode", {
|
{t("seasons.seasonAndEpisode", {
|
||||||
season: series.season,
|
season: series.season || 1,
|
||||||
episode: series.episode,
|
episode: series.episode,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
@@ -117,7 +117,7 @@ function MediaCardContent({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mb-1 max-h-[4.5rem] text-ellipsis break-words font-bold text-white line-clamp-3">
|
<h1 className="mb-1 line-clamp-3 max-h-[4.5rem] text-ellipsis break-words font-bold text-white">
|
||||||
<span>{media.title}</span>
|
<span>{media.title}</span>
|
||||||
</h1>
|
</h1>
|
||||||
<DotList className="text-xs" content={dotListContent} />
|
<DotList className="text-xs" content={dotListContent} />
|
||||||
@@ -132,12 +132,17 @@ export function MediaCard(props: MediaCardProps) {
|
|||||||
const canLink = props.linkable && !props.closable;
|
const canLink = props.linkable && !props.closable;
|
||||||
|
|
||||||
let link = canLink
|
let link = canLink
|
||||||
? `/media/${encodeURIComponent(JWMediaToId(props.media))}`
|
? `/media/${encodeURIComponent(TMDBMediaToId(props.media))}`
|
||||||
: "#";
|
: "#";
|
||||||
if (canLink && props.series)
|
if (canLink && props.series) {
|
||||||
link += `/${encodeURIComponent(props.series.seasonId)}/${encodeURIComponent(
|
if (props.series.season === 0 && !props.series.episodeId) {
|
||||||
props.series.episodeId
|
link += `/${encodeURIComponent(props.series.seasonId)}`;
|
||||||
)}`;
|
} else {
|
||||||
|
link += `/${encodeURIComponent(
|
||||||
|
props.series.seasonId
|
||||||
|
)}/${encodeURIComponent(props.series.episodeId)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!props.linkable) return <span>{content}</span>;
|
if (!props.linkable) return <span>{content}</span>;
|
||||||
return (
|
return (
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
import { MWMediaMeta } from "@/backend/metadata/types/mw";
|
||||||
import { useWatchedContext } from "@/state/watched";
|
import { useWatchedContext } from "@/state/watched";
|
||||||
|
|
||||||
import { MediaCard } from "./MediaCard";
|
import { MediaCard } from "./MediaCard";
|
||||||
|
@@ -154,7 +154,7 @@ export const FloatingCardView = {
|
|||||||
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
|
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
|
||||||
>
|
>
|
||||||
<Icon icon={Icons.X} />
|
<Icon icon={Icons.X} />
|
||||||
<span>Close</span>
|
<span>{t("videoPlayer.popouts.close")}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
@@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { findBestStream } from "@/backend/helpers/scrape";
|
import { findBestStream } from "@/backend/helpers/scrape";
|
||||||
import { MWStream } from "@/backend/helpers/streams";
|
import { MWStream } from "@/backend/helpers/streams";
|
||||||
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||||
import { MWMediaType } from "@/backend/metadata/types";
|
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||||
|
|
||||||
export interface ScrapeEventLog {
|
export interface ScrapeEventLog {
|
||||||
type: "provider" | "embed";
|
type: "provider" | "embed";
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { generatePath, useHistory, useRouteMatch } from "react-router-dom";
|
import { generatePath, useHistory, useRouteMatch } from "react-router-dom";
|
||||||
|
|
||||||
import { MWMediaType, MWQuery } from "@/backend/metadata/types";
|
import { MWMediaType, MWQuery } from "@/backend/metadata/types/mw";
|
||||||
|
|
||||||
function getInitialValue(params: { type: string; query: string }) {
|
function getInitialValue(params: { type: string; query: string }) {
|
||||||
const type =
|
const type =
|
||||||
|
@@ -7,14 +7,15 @@ import { registerSW } from "virtual:pwa-register";
|
|||||||
|
|
||||||
import { ErrorBoundary } from "@/components/layout/ErrorBoundary";
|
import { ErrorBoundary } from "@/components/layout/ErrorBoundary";
|
||||||
import App from "@/setup/App";
|
import App from "@/setup/App";
|
||||||
import { conf } from "@/setup/config";
|
import { assertConfig, conf } from "@/setup/config";
|
||||||
|
import i18n from "@/setup/i18n";
|
||||||
|
|
||||||
import "@/setup/ga";
|
import "@/setup/ga";
|
||||||
import "@/setup/sentry";
|
import "@/setup/sentry";
|
||||||
import "@/setup/i18n";
|
|
||||||
import "@/setup/index.css";
|
import "@/setup/index.css";
|
||||||
import "@/backend";
|
import "@/backend";
|
||||||
import { initializeChromecast } from "./setup/chromecast";
|
import { initializeChromecast } from "./setup/chromecast";
|
||||||
|
import { SettingsStore } from "./state/settings/store";
|
||||||
import { initializeStores } from "./utils/storage";
|
import { initializeStores } from "./utils/storage";
|
||||||
|
|
||||||
// initialize
|
// initialize
|
||||||
@@ -29,7 +30,9 @@ registerSW({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const LazyLoadedApp = React.lazy(async () => {
|
const LazyLoadedApp = React.lazy(async () => {
|
||||||
|
await assertConfig();
|
||||||
await initializeStores();
|
await initializeStores();
|
||||||
|
i18n.changeLanguage(SettingsStore.get().language ?? "en");
|
||||||
return {
|
return {
|
||||||
default: App,
|
default: App,
|
||||||
};
|
};
|
||||||
|
@@ -1,7 +1,14 @@
|
|||||||
import { lazy } from "react";
|
import { ReactElement, lazy, useEffect } from "react";
|
||||||
import { Redirect, Route, Switch } from "react-router-dom";
|
import {
|
||||||
|
Redirect,
|
||||||
|
Route,
|
||||||
|
Switch,
|
||||||
|
useHistory,
|
||||||
|
useLocation,
|
||||||
|
} from "react-router-dom";
|
||||||
|
|
||||||
import { MWMediaType } from "@/backend/metadata/types";
|
import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta";
|
||||||
|
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||||
import { BannerContextProvider } from "@/hooks/useBanner";
|
import { BannerContextProvider } from "@/hooks/useBanner";
|
||||||
import { Layout } from "@/setup/Layout";
|
import { Layout } from "@/setup/Layout";
|
||||||
import { BookmarkContextProvider } from "@/state/bookmark";
|
import { BookmarkContextProvider } from "@/state/bookmark";
|
||||||
@@ -12,6 +19,22 @@ import { NotFoundPage } from "@/views/notfound/NotFoundView";
|
|||||||
import { V2MigrationView } from "@/views/other/v2Migration";
|
import { V2MigrationView } from "@/views/other/v2Migration";
|
||||||
import { SearchView } from "@/views/search/SearchView";
|
import { SearchView } from "@/views/search/SearchView";
|
||||||
|
|
||||||
|
function LegacyUrlView({ children }: { children: ReactElement }) {
|
||||||
|
const location = useLocation();
|
||||||
|
const { replace } = useHistory();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const url = location.pathname;
|
||||||
|
if (!isLegacyUrl(url)) return;
|
||||||
|
convertLegacyUrl(location.pathname).then((convertedUrl) => {
|
||||||
|
replace(convertedUrl ?? "/");
|
||||||
|
});
|
||||||
|
}, [location.pathname, replace]);
|
||||||
|
|
||||||
|
if (isLegacyUrl(location.pathname)) return null;
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
@@ -27,12 +50,16 @@ function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* pages */}
|
{/* pages */}
|
||||||
<Route exact path="/media/:media" component={MediaView} />
|
<Route exact path="/media/:media">
|
||||||
<Route
|
<LegacyUrlView>
|
||||||
exact
|
<MediaView />
|
||||||
path="/media/:media/:season/:episode"
|
</LegacyUrlView>
|
||||||
component={MediaView}
|
</Route>
|
||||||
/>
|
<Route exact path="/media/:media/:season/:episode">
|
||||||
|
<LegacyUrlView>
|
||||||
|
<MediaView />
|
||||||
|
</LegacyUrlView>
|
||||||
|
</Route>
|
||||||
<Route
|
<Route
|
||||||
exact
|
exact
|
||||||
path="/search/:type/:query?"
|
path="/search/:type/:query?"
|
||||||
|
@@ -4,51 +4,54 @@ interface Config {
|
|||||||
APP_VERSION: string;
|
APP_VERSION: string;
|
||||||
GITHUB_LINK: string;
|
GITHUB_LINK: string;
|
||||||
DISCORD_LINK: string;
|
DISCORD_LINK: string;
|
||||||
OMDB_API_KEY: string;
|
TMDB_READ_API_KEY: string;
|
||||||
TMDB_API_KEY: string;
|
|
||||||
CORS_PROXY_URL: string;
|
CORS_PROXY_URL: string;
|
||||||
NORMAL_ROUTER: boolean;
|
NORMAL_ROUTER: boolean;
|
||||||
|
DISALLOWED_IDS: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RuntimeConfig {
|
export interface RuntimeConfig {
|
||||||
APP_VERSION: string;
|
APP_VERSION: string;
|
||||||
GITHUB_LINK: string;
|
GITHUB_LINK: string;
|
||||||
DISCORD_LINK: string;
|
DISCORD_LINK: string;
|
||||||
OMDB_API_KEY: string;
|
TMDB_READ_API_KEY: string;
|
||||||
TMDB_API_KEY: string;
|
|
||||||
NORMAL_ROUTER: boolean;
|
NORMAL_ROUTER: boolean;
|
||||||
PROXY_URLS: string[];
|
PROXY_URLS: string[];
|
||||||
|
DISALLOWED_IDS: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const env: Record<keyof Config, undefined | string> = {
|
const env: Record<keyof Config, undefined | string> = {
|
||||||
OMDB_API_KEY: import.meta.env.VITE_OMDB_API_KEY,
|
TMDB_READ_API_KEY: import.meta.env.VITE_TMDB_READ_API_KEY,
|
||||||
TMDB_API_KEY: import.meta.env.VITE_TMDB_API_KEY,
|
|
||||||
APP_VERSION: undefined,
|
APP_VERSION: undefined,
|
||||||
GITHUB_LINK: undefined,
|
GITHUB_LINK: undefined,
|
||||||
DISCORD_LINK: undefined,
|
DISCORD_LINK: undefined,
|
||||||
CORS_PROXY_URL: import.meta.env.VITE_CORS_PROXY_URL,
|
CORS_PROXY_URL: import.meta.env.VITE_CORS_PROXY_URL,
|
||||||
NORMAL_ROUTER: import.meta.env.VITE_NORMAL_ROUTER,
|
NORMAL_ROUTER: import.meta.env.VITE_NORMAL_ROUTER,
|
||||||
|
DISALLOWED_IDS: import.meta.env.VITE_DISALLOWED_IDS,
|
||||||
};
|
};
|
||||||
|
|
||||||
const alerts = [] as string[];
|
|
||||||
|
|
||||||
// loads from different locations, in order: environment (VITE_{KEY}), window (public/config.js)
|
// loads from different locations, in order: environment (VITE_{KEY}), window (public/config.js)
|
||||||
function getKey(key: keyof Config, defaultString?: string): string {
|
function getKeyValue(key: keyof Config): string | undefined {
|
||||||
let windowValue = (window as any)?.__CONFIG__?.[`VITE_${key}`];
|
let windowValue = (window as any)?.__CONFIG__?.[`VITE_${key}`];
|
||||||
if (windowValue !== undefined && windowValue.length === 0)
|
if (windowValue !== undefined && windowValue.length === 0)
|
||||||
windowValue = undefined;
|
windowValue = undefined;
|
||||||
const value = env[key] ?? windowValue ?? undefined;
|
return env[key] ?? windowValue ?? undefined;
|
||||||
if (value === undefined) {
|
}
|
||||||
if (defaultString) return defaultString;
|
|
||||||
if (!alerts.includes(key)) {
|
function getKey(key: keyof Config, defaultString?: string): string {
|
||||||
|
return getKeyValue(key) ?? defaultString ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertConfig() {
|
||||||
|
const keys: Array<keyof Config> = ["TMDB_READ_API_KEY", "CORS_PROXY_URL"];
|
||||||
|
const values = keys.map((key) => {
|
||||||
|
const val = getKeyValue(key);
|
||||||
|
if (val) return val;
|
||||||
// eslint-disable-next-line no-alert
|
// eslint-disable-next-line no-alert
|
||||||
window.alert(`Misconfigured instance, missing key: ${key}`);
|
window.alert(`Misconfigured instance, missing key: ${key}`);
|
||||||
alerts.push(key);
|
return val;
|
||||||
}
|
});
|
||||||
return "";
|
if (values.includes(undefined)) throw new Error("Misconfigured instance");
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function conf(): RuntimeConfig {
|
export function conf(): RuntimeConfig {
|
||||||
@@ -56,11 +59,13 @@ export function conf(): RuntimeConfig {
|
|||||||
APP_VERSION,
|
APP_VERSION,
|
||||||
GITHUB_LINK,
|
GITHUB_LINK,
|
||||||
DISCORD_LINK,
|
DISCORD_LINK,
|
||||||
OMDB_API_KEY: getKey("OMDB_API_KEY"),
|
TMDB_READ_API_KEY: getKey("TMDB_READ_API_KEY"),
|
||||||
TMDB_API_KEY: getKey("TMDB_API_KEY"),
|
|
||||||
PROXY_URLS: getKey("CORS_PROXY_URL")
|
PROXY_URLS: getKey("CORS_PROXY_URL")
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((v) => v.trim()),
|
.map((v) => v.trim()),
|
||||||
NORMAL_ROUTER: getKey("NORMAL_ROUTER", "false") === "true",
|
NORMAL_ROUTER: getKey("NORMAL_ROUTER", "false") === "true",
|
||||||
|
DISALLOWED_IDS: getKey("DISALLOWED_IDS", "")
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim()), // Should be comma-seperated and contain the media type and ID, formatted like so: movie-753342,movie-753342,movie-753342
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@@ -7,14 +7,21 @@ import cs from "./locales/cs/translation.json";
|
|||||||
import de from "./locales/de/translation.json";
|
import de from "./locales/de/translation.json";
|
||||||
import en from "./locales/en/translation.json";
|
import en from "./locales/en/translation.json";
|
||||||
import fr from "./locales/fr/translation.json";
|
import fr from "./locales/fr/translation.json";
|
||||||
|
import it from "./locales/it/translation.json";
|
||||||
import nl from "./locales/nl/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 tr from "./locales/tr/translation.json";
|
||||||
|
import vi from "./locales/vi/translation.json";
|
||||||
import zh from "./locales/zh/translation.json";
|
import zh from "./locales/zh/translation.json";
|
||||||
|
|
||||||
const locales = {
|
const locales = {
|
||||||
en: {
|
en: {
|
||||||
translation: en,
|
translation: en,
|
||||||
},
|
},
|
||||||
|
it: {
|
||||||
|
translation: it,
|
||||||
|
},
|
||||||
nl: {
|
nl: {
|
||||||
translation: nl,
|
translation: nl,
|
||||||
},
|
},
|
||||||
@@ -33,6 +40,15 @@ const locales = {
|
|||||||
cs: {
|
cs: {
|
||||||
translation: cs,
|
translation: cs,
|
||||||
},
|
},
|
||||||
|
pirate: {
|
||||||
|
translation: pirate,
|
||||||
|
},
|
||||||
|
vi: {
|
||||||
|
translation: vi,
|
||||||
|
},
|
||||||
|
pl: {
|
||||||
|
translation: pl,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
i18n
|
i18n
|
||||||
// pass the i18n instance to react-i18next.
|
// pass the i18n instance to react-i18next.
|
||||||
|
@@ -179,3 +179,19 @@ input[type=range].styled-slider.slider-progress::-ms-fill-lower {
|
|||||||
border: none;
|
border: none;
|
||||||
border-right-width: 0;
|
border-right-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background-color: theme("colors.denim-500");
|
||||||
|
border: 5px solid transparent;
|
||||||
|
border-left: 0;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
/* For some reason the styles don't get applied without the width */
|
||||||
|
width: 13px;
|
||||||
|
}
|
@@ -1,5 +1,6 @@
|
|||||||
export type LangCode =
|
export type LangCode =
|
||||||
| "none"
|
| "none"
|
||||||
|
| "pirate"
|
||||||
| "aa"
|
| "aa"
|
||||||
| "ab"
|
| "ab"
|
||||||
| "ae"
|
| "ae"
|
||||||
@@ -219,6 +220,12 @@ export const captionLanguages: CaptionLanguageOption[] = [
|
|||||||
name: "None",
|
name: "None",
|
||||||
nativeName: "Lorem ipsum",
|
nativeName: "Lorem ipsum",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "pirate",
|
||||||
|
englishName: "Pirate",
|
||||||
|
name: "Pirate English",
|
||||||
|
nativeName: "Pirate English",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "aa",
|
id: "aa",
|
||||||
englishName: "Afar",
|
englishName: "Afar",
|
||||||
|
@@ -3,8 +3,8 @@
|
|||||||
"name": "movie-web"
|
"name": "movie-web"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"loading_series": "Auf der Suche nach Ihrer Lieblingsserie...",
|
"loading_series": "Auf der Suche nach deiner Lieblingsserie...",
|
||||||
"loading_movie": "Auf der Suche nach Ihren Lieblingsfilmen...",
|
"loading_movie": "Auf der Suche nach deinen Lieblingsfilmen...",
|
||||||
"loading": "Wird geladen...",
|
"loading": "Wird geladen...",
|
||||||
"allResults": "Das ist alles, was wir haben!",
|
"allResults": "Das ist alles, was wir haben!",
|
||||||
"noResults": "Wir haben nichts gefunden!",
|
"noResults": "Wir haben nichts gefunden!",
|
||||||
@@ -12,15 +12,15 @@
|
|||||||
"headingTitle": "Suchergebnisse",
|
"headingTitle": "Suchergebnisse",
|
||||||
"bookmarks": "Favoriten",
|
"bookmarks": "Favoriten",
|
||||||
"continueWatching": "Weiter ansehen",
|
"continueWatching": "Weiter ansehen",
|
||||||
"title": "Was willst du sehen?",
|
"title": "Was willst du gucken?",
|
||||||
"placeholder": "Was willst du sehen?"
|
"placeholder": "Was willst du gucken?"
|
||||||
},
|
},
|
||||||
"media": {
|
"media": {
|
||||||
"movie": "Filme",
|
"movie": "Filme",
|
||||||
"series": "Serie",
|
"series": "Serie",
|
||||||
"stopEditing": "Beenden Sie die Bearbeitung",
|
"stopEditing": "Beenden die Bearbeitung",
|
||||||
"errors": {
|
"errors": {
|
||||||
"genericTitle": "Hoppla, etwas ist falsch gegangen!",
|
"genericTitle": "Hoppla, etwas ist schiefgegangen!",
|
||||||
"failedMeta": "Metadaten konnten nicht geladen werden",
|
"failedMeta": "Metadaten konnten nicht geladen werden",
|
||||||
"mediaFailed": "Wir konnten die angeforderten Medien nicht abrufen.",
|
"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>."
|
"videoFailed": "Beim Abspielen des angeforderten Videos ist ein Fehler aufgetreten. <0>Discord</0> Oder weiter <1>GitHub</1>."
|
||||||
@@ -48,17 +48,17 @@
|
|||||||
"searchBar": {
|
"searchBar": {
|
||||||
"movie": "Film",
|
"movie": "Film",
|
||||||
"series": "Serie",
|
"series": "Serie",
|
||||||
"Search": "Forschen"
|
"Search": "Suchen"
|
||||||
},
|
},
|
||||||
"videoPlayer": {
|
"videoPlayer": {
|
||||||
"findingBestVideo": "Auf der Suche nach dem besten Video für Sie",
|
"findingBestVideo": "Auf der Suche nach dem besten Video für Sie",
|
||||||
"noVideos": "Entschuldigung, wir konnten keine Videos für Sie finden",
|
"noVideos": "Entschuldigung, wir konnten keine Videos finden",
|
||||||
"loading": "Wird geladen...",
|
"loading": "Wird geladen...",
|
||||||
"backToHome": "Zurück zur Startseite",
|
"backToHome": "Zurück zur Startseite",
|
||||||
"backToHomeShort": "Rückmeldung",
|
"backToHomeShort": "Rückmeldung",
|
||||||
"seasonAndEpisode": "S{{season}} E{{episode}}",
|
"seasonAndEpisode": "S{{season}} E{{episode}}",
|
||||||
"timeLeft": "{{timeLeft}} bleibt",
|
"timeLeft": "{{timeLeft}} verbleibend",
|
||||||
"finishAt": "Ende um {{timeFinished, datetime}}",
|
"finishAt": "Endet um {{timeFinished, datetime}}",
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"episodes": "Folgen",
|
"episodes": "Folgen",
|
||||||
"source": "Quelle",
|
"source": "Quelle",
|
||||||
@@ -71,13 +71,13 @@
|
|||||||
"popouts": {
|
"popouts": {
|
||||||
"back": "Zurück",
|
"back": "Zurück",
|
||||||
"sources": "Quellen",
|
"sources": "Quellen",
|
||||||
"seasons": "Saison",
|
"seasons": "Staffel",
|
||||||
"captions": "Untertitel",
|
"captions": "Untertitel",
|
||||||
"playbackSpeed": "Lesegeschwindigkeit",
|
"playbackSpeed": "Lesegeschwindigkeit",
|
||||||
"customPlaybackSpeed": "Benutzerdefinierte Wiedergabegeschwindigkeit",
|
"customPlaybackSpeed": "Benutzerdefinierte Wiedergabegeschwindigkeit",
|
||||||
"captionPreferences": {
|
"captionPreferences": {
|
||||||
"title": "Personifizieren",
|
"title": "Bearbeiten",
|
||||||
"delay": "Zeitlimit",
|
"delay": "Verzögerung",
|
||||||
"fontSize": "Größe",
|
"fontSize": "Größe",
|
||||||
"opacity": "Opazität",
|
"opacity": "Opazität",
|
||||||
"color": "Farbe"
|
"color": "Farbe"
|
||||||
@@ -93,17 +93,17 @@
|
|||||||
"embedsError": "Beim Laden der eingebetteter Medien ist ein Problem aufgetreten"
|
"embedsError": "Beim Laden der eingebetteter Medien ist ein Problem aufgetreten"
|
||||||
},
|
},
|
||||||
"descriptions": {
|
"descriptions": {
|
||||||
"sources": "Welchen Anbieter möchten Sie nutzen?",
|
"sources": "Welchen Anbieter möchtest du nutzen?",
|
||||||
"embeds": "Wählen Sie das Video aus, das Sie ansehen möchten",
|
"embeds": "Wähle das Video aus, das du ansehen möchten",
|
||||||
"seasons": "Wählen Sie die Staffel aus, die Sie sehen möchten",
|
"seasons": "Wähle die Staffel aus, die du sehen möchten",
|
||||||
"episode": "Wählen Sie eine Folge aus",
|
"episode": "Wähle eine Folge aus",
|
||||||
"captions": "Wählen Sie eine Untertitelsprache",
|
"captions": "Wähle eine Untertitelsprache",
|
||||||
"captionPreferences": "Passen Sie das Erscheinungsbild von Untertiteln an",
|
"captionPreferences": "Passe das Erscheinungsbild von Untertiteln an",
|
||||||
"playbackSpeed": "Wiedergabegeschwindigkeit ändern"
|
"playbackSpeed": "Wiedergabegeschwindigkeit ändern"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"fatalError": "Der Videoplayer hat einen Fehler festgestellt, bitte melden Sie ihn dem Server <0>Discord</0> Oder weiter <1>GitHub</1>."
|
"fatalError": "Der Videoplayer hat einen Fehler festgestellt, bitte melde ihn dem Server <0>Discord</0> Oder weiter <1>GitHub</1>."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
@@ -115,13 +115,13 @@
|
|||||||
"newSiteTitle": "Neue Version verfügbar!",
|
"newSiteTitle": "Neue Version verfügbar!",
|
||||||
"newDomain": "https://movie-web.app",
|
"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>",
|
"newDomainText": "movie-web zieht in Kürze auf eine neue Domain um: <0>https://movie-web.app</0>. <1>Die alte Website funktioniert nicht mehr {{date}}.</1>",
|
||||||
"tireless": "Wir haben unermüdlich an diesem neuen Update gearbeitet und hoffen, dass Ihnen das gefällt, was wir in den letzten Monaten vorbereitet haben.",
|
"tireless": "Wir haben unermüdlich an diesem neuen Update gearbeitet und hoffen, dass dir gefällt, was wir in den letzten Monaten vorbereitet haben.",
|
||||||
"leaveAnnouncement": "Bring mich dahin!"
|
"leaveAnnouncement": "Bring mich dahin!"
|
||||||
},
|
},
|
||||||
"casting": {
|
"casting": {
|
||||||
"casting": "An Gerät übertragen..."
|
"casting": "An Gerät übertragen..."
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"offline": "Ihre Internetverbindung ist instabil"
|
"offline": "Internetverbindung ist instabil"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -71,7 +71,16 @@
|
|||||||
"popouts": {
|
"popouts": {
|
||||||
"back": "Go back",
|
"back": "Go back",
|
||||||
"sources": "Sources",
|
"sources": "Sources",
|
||||||
"seasons": "Seasons",
|
"close": "Close",
|
||||||
|
"seasons": {
|
||||||
|
"title":"Seasons",
|
||||||
|
"other": "Other seasons",
|
||||||
|
"noSeason": "No season"
|
||||||
|
},
|
||||||
|
"episodes": {
|
||||||
|
"unknown": "Unknown episode",
|
||||||
|
"noEpisode": "No episode"
|
||||||
|
},
|
||||||
"captions": "Captions",
|
"captions": "Captions",
|
||||||
"playbackSpeed": "Playback speed",
|
"playbackSpeed": "Playback speed",
|
||||||
"customPlaybackSpeed": "Custom playback speed",
|
"customPlaybackSpeed": "Custom playback speed",
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
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"
|
||||||
|
}
|
||||||
|
}
|
@@ -71,7 +71,16 @@
|
|||||||
"popouts": {
|
"popouts": {
|
||||||
"back": "Geri git",
|
"back": "Geri git",
|
||||||
"sources": "Kaynaklar",
|
"sources": "Kaynaklar",
|
||||||
"seasons": "Sezonlar",
|
"close":"Kapat",
|
||||||
|
"seasons": {
|
||||||
|
"title":"Sezonlar",
|
||||||
|
"other": "Diğer sezonlar",
|
||||||
|
"noSeason": "Sezon yok"
|
||||||
|
},
|
||||||
|
"episodes": {
|
||||||
|
"unknown": "Bilinmeyen bölüm",
|
||||||
|
"noEpisode": "Bölüm yok"
|
||||||
|
},
|
||||||
"captions": "Altyazılar",
|
"captions": "Altyazılar",
|
||||||
"playbackSpeed": "Oynatma hızı",
|
"playbackSpeed": "Oynatma hızı",
|
||||||
"customPlaybackSpeed": "Özel oynatma hızı",
|
"customPlaybackSpeed": "Özel oynatma hızı",
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
@@ -1,6 +1,6 @@
|
|||||||
import { ReactNode, createContext, useContext, useMemo } from "react";
|
import { ReactNode, createContext, useContext, useMemo } from "react";
|
||||||
|
|
||||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
import { MWMediaMeta } from "@/backend/metadata/types/mw";
|
||||||
import { useStore } from "@/utils/storage";
|
import { useStore } from "@/utils/storage";
|
||||||
|
|
||||||
import { BookmarkStore } from "./store";
|
import { BookmarkStore } from "./store";
|
||||||
|
@@ -2,6 +2,7 @@ import { createVersionedStore } from "@/utils/storage";
|
|||||||
|
|
||||||
import { BookmarkStoreData } from "./types";
|
import { BookmarkStoreData } from "./types";
|
||||||
import { OldBookmarks, migrateV1Bookmarks } from "../watched/migrations/v2";
|
import { OldBookmarks, migrateV1Bookmarks } from "../watched/migrations/v2";
|
||||||
|
import { migrateV2Bookmarks } from "../watched/migrations/v3";
|
||||||
|
|
||||||
export const BookmarkStore = createVersionedStore<BookmarkStoreData>()
|
export const BookmarkStore = createVersionedStore<BookmarkStoreData>()
|
||||||
.setKey("mw-bookmarks")
|
.setKey("mw-bookmarks")
|
||||||
@@ -13,6 +14,12 @@ export const BookmarkStore = createVersionedStore<BookmarkStoreData>()
|
|||||||
})
|
})
|
||||||
.addVersion({
|
.addVersion({
|
||||||
version: 1,
|
version: 1,
|
||||||
|
migrate(old: BookmarkStoreData) {
|
||||||
|
return migrateV2Bookmarks(old);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.addVersion({
|
||||||
|
version: 2,
|
||||||
create() {
|
create() {
|
||||||
return {
|
return {
|
||||||
bookmarks: [],
|
bookmarks: [],
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
import { MWMediaMeta } from "@/backend/metadata/types/mw";
|
||||||
|
|
||||||
export interface BookmarkStoreData {
|
export interface BookmarkStoreData {
|
||||||
bookmarks: MWMediaMeta[];
|
bookmarks: MWMediaMeta[];
|
||||||
|
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||||
import { MWMediaType } from "@/backend/metadata/types";
|
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||||
import { useStore } from "@/utils/storage";
|
import { useStore } from "@/utils/storage";
|
||||||
|
|
||||||
import { VideoProgressStore } from "./store";
|
import { VideoProgressStore } from "./store";
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta";
|
import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta";
|
||||||
import { searchForMedia } from "@/backend/metadata/search";
|
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 { compareTitle } from "@/utils/titleMatch";
|
||||||
|
|
||||||
import { WatchedStoreData, WatchedStoreItem } from "../types";
|
import { WatchedStoreData, WatchedStoreItem } from "../types";
|
||||||
|
89
src/state/watched/migrations/v3.ts
Normal file
89
src/state/watched/migrations/v3.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { getLegacyMetaFromId } from "@/backend/metadata/getmeta";
|
||||||
|
import {
|
||||||
|
getEpisodes,
|
||||||
|
getMediaDetails,
|
||||||
|
getMovieFromExternalId,
|
||||||
|
} from "@/backend/metadata/tmdb";
|
||||||
|
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||||
|
import { BookmarkStoreData } from "@/state/bookmark/types";
|
||||||
|
import { isNotNull } from "@/utils/typeguard";
|
||||||
|
|
||||||
|
import { WatchedStoreData } from "../types";
|
||||||
|
|
||||||
|
async function migrateId(
|
||||||
|
id: string,
|
||||||
|
type: MWMediaType
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const meta = await getLegacyMetaFromId(type, id);
|
||||||
|
|
||||||
|
if (!meta) return undefined;
|
||||||
|
const { tmdbId, imdbId } = meta;
|
||||||
|
if (!tmdbId && !imdbId) return undefined;
|
||||||
|
|
||||||
|
// movies always have an imdb id on tmdb
|
||||||
|
if (imdbId && type === MWMediaType.MOVIE) {
|
||||||
|
const movieId = await getMovieFromExternalId(imdbId);
|
||||||
|
if (movieId) return movieId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tmdbId) {
|
||||||
|
return tmdbId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function migrateV2Bookmarks(old: BookmarkStoreData) {
|
||||||
|
const updatedBookmarks = old.bookmarks.map(async (item) => ({
|
||||||
|
...item,
|
||||||
|
id: await migrateId(item.id, item.type).catch(() => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
bookmarks: (await Promise.all(updatedBookmarks)).filter((item) => item.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function migrateV3Videos(
|
||||||
|
old: WatchedStoreData
|
||||||
|
): Promise<WatchedStoreData> {
|
||||||
|
const updatedItems = await Promise.all(
|
||||||
|
old.items.map(async (progress) => {
|
||||||
|
try {
|
||||||
|
const migratedId = await migrateId(
|
||||||
|
progress.item.meta.id,
|
||||||
|
progress.item.meta.type
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!migratedId) return null;
|
||||||
|
|
||||||
|
const clone = structuredClone(progress);
|
||||||
|
clone.item.meta.id = migratedId;
|
||||||
|
if (clone.item.series) {
|
||||||
|
const series = clone.item.series;
|
||||||
|
const details = await getMediaDetails(migratedId, "show");
|
||||||
|
|
||||||
|
const season = details.seasons.find(
|
||||||
|
(v) => v.season_number === series.season
|
||||||
|
);
|
||||||
|
if (!season) return null;
|
||||||
|
|
||||||
|
const episodes = await getEpisodes(migratedId, season.season_number);
|
||||||
|
const episode = episodes.find(
|
||||||
|
(v) => v.episode_number === series.episode
|
||||||
|
);
|
||||||
|
if (!episode) return null;
|
||||||
|
|
||||||
|
clone.item.series.episodeId = episode.id.toString();
|
||||||
|
clone.item.series.seasonId = season.id.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return clone;
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: updatedItems.filter(isNotNull),
|
||||||
|
};
|
||||||
|
}
|
@@ -1,6 +1,7 @@
|
|||||||
import { createVersionedStore } from "@/utils/storage";
|
import { createVersionedStore } from "@/utils/storage";
|
||||||
|
|
||||||
import { OldData, migrateV2Videos } from "./migrations/v2";
|
import { OldData, migrateV2Videos } from "./migrations/v2";
|
||||||
|
import { migrateV3Videos } from "./migrations/v3";
|
||||||
import { WatchedStoreData } from "./types";
|
import { WatchedStoreData } from "./types";
|
||||||
|
|
||||||
export const VideoProgressStore = createVersionedStore<WatchedStoreData>()
|
export const VideoProgressStore = createVersionedStore<WatchedStoreData>()
|
||||||
@@ -21,6 +22,12 @@ export const VideoProgressStore = createVersionedStore<WatchedStoreData>()
|
|||||||
})
|
})
|
||||||
.addVersion({
|
.addVersion({
|
||||||
version: 2,
|
version: 2,
|
||||||
|
migrate(old: WatchedStoreData) {
|
||||||
|
return migrateV3Videos(old);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.addVersion({
|
||||||
|
version: 3,
|
||||||
create() {
|
create() {
|
||||||
return {
|
return {
|
||||||
items: [],
|
items: [],
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
import { MWMediaMeta } from "@/backend/metadata/types/mw";
|
||||||
|
|
||||||
export interface StoreMediaItem {
|
export interface StoreMediaItem {
|
||||||
meta: MWMediaMeta;
|
meta: MWMediaMeta;
|
||||||
|
@@ -46,9 +46,14 @@ export async function initializeStores() {
|
|||||||
let mostRecentData = data;
|
let mostRecentData = data;
|
||||||
try {
|
try {
|
||||||
for (const version of relevantVersions) {
|
for (const version of relevantVersions) {
|
||||||
if (version.migrate)
|
if (version.migrate) {
|
||||||
|
localStorage.setItem(
|
||||||
|
`BACKUP-v${version.version}-${internal.key}`,
|
||||||
|
JSON.stringify(mostRecentData)
|
||||||
|
);
|
||||||
mostRecentData = await version.migrate(mostRecentData);
|
mostRecentData = await version.migrate(mostRecentData);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`FAILED TO MIGRATE STORE ${internal.key}`, err);
|
console.error(`FAILED TO MIGRATE STORE ${internal.key}`, err);
|
||||||
// reset store to lastest version create
|
// reset store to lastest version create
|
||||||
|
3
src/utils/typeguard.ts
Normal file
3
src/utils/typeguard.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function isNotNull<T>(obj: T | null): obj is T {
|
||||||
|
return obj != null;
|
||||||
|
}
|
@@ -1,4 +1,4 @@
|
|||||||
import { MWMediaType } from "@/backend/metadata/types";
|
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
import { useMeta } from "@/video/state/logic/meta";
|
import { useMeta } from "@/video/state/logic/meta";
|
||||||
|
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { MWMediaType } from "@/backend/metadata/types";
|
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||||
import { Icons } from "@/components/Icon";
|
import { Icons } from "@/components/Icon";
|
||||||
import { FloatingAnchor } from "@/components/popout/FloatingAnchor";
|
import { FloatingAnchor } from "@/components/popout/FloatingAnchor";
|
||||||
import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton";
|
import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton";
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { MWCaption } from "@/backend/helpers/streams";
|
import { MWCaption } from "@/backend/helpers/streams";
|
||||||
import { MWSeasonWithEpisodeMeta } from "@/backend/metadata/types";
|
import { MWSeasonWithEpisodeMeta } from "@/backend/metadata/types/mw";
|
||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
import { useControls } from "@/video/state/logic/controls";
|
import { useControls } from "@/video/state/logic/controls";
|
||||||
import { VideoPlayerMeta } from "@/video/state/types";
|
import { VideoPlayerMeta } from "@/video/state/types";
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import throttle from "lodash.throttle";
|
import throttle from "lodash.throttle";
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
|
||||||
|
import { useQueryParams } from "@/hooks/useQueryParams";
|
||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
import { useControls } from "@/video/state/logic/controls";
|
import { useControls } from "@/video/state/logic/controls";
|
||||||
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
||||||
@@ -20,6 +21,7 @@ export function ProgressListenerController(props: Props) {
|
|||||||
const misc = useMisc(descriptor);
|
const misc = useMisc(descriptor);
|
||||||
const didInitialize = useRef<true | null>(null);
|
const didInitialize = useRef<true | null>(null);
|
||||||
const lastTime = useRef<number>(props.startAt ?? 0);
|
const lastTime = useRef<number>(props.startAt ?? 0);
|
||||||
|
const queryParams = useQueryParams();
|
||||||
|
|
||||||
// time updates (throttled)
|
// time updates (throttled)
|
||||||
const updateTime = useMemo(
|
const updateTime = useMemo(
|
||||||
@@ -56,9 +58,26 @@ export function ProgressListenerController(props: Props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lastStateProviderId.current === stateProviderId) return;
|
if (lastStateProviderId.current === stateProviderId) return;
|
||||||
if (mediaPlaying.isFirstLoading) return;
|
if (mediaPlaying.isFirstLoading) return;
|
||||||
|
|
||||||
lastStateProviderId.current = stateProviderId;
|
lastStateProviderId.current = stateProviderId;
|
||||||
|
|
||||||
|
if ((queryParams.t ?? null) !== null) {
|
||||||
|
// Convert `t` param to time. Supports having only seconds (like `?t=192`), but also `3:30` or `1:30:02`
|
||||||
|
|
||||||
|
const timeArr = queryParams.t.toString().split(":").map(Number).reverse(); // This is an array of [seconds, ?minutes, ?hours] as ints.
|
||||||
|
|
||||||
|
const hours = timeArr[2] ?? 0;
|
||||||
|
const minutes = Math.min(timeArr[1] ?? 0, 59);
|
||||||
|
const seconds = Math.min(timeArr[0] ?? 0, minutes > 0 ? 59 : Infinity);
|
||||||
|
|
||||||
|
const timeInSeconds = hours * 60 * 60 + minutes * 60 + seconds;
|
||||||
|
|
||||||
|
controls.setTime(timeInSeconds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
controls.setTime(lastTime.current);
|
controls.setTime(lastTime.current);
|
||||||
}, [controls, mediaPlaying, stateProviderId]);
|
}, [controls, mediaPlaying, stateProviderId, queryParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// if it initialized, but media starts loading for the first time again.
|
// if it initialized, but media starts loading for the first time again.
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { MWMediaType } from "@/backend/metadata/types";
|
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||||
import { useMeta } from "@/video/state/logic/meta";
|
import { useMeta } from "@/video/state/logic/meta";
|
||||||
|
|
||||||
export function useCurrentSeriesEpisodeInfo(descriptor: string) {
|
export function useCurrentSeriesEpisodeInfo(descriptor: string) {
|
||||||
|
@@ -2,7 +2,7 @@ import { Component } from "react";
|
|||||||
import { Trans } from "react-i18next";
|
import { Trans } from "react-i18next";
|
||||||
import type { ReactNode } from "react-router-dom/node_modules/@types/react/index";
|
import type { ReactNode } from "react-router-dom/node_modules/@types/react/index";
|
||||||
|
|
||||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
import { MWMediaMeta } from "@/backend/metadata/types/mw";
|
||||||
import { ErrorMessage } from "@/components/layout/ErrorBoundary";
|
import { ErrorMessage } from "@/components/layout/ErrorBoundary";
|
||||||
import { Link } from "@/components/text/Link";
|
import { Link } from "@/components/text/Link";
|
||||||
import { conf } from "@/setup/config";
|
import { conf } from "@/setup/config";
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
import { MWMediaMeta } from "@/backend/metadata/types/mw";
|
||||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
import { BrandPill } from "@/components/layout/BrandPill";
|
import { BrandPill } from "@/components/layout/BrandPill";
|
||||||
|
@@ -2,9 +2,11 @@ import { useCallback, useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
import { getMetaFromId } from "@/backend/metadata/getmeta";
|
import { decodeTMDBId, getMetaFromId } from "@/backend/metadata/getmeta";
|
||||||
import { decodeJWId } from "@/backend/metadata/justwatch";
|
import {
|
||||||
import { MWMediaType, MWSeasonWithEpisodeMeta } from "@/backend/metadata/types";
|
MWMediaType,
|
||||||
|
MWSeasonWithEpisodeMeta,
|
||||||
|
} from "@/backend/metadata/types/mw";
|
||||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
import { Loading } from "@/components/layout/Loading";
|
import { Loading } from "@/components/layout/Loading";
|
||||||
@@ -45,7 +47,7 @@ export function EpisodeSelectionPopout() {
|
|||||||
seasonId: sId,
|
seasonId: sId,
|
||||||
season: undefined,
|
season: undefined,
|
||||||
});
|
});
|
||||||
reqSeasonMeta(decodeJWId(params.media)?.id as string, sId).then((v) => {
|
reqSeasonMeta(decodeTMDBId(params.media)?.id as string, sId).then((v) => {
|
||||||
if (v?.meta.type !== MWMediaType.SERIES) return;
|
if (v?.meta.type !== MWMediaType.SERIES) return;
|
||||||
setCurrentVisibleSeason({
|
setCurrentVisibleSeason({
|
||||||
seasonId: sId,
|
seasonId: sId,
|
||||||
@@ -99,10 +101,10 @@ export function EpisodeSelectionPopout() {
|
|||||||
<>
|
<>
|
||||||
<FloatingView {...pageProps("seasons")} height={600} width={375}>
|
<FloatingView {...pageProps("seasons")} height={600} width={375}>
|
||||||
<FloatingCardView.Header
|
<FloatingCardView.Header
|
||||||
title={t("videoPlayer.popouts.seasons")}
|
title={t("videoPlayer.popouts.seasons.title")}
|
||||||
description={t("videoPlayer.popouts.descriptions.seasons")}
|
description={t("videoPlayer.popouts.descriptions.seasons")}
|
||||||
goBack={() => navigate("/episodes")}
|
goBack={() => navigate("/episodes")}
|
||||||
backText={`To ${currentSeasonInfo?.title.toLowerCase()}`}
|
backText={currentSeasonInfo?.title}
|
||||||
/>
|
/>
|
||||||
<FloatingCardView.Content>
|
<FloatingCardView.Content>
|
||||||
{currentSeasonInfo
|
{currentSeasonInfo
|
||||||
@@ -115,12 +117,15 @@ export function EpisodeSelectionPopout() {
|
|||||||
{season.title}
|
{season.title}
|
||||||
</PopoutListEntry>
|
</PopoutListEntry>
|
||||||
))
|
))
|
||||||
: "No season"}
|
: t("videoPlayer.popouts.seasons.noSeason")}
|
||||||
</FloatingCardView.Content>
|
</FloatingCardView.Content>
|
||||||
</FloatingView>
|
</FloatingView>
|
||||||
<FloatingView {...pageProps("episodes")} height={600} width={375}>
|
<FloatingView {...pageProps("episodes")} height={600} width={375}>
|
||||||
<FloatingCardView.Header
|
<FloatingCardView.Header
|
||||||
title={currentSeasonInfo?.title ?? "Unknown season"}
|
title={
|
||||||
|
currentSeasonInfo?.title ??
|
||||||
|
t("videoPlayer.popouts.episodes.unknown")
|
||||||
|
}
|
||||||
description={t("videoPlayer.popouts.descriptions.episode")}
|
description={t("videoPlayer.popouts.descriptions.episode")}
|
||||||
goBack={closePopout}
|
goBack={closePopout}
|
||||||
close
|
close
|
||||||
@@ -130,7 +135,7 @@ export function EpisodeSelectionPopout() {
|
|||||||
onClick={() => navigate("/episodes/seasons")}
|
onClick={() => navigate("/episodes/seasons")}
|
||||||
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
|
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
|
||||||
>
|
>
|
||||||
<span>Other seasons</span>
|
<span>{t("videoPlayer.popouts.seasons.other")}</span>
|
||||||
<Icon icon={Icons.CHEVRON_RIGHT} />
|
<Icon icon={Icons.CHEVRON_RIGHT} />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -181,7 +186,7 @@ export function EpisodeSelectionPopout() {
|
|||||||
})}
|
})}
|
||||||
</PopoutListEntry>
|
</PopoutListEntry>
|
||||||
))
|
))
|
||||||
: "No episodes"}
|
: t("videoPlayer.popouts.episodes.noEpisode")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</FloatingCardView.Content>
|
</FloatingCardView.Content>
|
||||||
|
@@ -9,8 +9,6 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
|||||||
import { useControls } from "@/video/state/logic/controls";
|
import { useControls } from "@/video/state/logic/controls";
|
||||||
import { useInterface } from "@/video/state/logic/interface";
|
import { useInterface } from "@/video/state/logic/interface";
|
||||||
|
|
||||||
import "./Popouts.css";
|
|
||||||
|
|
||||||
function ShowPopout(props: { popoutId: string | null; onClose: () => void }) {
|
function ShowPopout(props: { popoutId: string | null; onClose: () => void }) {
|
||||||
const popoutMap = {
|
const popoutMap = {
|
||||||
settings: <SettingsPopout />,
|
settings: <SettingsPopout />,
|
||||||
|
@@ -1,15 +0,0 @@
|
|||||||
.popout-wrapper ::-webkit-scrollbar-track {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popout-wrapper ::-webkit-scrollbar-thumb {
|
|
||||||
background-color: theme("colors.denim-500");
|
|
||||||
border: 5px solid transparent;
|
|
||||||
border-left: 0;
|
|
||||||
background-clip: content-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popout-wrapper ::-webkit-scrollbar {
|
|
||||||
/* For some reason the styles don't get applied without the width */
|
|
||||||
width: 13px;
|
|
||||||
}
|
|
@@ -3,7 +3,7 @@ import { Helmet } from "react-helmet";
|
|||||||
|
|
||||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
|
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
|
||||||
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||||
import { MWMediaType } from "@/backend/metadata/types";
|
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Dropdown } from "@/components/Dropdown";
|
import { Dropdown } from "@/components/Dropdown";
|
||||||
import { Navigation } from "@/components/layout/Navigation";
|
import { Navigation } from "@/components/layout/Navigation";
|
||||||
|
@@ -4,15 +4,23 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useHistory, useParams } from "react-router-dom";
|
import { useHistory, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import { MWStream } from "@/backend/helpers/streams";
|
import { MWStream } from "@/backend/helpers/streams";
|
||||||
import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta";
|
import {
|
||||||
import { decodeJWId } from "@/backend/metadata/justwatch";
|
DetailedMeta,
|
||||||
import { MWMediaType, MWSeasonWithEpisodeMeta } from "@/backend/metadata/types";
|
decodeTMDBId,
|
||||||
|
getMetaFromId,
|
||||||
|
} from "@/backend/metadata/getmeta";
|
||||||
|
import {
|
||||||
|
MWMediaType,
|
||||||
|
MWSeasonWithEpisodeMeta,
|
||||||
|
} from "@/backend/metadata/types/mw";
|
||||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||||
import { Icons } from "@/components/Icon";
|
import { Icons } from "@/components/Icon";
|
||||||
|
import { ErrorMessage } from "@/components/layout/ErrorBoundary";
|
||||||
import { Loading } from "@/components/layout/Loading";
|
import { Loading } from "@/components/layout/Loading";
|
||||||
import { useGoBack } from "@/hooks/useGoBack";
|
import { useGoBack } from "@/hooks/useGoBack";
|
||||||
import { useLoading } from "@/hooks/useLoading";
|
import { useLoading } from "@/hooks/useLoading";
|
||||||
import { SelectedMediaData, useScrape } from "@/hooks/useScrape";
|
import { SelectedMediaData, useScrape } from "@/hooks/useScrape";
|
||||||
|
import { conf } from "@/setup/config";
|
||||||
import { useWatchedItem } from "@/state/watched";
|
import { useWatchedItem } from "@/state/watched";
|
||||||
import { MetaController } from "@/video/components/controllers/MetaController";
|
import { MetaController } from "@/video/components/controllers/MetaController";
|
||||||
import { ProgressListenerController } from "@/video/components/controllers/ProgressListenerController";
|
import { ProgressListenerController } from "@/video/components/controllers/ProgressListenerController";
|
||||||
@@ -47,6 +55,31 @@ function MediaViewLoading(props: { onGoBack(): void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MediaVIewNotAllowed(props: { onGoBack(): void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-1 items-center justify-center">
|
||||||
|
<Helmet>
|
||||||
|
<title>{t("videoPlayer.got")}</title>
|
||||||
|
</Helmet>
|
||||||
|
<div className="absolute inset-x-0 top-0 px-8 py-6">
|
||||||
|
<VideoPlayerHeader onClick={props.onGoBack} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<ErrorMessage
|
||||||
|
error={{
|
||||||
|
name: "Media not allowed",
|
||||||
|
description:
|
||||||
|
"this media is no longer available due to a takedown notice or copyright claim",
|
||||||
|
path: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface MediaViewScrapingProps {
|
interface MediaViewScrapingProps {
|
||||||
onStream(stream: MWStream): void;
|
onStream(stream: MWStream): void;
|
||||||
onGoBack(): void;
|
onGoBack(): void;
|
||||||
@@ -181,7 +214,7 @@ export function MediaView() {
|
|||||||
const [selected, setSelected] = useState<SelectedMediaData | null>(null);
|
const [selected, setSelected] = useState<SelectedMediaData | null>(null);
|
||||||
const [exec, loading, error] = useLoading(
|
const [exec, loading, error] = useLoading(
|
||||||
async (mediaParams: string, seasonId?: string) => {
|
async (mediaParams: string, seasonId?: string) => {
|
||||||
const data = decodeJWId(mediaParams);
|
const data = decodeTMDBId(mediaParams);
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
return getMetaFromId(data.type, data.id, seasonId);
|
return getMetaFromId(data.type, data.id, seasonId);
|
||||||
}
|
}
|
||||||
@@ -234,6 +267,14 @@ export function MediaView() {
|
|||||||
});
|
});
|
||||||
}, [exec, history, params]);
|
}, [exec, history, params]);
|
||||||
|
|
||||||
|
const disallowedEntries = conf().DISALLOWED_IDS.map((id) => id.split("-"));
|
||||||
|
if (
|
||||||
|
disallowedEntries.find(
|
||||||
|
(entry) => meta?.tmdbId === entry[1] && meta?.meta?.type === entry[0]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return <MediaVIewNotAllowed onGoBack={goBack} />;
|
||||||
|
|
||||||
if (loading) return <MediaViewLoading onGoBack={goBack} />;
|
if (loading) return <MediaViewLoading onGoBack={goBack} />;
|
||||||
if (error) return <MediaFetchErrorView />;
|
if (error) return <MediaFetchErrorView />;
|
||||||
if (!meta || !selected)
|
if (!meta || !selected)
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import pako from "pako";
|
import pako from "pako";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { MWMediaType } from "@/backend/metadata/types";
|
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||||
import { conf } from "@/setup/config";
|
import { conf } from "@/setup/config";
|
||||||
|
|
||||||
function fromBinary(str: string): Uint8Array {
|
function fromBinary(str: string): Uint8Array {
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { MWQuery } from "@/backend/metadata/types";
|
import { MWQuery } from "@/backend/metadata/types/mw";
|
||||||
import { useDebounce } from "@/hooks/useDebounce";
|
import { useDebounce } from "@/hooks/useDebounce";
|
||||||
|
|
||||||
import { HomeView } from "./HomeView";
|
import { HomeView } from "./HomeView";
|
||||||
|
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { searchForMedia } from "@/backend/metadata/search";
|
import { searchForMedia } from "@/backend/metadata/search";
|
||||||
import { MWMediaMeta, MWQuery } from "@/backend/metadata/types";
|
import { MWMediaMeta, MWQuery } from "@/backend/metadata/types/mw";
|
||||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||||
import { Icons } from "@/components/Icon";
|
import { Icons } from "@/components/Icon";
|
||||||
import { SectionHeading } from "@/components/layout/SectionHeading";
|
import { SectionHeading } from "@/components/layout/SectionHeading";
|
||||||
|
@@ -42,5 +42,5 @@ module.exports = {
|
|||||||
animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" }
|
animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [require("tailwind-scrollbar"), require("@tailwindcss/line-clamp")]
|
plugins: [require("tailwind-scrollbar")]
|
||||||
};
|
};
|
||||||
|
Reference in New Issue
Block a user