mirror of
https://github.com/movie-web/movie-web.git
synced 2025-01-12 23:59:09 +01:00
Merge pull request #497 from movie-web/v4
(not a release) Don't get too hyped, but v4 is being merged into dev
This commit is contained in:
commit
4b5b0401ff
4
.docs/.eslintignore
Normal file
4
.docs/.eslintignore
Normal file
@ -0,0 +1,4 @@
|
||||
dist
|
||||
node_modules
|
||||
.output
|
||||
.nuxt
|
8
.docs/.eslintrc.cjs
Normal file
8
.docs/.eslintrc.cjs
Normal file
@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: '@nuxt/eslint-config',
|
||||
rules: {
|
||||
'vue/max-attributes-per-line': 'off',
|
||||
'vue/multi-word-component-names': 'off'
|
||||
}
|
||||
}
|
12
.docs/.gitignore
vendored
Executable file
12
.docs/.gitignore
vendored
Executable file
@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
*.iml
|
||||
.idea
|
||||
*.log*
|
||||
.nuxt
|
||||
.vscode
|
||||
.DS_Store
|
||||
coverage
|
||||
dist
|
||||
sw.*
|
||||
.env
|
||||
.output
|
1
.docs/.npmrc
Normal file
1
.docs/.npmrc
Normal file
@ -0,0 +1 @@
|
||||
shamefully-hoist=true
|
57
.docs/README.md
Executable file
57
.docs/README.md
Executable file
@ -0,0 +1,57 @@
|
||||
# Docus Starter
|
||||
|
||||
Starter template for [Docus](https://docus.dev).
|
||||
|
||||
## Clone
|
||||
|
||||
Clone the repository (using `nuxi`):
|
||||
|
||||
```bash
|
||||
npx nuxi init -t themes/docus
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
```
|
||||
|
||||
## Edge Side Rendering
|
||||
|
||||
Can be deployed to Vercel Functions, Netlify Functions, AWS, and most Node-compatible environments.
|
||||
|
||||
Look at all the available presets [here](https://v3.nuxtjs.org/guide/deploy/presets).
|
||||
|
||||
```bash
|
||||
yarn build
|
||||
```
|
||||
|
||||
## Static Generation
|
||||
|
||||
Use the `generate` command to build your application.
|
||||
|
||||
The HTML files will be generated in the .output/public directory and ready to be deployed to any static compatible hosting.
|
||||
|
||||
```bash
|
||||
yarn generate
|
||||
```
|
||||
|
||||
## Preview build
|
||||
|
||||
You might want to preview the result of your build locally, to do so, run the following command:
|
||||
|
||||
```bash
|
||||
yarn preview
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
For a detailed explanation of how things work, check out [Docus](https://docus.dev).
|
18
.docs/app.config.ts
Normal file
18
.docs/app.config.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export default defineAppConfig({
|
||||
docus: {
|
||||
title: 'movie-web',
|
||||
description: 'For all your media scraping needs',
|
||||
socials: {
|
||||
github: 'movie-web/providers',
|
||||
},
|
||||
image: '',
|
||||
aside: {
|
||||
level: 0,
|
||||
exclude: [],
|
||||
},
|
||||
header: {
|
||||
logo: false,
|
||||
title: "movie-web"
|
||||
},
|
||||
},
|
||||
});
|
50
.docs/content/0.index.md
Normal file
50
.docs/content/0.index.md
Normal file
@ -0,0 +1,50 @@
|
||||
---
|
||||
title: "movie-web | For all your movie and TV show needs"
|
||||
navigation: false
|
||||
layout: page
|
||||
---
|
||||
|
||||
::block-hero
|
||||
---
|
||||
cta:
|
||||
- Get Started
|
||||
- /introduction/getting-started
|
||||
secondary:
|
||||
- Open on GitHub →
|
||||
- https://github.com/movie-web/movie-web
|
||||
---
|
||||
|
||||
#title
|
||||
movie-web
|
||||
|
||||
#description
|
||||
A simple and no-BS app for watching movies and TV shows
|
||||
::
|
||||
|
||||
::card-grid
|
||||
#title
|
||||
What's all the fuss?
|
||||
|
||||
#root
|
||||
:ellipsis
|
||||
|
||||
#default
|
||||
::card{icon="mdi:server-network"}
|
||||
#title
|
||||
Easy to host
|
||||
#description
|
||||
movie-web can be easily hosted on any static website host.
|
||||
::
|
||||
::card{icon="material-symbols:hangout-video-off"}
|
||||
#title
|
||||
No ADs
|
||||
#description
|
||||
movie-web will never show ADs, enjoy watching without interruptions.
|
||||
::
|
||||
::card{icon="ic:baseline-ondemand-video"}
|
||||
#title
|
||||
Custom Player
|
||||
#description
|
||||
Enjoy a fully custom video player including streaming integration, subtitle customisation and easy TV season navigation.
|
||||
::
|
||||
::
|
2
.docs/content/1.introduction/1.getting-started.md
Normal file
2
.docs/content/1.introduction/1.getting-started.md
Normal file
@ -0,0 +1,2 @@
|
||||
# Getting Started
|
||||
|
2
.docs/content/1.introduction/_dir.yml
Normal file
2
.docs/content/1.introduction/_dir.yml
Normal file
@ -0,0 +1,2 @@
|
||||
icon: ph:star-duotone
|
||||
navigation.redirect: /introduction/getting-started
|
9
.docs/content/2.self-hosting/1.self-hosting.md
Normal file
9
.docs/content/2.self-hosting/1.self-hosting.md
Normal file
@ -0,0 +1,9 @@
|
||||
# How to self host
|
||||
|
||||
::alert{type="info"}
|
||||
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.
|
||||
::
|
||||
|
||||
The movie-web application is made of two parts: the proxy and the client. Click the following links to find out more:
|
||||
- [Setup the Proxy](2.proxy.md)
|
||||
- [Setup the Client](3.client.md)
|
37
.docs/content/2.self-hosting/2.proxy.md
Normal file
37
.docs/content/2.self-hosting/2.proxy.md
Normal file
@ -0,0 +1,37 @@
|
||||
# Setting up the proxy
|
||||
|
||||
Our proxy is used to bypass CORS-protected URLs on the client side, allowing users to make requests to protected endpoints without a backend server.
|
||||
|
||||
The proxy is made using [Nitro by UnJS](https://nitro.unjs.io/) which supports building the proxy to work on multiple providers including Cloudflare Workers, AWS Lambda and [more...](https://nitro.unjs.io/deploy)
|
||||
|
||||
Our recommended provider is Cloudflare due to its [generous free plan](https://www.cloudflare.com/en-gb/plans/developer-platform/).
|
||||
|
||||
## Cloudflare Workers
|
||||
|
||||
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.
|
||||
|
||||
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/movie-web/simple-proxy)
|
||||
|
||||
1. Create a GitHub account at https://github.com if you don't have one.
|
||||
1. Click the "Deploy with workers" button above.
|
||||
1. Click the "Authorize Workers" button to authorise Cloudflare to talk to GitHub.
|
||||
1. Authorize Cloudflare Workers in the GitHub page that pops up.
|
||||
1. Follow the instructions to configure your Cloudflare account. Select "I have an account" if you have a Cloudflare account already, otherwise follow the link to create one.
|
||||
1. Click the link to "Workers Dashboard" to find your account ID.
|
||||
1. If you have used workers in the past, there will be a place on the right hand side to copy your account ID.
|
||||
1. If you haven't used workers before, you can copy your account ID from the URL e.g. https://dash.cloudflare.com/ab7cb454c93987b6343350d4e84c16c7/workers-and-pages/create where `ab7cb454c93987b6343350d4e84c16c7` is the account ID.
|
||||
1. Paste the account ID into the text box on the original Cloudflare workers page.
|
||||
1. Click the link to "My Profile" to create an API token.
|
||||
1. Click "Create Token".
|
||||
1. Select "Use template" next to "Edit Cloudflare Workers".
|
||||
1. Under "Account Resources", select "Include" and your account under the dropdown.
|
||||
1. Under "Zone Resources", select "All zones" (You can select a more specific zone if you have the zones available).
|
||||
1. At the bottom of the page, click "Continue to summary".
|
||||
1. On the next screen, click "Create token".
|
||||
1. Copy the API token and **save it in a safe place, it won't be shown again**.
|
||||
1. Paste the API token into the Cloudflare Workers API Token text box.
|
||||
1. Click "Fork the Repository" and follow the instructions to enable workflows.
|
||||
1. Click "Deploy" to deploy to Cloudflare Workers.
|
||||
1. Congratulations! Your worker is now deploying. Please wait for the GitHub Action to build and publish your worker.
|
||||
1. You can click the "Worker dash" and "GitHub repo" buttons to see the status of the deploy.
|
||||
1. When the worker has deployed, you will need to take note of the URL. This can be found on Cloudflare under Workers and Pages -> Overview -> proxy.
|
49
.docs/content/2.self-hosting/3.client.md
Normal file
49
.docs/content/2.self-hosting/3.client.md
Normal file
@ -0,0 +1,49 @@
|
||||
# Setting up the client
|
||||
|
||||
## Vercel - Recommended
|
||||
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fmovie-web%2Fmovie-web&env=VITE_CORS_PROXY_URL,VITE_TMDB_READ_API_KEY)
|
||||
1. Click the Deploy button.
|
||||
1. Sign in using either a GitHub, GitLab, or Bitbucket.
|
||||
1. Follow the instructions to create a repository for movie-web.
|
||||
1. Configure the environment variables:
|
||||
- `VITE_CORS_PROXY_URL`: Enter your proxy URL here. Make sure to not have a slash at the end of your URL.
|
||||
|
||||
Example (THIS IS AN EXAMPLE, IT WON'T WORK FOR YOU): `https://test-proxy.test.workers.dev`
|
||||
- `VITE_TMDB_READ_API_KEY`: Enter your TMDB Read Access Token here. Please read [below](#tmdb-api-key) on how to get an API key.
|
||||
1. Click "Deploy"
|
||||
1. Congrats! You have your own version of movie-web hosted.
|
||||
1. You may wish to configure a custom domain - Please consult [the Vercel docs for how to do this](https://vercel.com/docs/getting-started-with-vercel/domains).
|
||||
|
||||
|
||||
## Any Static Web Host
|
||||
1. Download the file `movie-web.zip` from the latest release: https://github.com/movie-web/movie-web/releases/latest.
|
||||
2. Extract the ZIP file so you can edit the files.
|
||||
3. Open `config.js` in an editor such as Notepad, Visual Studio Code or similar.
|
||||
4. Put your 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 AN EXAMPLE, IT WON'T WORK FOR YOU): `VITE_CORS_PROXY_URL: "https://test-proxy.test.workers.dev"`
|
||||
5. Put your TMDB Read Access Token inside the quotes of `VITE_TMDB_READ_API_KEY: ""`. Please read [below](#tmdb-api-key) on how to get an API key.
|
||||
6. Save the file.
|
||||
7. Upload **all** of the files to a static website hosting such as:
|
||||
- GitHub Pages
|
||||
- Netlify
|
||||
- Vercel
|
||||
- Etc, there are lots - Google it if the ones above don't work for you.
|
||||
1. Congrats! You have your own version of movie-web hosted.
|
||||
|
||||
|
||||
## TMDB API Key
|
||||
In order to search for movies and TV shows, we use an API called "The Movie Database" (TMDB). In order for your client to be able to search, you need to generate an API key.
|
||||
|
||||
::alert{type="info"}
|
||||
The API key is **free**, you just need to create an account.
|
||||
::
|
||||
|
||||
1. Create an account at https://www.themoviedb.org/signup
|
||||
1. You will be required to verify your email; click the link that you get sent to verify your account.
|
||||
1. Go to https://www.themoviedb.org/settings/api/request to create a developer account.
|
||||
1. Read the terms and conditions and accept them.
|
||||
1. Fill out your details:
|
||||
- Select "Website" as type of use.
|
||||
- For the other details can put any values; they are not important.
|
||||
1. Copy the "API Read Access Token" - **DO NOT COPY THE API Key - IT WILL NOT WORK**
|
1
.docs/content/2.self-hosting/4.config.md
Normal file
1
.docs/content/2.self-hosting/4.config.md
Normal file
@ -0,0 +1 @@
|
||||
# Config Reference
|
2
.docs/content/2.self-hosting/_dir.yml
Normal file
2
.docs/content/2.self-hosting/_dir.yml
Normal file
@ -0,0 +1,2 @@
|
||||
title: 'Self-Hosting'
|
||||
icon: mdi:server-network
|
11
.docs/nuxt.config.ts
Executable file
11
.docs/nuxt.config.ts
Executable file
@ -0,0 +1,11 @@
|
||||
export default defineNuxtConfig({
|
||||
// https://github.com/nuxt-themes/docus
|
||||
extends: '@nuxt-themes/docus',
|
||||
devtools: { enabled: true },
|
||||
|
||||
modules: [
|
||||
// Remove it if you don't use Plausible analytics
|
||||
// https://github.com/nuxt-modules/plausible
|
||||
'@nuxtjs/plausible'
|
||||
]
|
||||
})
|
21
.docs/package.json
Executable file
21
.docs/package.json
Executable file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "docus-starter",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nuxi dev",
|
||||
"build": "nuxi build",
|
||||
"generate": "nuxi generate",
|
||||
"preview": "nuxi preview",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt-themes/docus": "latest",
|
||||
"@nuxt/devtools": "^0.8.5",
|
||||
"@nuxt/eslint-config": "^0.2.0",
|
||||
"@nuxtjs/plausible": "^0.2.3",
|
||||
"@types/node": "^20.8.2",
|
||||
"eslint": "^8.50.0",
|
||||
"nuxt": "^3.7.4"
|
||||
}
|
||||
}
|
9763
.docs/pnpm-lock.yaml
generated
Normal file
9763
.docs/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
.docs/public/cover.png
Normal file
BIN
.docs/public/cover.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 214 KiB |
BIN
.docs/public/favicon.ico
Normal file
BIN
.docs/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 24 KiB |
8
.docs/renovate.json
Executable file
8
.docs/renovate.json
Executable file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": [
|
||||
"@nuxtjs"
|
||||
],
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
18
.docs/tokens.config.ts
Normal file
18
.docs/tokens.config.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { defineTheme } from 'pinceau'
|
||||
|
||||
export default defineTheme({
|
||||
color: {
|
||||
primary: {
|
||||
50: "#F5E5FF",
|
||||
100: "#E7CCFF",
|
||||
200: "#D4A9FF",
|
||||
300: "#BE85FF",
|
||||
400: "#A861FF",
|
||||
500: "#8E3DFF",
|
||||
600: "#7F36D4",
|
||||
700: "#662CA6",
|
||||
800: "#552578",
|
||||
900: "#441E49"
|
||||
}
|
||||
}
|
||||
})
|
3
.docs/tsconfig.json
Executable file
3
.docs/tsconfig.json
Executable file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
46
.eslintrc.js
46
.eslintrc.js
@ -8,26 +8,33 @@ const a11yOff = Object.keys(require("eslint-plugin-jsx-a11y").rules).reduce(
|
||||
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
browser: true
|
||||
},
|
||||
extends: [
|
||||
"airbnb",
|
||||
"airbnb/hooks",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:prettier/recommended",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
ignorePatterns: [
|
||||
"public/*",
|
||||
"dist/*",
|
||||
"/*.js",
|
||||
"/*.ts",
|
||||
"/plugins/*.ts",
|
||||
"/themes/**/*.ts"
|
||||
],
|
||||
ignorePatterns: ["public/*", "dist/*", "/*.js", "/*.ts"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
tsconfigRootDir: "./",
|
||||
tsconfigRootDir: "./"
|
||||
},
|
||||
settings: {
|
||||
"import/resolver": {
|
||||
typescript: {
|
||||
project: "./tsconfig.json",
|
||||
},
|
||||
},
|
||||
project: "./tsconfig.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: ["@typescript-eslint", "import", "prettier"],
|
||||
rules: {
|
||||
@ -37,7 +44,7 @@ module.exports = {
|
||||
"react/destructuring-assignment": "off",
|
||||
"no-underscore-dangle": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"no-console": "off",
|
||||
"no-console": ["warn", { allow: ["warn", "error", "debug", "info"] }],
|
||||
"@typescript-eslint/no-this-alias": "off",
|
||||
"import/prefer-default-export": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
@ -52,18 +59,19 @@ module.exports = {
|
||||
"no-await-in-loop": "off",
|
||||
"no-nested-ternary": "off",
|
||||
"prefer-destructuring": "off",
|
||||
"no-param-reassign": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
||||
"react/jsx-filename-extension": [
|
||||
"error",
|
||||
{ extensions: [".js", ".tsx", ".jsx"] },
|
||||
{ extensions: [".js", ".tsx", ".jsx"] }
|
||||
],
|
||||
"import/extensions": [
|
||||
"error",
|
||||
"ignorePackages",
|
||||
{
|
||||
ts: "never",
|
||||
tsx: "never",
|
||||
},
|
||||
tsx: "never"
|
||||
}
|
||||
],
|
||||
"import/order": [
|
||||
"error",
|
||||
@ -74,14 +82,14 @@ module.exports = {
|
||||
"internal",
|
||||
["sibling", "parent"],
|
||||
"index",
|
||||
"unknown",
|
||||
"unknown"
|
||||
],
|
||||
"newlines-between": "always",
|
||||
alphabetize: {
|
||||
order: "asc",
|
||||
caseInsensitive: true,
|
||||
},
|
||||
},
|
||||
caseInsensitive: true
|
||||
}
|
||||
}
|
||||
],
|
||||
"sort-imports": [
|
||||
"error",
|
||||
@ -90,9 +98,9 @@ module.exports = {
|
||||
ignoreDeclarationSort: true,
|
||||
ignoreMemberSort: false,
|
||||
memberSyntaxSortOrder: ["none", "all", "multiple", "single"],
|
||||
allowSeparatedGroups: true,
|
||||
},
|
||||
allowSeparatedGroups: true
|
||||
}
|
||||
],
|
||||
...a11yOff,
|
||||
},
|
||||
...a11yOff
|
||||
}
|
||||
};
|
||||
|
84
.github/workflows/deploying.yml
vendored
84
.github/workflows/deploying.yml
vendored
@ -6,49 +6,92 @@ on:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
build_pwa:
|
||||
name: Build PWA
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install pnpm packages
|
||||
run: pnpm install
|
||||
|
||||
- name: Build project
|
||||
run: pnpm run build:pwa
|
||||
|
||||
- name: Upload production-ready build files
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: pwa
|
||||
path: ./dist
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Yarn packages
|
||||
run: yarn install
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install pnpm packages
|
||||
run: pnpm install
|
||||
|
||||
- name: Build project
|
||||
run: yarn build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Upload production-ready build files
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: production-files
|
||||
name: normal
|
||||
path: ./dist
|
||||
|
||||
release:
|
||||
name: Release
|
||||
needs: build
|
||||
needs: [build, build_pwa]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Download artifact
|
||||
- name: Download PWA artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: production-files
|
||||
path: ./dist
|
||||
name: pwa
|
||||
path: ./dist_pwa
|
||||
|
||||
- name: Zip files
|
||||
run: cd dist && zip -r ../movie-web.zip .
|
||||
- name: Zip PWA files
|
||||
run: cd dist_pwa && zip -r ../movie-web.pwa.zip .
|
||||
|
||||
- name: Download normal artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: normal
|
||||
path: ./dist_normal
|
||||
|
||||
- name: Zip normal files
|
||||
run: cd dist_normal && zip -r ../movie-web.zip .
|
||||
|
||||
- name: Get version
|
||||
id: package-version
|
||||
@ -65,7 +108,18 @@ jobs:
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
- name: Upload Release Asset
|
||||
- name: Upload release (PWA)
|
||||
id: upload-release-asset
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./movie-web.pwa.zip
|
||||
asset_name: movie-web.pwa.zip
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Upload Release (Normal)
|
||||
id: upload-release-asset
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
|
52
.github/workflows/docs.yml
vendored
Normal file
52
.github/workflows/docs.yml
vendored
Normal file
@ -0,0 +1,52 @@
|
||||
name: Publish docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
- name: Install packages
|
||||
working-directory: ./.docs
|
||||
run: npm install
|
||||
|
||||
- name: Build project
|
||||
working-directory: ./.docs
|
||||
run: npm run generate
|
||||
|
||||
- name: Upload production-ready build files
|
||||
uses: actions/upload-pages-artifact@v1
|
||||
with:
|
||||
path: ./.docs/.output/public
|
||||
|
||||
deploy:
|
||||
name: Deploy
|
||||
needs: build
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: docs
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v2
|
28
.github/workflows/linting_testing.yml
vendored
28
.github/workflows/linting_testing.yml
vendored
@ -15,18 +15,22 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Yarn packages
|
||||
run: yarn install
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install pnpm packages
|
||||
run: pnpm install
|
||||
|
||||
- name: Run ESLint
|
||||
run: yarn lint
|
||||
run: pnpm run lint
|
||||
|
||||
building:
|
||||
name: Build project
|
||||
@ -36,14 +40,18 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'yarn'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install Yarn packages
|
||||
run: yarn install
|
||||
- name: Install pnpm packages
|
||||
run: pnpm install
|
||||
|
||||
- name: Build Project
|
||||
run: yarn build
|
||||
run: pnpm run build
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -20,9 +20,9 @@ dev-dist
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# other package managers
|
||||
yarn.lock
|
||||
package-lock.json
|
||||
|
||||
# config
|
||||
|
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@ -4,5 +4,8 @@
|
||||
"eslint.format.enable": true,
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||
}
|
||||
}
|
||||
}
|
@ -34,16 +34,16 @@ Check it out here: [https://github.com/movie-web/movie-web/blob/dev/SELFHOSTING.
|
||||
## Running locally for development
|
||||
|
||||
To run this project locally for contributing or testing, run the following commands:
|
||||
<h5><b>note: must use yarn to install packages and run NodeJS 16</b></h5>
|
||||
<h5><b>note: must use pnpm to install packages and run NodeJS 16 (install with `npm i -g pnpm`)</b></h5>
|
||||
|
||||
```bash
|
||||
git clone https://github.com/movie-web/movie-web
|
||||
cd movie-web
|
||||
yarn install
|
||||
yarn dev
|
||||
pnpm install
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
To build production files, simply run `yarn build`.
|
||||
To build production files, simply run `pnpm run build`.
|
||||
|
||||
You'll need to deploy a cloudflare service worker as well. Check the [selfhosting guide](https://github.com/movie-web/movie-web/blob/dev/SELFHOSTING.md) on how to run the service worker. Afterwards you can make a `.env` file and put in the URL. (see `example.env` for an example)
|
||||
|
||||
|
@ -1,41 +0,0 @@
|
||||
# Self-hosting tutorial
|
||||
|
||||
> **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 would like to self-host. This app is made of two parts:
|
||||
- The proxy
|
||||
- The client
|
||||
|
||||
## 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.
|
||||
|
||||
1. Create a Cloudflare account at [https://dash.cloudflare.com](https://dash.cloudflare.com).
|
||||
2. Navigate to `Workers`.
|
||||
3. If it asks you, choose a subdomain.
|
||||
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`.
|
||||
6. On the service page, Click `Quick edit`.
|
||||
7. Remove the template code in the quick edit window.
|
||||
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.
|
||||
10. Paste the text contents into the edit screen of the Cloudflare service worker.
|
||||
11. Click `Save and deploy` and confirm.
|
||||
|
||||
Your proxy is now hosted on Cloudflare. Note the url of your worker as you will need it later.
|
||||
|
||||
## 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).
|
||||
2. Extract the zip file so you can edit the files.
|
||||
3. Open `config.js` in Notepad, Visual Studio Code or similar.
|
||||
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 AN EXAMPLE, IT WON'T WORK FOR YOU): `VITE_CORS_PROXY_URL: "https://test-proxy.test.workers.dev"`
|
||||
5. Put your TMDB read access token inside the quotes of `VITE_TMDB_READ_API_KEY: ""`. You can generate it for free at [https://www.themoviedb.org/settings/api](https://www.themoviedb.org/settings/api).
|
||||
6. Save the file
|
||||
|
||||
Your client has 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, it's just a standard static page.
|
13
dockerfile
13
dockerfile
@ -1,10 +1,15 @@
|
||||
FROM node:16.15-alpine as build
|
||||
WORKDIR /app
|
||||
ENV PATH /app/node_modules/.bin:$PATH
|
||||
COPY package*.json ./
|
||||
RUN yarn install
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
COPY package.json ./
|
||||
COPY pnpm-lock.yaml ./
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
|
||||
COPY . ./
|
||||
RUN yarn build
|
||||
RUN pnpm run build
|
||||
|
||||
# production environment
|
||||
FROM nginx:stable-alpine
|
||||
|
@ -1,3 +1,8 @@
|
||||
VITE_TMDB_READ_API_KEY=...
|
||||
VITE_OPENSEARCH_ENABLED=false
|
||||
|
||||
# make sure the cors proxy url does NOT have a slash at the end
|
||||
VITE_CORS_PROXY_URL=...
|
||||
VITE_TMDB_READ_API_KEY=...
|
||||
|
||||
# make sure the domain does NOT have a slash at the end
|
||||
VITE_APP_DOMAIN=http://localhost:5173
|
||||
|
26
index.html
26
index.html
@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta
|
||||
name="description"
|
||||
content="The place for your favourite movies & shows"
|
||||
@ -19,7 +19,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;600;700&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
@ -33,6 +33,28 @@
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
|
||||
<title>movie-web</title>
|
||||
|
||||
{{#if opensearchEnabled }}
|
||||
<!-- OpenSearch -->
|
||||
<link rel="search" type="application/opensearchdescription+xml" title="movie-web" href="/opensearch.xml">
|
||||
|
||||
<!-- Google Sitelinks -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"url": "{{ routeDomain }}",
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": {
|
||||
"@type": "EntryPoint",
|
||||
"urlTemplate": "{{ routeDomain }}/browse/?q={search_term_string}"
|
||||
},
|
||||
"query-input": "required name=search_term_string"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{{/if}}
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
60
package.json
60
package.json
@ -1,48 +1,54 @@
|
||||
{
|
||||
"name": "movie-web",
|
||||
"version": "3.2.4",
|
||||
"version": "4.0.0",
|
||||
"private": true,
|
||||
"homepage": "https://movie-web.app",
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^1.0.0-beta.5",
|
||||
"@formkit/auto-animate": "^0.7.0",
|
||||
"@headlessui/react": "^1.5.0",
|
||||
"@movie-web/providers": "^1.1.2",
|
||||
"@noble/hashes": "^1.3.2",
|
||||
"@react-spring/web": "^9.7.1",
|
||||
"@sentry/integrations": "^7.49.0",
|
||||
"@sentry/react": "^7.49.0",
|
||||
"@use-gesture/react": "^10.2.24",
|
||||
"@scure/bip39": "^1.2.1",
|
||||
"@sozialhelden/ietf-language-tags": "^5.4.2",
|
||||
"@types/node-forge": "^1.3.8",
|
||||
"classnames": "^2.3.2",
|
||||
"core-js": "^3.29.1",
|
||||
"crypto-js": "^4.1.1",
|
||||
"dompurify": "^3.0.1",
|
||||
"flag-icons": "^6.11.1",
|
||||
"focus-trap-react": "^10.2.3",
|
||||
"fscreen": "^1.2.0",
|
||||
"fuse.js": "^6.4.6",
|
||||
"hls.js": "^1.0.7",
|
||||
"i18next": "^22.4.5",
|
||||
"i18next-browser-languagedetector": "^7.0.1",
|
||||
"json5": "^2.2.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"nanoid": "^4.0.0",
|
||||
"immer": "^10.0.2",
|
||||
"iso-639-1": "^3.1.0",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"node-forge": "^1.3.1",
|
||||
"ofetch": "^1.0.0",
|
||||
"pako": "^2.1.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-ga4": "^2.0.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-google-recaptcha-v3": "^1.10.1",
|
||||
"react-helmet-async": "^1.3.0",
|
||||
"react-i18next": "^12.1.1",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-stickynode": "^4.1.0",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-sticky-el": "^2.1.0",
|
||||
"react-use": "^17.4.0",
|
||||
"slugify": "^1.6.6",
|
||||
"subsrt-ts": "^2.1.1",
|
||||
"unpacker": "^1.0.1"
|
||||
"zustand": "^4.3.9"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:pwa": "cross-env VITE_PWA_ENABLED=yes vite build",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint --ext .tsx,.ts src",
|
||||
"lint:fix": "eslint --fix --ext .tsx,.ts src",
|
||||
"lint:report": "eslint --ext .tsx,.ts --output-file eslint_report.json --format json src"
|
||||
"lint:report": "eslint --ext .tsx,.ts --output-file eslint_report.json --format json src",
|
||||
"preinstall": "npx -y only-allow pnpm"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
@ -59,11 +65,11 @@
|
||||
"@babel/core": "^7.21.3",
|
||||
"@babel/preset-env": "^7.20.2",
|
||||
"@babel/preset-typescript": "^7.21.0",
|
||||
"@tailwindcss/line-clamp": "^0.4.2",
|
||||
"@types/chromecast-caf-sender": "^1.0.5",
|
||||
"@types/crypto-js": "^4.1.1",
|
||||
"@types/dompurify": "^2.4.0",
|
||||
"@types/fscreen": "^1.0.1",
|
||||
"@types/lodash.isequal": "^4.5.8",
|
||||
"@types/lodash.throttle": "^4.1.7",
|
||||
"@types/node": "^17.0.15",
|
||||
"@types/pako": "^2.0.0",
|
||||
@ -78,6 +84,7 @@
|
||||
"@typescript-eslint/parser": "^5.13.0",
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.10.0",
|
||||
"eslint-config-airbnb": "19.0.4",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
@ -87,19 +94,30 @@
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "7.29.4",
|
||||
"eslint-plugin-react-hooks": "4.3.0",
|
||||
"glob": "^10.3.3",
|
||||
"handlebars": "^4.7.7",
|
||||
"jsdom": "^21.1.0",
|
||||
"postcss": "^8.4.20",
|
||||
"prettier": "^2.5.1",
|
||||
"prettier-plugin-tailwindcss": "^0.1.7",
|
||||
"tailwind-scrollbar": "^2.0.1",
|
||||
"tailwindcss": "^3.2.4",
|
||||
"tailwindcss-themer": "^3.1.0",
|
||||
"type-fest": "^4.3.3",
|
||||
"typescript": "^4.6.4",
|
||||
"vite": "^4.0.1",
|
||||
"vite-plugin-checker": "^0.5.6",
|
||||
"vite-plugin-package-version": "^1.0.2",
|
||||
"vite-plugin-pwa": "^0.14.4",
|
||||
"vitest": "^0.28.5",
|
||||
"workbox-build": "^6.5.4",
|
||||
"workbox-window": "^6.5.4"
|
||||
"vite-plugin-pwa": "^0.16.5",
|
||||
"vite-plugin-static-copy": "^0.16.0",
|
||||
"vitest": "^0.28.5"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"get-func-name@<2.0.1": ">=2.0.1",
|
||||
"postcss@<8.4.31": ">=8.4.31",
|
||||
"@babel/traverse@<7.23.2": ">=7.23.2",
|
||||
"crypto-js@<4.2.0": ">=4.2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
41
plugins/handlebars.ts
Normal file
41
plugins/handlebars.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { globSync } from "glob";
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy'
|
||||
import { PluginOption } from "vite";
|
||||
import Handlebars from "handlebars";
|
||||
import path from "path";
|
||||
|
||||
export const handlebars = (options: { vars?: Record<string, any> } = {}): PluginOption[] => {
|
||||
const files = globSync("src/assets/**/**.hbs");
|
||||
|
||||
function render(content: string): string {
|
||||
const template = Handlebars.compile(content);
|
||||
return template(options?.vars ?? {});
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'hbs-templating',
|
||||
enforce: "pre",
|
||||
transformIndexHtml: {
|
||||
order: 'pre',
|
||||
handler(html) {
|
||||
return render(html);
|
||||
}
|
||||
},
|
||||
},
|
||||
viteStaticCopy({
|
||||
silent: true,
|
||||
targets: files.map(file => ({
|
||||
src: file,
|
||||
dest: '',
|
||||
rename: path.basename(file).slice(0, -4), // remove .hbs file extension
|
||||
transform: {
|
||||
encoding: 'utf8',
|
||||
handler(content: string) {
|
||||
return render(content);
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
]
|
||||
}
|
6795
pnpm-lock.yaml
generated
Normal file
6795
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -3,3 +3,11 @@
|
||||
X-XSS-Protection: 1; mode=block
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: origin-when-cross-origin
|
||||
Cache-Control: public, max-age=0, s-maxage=0, must-revalidate
|
||||
|
||||
/manifest.webmanifest
|
||||
Content-Type: application/manifest+json
|
||||
|
||||
# assets get a long cache instead of no cache
|
||||
/assets/*
|
||||
Cache-Control: public, max-age=31536000, s-maxage=31536000, immutable
|
||||
|
@ -1 +1,2 @@
|
||||
/assets/* /assets/:splat 200
|
||||
/* /index.html 200
|
||||
|
BIN
public/fishie.png
Normal file
BIN
public/fishie.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 314 B |
1
public/skull.svg
Normal file
1
public/skull.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#CCD6DD" d="M27.865 16.751c0-6.242-4.411-9.988-9.927-9.988s-9.835 3.746-9.835 9.988c0 3.48-.103 6.485 3.897 7.89v2.722c0 1.034.966 1.872 2 1.872 1.035 0 2-.838 2-1.872v-1.97 1.97c0 1.034.965 1.872 2 1.872 1.036 0 2-.838 2-1.872v-1.97 1.97c0 1.034.966 1.872 2 1.872s2-.838 2-1.872v-2.722c4-1.405 3.865-4.41 3.865-7.89z"/><circle fill="#292F33" cx="13.629" cy="15.503" r="3.121"/><path fill="#292F33" d="M25.488 15.503c0 1.724 0 3.121-3.121 3.121-3.12 0-3.12-1.397-3.12-3.121s1.396-3.121 3.12-3.121c1.725 0 3.121 1.397 3.121 3.121zm-6.301 5.656c-.157-.382-.626-.662-1.189-.662-.561 0-1.031.28-1.188.662-.394.11-.685.469-.685.898 0 .517.419.936.937.936.409 0 .753-.263.88-.628.019 0 .037.004.056.004.019 0 .037-.004.057-.004.128.365.472.628.88.628.517 0 .936-.419.936-.936 0-.429-.291-.786-.684-.898z"/><path d="M11 27c0-.367.075-.713.195-1.038-.984-.447-1.831-1.082-2.503-1.97-1.107.969-2.163 1.876-3.127 2.695C4.985 26.26 4.275 26 3.5 26 1.567 26 0 27.566 0 29.5c0 1.778 1.33 3.229 3.046 3.454C3.271 34.671 4.722 36 6.5 36c1.933 0 3.5-1.566 3.5-3.5 0-.775-.26-1.485-.686-2.065.6-.706 1.246-1.46 1.931-2.25C11.088 27.821 11 27.421 11 27zm16.872-15.482c.884-.769 1.729-1.495 2.515-2.163.569.403 1.262.645 2.013.645 1.934 0 3.5-1.567 3.5-3.5 0-1.743-1.277-3.177-2.945-3.444C32.735 1.335 31.281 0 29.5 0 27.566 0 26 1.567 26 3.5c0 .775.26 1.485.687 2.065-.594.7-1.233 1.445-1.911 2.227 1.3.871 2.361 2.095 3.096 3.726zM3.5 10c.775 0 1.485-.26 2.065-.687.799.679 1.661 1.419 2.564 2.204.735-1.631 1.795-2.855 3.096-3.726-.679-.781-1.317-1.527-1.912-2.226.427-.58.687-1.29.687-2.065C10 1.567 8.433 0 6.5 0 4.722 0 3.271 1.33 3.046 3.046 1.33 3.271 0 4.722 0 6.5 0 8.433 1.567 10 3.5 10zm28.9 16c-.752 0-1.444.242-2.014.645-.952-.809-1.99-1.701-3.079-2.653-.672.889-1.519 1.523-2.503 1.971.121.324.196.67.196 1.037 0 .421-.088.821-.245 1.185.685.79 1.331 1.544 1.931 2.25-.426.58-.686 1.29-.686 2.065 0 1.934 1.566 3.5 3.5 3.5 1.781 0 3.235-1.334 3.455-3.056 1.668-.267 2.945-1.701 2.945-3.444 0-1.934-1.566-3.5-3.5-3.5z" fill="#AAB8C2"/></svg>
|
After Width: | Height: | Size: 2.1 KiB |
@ -1,52 +0,0 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import "@/backend";
|
||||
import { testData } from "@/__tests__/providers/testdata";
|
||||
import { getProviders } from "@/backend/helpers/register";
|
||||
import { runProvider } from "@/backend/helpers/run";
|
||||
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||
|
||||
describe("providers", () => {
|
||||
const providers = getProviders();
|
||||
|
||||
it("have at least one provider", ({ expect }) => {
|
||||
expect(providers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
for (const provider of providers) {
|
||||
describe(provider.displayName, () => {
|
||||
it("must have at least one type", async ({ expect }) => {
|
||||
expect(provider.type.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
if (provider.type.includes(MWMediaType.MOVIE)) {
|
||||
it("must work with movies", async ({ expect }) => {
|
||||
const movie = testData.find((v) => v.meta.type === MWMediaType.MOVIE);
|
||||
if (!movie) throw new Error("no movie to test with");
|
||||
const results = await runProvider(provider, {
|
||||
media: movie,
|
||||
progress() {},
|
||||
type: movie.meta.type as any,
|
||||
});
|
||||
expect(results).toBeTruthy();
|
||||
});
|
||||
}
|
||||
|
||||
if (provider.type.includes(MWMediaType.SERIES)) {
|
||||
it("must work with series", async ({ expect }) => {
|
||||
const show = testData.find((v) => v.meta.type === MWMediaType.SERIES);
|
||||
if (show?.meta.type !== MWMediaType.SERIES)
|
||||
throw new Error("no show to test with");
|
||||
const results = await runProvider(provider, {
|
||||
media: show,
|
||||
progress() {},
|
||||
type: show.meta.type as MWMediaType.SERIES,
|
||||
episode: show.meta.seasonData.episodes[0].id,
|
||||
season: show.meta.seasons[0].id,
|
||||
});
|
||||
expect(results).toBeTruthy();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
@ -1,45 +0,0 @@
|
||||
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||
|
||||
export const testData: DetailedMeta[] = [
|
||||
{
|
||||
imdbId: "tt10954562",
|
||||
tmdbId: "572716",
|
||||
meta: {
|
||||
id: "439596",
|
||||
title: "Hamilton",
|
||||
type: MWMediaType.MOVIE,
|
||||
year: "2020",
|
||||
seasons: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
imdbId: "tt11126994",
|
||||
tmdbId: "94605",
|
||||
meta: {
|
||||
id: "222333",
|
||||
title: "Arcane",
|
||||
type: MWMediaType.SERIES,
|
||||
year: "2021",
|
||||
seasons: [
|
||||
{
|
||||
id: "230301",
|
||||
number: 1,
|
||||
title: "Season 1",
|
||||
},
|
||||
],
|
||||
seasonData: {
|
||||
id: "230301",
|
||||
number: 1,
|
||||
title: "Season 1",
|
||||
episodes: [
|
||||
{
|
||||
id: "4243445",
|
||||
number: 1,
|
||||
title: "Welcome to the Playground",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
@ -1,152 +0,0 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import {
|
||||
getMWCaptionTypeFromUrl,
|
||||
isSupportedSubtitle,
|
||||
parseSubtitles,
|
||||
} from "@/backend/helpers/captions";
|
||||
import { MWCaptionType } from "@/backend/helpers/streams";
|
||||
|
||||
import {
|
||||
ass,
|
||||
multilineSubtitlesTestVtt,
|
||||
srt,
|
||||
visibleSubtitlesTestVtt,
|
||||
vtt,
|
||||
} from "./testdata";
|
||||
|
||||
describe("subtitles", () => {
|
||||
it("should return true if given url ends with a known subtitle type", ({
|
||||
expect,
|
||||
}) => {
|
||||
expect(isSupportedSubtitle("https://example.com/test.srt")).toBe(true);
|
||||
expect(isSupportedSubtitle("https://example.com/test.vtt")).toBe(true);
|
||||
expect(isSupportedSubtitle("https://example.com/test.txt")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return corresponding MWCaptionType", ({ expect }) => {
|
||||
expect(getMWCaptionTypeFromUrl("https://example.com/test.srt")).toBe(
|
||||
MWCaptionType.SRT
|
||||
);
|
||||
expect(getMWCaptionTypeFromUrl("https://example.com/test.vtt")).toBe(
|
||||
MWCaptionType.VTT
|
||||
);
|
||||
expect(getMWCaptionTypeFromUrl("https://example.com/test.txt")).toBe(
|
||||
MWCaptionType.UNKNOWN
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw when empty text is given", ({ expect }) => {
|
||||
expect(() => parseSubtitles("")).toThrow("Given text is empty");
|
||||
});
|
||||
|
||||
it("should parse srt", ({ expect }) => {
|
||||
const parsed = parseSubtitles(srt);
|
||||
const parsedSrt = [
|
||||
{
|
||||
type: "caption",
|
||||
index: 1,
|
||||
start: 0,
|
||||
end: 0,
|
||||
duration: 0,
|
||||
content: "Test",
|
||||
text: "Test",
|
||||
},
|
||||
{
|
||||
type: "caption",
|
||||
index: 2,
|
||||
start: 0,
|
||||
end: 0,
|
||||
duration: 0,
|
||||
content: "Test",
|
||||
text: "Test",
|
||||
},
|
||||
];
|
||||
expect(parsed).toHaveLength(2);
|
||||
expect(parsed).toEqual(parsedSrt);
|
||||
});
|
||||
|
||||
it("should parse vtt", ({ expect }) => {
|
||||
const parsed = parseSubtitles(vtt);
|
||||
const parsedVtt = [
|
||||
{
|
||||
type: "caption",
|
||||
index: 1,
|
||||
start: 0,
|
||||
end: 4000,
|
||||
duration: 4000,
|
||||
content: "Where did he go?",
|
||||
text: "Where did he go?",
|
||||
},
|
||||
{
|
||||
type: "caption",
|
||||
index: 2,
|
||||
start: 3000,
|
||||
end: 6500,
|
||||
duration: 3500,
|
||||
content: "I think he went down this lane.",
|
||||
text: "I think he went down this lane.",
|
||||
},
|
||||
{
|
||||
type: "caption",
|
||||
index: 3,
|
||||
start: 4000,
|
||||
end: 6500,
|
||||
duration: 2500,
|
||||
content: "What are you waiting for?",
|
||||
text: "What are you waiting for?",
|
||||
},
|
||||
];
|
||||
expect(parsed).toHaveLength(3);
|
||||
expect(parsed).toEqual(parsedVtt);
|
||||
});
|
||||
|
||||
it("should parse ass", ({ expect }) => {
|
||||
const parsed = parseSubtitles(ass);
|
||||
expect(parsed).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should delay subtitles when given a delay", ({ expect }) => {
|
||||
const videoTime = 11;
|
||||
let delayedSeconds = 0;
|
||||
const parsed = parseSubtitles(visibleSubtitlesTestVtt);
|
||||
const isVisible = (start: number, end: number, delay: number): boolean => {
|
||||
const delayedStart = start / 1000 + delay;
|
||||
const delayedEnd = end / 1000 + delay;
|
||||
return (
|
||||
Math.max(0, delayedStart) <= videoTime &&
|
||||
Math.max(0, delayedEnd) >= videoTime
|
||||
);
|
||||
};
|
||||
const visibleSubtitles = parsed.filter((c) =>
|
||||
isVisible(c.start, c.end, delayedSeconds)
|
||||
);
|
||||
expect(visibleSubtitles).toHaveLength(1);
|
||||
|
||||
delayedSeconds = 10;
|
||||
const delayedVisibleSubtitles = parsed.filter((c) =>
|
||||
isVisible(c.start, c.end, delayedSeconds)
|
||||
);
|
||||
expect(delayedVisibleSubtitles).toHaveLength(1);
|
||||
|
||||
delayedSeconds = -10;
|
||||
const delayedVisibleSubtitles2 = parsed.filter((c) =>
|
||||
isVisible(c.start, c.end, delayedSeconds)
|
||||
);
|
||||
expect(delayedVisibleSubtitles2).toHaveLength(1);
|
||||
|
||||
delayedSeconds = -20;
|
||||
const delayedVisibleSubtitles3 = parsed.filter((c) =>
|
||||
isVisible(c.start, c.end, delayedSeconds)
|
||||
);
|
||||
expect(delayedVisibleSubtitles3).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should parse multiline captions", ({ expect }) => {
|
||||
const parsed = parseSubtitles(multilineSubtitlesTestVtt);
|
||||
|
||||
expect(parsed[0].text).toBe(`- Test 1\n- Test 2\n- Test 3`);
|
||||
expect(parsed[1].text).toBe(`- Test 4`);
|
||||
expect(parsed[2].text).toBe(`- Test 6`);
|
||||
});
|
||||
});
|
@ -1,68 +0,0 @@
|
||||
const srt = `
|
||||
1
|
||||
00:00:00,000 --> 00:00:00,000
|
||||
Test
|
||||
|
||||
2
|
||||
00:00:00,000 --> 00:00:00,000
|
||||
Test
|
||||
`;
|
||||
const vtt = `
|
||||
WEBVTT
|
||||
|
||||
00:00:00.000 --> 00:00:04.000 position:10%,line-left align:left size:35%
|
||||
Where did he go?
|
||||
|
||||
00:00:03.000 --> 00:00:06.500 position:90% align:right size:35%
|
||||
I think he went down this lane.
|
||||
|
||||
00:00:04.000 --> 00:00:06.500 position:45%,line-right align:center size:35%
|
||||
What are you waiting for?
|
||||
`;
|
||||
const ass = `[Script Info]
|
||||
; Generated by Ebby.co
|
||||
Title:
|
||||
Original Script:
|
||||
ScriptType: v4.00+
|
||||
Collisions: Normal
|
||||
PlayResX: 384
|
||||
PlayResY: 288
|
||||
PlayDepth: 0
|
||||
Timer: 100.0
|
||||
WrapStyle: 0
|
||||
|
||||
[v4+ Styles]
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
Style: Default, Arial, 16, &H00FFFFFF, &H00000000, &H00000000, &H00000000, 0, 0, 0, 0, 100, 100, 0, 0, 1, 1, 0, 2, 15, 15, 15, 0
|
||||
|
||||
[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
Dialogue: 0,0:00:10.00,0:00:20.00,Default,,0000,0000,0000,,This is the first subtitle.
|
||||
Dialogue: 0,0:00:30.00,0:00:34.00,Default,,0000,0000,0000,,This is the second.
|
||||
Dialogue: 0,0:00:34.00,0:00:35.00,Default,,0000,0000,0000,,Third`;
|
||||
|
||||
const visibleSubtitlesTestVtt = `WEBVTT
|
||||
|
||||
00:00:00.000 --> 00:00:10.000 position:10%,line-left align:left size:35%
|
||||
Test 1
|
||||
|
||||
00:00:10.000 --> 00:00:20.000 position:90% align:right size:35%
|
||||
Test 2
|
||||
|
||||
00:00:20.000 --> 00:00:31.000 position:45%,line-right align:center size:35%
|
||||
Test 3
|
||||
`;
|
||||
|
||||
const multilineSubtitlesTestVtt = `WEBVTT
|
||||
|
||||
00:00:00.000 --> 00:00:10.000
|
||||
- Test 1\n- Test 2\n- Test 3
|
||||
|
||||
00:00:10.000 --> 00:00:20.000
|
||||
- Test 4
|
||||
|
||||
00:00:20.000 --> 00:00:31.000
|
||||
- Test 6
|
||||
`;
|
||||
|
||||
export { vtt, srt, ass, visibleSubtitlesTestVtt, multilineSubtitlesTestVtt };
|
@ -4,7 +4,7 @@
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply bg-denim-100 font-open-sans text-denim-700 overflow-x-hidden;
|
||||
@apply bg-background-main font-open-sans text-type-text overflow-x-hidden;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
@ -30,10 +30,18 @@ body[data-no-select] {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
html[data-no-scroll], html[data-no-scroll] body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.roll {
|
||||
animation: roll 1s;
|
||||
}
|
||||
|
||||
.roll-infinite {
|
||||
animation: roll 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes roll {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
@ -60,6 +68,16 @@ body[data-no-select] {
|
||||
height: 60vh;
|
||||
}
|
||||
|
||||
.h-screen {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.min-h-screen {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/*generated with Input range slider CSS style generator (version 20211225)
|
||||
https://toughengineer.github.io/demo/slider-styler*/
|
||||
:root {
|
||||
@ -185,7 +203,7 @@ input[type=range].styled-slider.slider-progress::-ms-fill-lower {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: theme("colors.denim-500");
|
||||
background-color: theme("colors.video.context.border");
|
||||
border: 5px solid transparent;
|
||||
border-left: 0;
|
||||
background-clip: content-box;
|
||||
@ -194,4 +212,13 @@ input[type=range].styled-slider.slider-progress::-ms-fill-lower {
|
||||
::-webkit-scrollbar {
|
||||
/* For some reason the styles don't get applied without the width */
|
||||
width: 13px;
|
||||
}
|
||||
|
||||
.grecaptcha-badge {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.tabbable:focus-visible {
|
||||
outline: 2px solid theme('colors.themePreview.primary');
|
||||
box-shadow: 0 0 10px theme('colors.themePreview.secondary');
|
||||
}
|
23
src/assets/languages.ts
Normal file
23
src/assets/languages.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import cs from "@/assets/locales/cs.json";
|
||||
import de from "@/assets/locales/de.json";
|
||||
import en from "@/assets/locales/en.json";
|
||||
import fr from "@/assets/locales/fr.json";
|
||||
import it from "@/assets/locales/it.json";
|
||||
import nl from "@/assets/locales/nl.json";
|
||||
import pl from "@/assets/locales/pl.json";
|
||||
import tr from "@/assets/locales/tr.json";
|
||||
import vi from "@/assets/locales/vi.json";
|
||||
import zh from "@/assets/locales/zh.json";
|
||||
|
||||
export const locales = {
|
||||
en,
|
||||
cs,
|
||||
de,
|
||||
fr,
|
||||
it,
|
||||
nl,
|
||||
pl,
|
||||
tr,
|
||||
vi,
|
||||
zh,
|
||||
};
|
77
src/assets/locales/cs.json
Normal file
77
src/assets/locales/cs.json
Normal file
@ -0,0 +1,77 @@
|
||||
{
|
||||
"global": {
|
||||
"name": "movie-web"
|
||||
},
|
||||
"menus": {
|
||||
"episodes": {
|
||||
"loadingTitle": "Načítání...",
|
||||
"loadingList": "Načítání..."
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"search": {
|
||||
"allResults": "To je vše co máme!",
|
||||
"sectionTitle": "Výsledky vyhledávání",
|
||||
"noResults": "Nemohli jsme nic najít!",
|
||||
"failed": "Nepodařilo se najít média, zkuste to znovu!",
|
||||
"loading": "Načítání...",
|
||||
"placeholder": "Co si přejete sledovat?"
|
||||
},
|
||||
"bookmarks": {
|
||||
"sectionTitle": "Záložky"
|
||||
},
|
||||
"continueWatching": {
|
||||
"sectionTitle": "Pokračujte ve sledování"
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"types": {
|
||||
"movie": "Film",
|
||||
"show": "Seriál"
|
||||
},
|
||||
"episodeDisplay": "S{{season}} E{{episode}}"
|
||||
},
|
||||
"player": {
|
||||
"playbackError": {
|
||||
"title": "Jejda, rozbilo se to!"
|
||||
},
|
||||
"metadata": {
|
||||
"notFound": {
|
||||
"badge": "Nenalezeno",
|
||||
"homeButton": "Zpátky domů",
|
||||
"title": "Nemohli jsme najít Vaše média.",
|
||||
"text": "Nemohli jsme najít média o které jste požádali. Buďto jsme ho nemohli najít, nebo jste manipulovali s URL."
|
||||
}
|
||||
},
|
||||
"menus": {
|
||||
"captions": {
|
||||
"customChoice": "Nahrát titulky",
|
||||
"customizeLabel": "Upravit",
|
||||
"title": "Titulky"
|
||||
},
|
||||
"sources": {
|
||||
"title": "Zdroje"
|
||||
},
|
||||
"episodes": {
|
||||
"button": "Epizody",
|
||||
"loadingTitle": "Načítání...",
|
||||
"loadingList": "Načítání..."
|
||||
}
|
||||
},
|
||||
"back": {
|
||||
"default": "Zpátky domů",
|
||||
"short": "Zpět"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"badge": "Nenalezeno",
|
||||
"goHome": "Zpátky domů",
|
||||
"title": "Tuto stránku se nepodařilo najít",
|
||||
"message": "Dívali jsme se všude: pod koši, ve skříni, za proxy, ale nakonec jsme nemohli najít stránku, kterou hledáte."
|
||||
},
|
||||
"navigation": {
|
||||
"banner": {
|
||||
"offline": "Zkontrolujte své internetové připojení"
|
||||
}
|
||||
}
|
||||
}
|
77
src/assets/locales/de.json
Normal file
77
src/assets/locales/de.json
Normal file
@ -0,0 +1,77 @@
|
||||
{
|
||||
"global": {
|
||||
"name": "movie-web"
|
||||
},
|
||||
"menus": {
|
||||
"episodes": {
|
||||
"loadingTitle": "Wird geladen...",
|
||||
"loadingList": "Wird geladen..."
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"search": {
|
||||
"allResults": "Das ist alles, was wir haben!",
|
||||
"sectionTitle": "Suchergebnisse",
|
||||
"noResults": "Wir haben nichts gefunden!",
|
||||
"failed": "Das Medium wurde nicht gefunden, bitte versuchen Sie es erneut!",
|
||||
"loading": "Wird geladen...",
|
||||
"placeholder": "Was willst du gucken?"
|
||||
},
|
||||
"bookmarks": {
|
||||
"sectionTitle": "Favoriten"
|
||||
},
|
||||
"continueWatching": {
|
||||
"sectionTitle": "Weiter ansehen"
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"types": {
|
||||
"movie": "Film",
|
||||
"show": "Serie"
|
||||
},
|
||||
"episodeDisplay": "S{{season}} E{{episode}}"
|
||||
},
|
||||
"player": {
|
||||
"playbackError": {
|
||||
"title": "Hoppla, etwas ist schiefgegangen!"
|
||||
},
|
||||
"metadata": {
|
||||
"notFound": {
|
||||
"badge": "Nicht gefunden",
|
||||
"homeButton": "Zurück zur Startseite",
|
||||
"title": "Das Medium konnte nicht gefunden werden",
|
||||
"text": "Wir konnten die angeforderten Medien nicht finden."
|
||||
}
|
||||
},
|
||||
"menus": {
|
||||
"captions": {
|
||||
"customChoice": "Untertitel hochladen",
|
||||
"customizeLabel": "Bearbeiten",
|
||||
"title": "Untertitel"
|
||||
},
|
||||
"sources": {
|
||||
"title": "Quellen"
|
||||
},
|
||||
"episodes": {
|
||||
"button": "Folgen",
|
||||
"loadingTitle": "Wird geladen...",
|
||||
"loadingList": "Wird geladen..."
|
||||
}
|
||||
},
|
||||
"back": {
|
||||
"default": "Zurück zur Startseite",
|
||||
"short": "Rückmeldung"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"badge": "Nicht gefunden",
|
||||
"goHome": "Zurück zur Startseite",
|
||||
"title": "Diese Seite kann nicht gefunden werden",
|
||||
"message": "Wir haben überall gesucht, aber am Ende konnten wir die gesuchte Seite nicht finden."
|
||||
},
|
||||
"navigation": {
|
||||
"banner": {
|
||||
"offline": "Internetverbindung ist instabil"
|
||||
}
|
||||
}
|
||||
}
|
381
src/assets/locales/en.json
Normal file
381
src/assets/locales/en.json
Normal file
@ -0,0 +1,381 @@
|
||||
{
|
||||
"auth": {
|
||||
"deviceNameLabel": "Device name",
|
||||
"deviceNamePlaceholder": "Muad'Dib's Nintendo Switch",
|
||||
"register": {
|
||||
"information": {
|
||||
"title": "Account information",
|
||||
"color1": "First color",
|
||||
"color2": "Second color",
|
||||
"icon": "User icon",
|
||||
"header": "Enter a name for your device and choose a user icon and colours"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"title": "Login to your account",
|
||||
"description": "Oh, you're asking for the key to my top-secret lair, also known as The Fortress of Wordsmithery, accessed only by reciting the sacred incantation of the 12-word passphrase!",
|
||||
"validationError": "Invalid or incomplete passphrase",
|
||||
"submit": "Login",
|
||||
"passphraseLabel": "12-Word Passphrase",
|
||||
"passphrasePlaceholder": "Passphrase"
|
||||
},
|
||||
"generate": {
|
||||
"title": "Your passphrase",
|
||||
"description": "If you lose this, you're a silly goose and will be posted on the wall of shame™️"
|
||||
},
|
||||
"trust": {
|
||||
"title": "Do you trust this host?",
|
||||
"host": "Do you trust <0>{{hostname}}</0>?",
|
||||
"failed": {
|
||||
"title": "Failed to reach backend",
|
||||
"text": "Did you configure it correctly?"
|
||||
},
|
||||
"yes": "Trust",
|
||||
"no": "Go back"
|
||||
},
|
||||
"verify": {
|
||||
"title": "Enter your passphrase",
|
||||
"description": "If you've already lost it, how will you ever be able to take care of a child?",
|
||||
"invalidData": "Data is not valid",
|
||||
"noMatch": "Passphrase doesn't match",
|
||||
"recaptchaFailed": "ReCaptcha validation failed",
|
||||
"passphraseLabel": "Your passphrase",
|
||||
"register": "Register"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"details": "Error details",
|
||||
"reloadPage": "Reload the page",
|
||||
"badge": "It broke",
|
||||
"title": "That's an error boss"
|
||||
},
|
||||
"notFound": {
|
||||
"badge": "Not found",
|
||||
"title": "Couldn't find that page",
|
||||
"message": "We looked everywhere: under the bins, in the closet, behind the proxy but ultimately couldn't find the page you are looking for.",
|
||||
"goHome": "Back to home"
|
||||
},
|
||||
"global": {
|
||||
"name": "movie-web"
|
||||
},
|
||||
"media": {
|
||||
"types": {
|
||||
"movie": "Movie",
|
||||
"show": "Show"
|
||||
},
|
||||
"episodeDisplay": "S{{season}} E{{episode}}"
|
||||
},
|
||||
"player": {
|
||||
"scraping": {
|
||||
"notFound": {
|
||||
"badge": "Not found",
|
||||
"title": "Goo goo gaa gaa",
|
||||
"text": "Oh, my apowogies, sweetie! The itty-bitty movie-web did its utmost bestest, but alas, no wucky videos to be spotted anywhere (´⊙ω⊙`) Please don't be angwy, wittle movie-web ish twying so hard. Can you find it in your heart to forgive? UwU 💖",
|
||||
"homeButton": "Go home"
|
||||
},
|
||||
"items": {
|
||||
"pending": "Checking for videos...",
|
||||
"notFound": "Doesn't have the video",
|
||||
"failure": "Error occured"
|
||||
}
|
||||
},
|
||||
"playbackError": {
|
||||
"badge": "Not found",
|
||||
"title": "Whoops, it broke!",
|
||||
"text": "Oh, my apowogies, sweetie! The itty-bitty movie-web did its utmost bestest, but alas, no wucky videos to be spotted anywhere (´⊙ω⊙`) Please don't be angwy, wittle movie-web ish twying so hard. Can you find it in your heart to forgive? UwU 💖",
|
||||
"homeButton": "Go home",
|
||||
"errors": {
|
||||
"errorAborted": "The fetching of the associated resource was aborted by the user's request.",
|
||||
"errorNetwork": "Some kind of network error occurred which prevented the media from being successfully fetched, despite having previously been available.",
|
||||
"errorDecode": "Despite having previously been determined to be usable, an error occurred while trying to decode the media resource, resulting in an error.",
|
||||
"errorNotSupported": "The associated resource or media provider object has been found to be unsuitable.",
|
||||
"errorGenericMedia": "Unknown media error occured"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"notFound": {
|
||||
"badge": "Not found",
|
||||
"title": "Couldn't find that media",
|
||||
"text": "We couldn't find the media you requested. Either it's been removed or you tampered with the URL",
|
||||
"homeButton": "Back to home"
|
||||
},
|
||||
"failed": {
|
||||
"badge": "Failed",
|
||||
"title": "Failed to load meta data",
|
||||
"text": "Oh, my apowogies, sweetie! The itty-bitty movie-web did its utmost bestest, but alas, no wucky videos to be spotted anywhere (´⊙ω⊙`) Please don't be angwy, wittle movie-web ish twying so hard. Can you find it in your heart to forgive? UwU 💖",
|
||||
"homeButton": "Go home"
|
||||
}
|
||||
},
|
||||
"back": {
|
||||
"default": "Back to home",
|
||||
"short": "Back"
|
||||
},
|
||||
"time": {
|
||||
"short": "-{{timeLeft}}",
|
||||
"regular": "{{timeWatched}} / {{duration}}",
|
||||
"remaining": "{{timeLeft}} left • Finish at {{timeFinished, datetime}}"
|
||||
},
|
||||
"nextEpisode": {
|
||||
"next": "Next episode",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"menus": {
|
||||
"settings": {
|
||||
"videoSection": "Video settings",
|
||||
"experienceSection": "Viewing Experience",
|
||||
"enableCaptions": "Enable Captions",
|
||||
"captionItem": "Caption settings",
|
||||
"sourceItem": "Video sources",
|
||||
"playbackItem": "Playback settings",
|
||||
"downloadItem": "Download",
|
||||
"qualityItem": "Quality"
|
||||
},
|
||||
"episodes": {
|
||||
"button": "Episodes",
|
||||
"loadingTitle": "Loading...",
|
||||
"loadingList": "Loading...",
|
||||
"loadingError": "Error loading season",
|
||||
"emptyState": "There are no episodes in this season, check back later!",
|
||||
"episodeBadge": "E{{episode}}"
|
||||
},
|
||||
"sources": {
|
||||
"title": "Sources",
|
||||
"unknownOption": "Unknown",
|
||||
"noStream": {
|
||||
"title": "No stream",
|
||||
"text": "This source has no streams for this movie or show."
|
||||
},
|
||||
"noEmbeds": {
|
||||
"title": "No embeds found",
|
||||
"text": "We were unable to find any embeds for this source, please try another."
|
||||
},
|
||||
"failed": {
|
||||
"title": "Failed to scrape",
|
||||
"text": "We were unable to find any videos for this source. Don't come bitchin' to us about it, just try another source."
|
||||
}
|
||||
},
|
||||
"captions": {
|
||||
"title": "Captions",
|
||||
"customizeLabel": "Customize",
|
||||
"settings": {
|
||||
"fixCapitals": "Fix capitalization",
|
||||
"delay": "Caption delay"
|
||||
},
|
||||
"customChoice": "Upload captions",
|
||||
"offChoice": "Off",
|
||||
"unknownLanguage": "Unknown"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Download",
|
||||
"disclaimer": "Downloads are taken directly from the provider. movie-web does not have control over how the downloads are provided.",
|
||||
"hlsExplanation": "Insert explanation for why you can't download HLS here",
|
||||
"downloadVideo": "Download video",
|
||||
"downloadCaption": "Download current caption",
|
||||
"onPc": {
|
||||
"1": "On PC, right click the video and select <bold>Save video as</bold>",
|
||||
"title": "Downloading on PC",
|
||||
"shortTitle": "Download / PC"
|
||||
},
|
||||
"onAndroid": {
|
||||
"1": "To download on Android, <bold>tap and hold</bold> on the video, then select <bold>save</bold>.",
|
||||
"title": "Downloading on Android",
|
||||
"shortTitle": "Download / Android"
|
||||
},
|
||||
"onIos": {
|
||||
"1": "To download on iOS, click <bold><ios_share /></bold>, then <bold>Save to Files <ios_files /></bold>. All that's left to do now is to pick a nice and cozy folder for your video!",
|
||||
"title": "Downloading on iOS",
|
||||
"shortTitle": "Download / iOS"
|
||||
}
|
||||
},
|
||||
"playback": {
|
||||
"title": "Playback settings",
|
||||
"speedLabel": "Playback speed"
|
||||
},
|
||||
"quality": {
|
||||
"title": "Quality",
|
||||
"automaticLabel": "Automatic quality",
|
||||
"hint": "You can try <0>switching source</0> to get different quality options."
|
||||
}
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"mediaList": {
|
||||
"stopEditing": "Stop editing"
|
||||
},
|
||||
"titles": {
|
||||
"morning": ["Morning title"],
|
||||
"day": ["Day title"],
|
||||
"night": ["Night title"]
|
||||
},
|
||||
"search": {
|
||||
"loading": "Loading...",
|
||||
"sectionTitle": "Search results",
|
||||
"allResults": "That's all we have!",
|
||||
"noResults": "We couldn't find anything!",
|
||||
"failed": "Failed to find media, try again!",
|
||||
"placeholder": "What do you want to watch?"
|
||||
},
|
||||
"continueWatching": {
|
||||
"sectionTitle": "Continue Watching"
|
||||
},
|
||||
"bookmarks": {
|
||||
"sectionTitle": "Bookmarks"
|
||||
}
|
||||
},
|
||||
"overlays": {
|
||||
"close": "Close"
|
||||
},
|
||||
"screens": {
|
||||
"loadingUser": "Loading your profile",
|
||||
"loadingApp": "Loading application",
|
||||
"loadingUserError": {
|
||||
"text": "Failed to load your profile",
|
||||
"textWithReset": "Failed to load your profile from your custom server, want to reset back to default?",
|
||||
"reset": "Reset custom server"
|
||||
},
|
||||
"migration": {
|
||||
"failed": "Failed to migrate your data.",
|
||||
"inProgress": "Please hold, we are migrating your data. This shouldn't take long."
|
||||
},
|
||||
"dmca": {
|
||||
"title": "DMCA",
|
||||
"text": "In an effort to address the copyright concerns associated with the website known as \"movie-web,\" the DMCA, or Digital Millennium Copyright Act, has been initiated to safeguard the intellectual property rights of content creators by reporting infringements on this platform, thereby adhering to legal protocols for takedown requests, which, like, you know, it's all about, like, maintaining the integrity of intellectual property, and, um, making sure, like, creators get their fair share, but then, it's, like, this intricate dance of digital legalities, where you have to, uh, like, navigate this labyrinth of code and bytes and, uh, send, you know, these, like, electronic documents that, um, point out the, uh, alleged infringement, and it's, like, this whole, like, teeter-totter of legality, where you're, like, balancing, um, the rights of the, you know, creators and the, um, operation of this, like, online, uh, entity, and, like, the DMCA, it's, like, this, um, powerful tool, but, uh, it's also, like, this, um, complex puzzle, where, you know, you're, like, seeking justice in the digital wilderness, and, uh, striving for harmony amidst the chaos of the internet, and, um, yeah, that's, like, the whole, like, DMCA-ing thing with movie-web, you know?"
|
||||
}
|
||||
},
|
||||
"navigation": {
|
||||
"banner": {
|
||||
"offline": "Check your internet connection"
|
||||
},
|
||||
"menu": {
|
||||
"register": "Sync to cloud",
|
||||
"settings": "Settings",
|
||||
"about": "About us",
|
||||
"support": "Support",
|
||||
"logout": "Log out"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"next": "Next"
|
||||
},
|
||||
"settings": {
|
||||
"unsaved": "You have unsaved changes",
|
||||
"reset": "Reset",
|
||||
"save": "Save",
|
||||
"sidebar": {
|
||||
"info": {
|
||||
"title": "App information",
|
||||
"hostname": "Hostname",
|
||||
"backendUrl": "Backend URL",
|
||||
"userId": "User ID",
|
||||
"notLoggedIn": "Not logged in",
|
||||
"appVersion": "App version",
|
||||
"backendVersion": "Backend version",
|
||||
"unknownVersion": "Unknown",
|
||||
"secure": "Secure",
|
||||
"insecure": "Insecure"
|
||||
}
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"activeTheme": "Active",
|
||||
"themes": {
|
||||
"default": "Default",
|
||||
"blue": "Blue",
|
||||
"teal": "Teal",
|
||||
"red": "Red",
|
||||
"gray": "Gray"
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"register": {
|
||||
"title": "Sync to the cloud",
|
||||
"text": "Instantly share your watch progress between devices and keep them synced.",
|
||||
"cta": "Get started"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Edit profile picture",
|
||||
"firstColor": "First color",
|
||||
"secondColor": "Second color",
|
||||
"userIcon": "User icon",
|
||||
"finish": "Finish editing"
|
||||
},
|
||||
"devices": {
|
||||
"title": "Devices",
|
||||
"failed": "Failed to load sessions",
|
||||
"deviceNameLabel": "Device name",
|
||||
"removeDevice": "Remove"
|
||||
},
|
||||
"accountDetails": {
|
||||
"editProfile": "Edit",
|
||||
"deviceNameLabel": "Device name",
|
||||
"deviceNamePlaceholder": "Fremen tablet",
|
||||
"logoutButton": "Log out"
|
||||
},
|
||||
"actions": {
|
||||
"title": "Actions",
|
||||
"delete": {
|
||||
"title": "Delete account",
|
||||
"text": "This action is irreversible. All data will be deleted and nothing can be recovered.",
|
||||
"button": "Delete account",
|
||||
"confirmTitle": "Are you sure?",
|
||||
"confirmDescription": "Are you sure you want to delete your account? All your data will be lost!",
|
||||
"confirmButton": "Delete account"
|
||||
}
|
||||
}
|
||||
},
|
||||
"locale": {
|
||||
"title": "Locale",
|
||||
"language": "Application language",
|
||||
"languageDescription": "Language applied to the entire application."
|
||||
},
|
||||
"captions": {
|
||||
"title": "Captions",
|
||||
"previewQuote": "I must not fear. Fear is the mind-killer.",
|
||||
"backgroundLabel": "Background opacity",
|
||||
"textSizeLabel": "Text size",
|
||||
"colorLabel": "Color"
|
||||
},
|
||||
"connections": {
|
||||
"title": "Connections",
|
||||
"workers": {
|
||||
"label": "Use custom proxy workers",
|
||||
"description": "To make the application function, all traffic is routed through proxies. Enable this if you want to bring your own workers.",
|
||||
"urlLabel": "Worker URLs",
|
||||
"emptyState": "No workers yet, add one below",
|
||||
"urlPlaceholder": "https://",
|
||||
"addButton": "Add new worker"
|
||||
},
|
||||
"server": {
|
||||
"label": "Custom server",
|
||||
"description": "To make the application function, all traffic is routed through proxies. Enable this if you want to bring your own workers.",
|
||||
"urlLabel": "Custom server URL"
|
||||
}
|
||||
}
|
||||
},
|
||||
"faq": {
|
||||
"title": "About us",
|
||||
"q1": {
|
||||
"title": "1",
|
||||
"body": "Body of 1"
|
||||
},
|
||||
"how": {
|
||||
"title": "1",
|
||||
"body": "Body of 1"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"tagline": "Watch your favorite shows and movies with this open source streaming app.",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
"dmca": "DMCA",
|
||||
"discord": "Discord"
|
||||
},
|
||||
"legal": {
|
||||
"disclaimer": "Disclaimer",
|
||||
"disclaimerText": "movie-web does not host any files, it merely links to 3rd party services. Legal issues should be taken up with the file hosts and providers. movie-web is not responsible for any media files shown by the video providers."
|
||||
}
|
||||
}
|
||||
}
|
77
src/assets/locales/fr.json
Normal file
77
src/assets/locales/fr.json
Normal file
@ -0,0 +1,77 @@
|
||||
{
|
||||
"global": {
|
||||
"name": "movie-web"
|
||||
},
|
||||
"menus": {
|
||||
"episodes": {
|
||||
"loadingTitle": "Chargement...",
|
||||
"loadingList": "Chargement..."
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"search": {
|
||||
"allResults": "C'est tout ce que nous avons!",
|
||||
"sectionTitle": "Résultats de la recherche",
|
||||
"noResults": "Nous n'avons rien trouvé!",
|
||||
"failed": "Le média n'a pas été trouvé, veuillez réessayez!",
|
||||
"loading": "Chargement...",
|
||||
"placeholder": "Que voulez-vous voir?"
|
||||
},
|
||||
"bookmarks": {
|
||||
"sectionTitle": "Favoris"
|
||||
},
|
||||
"continueWatching": {
|
||||
"sectionTitle": "Continuer le visionnage"
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"types": {
|
||||
"movie": "Film",
|
||||
"show": "Série"
|
||||
},
|
||||
"episodeDisplay": "S{{season}} E{{episode}}"
|
||||
},
|
||||
"player": {
|
||||
"playbackError": {
|
||||
"title": "Oups, c'est coupé !"
|
||||
},
|
||||
"metadata": {
|
||||
"notFound": {
|
||||
"badge": "Introuvable",
|
||||
"homeButton": "Retour à l'accueil",
|
||||
"title": "Impossible de trouver ce média",
|
||||
"text": "Nous n'avons pas trouvé le média que vous avez demandé. Soit il a été supprimé, soit vous avez modifié l'URL."
|
||||
}
|
||||
},
|
||||
"menus": {
|
||||
"captions": {
|
||||
"customChoice": "Télécharger des sous-titres",
|
||||
"customizeLabel": "Personnaliser",
|
||||
"title": "Sous-titres"
|
||||
},
|
||||
"sources": {
|
||||
"title": "Sources"
|
||||
},
|
||||
"episodes": {
|
||||
"button": "Épisodes",
|
||||
"loadingTitle": "Chargement...",
|
||||
"loadingList": "Chargement..."
|
||||
}
|
||||
},
|
||||
"back": {
|
||||
"default": "Retour à la page d'accueil",
|
||||
"short": "Retour"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"badge": "Introuvable",
|
||||
"goHome": "Retour à l'accueil",
|
||||
"title": "Impossible de trouver cette page",
|
||||
"message": "Nous avons cherché partout : sous les poubelles, dans le placard, derrière le proxy, mais nous n'avons finalement pas trouvé la page que vous cherchez."
|
||||
},
|
||||
"navigation": {
|
||||
"banner": {
|
||||
"offline": "Vérifiez votre connexion internet"
|
||||
}
|
||||
}
|
||||
}
|
77
src/assets/locales/it.json
Normal file
77
src/assets/locales/it.json
Normal file
@ -0,0 +1,77 @@
|
||||
{
|
||||
"global": {
|
||||
"name": "movie-web"
|
||||
},
|
||||
"menus": {
|
||||
"episodes": {
|
||||
"loadingTitle": "Caricamento...",
|
||||
"loadingList": "Caricamento..."
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"search": {
|
||||
"allResults": "Ecco tutto ciò che abbiamo!",
|
||||
"sectionTitle": "Risultati della ricerca",
|
||||
"noResults": "Non abbiamo trovato nulla!",
|
||||
"failed": "Impossibile trovare i media, riprova!",
|
||||
"loading": "Caricamento...",
|
||||
"placeholder": "Cosa vuoi guardare?"
|
||||
},
|
||||
"bookmarks": {
|
||||
"sectionTitle": "Segnalibri"
|
||||
},
|
||||
"continueWatching": {
|
||||
"sectionTitle": "Continua a guardare"
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"types": {
|
||||
"movie": "Film",
|
||||
"show": "Serie"
|
||||
},
|
||||
"episodeDisplay": "S{{season}} E{{episode}}"
|
||||
},
|
||||
"player": {
|
||||
"playbackError": {
|
||||
"title": "Ops, qualcosa si è rotto!"
|
||||
},
|
||||
"metadata": {
|
||||
"notFound": {
|
||||
"badge": "Non trovato",
|
||||
"homeButton": "Torna alla home",
|
||||
"title": "Impossibile trovare quel media",
|
||||
"text": "Non siamo riusciti a trovare il media richiesto. È stato rimosso o hai manomesso l'URL."
|
||||
}
|
||||
},
|
||||
"menus": {
|
||||
"captions": {
|
||||
"customChoice": "Carica sottotitolo",
|
||||
"customizeLabel": "Personalizza",
|
||||
"title": "Sottotitoli"
|
||||
},
|
||||
"sources": {
|
||||
"title": "Fonti"
|
||||
},
|
||||
"episodes": {
|
||||
"button": "Episodi",
|
||||
"loadingTitle": "Caricamento...",
|
||||
"loadingList": "Caricamento..."
|
||||
}
|
||||
},
|
||||
"back": {
|
||||
"default": "Torna alla home",
|
||||
"short": "Indietro"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"badge": "Non trovato",
|
||||
"goHome": "Torna alla home",
|
||||
"title": "Impossibile trovare quella pagina",
|
||||
"message": "Abbiamo cercato ovunque: sotto i bidoni, nell'armadio, dietro il proxy, ma alla fine non siamo riusciti a trovare la pagina che stai cercando."
|
||||
},
|
||||
"navigation": {
|
||||
"banner": {
|
||||
"offline": "Controlla la tua connessione internet"
|
||||
}
|
||||
}
|
||||
}
|
77
src/assets/locales/nl.json
Normal file
77
src/assets/locales/nl.json
Normal file
@ -0,0 +1,77 @@
|
||||
{
|
||||
"global": {
|
||||
"name": "movie-web"
|
||||
},
|
||||
"menus": {
|
||||
"episodes": {
|
||||
"loadingTitle": "Aan het zoeken...",
|
||||
"loadingList": "Aan het zoeken..."
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"search": {
|
||||
"allResults": "Dat is het!",
|
||||
"sectionTitle": "Zoekresultaten",
|
||||
"noResults": "We konden helaas niets vinden.",
|
||||
"failed": "Het is niet gelukt de media te laden, probeer het nog eens.",
|
||||
"loading": "Aan het zoeken...",
|
||||
"placeholder": "Wat wil je graag kijken?"
|
||||
},
|
||||
"bookmarks": {
|
||||
"sectionTitle": "Opgeslagen"
|
||||
},
|
||||
"continueWatching": {
|
||||
"sectionTitle": "Kijk verder"
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"types": {
|
||||
"movie": "Films",
|
||||
"show": "Series"
|
||||
},
|
||||
"episodeDisplay": "S{{season}} A{{episode}}"
|
||||
},
|
||||
"player": {
|
||||
"playbackError": {
|
||||
"title": "Oeps, hier ging iets mis!"
|
||||
},
|
||||
"metadata": {
|
||||
"notFound": {
|
||||
"badge": "Pagina niet gevonden",
|
||||
"homeButton": "Naar de home-pagina",
|
||||
"title": "We konden deze media niet vinden.",
|
||||
"text": "We konden dit stukje media niet vinden. Het is mogelijk verwijderd, of jij hebt zelf de URL aangepast."
|
||||
}
|
||||
},
|
||||
"menus": {
|
||||
"captions": {
|
||||
"customChoice": "Ondertiteling uploaden",
|
||||
"customizeLabel": "Instellingen",
|
||||
"title": "Ondertiteling"
|
||||
},
|
||||
"sources": {
|
||||
"title": "Bronnen"
|
||||
},
|
||||
"episodes": {
|
||||
"button": "Afleveringen",
|
||||
"loadingTitle": "Aan het zoeken...",
|
||||
"loadingList": "Aan het zoeken..."
|
||||
}
|
||||
},
|
||||
"back": {
|
||||
"default": "Naar de home-pagina",
|
||||
"short": "Terug"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"badge": "Pagina niet gevonden",
|
||||
"goHome": "Naar de home-pagina",
|
||||
"title": "Pagina niet gevonden",
|
||||
"message": "We hebben echt alles geprobeerd, zelfs tijdrijzen; echter hebben we deze pagina helaas niet kunnen vinden."
|
||||
},
|
||||
"navigation": {
|
||||
"banner": {
|
||||
"offline": "Controleer je internetverbinding"
|
||||
}
|
||||
}
|
||||
}
|
80
src/assets/locales/pl.json
Normal file
80
src/assets/locales/pl.json
Normal file
@ -0,0 +1,80 @@
|
||||
{
|
||||
"global": {
|
||||
"name": "movie-web"
|
||||
},
|
||||
"menus": {
|
||||
"episodes": {
|
||||
"loadingTitle": "Wczytywanie...",
|
||||
"loadingList": "Wczytywanie..."
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"search": {
|
||||
"allResults": "To wszystko co mamy!",
|
||||
"sectionTitle": "Wyniki wyszukiwania",
|
||||
"noResults": "Nie mogliśmy niczego znaleźć!",
|
||||
"failed": "Nie udało się znaleźć mediów, Spróbuj ponownie!",
|
||||
"loading": "Wczytywanie...",
|
||||
"placeholder": "Co chciałbyś obejrzeć?"
|
||||
},
|
||||
"bookmarks": {
|
||||
"sectionTitle": "Zakładki"
|
||||
},
|
||||
"continueWatching": {
|
||||
"sectionTitle": "Kontynuuj oglądanie"
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"types": {
|
||||
"movie": "Filmy",
|
||||
"show": "Seriale"
|
||||
},
|
||||
"episodeDisplay": "S{{season}} E{{episode}}"
|
||||
},
|
||||
"player": {
|
||||
"playbackError": {
|
||||
"title": "Ups, popsuło się!"
|
||||
},
|
||||
"metadata": {
|
||||
"notFound": {
|
||||
"badge": "Nie znaleziono",
|
||||
"homeButton": "Wróć na stronę główną",
|
||||
"title": "Nie można znaleźć multimediów",
|
||||
"text": "Nie mogliśmy znaleźć rządanych multimediów. Albo zostały usunięte, albo grzebałeś przy adresie URL."
|
||||
}
|
||||
},
|
||||
"menus": {
|
||||
"captions": {
|
||||
"customChoice": "Załącz",
|
||||
"customizeLabel": "Personalizuj",
|
||||
"title": "Napisy"
|
||||
},
|
||||
"sources": {
|
||||
"title": "Źródła"
|
||||
},
|
||||
"episodes": {
|
||||
"button": "Odcinki",
|
||||
"loadingTitle": "Wczytywanie...",
|
||||
"loadingList": "Wczytywanie..."
|
||||
}
|
||||
},
|
||||
"back": {
|
||||
"default": "Wróć na stronę główną",
|
||||
"short": "Wróć"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"badge": "Nie znaleziono",
|
||||
"goHome": "Wróć na stronę główną",
|
||||
"title": "Nie można znaleźć tej strony",
|
||||
"message": "Szukaliśmy wszędzie: w koszu, w szafie a nawet w piwnicy, ale nie byliśmy w stanie znaleźć strony której szukasz."
|
||||
},
|
||||
"navigation": {
|
||||
"banner": {
|
||||
"offline": "Sprawdź swoje połączenie sieciowe"
|
||||
}
|
||||
},
|
||||
"overlays": {
|
||||
"close": "Zamknąć"
|
||||
}
|
||||
}
|
80
src/assets/locales/tr.json
Normal file
80
src/assets/locales/tr.json
Normal file
@ -0,0 +1,80 @@
|
||||
{
|
||||
"global": {
|
||||
"name": "movie-web"
|
||||
},
|
||||
"menus": {
|
||||
"episodes": {
|
||||
"loadingTitle": "Yükleniyor...",
|
||||
"loadingList": "Yükleniyor..."
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"search": {
|
||||
"allResults": "Bu kadarını bulabildik!",
|
||||
"sectionTitle": "Arama sonuçları",
|
||||
"noResults": "Hiçbir şey bulamadık!",
|
||||
"failed": "Medya bulunamadı, tekrar deneyin!",
|
||||
"loading": "Yükleniyor...",
|
||||
"placeholder": "Ne izlemek istersiniz?"
|
||||
},
|
||||
"bookmarks": {
|
||||
"sectionTitle": "Yerimleri"
|
||||
},
|
||||
"continueWatching": {
|
||||
"sectionTitle": "İzlemeye devam edin"
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"types": {
|
||||
"movie": "Film",
|
||||
"show": "Dizi"
|
||||
},
|
||||
"episodeDisplay": "S{{season}} B{{episode}}"
|
||||
},
|
||||
"player": {
|
||||
"playbackError": {
|
||||
"title": "Hay aksi, bozuldu!"
|
||||
},
|
||||
"metadata": {
|
||||
"notFound": {
|
||||
"badge": "Bulunamadı",
|
||||
"homeButton": "Geri",
|
||||
"title": "Medya bulunamadı",
|
||||
"text": "İstediğiniz medyayı bulamadık. URL'i yanlış girdiniz ya da medya kaldırıldı."
|
||||
}
|
||||
},
|
||||
"menus": {
|
||||
"captions": {
|
||||
"customChoice": "Altyazı yükle",
|
||||
"customizeLabel": "Kişiselleştirme",
|
||||
"title": "Altyazılar"
|
||||
},
|
||||
"sources": {
|
||||
"title": "Kaynaklar"
|
||||
},
|
||||
"episodes": {
|
||||
"button": "Bölümler",
|
||||
"loadingTitle": "Yükleniyor...",
|
||||
"loadingList": "Yükleniyor..."
|
||||
}
|
||||
},
|
||||
"back": {
|
||||
"default": "Ana sayfaya dön",
|
||||
"short": "Geri"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"badge": "Bulunamadı",
|
||||
"goHome": "Geri",
|
||||
"title": "Sayfa bulunamadı",
|
||||
"message": "Her yere baktık: bazanın altına, dolabın içine hatta ara sunucuya ama maalesef aradığınız sayfayı bulamadık."
|
||||
},
|
||||
"navigation": {
|
||||
"banner": {
|
||||
"offline": "İnternet bağlantınızı kontrol ediniz"
|
||||
}
|
||||
},
|
||||
"overlays": {
|
||||
"close": "Kapat"
|
||||
}
|
||||
}
|
77
src/assets/locales/vi.json
Normal file
77
src/assets/locales/vi.json
Normal file
@ -0,0 +1,77 @@
|
||||
{
|
||||
"global": {
|
||||
"name": "movie-web"
|
||||
},
|
||||
"menus": {
|
||||
"episodes": {
|
||||
"loadingTitle": "Đang tải...",
|
||||
"loadingList": "Đang tải..."
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"search": {
|
||||
"allResults": "Đó là tất cả chúng tôi có!",
|
||||
"sectionTitle": "Kết quả tìm kiếm",
|
||||
"noResults": "Chúng tôi không thể tìm thấy gì!",
|
||||
"failed": "Không thể tìm thấy nội dung, hãy thử lại!",
|
||||
"loading": "Đang tải...",
|
||||
"placeholder": "Bạn muốn xem gì?"
|
||||
},
|
||||
"bookmarks": {
|
||||
"sectionTitle": "Đánh dấu"
|
||||
},
|
||||
"continueWatching": {
|
||||
"sectionTitle": "Tiếp tục xem"
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"types": {
|
||||
"movie": "Phim",
|
||||
"show": "Chương trình truyền hình"
|
||||
},
|
||||
"episodeDisplay": "M{{season}} T{{episode}}"
|
||||
},
|
||||
"player": {
|
||||
"playbackError": {
|
||||
"title": "Rất tiếc, đã hỏng!"
|
||||
},
|
||||
"metadata": {
|
||||
"notFound": {
|
||||
"badge": "Không tìm thấy",
|
||||
"homeButton": "Quay lại trang chính",
|
||||
"title": "Không thể tìm thấy nội dung",
|
||||
"text": "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"
|
||||
}
|
||||
},
|
||||
"menus": {
|
||||
"captions": {
|
||||
"customChoice": "Tải phụ đề lên",
|
||||
"customizeLabel": "Tùy chỉnh",
|
||||
"title": "Phụ đề"
|
||||
},
|
||||
"sources": {
|
||||
"title": "Nguồn"
|
||||
},
|
||||
"episodes": {
|
||||
"button": "Tập",
|
||||
"loadingTitle": "Đang tải...",
|
||||
"loadingList": "Đang tải..."
|
||||
}
|
||||
},
|
||||
"back": {
|
||||
"default": "Quay lại trang chính",
|
||||
"short": "Quay lại"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"badge": "Không tìm thấy",
|
||||
"goHome": "Quay lại trang chính",
|
||||
"title": "Không thể tìm thấy trang",
|
||||
"message": "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."
|
||||
},
|
||||
"navigation": {
|
||||
"banner": {
|
||||
"offline": "Hãy kiểm tra kết nối Internet của bạn"
|
||||
}
|
||||
}
|
||||
}
|
77
src/assets/locales/zh.json
Normal file
77
src/assets/locales/zh.json
Normal file
@ -0,0 +1,77 @@
|
||||
{
|
||||
"global": {
|
||||
"name": "movie-web"
|
||||
},
|
||||
"menus": {
|
||||
"episodes": {
|
||||
"loadingTitle": "载入中……",
|
||||
"loadingList": "载入中……"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"search": {
|
||||
"allResults": "以上是我们能找到的所有结果!",
|
||||
"sectionTitle": "搜索结果",
|
||||
"noResults": "我们找不到任何结果!",
|
||||
"failed": "查找媒体失败,请重试!",
|
||||
"loading": "载入中……",
|
||||
"placeholder": "您想看些什么?"
|
||||
},
|
||||
"bookmarks": {
|
||||
"sectionTitle": "书签"
|
||||
},
|
||||
"continueWatching": {
|
||||
"sectionTitle": "继续观看"
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"types": {
|
||||
"movie": "电影",
|
||||
"show": "连续剧"
|
||||
},
|
||||
"episodeDisplay": "第{{season}}季 第{{episode}}集"
|
||||
},
|
||||
"player": {
|
||||
"playbackError": {
|
||||
"title": "哎呀,出问题了!"
|
||||
},
|
||||
"metadata": {
|
||||
"notFound": {
|
||||
"badge": "未找到",
|
||||
"homeButton": "返回首页",
|
||||
"title": "无法找到媒体",
|
||||
"text": "我们无法找到您请求的媒体。它可能已被删除,或您篡改了 URL"
|
||||
}
|
||||
},
|
||||
"menus": {
|
||||
"captions": {
|
||||
"customChoice": "上传字幕",
|
||||
"customizeLabel": "自定义",
|
||||
"title": "字幕"
|
||||
},
|
||||
"sources": {
|
||||
"title": "视频源"
|
||||
},
|
||||
"episodes": {
|
||||
"button": "分集",
|
||||
"loadingTitle": "载入中……",
|
||||
"loadingList": "载入中……"
|
||||
}
|
||||
},
|
||||
"back": {
|
||||
"default": "返回首页",
|
||||
"short": "返回"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"badge": "未找到",
|
||||
"goHome": "返回首页",
|
||||
"title": "无法找到页面",
|
||||
"message": "我们已经到处找过了:不管是垃圾桶下、橱柜里或是代理之后。但最终并没有发现您查找的页面。"
|
||||
},
|
||||
"navigation": {
|
||||
"banner": {
|
||||
"offline": "检查您的互联网连接"
|
||||
}
|
||||
}
|
||||
}
|
6
src/assets/templates/opensearch.xml.hbs
Normal file
6
src/assets/templates/opensearch.xml.hbs
Normal file
@ -0,0 +1,6 @@
|
||||
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
|
||||
<ShortName>movie-web</ShortName>
|
||||
<Description>The place for your favorite movies & shows</Description>
|
||||
<InputEncoding>UTF-8</InputEncoding>
|
||||
<Url type="text/html" template="{{ routeDomain }}/browse/?q={searchTerms}" />
|
||||
</OpenSearchDescription>
|
35
src/backend/accounts/auth.ts
Normal file
35
src/backend/accounts/auth.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
export interface SessionResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
createdAt: string;
|
||||
accessedAt: string;
|
||||
device: string;
|
||||
userAgent: string;
|
||||
}
|
||||
export interface LoginResponse {
|
||||
session: SessionResponse;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export function getAuthHeaders(token: string): Record<string, string> {
|
||||
return {
|
||||
authorization: `Bearer ${token}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function accountLogin(
|
||||
url: string,
|
||||
id: string,
|
||||
deviceName: string
|
||||
): Promise<LoginResponse> {
|
||||
return ofetch<LoginResponse>("/auth/login", {
|
||||
method: "POST",
|
||||
body: {
|
||||
id,
|
||||
device: deviceName,
|
||||
},
|
||||
baseURL: url,
|
||||
});
|
||||
}
|
64
src/backend/accounts/bookmarks.ts
Normal file
64
src/backend/accounts/bookmarks.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
import { getAuthHeaders } from "@/backend/accounts/auth";
|
||||
import { BookmarkResponse } from "@/backend/accounts/user";
|
||||
import { AccountWithToken } from "@/stores/auth";
|
||||
import { BookmarkMediaItem } from "@/stores/bookmarks";
|
||||
|
||||
export interface BookmarkMetaInput {
|
||||
title: string;
|
||||
year: number;
|
||||
poster?: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface BookmarkInput {
|
||||
tmdbId: string;
|
||||
meta: BookmarkMetaInput;
|
||||
}
|
||||
|
||||
export function bookmarkMediaToInput(
|
||||
tmdbId: string,
|
||||
item: BookmarkMediaItem
|
||||
): BookmarkInput {
|
||||
return {
|
||||
meta: {
|
||||
title: item.title,
|
||||
type: item.type,
|
||||
poster: item.poster,
|
||||
year: item.year ?? 0,
|
||||
},
|
||||
tmdbId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function addBookmark(
|
||||
url: string,
|
||||
account: AccountWithToken,
|
||||
input: BookmarkInput
|
||||
) {
|
||||
return ofetch<BookmarkResponse>(
|
||||
`/users/${account.userId}/bookmarks/${input.tmdbId}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(account.token),
|
||||
baseURL: url,
|
||||
body: input,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeBookmark(
|
||||
url: string,
|
||||
account: AccountWithToken,
|
||||
id: string
|
||||
) {
|
||||
return ofetch<{ tmdbId: string }>(
|
||||
`/users/${account.userId}/bookmarks/${id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(account.token),
|
||||
baseURL: url,
|
||||
}
|
||||
);
|
||||
}
|
131
src/backend/accounts/crypto.ts
Normal file
131
src/backend/accounts/crypto.ts
Normal file
@ -0,0 +1,131 @@
|
||||
import { pbkdf2Async } from "@noble/hashes/pbkdf2";
|
||||
import { sha256 } from "@noble/hashes/sha256";
|
||||
import { generateMnemonic, validateMnemonic } from "@scure/bip39";
|
||||
import { wordlist } from "@scure/bip39/wordlists/english";
|
||||
import forge from "node-forge";
|
||||
|
||||
type Keys = {
|
||||
privateKey: Uint8Array;
|
||||
publicKey: Uint8Array;
|
||||
seed: Uint8Array;
|
||||
};
|
||||
|
||||
async function seedFromMnemonic(mnemonic: string) {
|
||||
return pbkdf2Async(sha256, mnemonic, "mnemonic", {
|
||||
c: 2048,
|
||||
dkLen: 32,
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyValidMnemonic(mnemonic: string) {
|
||||
return validateMnemonic(mnemonic, wordlist);
|
||||
}
|
||||
|
||||
export async function keysFromMnemonic(mnemonic: string): Promise<Keys> {
|
||||
const seed = await seedFromMnemonic(mnemonic);
|
||||
|
||||
const { privateKey, publicKey } = forge.pki.ed25519.generateKeyPair({
|
||||
seed,
|
||||
});
|
||||
|
||||
return {
|
||||
privateKey,
|
||||
publicKey,
|
||||
seed,
|
||||
};
|
||||
}
|
||||
|
||||
export function genMnemonic(): string {
|
||||
return generateMnemonic(wordlist);
|
||||
}
|
||||
|
||||
export async function signCode(
|
||||
code: string,
|
||||
privateKey: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
return forge.pki.ed25519.sign({
|
||||
encoding: "utf8",
|
||||
message: code,
|
||||
privateKey,
|
||||
});
|
||||
}
|
||||
|
||||
export function bytesToBase64(bytes: Uint8Array) {
|
||||
return forge.util.encode64(String.fromCodePoint(...bytes));
|
||||
}
|
||||
|
||||
export function bytesToBase64Url(bytes: Uint8Array): string {
|
||||
return bytesToBase64(bytes)
|
||||
.replace(/\//g, "_")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
export async function signChallenge(keys: Keys, challengeCode: string) {
|
||||
const signature = await signCode(challengeCode, keys.privateKey);
|
||||
return bytesToBase64Url(signature);
|
||||
}
|
||||
|
||||
export function base64ToBuffer(data: string) {
|
||||
return forge.util.binary.base64.decode(data);
|
||||
}
|
||||
|
||||
export function base64ToStringBuffer(data: string) {
|
||||
return forge.util.createBuffer(base64ToBuffer(data));
|
||||
}
|
||||
|
||||
export function stringBufferToBase64(buffer: forge.util.ByteStringBuffer) {
|
||||
return forge.util.encode64(buffer.getBytes());
|
||||
}
|
||||
|
||||
export async function encryptData(data: string, secret: Uint8Array) {
|
||||
if (secret.byteLength !== 32)
|
||||
throw new Error("Secret must be at least 256-bit");
|
||||
|
||||
const iv = await new Promise<string>((resolve, reject) => {
|
||||
forge.random.getBytes(16, (err, bytes) => {
|
||||
if (err) reject(err);
|
||||
resolve(bytes);
|
||||
});
|
||||
});
|
||||
|
||||
const cipher = forge.cipher.createCipher(
|
||||
"AES-GCM",
|
||||
forge.util.createBuffer(secret)
|
||||
);
|
||||
cipher.start({
|
||||
iv,
|
||||
tagLength: 128,
|
||||
});
|
||||
cipher.update(forge.util.createBuffer(data, "utf8"));
|
||||
cipher.finish();
|
||||
|
||||
const encryptedData = cipher.output;
|
||||
const tag = cipher.mode.tag;
|
||||
|
||||
return `${forge.util.encode64(iv)}.${stringBufferToBase64(
|
||||
encryptedData
|
||||
)}.${stringBufferToBase64(tag)}` as const;
|
||||
}
|
||||
|
||||
export function decryptData(data: string, secret: Uint8Array) {
|
||||
if (secret.byteLength !== 32) throw new Error("Secret must be 256-bit");
|
||||
|
||||
const [iv, encryptedData, tag] = data.split(".");
|
||||
|
||||
const decipher = forge.cipher.createDecipher(
|
||||
"AES-GCM",
|
||||
forge.util.createBuffer(secret)
|
||||
);
|
||||
decipher.start({
|
||||
iv: base64ToStringBuffer(iv),
|
||||
tag: base64ToStringBuffer(tag),
|
||||
tagLength: 128,
|
||||
});
|
||||
decipher.update(base64ToStringBuffer(encryptedData));
|
||||
const pass = decipher.finish();
|
||||
|
||||
if (!pass) throw new Error("Error decrypting data");
|
||||
|
||||
return decipher.output.toString();
|
||||
}
|
33
src/backend/accounts/import.ts
Normal file
33
src/backend/accounts/import.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
import { getAuthHeaders } from "@/backend/accounts/auth";
|
||||
import { AccountWithToken } from "@/stores/auth";
|
||||
|
||||
import { BookmarkInput } from "./bookmarks";
|
||||
import { ProgressInput } from "./progress";
|
||||
|
||||
export function importProgress(
|
||||
url: string,
|
||||
account: AccountWithToken,
|
||||
progressItems: ProgressInput[]
|
||||
) {
|
||||
return ofetch<void>(`/users/${account.userId}/progress/import`, {
|
||||
method: "PUT",
|
||||
body: progressItems,
|
||||
baseURL: url,
|
||||
headers: getAuthHeaders(account.token),
|
||||
});
|
||||
}
|
||||
|
||||
export function importBookmarks(
|
||||
url: string,
|
||||
account: AccountWithToken,
|
||||
bookmarks: BookmarkInput[]
|
||||
) {
|
||||
return ofetch<void>(`/users/${account.userId}/bookmarks`, {
|
||||
method: "PUT",
|
||||
body: bookmarks,
|
||||
baseURL: url,
|
||||
headers: getAuthHeaders(account.token),
|
||||
});
|
||||
}
|
48
src/backend/accounts/login.ts
Normal file
48
src/backend/accounts/login.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
import { SessionResponse } from "@/backend/accounts/auth";
|
||||
|
||||
export interface ChallengeTokenResponse {
|
||||
challenge: string;
|
||||
}
|
||||
|
||||
export async function getLoginChallengeToken(
|
||||
url: string,
|
||||
publicKey: string
|
||||
): Promise<ChallengeTokenResponse> {
|
||||
return ofetch<ChallengeTokenResponse>("/auth/login/start", {
|
||||
method: "POST",
|
||||
body: {
|
||||
publicKey,
|
||||
},
|
||||
baseURL: url,
|
||||
});
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
session: SessionResponse;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface LoginInput {
|
||||
publicKey: string;
|
||||
challenge: {
|
||||
code: string;
|
||||
signature: string;
|
||||
};
|
||||
device: string;
|
||||
}
|
||||
|
||||
export async function loginAccount(
|
||||
url: string,
|
||||
data: LoginInput
|
||||
): Promise<LoginResponse> {
|
||||
return ofetch<LoginResponse>("/auth/login/complete", {
|
||||
method: "POST",
|
||||
body: {
|
||||
namespace: "movie-web",
|
||||
...data,
|
||||
},
|
||||
baseURL: url,
|
||||
});
|
||||
}
|
15
src/backend/accounts/meta.ts
Normal file
15
src/backend/accounts/meta.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
export interface MetaResponse {
|
||||
version: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
hasCaptcha: boolean;
|
||||
captchaClientKey?: string;
|
||||
}
|
||||
|
||||
export async function getBackendMeta(url: string): Promise<MetaResponse> {
|
||||
return ofetch<MetaResponse>("/meta", {
|
||||
baseURL: url,
|
||||
});
|
||||
}
|
112
src/backend/accounts/progress.ts
Normal file
112
src/backend/accounts/progress.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
import { getAuthHeaders } from "@/backend/accounts/auth";
|
||||
import { ProgressResponse } from "@/backend/accounts/user";
|
||||
import { AccountWithToken } from "@/stores/auth";
|
||||
import { ProgressMediaItem, ProgressUpdateItem } from "@/stores/progress";
|
||||
|
||||
export interface ProgressInput {
|
||||
meta?: {
|
||||
title: string;
|
||||
year: number;
|
||||
poster?: string;
|
||||
type: string;
|
||||
};
|
||||
tmdbId: string;
|
||||
watched: number;
|
||||
duration: number;
|
||||
seasonId?: string;
|
||||
episodeId?: string;
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
}
|
||||
|
||||
export function progressUpdateItemToInput(
|
||||
item: ProgressUpdateItem
|
||||
): ProgressInput {
|
||||
return {
|
||||
duration: item.progress?.duration ?? 0,
|
||||
watched: item.progress?.watched ?? 0,
|
||||
tmdbId: item.tmdbId,
|
||||
meta: {
|
||||
title: item.title ?? "",
|
||||
type: item.type ?? "",
|
||||
year: item.year ?? NaN,
|
||||
poster: item.poster,
|
||||
},
|
||||
episodeId: item.episodeId,
|
||||
seasonId: item.seasonId,
|
||||
episodeNumber: item.episodeNumber,
|
||||
seasonNumber: item.seasonNumber,
|
||||
};
|
||||
}
|
||||
|
||||
export function progressMediaItemToInputs(
|
||||
tmdbId: string,
|
||||
item: ProgressMediaItem
|
||||
): ProgressInput[] {
|
||||
if (item.type === "show") {
|
||||
return Object.entries(item.episodes).flatMap(([_, episode]) => ({
|
||||
duration: item.progress?.duration ?? episode.progress.duration,
|
||||
watched: item.progress?.watched ?? episode.progress.watched,
|
||||
tmdbId,
|
||||
meta: {
|
||||
title: item.title ?? "",
|
||||
type: item.type ?? "",
|
||||
year: item.year ?? NaN,
|
||||
poster: item.poster,
|
||||
},
|
||||
episodeId: episode.id,
|
||||
seasonId: episode.seasonId,
|
||||
episodeNumber: episode.number,
|
||||
seasonNumber: item.seasons[episode.seasonId].number,
|
||||
}));
|
||||
}
|
||||
return [
|
||||
{
|
||||
duration: item.progress?.duration ?? 0,
|
||||
watched: item.progress?.watched ?? 0,
|
||||
tmdbId,
|
||||
meta: {
|
||||
title: item.title ?? "",
|
||||
type: item.type ?? "",
|
||||
year: item.year ?? NaN,
|
||||
poster: item.poster,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export async function setProgress(
|
||||
url: string,
|
||||
account: AccountWithToken,
|
||||
input: ProgressInput
|
||||
) {
|
||||
return ofetch<ProgressResponse>(
|
||||
`/users/${account.userId}/progress/${input.tmdbId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: getAuthHeaders(account.token),
|
||||
baseURL: url,
|
||||
body: input,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeProgress(
|
||||
url: string,
|
||||
account: AccountWithToken,
|
||||
id: string,
|
||||
episodeId?: string,
|
||||
seasonId?: string
|
||||
) {
|
||||
await ofetch(`/users/${account.userId}/progress/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(account.token),
|
||||
baseURL: url,
|
||||
body: {
|
||||
episodeId,
|
||||
seasonId,
|
||||
},
|
||||
});
|
||||
}
|
55
src/backend/accounts/register.ts
Normal file
55
src/backend/accounts/register.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
import { SessionResponse } from "@/backend/accounts/auth";
|
||||
import { UserResponse } from "@/backend/accounts/user";
|
||||
|
||||
export interface ChallengeTokenResponse {
|
||||
challenge: string;
|
||||
}
|
||||
|
||||
export async function getRegisterChallengeToken(
|
||||
url: string,
|
||||
captchaToken?: string
|
||||
): Promise<ChallengeTokenResponse> {
|
||||
return ofetch<ChallengeTokenResponse>("/auth/register/start", {
|
||||
method: "POST",
|
||||
body: {
|
||||
captchaToken,
|
||||
},
|
||||
baseURL: url,
|
||||
});
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
user: UserResponse;
|
||||
session: SessionResponse;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface RegisterInput {
|
||||
publicKey: string;
|
||||
challenge: {
|
||||
code: string;
|
||||
signature: string;
|
||||
};
|
||||
device: string;
|
||||
profile: {
|
||||
colorA: string;
|
||||
colorB: string;
|
||||
icon: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerAccount(
|
||||
url: string,
|
||||
data: RegisterInput
|
||||
): Promise<RegisterResponse> {
|
||||
return ofetch<RegisterResponse>("/auth/register/complete", {
|
||||
method: "POST",
|
||||
body: {
|
||||
namespace: "movie-web",
|
||||
...data,
|
||||
},
|
||||
baseURL: url,
|
||||
});
|
||||
}
|
49
src/backend/accounts/sessions.ts
Normal file
49
src/backend/accounts/sessions.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
import { getAuthHeaders } from "@/backend/accounts/auth";
|
||||
import { AccountWithToken } from "@/stores/auth";
|
||||
|
||||
export interface SessionResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
createdAt: string;
|
||||
accessedAt: string;
|
||||
device: string;
|
||||
userAgent: string;
|
||||
}
|
||||
|
||||
export interface SessionUpdate {
|
||||
deviceName: string;
|
||||
}
|
||||
|
||||
export async function getSessions(url: string, account: AccountWithToken) {
|
||||
return ofetch<SessionResponse[]>(`/users/${account.userId}/sessions`, {
|
||||
headers: getAuthHeaders(account.token),
|
||||
baseURL: url,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSession(
|
||||
url: string,
|
||||
account: AccountWithToken,
|
||||
update: SessionUpdate
|
||||
) {
|
||||
return ofetch<SessionResponse[]>(`/sessions/${account.sessionId}`, {
|
||||
method: "PATCH",
|
||||
headers: getAuthHeaders(account.token),
|
||||
body: update,
|
||||
baseURL: url,
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeSession(
|
||||
url: string,
|
||||
token: string,
|
||||
sessionId: string
|
||||
) {
|
||||
return ofetch<SessionResponse[]>(`/sessions/${sessionId}`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(token),
|
||||
baseURL: url,
|
||||
});
|
||||
}
|
37
src/backend/accounts/settings.ts
Normal file
37
src/backend/accounts/settings.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
import { getAuthHeaders } from "@/backend/accounts/auth";
|
||||
import { AccountWithToken } from "@/stores/auth";
|
||||
|
||||
export interface SettingsInput {
|
||||
applicationLanguage?: string;
|
||||
applicationTheme?: string | null;
|
||||
defaultSubtitleLanguage?: string;
|
||||
}
|
||||
|
||||
export interface SettingsResponse {
|
||||
applicationTheme?: string | null;
|
||||
applicationLanguage?: string | null;
|
||||
defaultSubtitleLanguage?: string | null;
|
||||
}
|
||||
|
||||
export function updateSettings(
|
||||
url: string,
|
||||
account: AccountWithToken,
|
||||
settings: SettingsInput
|
||||
) {
|
||||
return ofetch<SettingsResponse>(`/users/${account.userId}/settings`, {
|
||||
method: "PUT",
|
||||
body: settings,
|
||||
baseURL: url,
|
||||
headers: getAuthHeaders(account.token),
|
||||
});
|
||||
}
|
||||
|
||||
export function getSettings(url: string, account: AccountWithToken) {
|
||||
return ofetch<SettingsResponse>(`/users/${account.userId}/settings`, {
|
||||
method: "GET",
|
||||
baseURL: url,
|
||||
headers: getAuthHeaders(account.token),
|
||||
});
|
||||
}
|
171
src/backend/accounts/user.ts
Normal file
171
src/backend/accounts/user.ts
Normal file
@ -0,0 +1,171 @@
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
import { SessionResponse, getAuthHeaders } from "@/backend/accounts/auth";
|
||||
import { AccountWithToken } from "@/stores/auth";
|
||||
import { BookmarkMediaItem } from "@/stores/bookmarks";
|
||||
import { ProgressMediaItem } from "@/stores/progress";
|
||||
|
||||
export interface UserResponse {
|
||||
id: string;
|
||||
namespace: string;
|
||||
name: string;
|
||||
roles: string[];
|
||||
createdAt: string;
|
||||
profile: {
|
||||
colorA: string;
|
||||
colorB: string;
|
||||
icon: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UserEdit {
|
||||
profile?: {
|
||||
colorA: string;
|
||||
colorB: string;
|
||||
icon: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BookmarkResponse {
|
||||
tmdbId: string;
|
||||
meta: {
|
||||
title: string;
|
||||
year: number;
|
||||
poster?: string;
|
||||
type: "show" | "movie";
|
||||
};
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProgressResponse {
|
||||
tmdbId: string;
|
||||
season: {
|
||||
id?: string;
|
||||
number?: number;
|
||||
};
|
||||
episode: {
|
||||
id?: string;
|
||||
number?: number;
|
||||
};
|
||||
meta: {
|
||||
title: string;
|
||||
year: number;
|
||||
poster?: string;
|
||||
type: "show" | "movie";
|
||||
};
|
||||
duration: string;
|
||||
watched: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function bookmarkResponsesToEntries(responses: BookmarkResponse[]) {
|
||||
const entries = responses.map((bookmark) => {
|
||||
const item: BookmarkMediaItem = {
|
||||
...bookmark.meta,
|
||||
updatedAt: new Date(bookmark.updatedAt).getTime(),
|
||||
};
|
||||
return [bookmark.tmdbId, item] as const;
|
||||
});
|
||||
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
export function progressResponsesToEntries(responses: ProgressResponse[]) {
|
||||
const items: Record<string, ProgressMediaItem> = {};
|
||||
|
||||
responses.forEach((v) => {
|
||||
if (!items[v.tmdbId]) {
|
||||
items[v.tmdbId] = {
|
||||
title: v.meta.title,
|
||||
poster: v.meta.poster,
|
||||
type: v.meta.type,
|
||||
updatedAt: new Date(v.updatedAt).getTime(),
|
||||
episodes: {},
|
||||
seasons: {},
|
||||
year: v.meta.year,
|
||||
};
|
||||
}
|
||||
|
||||
const item = items[v.tmdbId];
|
||||
if (item.type === "movie") {
|
||||
item.progress = {
|
||||
duration: Number(v.duration),
|
||||
watched: Number(v.watched),
|
||||
};
|
||||
}
|
||||
|
||||
if (item.type === "show" && v.season.id && v.episode.id) {
|
||||
item.seasons[v.season.id] = {
|
||||
id: v.season.id,
|
||||
number: v.season.number ?? 0,
|
||||
title: "",
|
||||
};
|
||||
item.episodes[v.episode.id] = {
|
||||
id: v.episode.id,
|
||||
number: v.episode.number ?? 0,
|
||||
title: "",
|
||||
progress: {
|
||||
duration: Number(v.duration),
|
||||
watched: Number(v.watched),
|
||||
},
|
||||
seasonId: v.season.id,
|
||||
updatedAt: new Date(v.updatedAt).getTime(),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function getUser(
|
||||
url: string,
|
||||
token: string
|
||||
): Promise<{ user: UserResponse; session: SessionResponse }> {
|
||||
return ofetch<{ user: UserResponse; session: SessionResponse }>(
|
||||
"/users/@me",
|
||||
{
|
||||
headers: getAuthHeaders(token),
|
||||
baseURL: url,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function editUser(
|
||||
url: string,
|
||||
account: AccountWithToken,
|
||||
object: UserEdit
|
||||
): Promise<{ user: UserResponse; session: SessionResponse }> {
|
||||
return ofetch<{ user: UserResponse; session: SessionResponse }>(
|
||||
`/users/${account.userId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: getAuthHeaders(account.token),
|
||||
body: object,
|
||||
baseURL: url,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteUser(
|
||||
url: string,
|
||||
account: AccountWithToken
|
||||
): Promise<UserResponse> {
|
||||
return ofetch<UserResponse>(`/users/${account.userId}`, {
|
||||
headers: getAuthHeaders(account.token),
|
||||
baseURL: url,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getBookmarks(url: string, account: AccountWithToken) {
|
||||
return ofetch<BookmarkResponse[]>(`/users/${account.userId}/bookmarks`, {
|
||||
headers: getAuthHeaders(account.token),
|
||||
baseURL: url,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getProgress(url: string, account: AccountWithToken) {
|
||||
return ofetch<ProgressResponse[]>(`/users/${account.userId}/progress`, {
|
||||
headers: getAuthHeaders(account.token),
|
||||
baseURL: url,
|
||||
});
|
||||
}
|
@ -1 +0,0 @@
|
||||
embed scrapers go here
|
@ -1,32 +0,0 @@
|
||||
import { MWEmbedType } from "@/backend/helpers/embed";
|
||||
import { registerEmbedScraper } from "@/backend/helpers/register";
|
||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
|
||||
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
|
||||
registerEmbedScraper({
|
||||
id: "mp4upload",
|
||||
displayName: "mp4upload",
|
||||
for: MWEmbedType.MP4UPLOAD,
|
||||
rank: 170,
|
||||
async getStream({ url }) {
|
||||
const embed = await proxiedFetch<any>(url);
|
||||
|
||||
const playerSrcRegex =
|
||||
/(?<=player\.src\()\s*{\s*type:\s*"[^"]+",\s*src:\s*"([^"]+)"\s*}\s*(?=\);)/s;
|
||||
|
||||
const playerSrc = embed.match(playerSrcRegex);
|
||||
|
||||
const streamUrl = playerSrc[1];
|
||||
|
||||
if (!streamUrl) throw new Error("Stream url not found");
|
||||
|
||||
return {
|
||||
embedId: MWEmbedType.MP4UPLOAD,
|
||||
streamUrl,
|
||||
quality: MWStreamQuality.Q1080P,
|
||||
captions: [],
|
||||
type: MWStreamType.MP4,
|
||||
};
|
||||
},
|
||||
});
|
@ -1,20 +0,0 @@
|
||||
import { MWEmbedType } from "@/backend/helpers/embed";
|
||||
import { registerEmbedScraper } from "@/backend/helpers/register";
|
||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
|
||||
|
||||
registerEmbedScraper({
|
||||
id: "playm4u",
|
||||
displayName: "playm4u",
|
||||
for: MWEmbedType.PLAYM4U,
|
||||
rank: 0,
|
||||
async getStream() {
|
||||
// throw new Error("Oh well 2")
|
||||
return {
|
||||
embedId: "",
|
||||
streamUrl: "",
|
||||
quality: MWStreamQuality.Q1080P,
|
||||
captions: [],
|
||||
type: MWStreamType.MP4,
|
||||
};
|
||||
},
|
||||
});
|
@ -1,66 +0,0 @@
|
||||
import { MWEmbedType } from "@/backend/helpers/embed";
|
||||
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||
import { registerEmbedScraper } from "@/backend/helpers/register";
|
||||
import {
|
||||
MWEmbedStream,
|
||||
MWStreamQuality,
|
||||
MWStreamType,
|
||||
} from "@/backend/helpers/streams";
|
||||
|
||||
const HOST = "streamm4u.club";
|
||||
const URL_BASE = `https://${HOST}`;
|
||||
const URL_API = `${URL_BASE}/api`;
|
||||
const URL_API_SOURCE = `${URL_API}/source`;
|
||||
|
||||
async function scrape(embed: string) {
|
||||
const sources: MWEmbedStream[] = [];
|
||||
|
||||
const embedID = embed.split("/").pop();
|
||||
|
||||
console.log(`${URL_API_SOURCE}/${embedID}`);
|
||||
const json = await proxiedFetch<any>(`${URL_API_SOURCE}/${embedID}`, {
|
||||
method: "POST",
|
||||
body: `r=&d=${HOST}`,
|
||||
});
|
||||
|
||||
if (json.success) {
|
||||
const streams = json.data;
|
||||
|
||||
for (const stream of streams) {
|
||||
sources.push({
|
||||
embedId: "",
|
||||
streamUrl: stream.file as string,
|
||||
quality: stream.label as MWStreamQuality,
|
||||
type: stream.type as MWStreamType,
|
||||
captions: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
// TODO check out 403 / 404 on successfully returned video stream URLs
|
||||
registerEmbedScraper({
|
||||
id: "streamm4u",
|
||||
displayName: "streamm4u",
|
||||
for: MWEmbedType.STREAMM4U,
|
||||
rank: 100,
|
||||
async getStream({ progress, url }) {
|
||||
// const scrapingThreads = [];
|
||||
// const streams = [];
|
||||
|
||||
const sources = (await scrape(url)).sort(
|
||||
(a, b) =>
|
||||
Number(b.quality.replace("p", "")) - Number(a.quality.replace("p", ""))
|
||||
);
|
||||
// const preferredSourceIndex = 0;
|
||||
const preferredSource = sources[0];
|
||||
|
||||
if (!preferredSource) throw new Error("No source found");
|
||||
|
||||
progress(100);
|
||||
|
||||
return preferredSource;
|
||||
},
|
||||
});
|
@ -1,211 +0,0 @@
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
@ -1,135 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
@ -1,62 +0,0 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { convert, detect, list, parse } from "subsrt-ts";
|
||||
import { ContentCaption } from "subsrt-ts/dist/types/handler";
|
||||
|
||||
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
|
||||
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
|
||||
|
||||
export const customCaption = "external-custom";
|
||||
export function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
|
||||
return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`;
|
||||
}
|
||||
export const subtitleTypeList = list().map((type) => `.${type}`);
|
||||
export function isSupportedSubtitle(url: string): boolean {
|
||||
return subtitleTypeList.some((type) => url.endsWith(type));
|
||||
}
|
||||
|
||||
export function getMWCaptionTypeFromUrl(url: string): MWCaptionType {
|
||||
if (!isSupportedSubtitle(url)) return MWCaptionType.UNKNOWN;
|
||||
const type = subtitleTypeList.find((t) => url.endsWith(t));
|
||||
if (!type) return MWCaptionType.UNKNOWN;
|
||||
return type.slice(1) as MWCaptionType;
|
||||
}
|
||||
|
||||
export const sanitize = DOMPurify.sanitize;
|
||||
export async function getCaptionUrl(caption: MWCaption): Promise<string> {
|
||||
let captionBlob: Blob;
|
||||
if (caption.url.startsWith("blob:")) {
|
||||
// custom subtitle
|
||||
captionBlob = await (await fetch(caption.url)).blob();
|
||||
} else if (caption.needsProxy) {
|
||||
captionBlob = await proxiedFetch<Blob>(caption.url, {
|
||||
responseType: "blob" as any,
|
||||
});
|
||||
} else {
|
||||
captionBlob = await mwFetch<Blob>(caption.url, {
|
||||
responseType: "blob" as any,
|
||||
});
|
||||
}
|
||||
// convert to vtt for track element source which will be used in PiP mode
|
||||
const text = await captionBlob.text();
|
||||
const vtt = convert(text, "vtt");
|
||||
return URL.createObjectURL(new Blob([vtt], { type: "text/vtt" }));
|
||||
}
|
||||
|
||||
export function revokeCaptionBlob(url: string | undefined) {
|
||||
if (url && url.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSubtitles(text: string): ContentCaption[] {
|
||||
const textTrimmed = text.trim();
|
||||
if (textTrimmed === "") {
|
||||
throw new Error("Given text is empty");
|
||||
}
|
||||
if (detect(textTrimmed) === "") {
|
||||
throw new Error("Invalid subtitle format");
|
||||
}
|
||||
return parse(textTrimmed).filter(
|
||||
(cue) => cue.type === "caption"
|
||||
) as ContentCaption[];
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
import { MWEmbedStream } from "./streams";
|
||||
|
||||
export enum MWEmbedType {
|
||||
M4UFREE = "m4ufree",
|
||||
STREAMM4U = "streamm4u",
|
||||
PLAYM4U = "playm4u",
|
||||
UPCLOUD = "upcloud",
|
||||
STREAMSB = "streamsb",
|
||||
MP4UPLOAD = "mp4upload",
|
||||
}
|
||||
|
||||
export type MWEmbed = {
|
||||
type: MWEmbedType;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type MWEmbedContext = {
|
||||
progress(percentage: number): void;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type MWEmbedScraper = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
for: MWEmbedType;
|
||||
rank: number;
|
||||
disabled?: boolean;
|
||||
|
||||
getStream(ctx: MWEmbedContext): Promise<MWEmbedStream>;
|
||||
};
|
@ -1,18 +1,9 @@
|
||||
import { FetchOptions, FetchResponse, ofetch } from "ofetch";
|
||||
|
||||
import { conf } from "@/setup/config";
|
||||
import { getLoadbalancedProxyUrl } from "@/utils/providers";
|
||||
|
||||
let proxyUrlIndex = Math.floor(Math.random() * conf().PROXY_URLS.length);
|
||||
|
||||
// round robins all proxy urls
|
||||
function getProxyUrl(): string {
|
||||
const url = conf().PROXY_URLS[proxyUrlIndex];
|
||||
proxyUrlIndex = (proxyUrlIndex + 1) % conf().PROXY_URLS.length;
|
||||
return url;
|
||||
}
|
||||
|
||||
type P<T> = Parameters<typeof ofetch<T>>;
|
||||
type R<T> = ReturnType<typeof ofetch<T>>;
|
||||
type P<T> = Parameters<typeof ofetch<T, any>>;
|
||||
type R<T> = ReturnType<typeof ofetch<T, any>>;
|
||||
|
||||
const baseFetch = ofetch.create({
|
||||
retry: 0,
|
||||
@ -50,13 +41,17 @@ export function proxiedFetch<T>(url: string, ops: P<T>[1] = {}): R<T> {
|
||||
Object.entries(ops?.params ?? {}).forEach(([k, v]) => {
|
||||
parsedUrl.searchParams.set(k, v);
|
||||
});
|
||||
Object.entries(ops?.query ?? {}).forEach(([k, v]) => {
|
||||
parsedUrl.searchParams.set(k, v);
|
||||
});
|
||||
|
||||
return baseFetch<T>(getProxyUrl(), {
|
||||
return baseFetch<T>(getLoadbalancedProxyUrl(), {
|
||||
...ops,
|
||||
baseURL: undefined,
|
||||
params: {
|
||||
destination: parsedUrl.toString(),
|
||||
},
|
||||
query: {},
|
||||
});
|
||||
}
|
||||
|
||||
@ -84,7 +79,7 @@ export function rawProxiedFetch<T>(
|
||||
parsedUrl.searchParams.set(k, v);
|
||||
});
|
||||
|
||||
return baseFetch.raw(getProxyUrl(), {
|
||||
return baseFetch.raw(getLoadbalancedProxyUrl(), {
|
||||
...ops,
|
||||
baseURL: undefined,
|
||||
params: {
|
||||
|
@ -1,36 +0,0 @@
|
||||
import { MWEmbed } from "./embed";
|
||||
import { MWStream } from "./streams";
|
||||
import { DetailedMeta } from "../metadata/getmeta";
|
||||
import { MWMediaType } from "../metadata/types/mw";
|
||||
|
||||
export type MWProviderScrapeResult = {
|
||||
stream?: MWStream;
|
||||
embeds: MWEmbed[];
|
||||
};
|
||||
|
||||
type MWProviderBase = {
|
||||
progress(percentage: number): void;
|
||||
media: DetailedMeta;
|
||||
};
|
||||
type MWProviderTypeSpecific =
|
||||
| {
|
||||
type: MWMediaType.MOVIE | MWMediaType.ANIME;
|
||||
episode?: undefined;
|
||||
season?: undefined;
|
||||
}
|
||||
| {
|
||||
type: MWMediaType.SERIES;
|
||||
episode: string;
|
||||
season: string;
|
||||
};
|
||||
export type MWProviderContext = MWProviderTypeSpecific & MWProviderBase;
|
||||
|
||||
export type MWProvider = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
rank: number;
|
||||
disabled?: boolean;
|
||||
type: MWMediaType[];
|
||||
|
||||
scrape(ctx: MWProviderContext): Promise<MWProviderScrapeResult>;
|
||||
};
|
@ -1,72 +0,0 @@
|
||||
import { MWEmbedScraper, MWEmbedType } from "./embed";
|
||||
import { MWProvider } from "./provider";
|
||||
|
||||
let providers: MWProvider[] = [];
|
||||
let embeds: MWEmbedScraper[] = [];
|
||||
|
||||
export function registerProvider(provider: MWProvider) {
|
||||
if (provider.disabled) return;
|
||||
providers.push(provider);
|
||||
}
|
||||
export function registerEmbedScraper(embed: MWEmbedScraper) {
|
||||
if (embed.disabled) return;
|
||||
embeds.push(embed);
|
||||
}
|
||||
|
||||
export function initializeScraperStore() {
|
||||
// sort by ranking
|
||||
providers = providers.sort((a, b) => b.rank - a.rank);
|
||||
embeds = embeds.sort((a, b) => b.rank - a.rank);
|
||||
|
||||
// check for invalid ranks
|
||||
let lastRank: null | number = null;
|
||||
providers.forEach((v) => {
|
||||
if (lastRank === null) {
|
||||
lastRank = v.rank;
|
||||
return;
|
||||
}
|
||||
if (lastRank === v.rank)
|
||||
throw new Error(`Duplicate rank number for provider ${v.id}`);
|
||||
lastRank = v.rank;
|
||||
});
|
||||
lastRank = null;
|
||||
providers.forEach((v) => {
|
||||
if (lastRank === null) {
|
||||
lastRank = v.rank;
|
||||
return;
|
||||
}
|
||||
if (lastRank === v.rank)
|
||||
throw new Error(`Duplicate rank number for embed scraper ${v.id}`);
|
||||
lastRank = v.rank;
|
||||
});
|
||||
|
||||
// check for duplicate ids
|
||||
const providerIds = providers.map((v) => v.id);
|
||||
if (
|
||||
providerIds.length > 0 &&
|
||||
new Set(providerIds).size !== providerIds.length
|
||||
)
|
||||
throw new Error("Duplicate IDS in providers");
|
||||
const embedIds = embeds.map((v) => v.id);
|
||||
if (embedIds.length > 0 && new Set(embedIds).size !== embedIds.length)
|
||||
throw new Error("Duplicate IDS in embed scrapers");
|
||||
|
||||
// check for duplicate embed types
|
||||
const embedTypes = embeds.map((v) => v.for);
|
||||
if (embedTypes.length > 0 && new Set(embedTypes).size !== embedTypes.length)
|
||||
throw new Error("Duplicate types in embed scrapers");
|
||||
}
|
||||
|
||||
export function getProviders(): MWProvider[] {
|
||||
return providers;
|
||||
}
|
||||
|
||||
export function getEmbeds(): MWEmbedScraper[] {
|
||||
return embeds;
|
||||
}
|
||||
|
||||
export function getEmbedScraperByType(
|
||||
type: MWEmbedType
|
||||
): MWEmbedScraper | null {
|
||||
return getEmbeds().find((v) => v.for === type) ?? null;
|
||||
}
|
140
src/backend/helpers/report.ts
Normal file
140
src/backend/helpers/report.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import { ScrapeMedia } from "@movie-web/providers";
|
||||
import { ofetch } from "ofetch";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { ScrapingItems, ScrapingSegment } from "@/hooks/useProviderScrape";
|
||||
import { PlayerMeta } from "@/stores/player/slices/source";
|
||||
|
||||
// for anybody who cares - these are anonymous metrics.
|
||||
// They are just used for figuring out if providers are broken or not
|
||||
const metricsEndpoint = "https://backend.movie-web.app/metrics/providers";
|
||||
|
||||
export type ProviderMetric = {
|
||||
tmdbId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
seasonId?: string;
|
||||
episodeId?: string;
|
||||
status: "failed" | "notfound" | "success";
|
||||
providerId: string;
|
||||
embedId?: string;
|
||||
errorMessage?: string;
|
||||
fullError?: string;
|
||||
};
|
||||
|
||||
function getStackTrace(error: Error, lines: number) {
|
||||
const topMessage = error.toString();
|
||||
const stackTraceLines = (error.stack ?? "").split("\n", lines + 1);
|
||||
stackTraceLines.pop();
|
||||
return `${topMessage}\n\n${stackTraceLines.join("\n")}`;
|
||||
}
|
||||
|
||||
export async function reportProviders(items: ProviderMetric[]): Promise<void> {
|
||||
return ofetch(metricsEndpoint, {
|
||||
method: "POST",
|
||||
body: {
|
||||
items,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const segmentStatusMap: Record<
|
||||
ScrapingSegment["status"],
|
||||
ProviderMetric["status"] | null
|
||||
> = {
|
||||
success: "success",
|
||||
notfound: "notfound",
|
||||
failure: "failed",
|
||||
pending: null,
|
||||
waiting: null,
|
||||
};
|
||||
|
||||
export function scrapeSourceOutputToProviderMetric(
|
||||
media: PlayerMeta,
|
||||
providerId: string,
|
||||
embedId: string | null,
|
||||
status: ProviderMetric["status"],
|
||||
err: unknown | null
|
||||
): ProviderMetric {
|
||||
const episodeId = media.episode?.tmdbId;
|
||||
const seasonId = media.season?.tmdbId;
|
||||
let error: undefined | Error;
|
||||
if (err instanceof Error) error = err;
|
||||
|
||||
return {
|
||||
status,
|
||||
providerId,
|
||||
title: media.title,
|
||||
tmdbId: media.tmdbId,
|
||||
type: media.type,
|
||||
embedId: embedId ?? undefined,
|
||||
episodeId,
|
||||
seasonId,
|
||||
errorMessage: error?.message,
|
||||
fullError: error ? getStackTrace(error, 5) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function scrapeSegmentToProviderMetric(
|
||||
media: ScrapeMedia,
|
||||
providerId: string,
|
||||
segment: ScrapingSegment
|
||||
): ProviderMetric | null {
|
||||
const status = segmentStatusMap[segment.status];
|
||||
if (!status) return null;
|
||||
let episodeId: string | undefined;
|
||||
let seasonId: string | undefined;
|
||||
if (media.type === "show") {
|
||||
episodeId = media.episode.tmdbId;
|
||||
seasonId = media.season.tmdbId;
|
||||
}
|
||||
let error: undefined | Error;
|
||||
if (segment.error instanceof Error) error = segment.error;
|
||||
|
||||
return {
|
||||
status,
|
||||
providerId,
|
||||
title: media.title,
|
||||
tmdbId: media.tmdbId,
|
||||
type: media.type,
|
||||
embedId: segment.embedId,
|
||||
episodeId,
|
||||
seasonId,
|
||||
errorMessage: segment.reason ?? error?.message,
|
||||
fullError: error ? getStackTrace(error, 5) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function scrapePartsToProviderMetric(
|
||||
media: ScrapeMedia,
|
||||
order: ScrapingItems[],
|
||||
sources: Record<string, ScrapingSegment>
|
||||
): ProviderMetric[] {
|
||||
const output: ProviderMetric[] = [];
|
||||
|
||||
order.forEach((orderItem) => {
|
||||
const source = sources[orderItem.id];
|
||||
orderItem.children.forEach((embedId) => {
|
||||
const embed = sources[embedId];
|
||||
if (!embed.embedId) return;
|
||||
const metric = scrapeSegmentToProviderMetric(media, source.id, embed);
|
||||
if (!metric) return;
|
||||
output.push(metric);
|
||||
});
|
||||
|
||||
const metric = scrapeSegmentToProviderMetric(media, source.id, source);
|
||||
if (!metric) return;
|
||||
output.push(metric);
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
export function useReportProviders() {
|
||||
const report = useCallback((items: ProviderMetric[]) => {
|
||||
if (items.length === 0) return;
|
||||
reportProviders(items);
|
||||
}, []);
|
||||
|
||||
return { report };
|
||||
}
|
@ -1,52 +0,0 @@
|
||||
import { MWEmbed, MWEmbedContext, MWEmbedScraper } from "./embed";
|
||||
import {
|
||||
MWProvider,
|
||||
MWProviderContext,
|
||||
MWProviderScrapeResult,
|
||||
} from "./provider";
|
||||
import { getEmbedScraperByType } from "./register";
|
||||
import { MWStream } from "./streams";
|
||||
|
||||
function sortProviderResult(
|
||||
ctx: MWProviderScrapeResult
|
||||
): MWProviderScrapeResult {
|
||||
ctx.embeds = ctx.embeds
|
||||
.map<[MWEmbed, MWEmbedScraper | null]>((v) => [
|
||||
v,
|
||||
v.type ? getEmbedScraperByType(v.type) : null,
|
||||
])
|
||||
.sort(([, a], [, b]) => (b?.rank ?? 0) - (a?.rank ?? 0))
|
||||
.map((v) => v[0]);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export async function runProvider(
|
||||
provider: MWProvider,
|
||||
ctx: MWProviderContext
|
||||
): Promise<MWProviderScrapeResult> {
|
||||
try {
|
||||
const data = await provider.scrape(ctx);
|
||||
return sortProviderResult(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to run provider", err, {
|
||||
id: provider.id,
|
||||
ctx: { ...ctx },
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runEmbedScraper(
|
||||
scraper: MWEmbedScraper,
|
||||
ctx: MWEmbedContext
|
||||
): Promise<MWStream> {
|
||||
try {
|
||||
return await scraper.getStream(ctx);
|
||||
} catch (err) {
|
||||
console.error("Failed to run embed scraper", {
|
||||
id: scraper.id,
|
||||
ctx: { ...ctx },
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
@ -1,173 +0,0 @@
|
||||
import { MWProviderContext, MWProviderScrapeResult } from "./provider";
|
||||
import { getEmbedScraperByType, getProviders } from "./register";
|
||||
import { runEmbedScraper, runProvider } from "./run";
|
||||
import { MWStream } from "./streams";
|
||||
import { DetailedMeta } from "../metadata/getmeta";
|
||||
import { MWMediaType } from "../metadata/types/mw";
|
||||
|
||||
interface MWProgressData {
|
||||
type: "embed" | "provider";
|
||||
id: string;
|
||||
eventId: string;
|
||||
percentage: number;
|
||||
errored: boolean;
|
||||
}
|
||||
interface MWNextData {
|
||||
id: string;
|
||||
eventId: string;
|
||||
type: "embed" | "provider";
|
||||
}
|
||||
|
||||
type MWProviderRunContextBase = {
|
||||
media: DetailedMeta;
|
||||
onProgress?: (data: MWProgressData) => void;
|
||||
onNext?: (data: MWNextData) => void;
|
||||
};
|
||||
type MWProviderRunContextTypeSpecific =
|
||||
| {
|
||||
type: MWMediaType.MOVIE | MWMediaType.ANIME;
|
||||
episode: undefined;
|
||||
season: undefined;
|
||||
}
|
||||
| {
|
||||
type: MWMediaType.SERIES;
|
||||
episode: string;
|
||||
season: string;
|
||||
};
|
||||
|
||||
export type MWProviderRunContext = MWProviderRunContextBase &
|
||||
MWProviderRunContextTypeSpecific;
|
||||
|
||||
async function findBestEmbedStream(
|
||||
result: MWProviderScrapeResult,
|
||||
providerId: string,
|
||||
ctx: MWProviderRunContext
|
||||
): Promise<MWStream | null> {
|
||||
if (result.stream) {
|
||||
return {
|
||||
...result.stream,
|
||||
providerId,
|
||||
embedId: providerId,
|
||||
};
|
||||
}
|
||||
|
||||
let embedNum = 0;
|
||||
for (const embed of result.embeds) {
|
||||
embedNum += 1;
|
||||
if (!embed.type) continue;
|
||||
const scraper = getEmbedScraperByType(embed.type);
|
||||
if (!scraper) throw new Error(`Type for embed not found: ${embed.type}`);
|
||||
|
||||
const eventId = [providerId, scraper.id, embedNum].join("|");
|
||||
|
||||
ctx.onNext?.({ id: scraper.id, type: "embed", eventId });
|
||||
|
||||
let stream: MWStream;
|
||||
try {
|
||||
stream = await runEmbedScraper(scraper, {
|
||||
url: embed.url,
|
||||
progress(num) {
|
||||
ctx.onProgress?.({
|
||||
errored: false,
|
||||
eventId,
|
||||
id: scraper.id,
|
||||
percentage: num,
|
||||
type: "embed",
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
ctx.onProgress?.({
|
||||
errored: true,
|
||||
eventId,
|
||||
id: scraper.id,
|
||||
percentage: 100,
|
||||
type: "embed",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.onProgress?.({
|
||||
errored: false,
|
||||
eventId,
|
||||
id: scraper.id,
|
||||
percentage: 100,
|
||||
type: "embed",
|
||||
});
|
||||
|
||||
stream.providerId = providerId;
|
||||
return stream;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function findBestStream(
|
||||
ctx: MWProviderRunContext
|
||||
): Promise<MWStream | null> {
|
||||
const providers = getProviders();
|
||||
|
||||
for (const provider of providers) {
|
||||
const eventId = provider.id;
|
||||
ctx.onNext?.({ id: provider.id, type: "provider", eventId });
|
||||
let result: MWProviderScrapeResult;
|
||||
try {
|
||||
let context: MWProviderContext;
|
||||
if (ctx.type === MWMediaType.SERIES) {
|
||||
context = {
|
||||
media: ctx.media,
|
||||
type: ctx.type,
|
||||
episode: ctx.episode,
|
||||
season: ctx.season,
|
||||
progress(num) {
|
||||
ctx.onProgress?.({
|
||||
percentage: num,
|
||||
eventId,
|
||||
errored: false,
|
||||
id: provider.id,
|
||||
type: "provider",
|
||||
});
|
||||
},
|
||||
};
|
||||
} else {
|
||||
context = {
|
||||
media: ctx.media,
|
||||
type: ctx.type,
|
||||
progress(num) {
|
||||
ctx.onProgress?.({
|
||||
percentage: num,
|
||||
eventId,
|
||||
errored: false,
|
||||
id: provider.id,
|
||||
type: "provider",
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
result = await runProvider(provider, context);
|
||||
} catch (err) {
|
||||
ctx.onProgress?.({
|
||||
percentage: 100,
|
||||
errored: true,
|
||||
eventId,
|
||||
id: provider.id,
|
||||
type: "provider",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.onProgress?.({
|
||||
errored: false,
|
||||
id: provider.id,
|
||||
eventId,
|
||||
percentage: 100,
|
||||
type: "provider",
|
||||
});
|
||||
|
||||
const stream = await findBestEmbedStream(result, provider.id, ctx);
|
||||
if (!stream) continue;
|
||||
return stream;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
@ -1,46 +0,0 @@
|
||||
export enum MWStreamType {
|
||||
MP4 = "mp4",
|
||||
HLS = "hls",
|
||||
}
|
||||
|
||||
// subsrt-ts supported types
|
||||
export enum MWCaptionType {
|
||||
VTT = "vtt",
|
||||
SRT = "srt",
|
||||
LRC = "lrc",
|
||||
SBV = "sbv",
|
||||
SUB = "sub",
|
||||
SSA = "ssa",
|
||||
ASS = "ass",
|
||||
JSON = "json",
|
||||
UNKNOWN = "unknown",
|
||||
}
|
||||
|
||||
export enum MWStreamQuality {
|
||||
Q360P = "360p",
|
||||
Q540P = "540p",
|
||||
Q480P = "480p",
|
||||
Q720P = "720p",
|
||||
Q1080P = "1080p",
|
||||
QUNKNOWN = "unknown",
|
||||
}
|
||||
|
||||
export type MWCaption = {
|
||||
needsProxy?: boolean;
|
||||
url: string;
|
||||
type: MWCaptionType;
|
||||
langIso: string;
|
||||
};
|
||||
|
||||
export type MWStream = {
|
||||
streamUrl: string;
|
||||
type: MWStreamType;
|
||||
quality: MWStreamQuality;
|
||||
providerId?: string;
|
||||
embedId?: string;
|
||||
captions: MWCaption[];
|
||||
};
|
||||
|
||||
export type MWEmbedStream = MWStream & {
|
||||
embedId: string;
|
||||
};
|
33
src/backend/helpers/subs.ts
Normal file
33
src/backend/helpers/subs.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { list } from "subsrt-ts";
|
||||
|
||||
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||
import { convertSubtitlesToSrt } from "@/components/player/utils/captions";
|
||||
import { CaptionListItem } from "@/stores/player/slices/source";
|
||||
import { SimpleCache } from "@/utils/cache";
|
||||
|
||||
export const subtitleTypeList = list().map((type) => `.${type}`);
|
||||
const downloadCache = new SimpleCache<string, string>();
|
||||
downloadCache.setCompare((a, b) => a === b);
|
||||
const expirySeconds = 24 * 60 * 60;
|
||||
|
||||
/**
|
||||
* Always returns SRT
|
||||
*/
|
||||
export async function downloadCaption(
|
||||
caption: CaptionListItem
|
||||
): Promise<string> {
|
||||
const cached = downloadCache.get(caption.url);
|
||||
if (cached) return cached;
|
||||
|
||||
let data: string | undefined;
|
||||
if (caption.needsProxy) {
|
||||
data = await proxiedFetch<string>(caption.url, { responseType: "text" });
|
||||
} else {
|
||||
data = await fetch(caption.url).then((v) => v.text());
|
||||
}
|
||||
if (!data) throw new Error("failed to get caption data");
|
||||
|
||||
const output = convertSubtitlesToSrt(data);
|
||||
downloadCache.set(caption.url, output, expirySeconds);
|
||||
return output;
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
import { initializeScraperStore } from "./helpers/register";
|
||||
|
||||
// providers
|
||||
// import "./providers/gdriveplayer";
|
||||
import "./providers/flixhq";
|
||||
import "./providers/superstream";
|
||||
import "./providers/netfilm";
|
||||
import "./providers/m4ufree";
|
||||
import "./providers/hdwatched";
|
||||
import "./providers/2embed";
|
||||
import "./providers/sflix";
|
||||
import "./providers/gomovies";
|
||||
import "./providers/kissasian";
|
||||
import "./providers/streamflix";
|
||||
import "./providers/remotestream";
|
||||
|
||||
// embeds
|
||||
import "./embeds/streamm4u";
|
||||
import "./embeds/playm4u";
|
||||
import "./embeds/upcloud";
|
||||
import "./embeds/streamsb";
|
||||
import "./embeds/mp4upload";
|
||||
|
||||
initializeScraperStore();
|
@ -2,22 +2,23 @@ import { FetchError } from "ofetch";
|
||||
|
||||
import { formatJWMeta, mediaTypeToJW } from "./justwatch";
|
||||
import {
|
||||
TMDBIdToUrlId,
|
||||
TMDBMediaToMediaType,
|
||||
formatTMDBMeta,
|
||||
getEpisodes,
|
||||
getExternalIds,
|
||||
getMediaDetails,
|
||||
getMediaPoster,
|
||||
getMovieFromExternalId,
|
||||
mediaTypeToTMDB,
|
||||
} from "./tmdb";
|
||||
import {
|
||||
JWMediaResult,
|
||||
JWDetailedMeta,
|
||||
JWSeasonMetaResult,
|
||||
JW_API_BASE,
|
||||
} from "./types/justwatch";
|
||||
import { MWMediaMeta, MWMediaType } from "./types/mw";
|
||||
import {
|
||||
TMDBContentTypes,
|
||||
TMDBMediaResult,
|
||||
TMDBMovieData,
|
||||
TMDBSeasonMetaResult,
|
||||
@ -25,23 +26,6 @@ import {
|
||||
} from "./types/tmdb";
|
||||
import { makeUrl, proxiedFetch } from "../helpers/fetch";
|
||||
|
||||
type JWExternalIdType =
|
||||
| "eidr"
|
||||
| "imdb_latest"
|
||||
| "imdb"
|
||||
| "tmdb_latest"
|
||||
| "tmdb"
|
||||
| "tms";
|
||||
|
||||
interface JWExternalId {
|
||||
provider: JWExternalIdType;
|
||||
external_id: string;
|
||||
}
|
||||
|
||||
interface JWDetailedMeta extends JWMediaResult {
|
||||
external_ids: JWExternalId[];
|
||||
}
|
||||
|
||||
export interface DetailedMeta {
|
||||
meta: MWMediaMeta;
|
||||
imdbId?: string;
|
||||
@ -90,8 +74,7 @@ export async function getMetaFromId(
|
||||
|
||||
if (!details) return null;
|
||||
|
||||
const externalIds = await getExternalIds(id, mediaTypeToTMDB(type));
|
||||
const imdbId = externalIds.imdb_id ?? undefined;
|
||||
const imdbId = details.external_ids.imdb_id ?? undefined;
|
||||
|
||||
let seasonData: TMDBSeasonMetaResult | undefined;
|
||||
|
||||
@ -180,29 +163,14 @@ export async function getLegacyMetaFromId(
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
if (url.startsWith("/media/JW") || url.startsWith("/media/tmdb-show"))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isLegacyMediaType(url: string): boolean {
|
||||
if (url.startsWith("/media/tmdb-show")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -213,8 +181,21 @@ export async function convertLegacyUrl(
|
||||
|
||||
const urlParts = url.split("/").slice(2);
|
||||
const [, type, id] = urlParts[0].split("-", 3);
|
||||
const suffix = urlParts
|
||||
.slice(1)
|
||||
.map((v) => `/${v}`)
|
||||
.join("");
|
||||
|
||||
const mediaType = TMDBMediaToMediaType(type);
|
||||
if (isLegacyMediaType(url)) {
|
||||
const details = await getMediaDetails(id, TMDBContentTypes.TV);
|
||||
return `/media/${TMDBIdToUrlId(
|
||||
MWMediaType.SERIES,
|
||||
details.id.toString(),
|
||||
details.name
|
||||
)}${suffix}`;
|
||||
}
|
||||
|
||||
const mediaType = TMDBMediaToMediaType(type as TMDBContentTypes);
|
||||
const meta = await getLegacyMetaFromId(mediaType, id);
|
||||
|
||||
if (!meta) return undefined;
|
||||
@ -224,10 +205,12 @@ export async function convertLegacyUrl(
|
||||
// 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 (movieId) {
|
||||
return `/media/${TMDBIdToUrlId(mediaType, movieId, meta.meta.title)}`;
|
||||
}
|
||||
|
||||
if (tmdbId) {
|
||||
return `/media/tmdb-${type}-${tmdbId}`;
|
||||
if (tmdbId) {
|
||||
return `/media/${TMDBIdToUrlId(mediaType, tmdbId, meta.meta.title)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,27 +1,27 @@
|
||||
import { SimpleCache } from "@/utils/cache";
|
||||
import { MediaItem } from "@/utils/mediaTypes";
|
||||
|
||||
import {
|
||||
formatTMDBMeta,
|
||||
formatTMDBMetaToMediaItem,
|
||||
formatTMDBSearchResult,
|
||||
mediaTypeToTMDB,
|
||||
searchMedia,
|
||||
multiSearch,
|
||||
} from "./tmdb";
|
||||
import { MWMediaMeta, MWQuery } from "./types/mw";
|
||||
import { MWQuery } from "./types/mw";
|
||||
|
||||
const cache = new SimpleCache<MWQuery, MWMediaMeta[]>();
|
||||
const cache = new SimpleCache<MWQuery, MediaItem[]>();
|
||||
cache.setCompare((a, b) => {
|
||||
return a.type === b.type && a.searchQuery.trim() === b.searchQuery.trim();
|
||||
return a.searchQuery.trim() === b.searchQuery.trim();
|
||||
});
|
||||
cache.initialize();
|
||||
|
||||
export async function searchForMedia(query: MWQuery): Promise<MWMediaMeta[]> {
|
||||
if (cache.has(query)) return cache.get(query) as MWMediaMeta[];
|
||||
const { searchQuery, type } = query;
|
||||
export async function searchForMedia(query: MWQuery): Promise<MediaItem[]> {
|
||||
if (cache.has(query)) return cache.get(query) as MediaItem[];
|
||||
const { searchQuery } = query;
|
||||
|
||||
const data = await searchMedia(searchQuery, mediaTypeToTMDB(type));
|
||||
const results = data.results.map((v) => {
|
||||
const formattedResult = formatTMDBSearchResult(v, mediaTypeToTMDB(type));
|
||||
return formatTMDBMeta(formattedResult);
|
||||
const data = await multiSearch(searchQuery);
|
||||
const results = data.map((v) => {
|
||||
const formattedResult = formatTMDBSearchResult(v, v.media_type);
|
||||
return formatTMDBMetaToMediaItem(formattedResult);
|
||||
});
|
||||
|
||||
cache.set(query, results, 3600); // cache results for 1 hour
|
||||
|
@ -1,37 +1,50 @@
|
||||
import slugify from "slugify";
|
||||
|
||||
import { conf } from "@/setup/config";
|
||||
import { MediaItem } from "@/utils/mediaTypes";
|
||||
|
||||
import { MWMediaMeta, MWMediaType, MWSeasonMeta } from "./types/mw";
|
||||
import {
|
||||
ExternalIdMovieSearchResult,
|
||||
TMDBContentTypes,
|
||||
TMDBEpisodeShort,
|
||||
TMDBExternalIds,
|
||||
TMDBMediaResult,
|
||||
TMDBMovieData,
|
||||
TMDBMovieExternalIds,
|
||||
TMDBMovieResponse,
|
||||
TMDBMovieResult,
|
||||
TMDBMovieSearchResult,
|
||||
TMDBSearchResult,
|
||||
TMDBSeason,
|
||||
TMDBSeasonMetaResult,
|
||||
TMDBShowData,
|
||||
TMDBShowExternalIds,
|
||||
TMDBShowResponse,
|
||||
TMDBShowResult,
|
||||
TMDBShowSearchResult,
|
||||
} 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";
|
||||
if (type === MWMediaType.MOVIE) return TMDBContentTypes.MOVIE;
|
||||
if (type === MWMediaType.SERIES) return TMDBContentTypes.TV;
|
||||
throw new Error("unsupported type");
|
||||
}
|
||||
|
||||
export function TMDBMediaToMediaType(type: string): MWMediaType {
|
||||
export function mediaItemTypeToMediaType(type: MediaItem["type"]): MWMediaType {
|
||||
if (type === "movie") return MWMediaType.MOVIE;
|
||||
if (type === "show") return MWMediaType.SERIES;
|
||||
throw new Error("unsupported type");
|
||||
}
|
||||
|
||||
export function TMDBMediaToMediaType(type: TMDBContentTypes): MWMediaType {
|
||||
if (type === TMDBContentTypes.MOVIE) return MWMediaType.MOVIE;
|
||||
if (type === TMDBContentTypes.TV) return MWMediaType.SERIES;
|
||||
throw new Error("unsupported type");
|
||||
}
|
||||
|
||||
export function TMDBMediaToMediaItemType(
|
||||
type: TMDBContentTypes
|
||||
): MediaItem["type"] {
|
||||
if (type === TMDBContentTypes.MOVIE) return "movie";
|
||||
if (type === TMDBContentTypes.TV) return "show";
|
||||
throw new Error("unsupported type");
|
||||
}
|
||||
|
||||
export function formatTMDBMeta(
|
||||
media: TMDBMediaResult,
|
||||
season?: TMDBSeasonMetaResult
|
||||
@ -74,8 +87,41 @@ export function formatTMDBMeta(
|
||||
};
|
||||
}
|
||||
|
||||
export function formatTMDBMetaToMediaItem(media: TMDBMediaResult): MediaItem {
|
||||
const type = TMDBMediaToMediaItemType(media.object_type);
|
||||
|
||||
return {
|
||||
title: media.title,
|
||||
id: media.id.toString(),
|
||||
year: media.original_release_year ?? 0,
|
||||
poster: media.poster,
|
||||
type,
|
||||
};
|
||||
}
|
||||
|
||||
export function TMDBIdToUrlId(
|
||||
type: MWMediaType,
|
||||
tmdbId: string,
|
||||
title: string
|
||||
) {
|
||||
return [
|
||||
"tmdb",
|
||||
mediaTypeToTMDB(type),
|
||||
tmdbId,
|
||||
slugify(title, { lower: true, strict: true }),
|
||||
].join("-");
|
||||
}
|
||||
|
||||
export function TMDBMediaToId(media: MWMediaMeta): string {
|
||||
return ["tmdb", mediaTypeToTMDB(media.type), media.id].join("-");
|
||||
return TMDBIdToUrlId(media.type, media.id, media.title);
|
||||
}
|
||||
|
||||
export function mediaItemToId(media: MediaItem): string {
|
||||
return TMDBIdToUrlId(
|
||||
mediaItemTypeToMediaType(media.type),
|
||||
media.id,
|
||||
media.title
|
||||
);
|
||||
}
|
||||
|
||||
export function decodeTMDBId(
|
||||
@ -85,7 +131,7 @@ export function decodeTMDBId(
|
||||
if (prefix !== "tmdb") return null;
|
||||
let mediaType;
|
||||
try {
|
||||
mediaType = TMDBMediaToMediaType(type);
|
||||
mediaType = TMDBMediaToMediaType(type as TMDBContentTypes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@ -113,52 +159,57 @@ async function get<T>(url: string, params?: object): Promise<T> {
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function searchMedia(
|
||||
query: string,
|
||||
type: TMDBContentTypes
|
||||
): Promise<TMDBMovieResponse | TMDBShowResponse> {
|
||||
let data;
|
||||
export async function multiSearch(
|
||||
query: string
|
||||
): Promise<(TMDBMovieSearchResult | TMDBShowSearchResult)[]> {
|
||||
const data = await get<TMDBSearchResult>("search/multi", {
|
||||
query,
|
||||
include_adult: false,
|
||||
language: "en-US",
|
||||
page: 1,
|
||||
});
|
||||
// filter out results that aren't movies or shows
|
||||
const results = data.results.filter(
|
||||
(r) =>
|
||||
r.media_type === TMDBContentTypes.MOVIE ||
|
||||
r.media_type === TMDBContentTypes.TV
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
export async function generateQuickSearchMediaUrl(
|
||||
query: string
|
||||
): Promise<string | undefined> {
|
||||
const data = await multiSearch(query);
|
||||
if (data.length === 0) return undefined;
|
||||
const result = data[0];
|
||||
const title =
|
||||
result.media_type === TMDBContentTypes.MOVIE ? result.title : result.name;
|
||||
|
||||
return data;
|
||||
return `/media/${TMDBIdToUrlId(
|
||||
TMDBMediaToMediaType(result.media_type),
|
||||
result.id.toString(),
|
||||
title
|
||||
)}`;
|
||||
}
|
||||
|
||||
// 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;
|
||||
type MediaDetailReturn<T extends TMDBContentTypes> =
|
||||
T extends TMDBContentTypes.MOVIE
|
||||
? TMDBMovieData
|
||||
: T extends TMDBContentTypes.TV
|
||||
? 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 === TMDBContentTypes.MOVIE) {
|
||||
return get<TReturn>(`/movie/${id}`, { append_to_response: "external_ids" });
|
||||
}
|
||||
if (type === "show") {
|
||||
return get<TReturn>(`/tv/${id}`);
|
||||
if (type === TMDBContentTypes.TV) {
|
||||
return get<TReturn>(`/tv/${id}`, { append_to_response: "external_ids" });
|
||||
}
|
||||
throw new Error("Invalid media type");
|
||||
}
|
||||
@ -179,26 +230,6 @@ export async function getEpisodes(
|
||||
}));
|
||||
}
|
||||
|
||||
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> {
|
||||
@ -213,12 +244,12 @@ export async function getMovieFromExternalId(
|
||||
}
|
||||
|
||||
export function formatTMDBSearchResult(
|
||||
result: TMDBShowResult | TMDBMovieResult,
|
||||
result: TMDBMovieSearchResult | TMDBShowSearchResult,
|
||||
mediatype: TMDBContentTypes
|
||||
): TMDBMediaResult {
|
||||
const type = TMDBMediaToMediaType(mediatype);
|
||||
if (type === MWMediaType.SERIES) {
|
||||
const show = result as TMDBShowResult;
|
||||
const show = result as TMDBShowSearchResult;
|
||||
return {
|
||||
title: show.name,
|
||||
poster: getMediaPoster(show.poster_path),
|
||||
@ -227,7 +258,8 @@ export function formatTMDBSearchResult(
|
||||
object_type: mediatype,
|
||||
};
|
||||
}
|
||||
const movie = result as TMDBMovieResult;
|
||||
|
||||
const movie = result as TMDBMovieSearchResult;
|
||||
|
||||
return {
|
||||
title: movie.title,
|
||||
|
@ -46,3 +46,20 @@ export type JWSeasonMetaResult = {
|
||||
season_number: number;
|
||||
episodes: JWEpisodeShort[];
|
||||
};
|
||||
|
||||
export type JWExternalIdType =
|
||||
| "eidr"
|
||||
| "imdb_latest"
|
||||
| "imdb"
|
||||
| "tmdb_latest"
|
||||
| "tmdb"
|
||||
| "tms";
|
||||
|
||||
export interface JWExternalId {
|
||||
provider: JWExternalIdType;
|
||||
external_id: string;
|
||||
}
|
||||
|
||||
export interface JWDetailedMeta extends JWMediaResult {
|
||||
external_ids: JWExternalId[];
|
||||
}
|
||||
|
@ -43,7 +43,6 @@ export type MWMediaMeta = MWMediaMetaBase & MWMediaMetaSpecific;
|
||||
|
||||
export interface MWQuery {
|
||||
searchQuery: string;
|
||||
type: MWMediaType;
|
||||
}
|
||||
|
||||
export interface DetailedMeta {
|
||||
|
@ -1,4 +1,7 @@
|
||||
export type TMDBContentTypes = "movie" | "show";
|
||||
export enum TMDBContentTypes {
|
||||
MOVIE = "movie",
|
||||
TV = "tv",
|
||||
}
|
||||
|
||||
export type TMDBSeasonShort = {
|
||||
title: string;
|
||||
@ -121,6 +124,9 @@ export interface TMDBShowData {
|
||||
type: string;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
external_ids: {
|
||||
imdb_id: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TMDBMovieData {
|
||||
@ -169,6 +175,9 @@ export interface TMDBMovieData {
|
||||
video: boolean;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
external_ids: {
|
||||
imdb_id: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TMDBEpisodeResult {
|
||||
@ -183,54 +192,6 @@ export interface TMDBEpisodeResult {
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
@ -259,30 +220,6 @@ export interface TMDBSeason {
|
||||
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;
|
||||
@ -306,3 +243,46 @@ export interface ExternalIdMovieSearchResult {
|
||||
tv_episode_results: any[];
|
||||
tv_season_results: any[];
|
||||
}
|
||||
|
||||
export interface TMDBMovieSearchResult {
|
||||
adult: boolean;
|
||||
backdrop_path: string;
|
||||
id: number;
|
||||
title: string;
|
||||
original_language: string;
|
||||
original_title: string;
|
||||
overview: string;
|
||||
poster_path: string;
|
||||
media_type: TMDBContentTypes.MOVIE;
|
||||
genre_ids: number[];
|
||||
popularity: number;
|
||||
release_date: string;
|
||||
video: boolean;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
}
|
||||
|
||||
export interface TMDBShowSearchResult {
|
||||
adult: boolean;
|
||||
backdrop_path: string;
|
||||
id: number;
|
||||
name: string;
|
||||
original_language: string;
|
||||
original_name: string;
|
||||
overview: string;
|
||||
poster_path: string;
|
||||
media_type: TMDBContentTypes.TV;
|
||||
genre_ids: number[];
|
||||
popularity: number;
|
||||
first_air_date: string;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
origin_country: string[];
|
||||
}
|
||||
|
||||
export interface TMDBSearchResult {
|
||||
page: number;
|
||||
results: (TMDBMovieSearchResult | TMDBShowSearchResult)[];
|
||||
total_pages: number;
|
||||
total_results: number;
|
||||
}
|
||||
|
@ -1,252 +0,0 @@
|
||||
import Base64 from "crypto-js/enc-base64";
|
||||
import Utf8 from "crypto-js/enc-utf8";
|
||||
|
||||
import { proxiedFetch, rawProxiedFetch } from "../helpers/fetch";
|
||||
import { registerProvider } from "../helpers/register";
|
||||
import {
|
||||
MWCaptionType,
|
||||
MWStreamQuality,
|
||||
MWStreamType,
|
||||
} from "../helpers/streams";
|
||||
import { MWMediaType } from "../metadata/types/mw";
|
||||
|
||||
const twoEmbedBase = "https://www.2embed.to";
|
||||
|
||||
async function fetchCaptchaToken(recaptchaKey: string) {
|
||||
const domainHash = Base64.stringify(Utf8.parse(twoEmbedBase)).replace(
|
||||
/=/g,
|
||||
"."
|
||||
);
|
||||
|
||||
const recaptchaRender = await proxiedFetch<any>(
|
||||
`https://www.google.com/recaptcha/api.js?render=${recaptchaKey}`
|
||||
);
|
||||
|
||||
const vToken = recaptchaRender.substring(
|
||||
recaptchaRender.indexOf("/releases/") + 10,
|
||||
recaptchaRender.indexOf("/recaptcha__en.js")
|
||||
);
|
||||
|
||||
const recaptchaAnchor = await proxiedFetch<any>(
|
||||
`https://www.google.com/recaptcha/api2/anchor?ar=1&hl=en&size=invisible&cb=flicklax&k=${recaptchaKey}&co=${domainHash}&v=${vToken}`
|
||||
);
|
||||
|
||||
const cToken = new DOMParser()
|
||||
.parseFromString(recaptchaAnchor, "text/html")
|
||||
.getElementById("recaptcha-token")
|
||||
?.getAttribute("value");
|
||||
|
||||
if (!cToken) throw new Error("Unable to find cToken");
|
||||
|
||||
const payload = {
|
||||
v: vToken,
|
||||
reason: "q",
|
||||
k: recaptchaKey,
|
||||
c: cToken,
|
||||
sa: "",
|
||||
co: twoEmbedBase,
|
||||
};
|
||||
|
||||
const tokenData = await proxiedFetch<string>(
|
||||
`https://www.google.com/recaptcha/api2/reload?${new URLSearchParams(
|
||||
payload
|
||||
).toString()}`,
|
||||
{
|
||||
headers: { referer: "https://www.google.com/recaptcha/api2/" },
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
|
||||
const token = tokenData.match('rresp","(.+?)"');
|
||||
return token ? token[1] : null;
|
||||
}
|
||||
|
||||
interface IEmbedRes {
|
||||
link: string;
|
||||
sources: [];
|
||||
tracks: [];
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface IStreamData {
|
||||
status: string;
|
||||
message: string;
|
||||
type: string;
|
||||
token: string;
|
||||
result:
|
||||
| {
|
||||
Original: {
|
||||
label: string;
|
||||
file: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
label: string;
|
||||
size: number;
|
||||
url: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface ISubtitles {
|
||||
url: string;
|
||||
lang: string;
|
||||
}
|
||||
|
||||
async function fetchStream(sourceId: string, captchaToken: string) {
|
||||
const embedRes = await proxiedFetch<IEmbedRes>(
|
||||
`${twoEmbedBase}/ajax/embed/play?id=${sourceId}&_token=${captchaToken}`,
|
||||
{
|
||||
headers: {
|
||||
Referer: twoEmbedBase,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Link format: https://rabbitstream.net/embed-4/{data-id}?z=
|
||||
const rabbitStreamUrl = new URL(embedRes.link);
|
||||
|
||||
const dataPath = rabbitStreamUrl.pathname.split("/");
|
||||
const dataId = dataPath[dataPath.length - 1];
|
||||
|
||||
// https://rabbitstream.net/embed/m-download/{data-id}
|
||||
const download = await proxiedFetch<any>(
|
||||
`${rabbitStreamUrl.origin}/embed/m-download/${dataId}`,
|
||||
{
|
||||
headers: {
|
||||
referer: twoEmbedBase,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const downloadPage = new DOMParser().parseFromString(download, "text/html");
|
||||
|
||||
const streamlareEl = Array.from(
|
||||
downloadPage.querySelectorAll(".dls-brand")
|
||||
).find((el) => el.textContent?.trim() === "Streamlare");
|
||||
if (!streamlareEl) throw new Error("Unable to find streamlare element");
|
||||
|
||||
const streamlareUrl =
|
||||
streamlareEl.nextElementSibling?.querySelector("a")?.href;
|
||||
if (!streamlareUrl) throw new Error("Unable to parse streamlare url");
|
||||
|
||||
const subtitles: ISubtitles[] = [];
|
||||
const subtitlesDropdown = downloadPage.querySelectorAll(
|
||||
"#user_menu .dropdown-item"
|
||||
);
|
||||
subtitlesDropdown.forEach((item) => {
|
||||
const url = item.getAttribute("href");
|
||||
const lang = item.textContent?.trim().replace("Download", "").trim();
|
||||
if (url && lang) subtitles.push({ url, lang });
|
||||
});
|
||||
|
||||
const streamlare = await proxiedFetch<any>(streamlareUrl);
|
||||
|
||||
const streamlarePage = new DOMParser().parseFromString(
|
||||
streamlare,
|
||||
"text/html"
|
||||
);
|
||||
|
||||
const csrfToken = streamlarePage
|
||||
.querySelector("head > meta:nth-child(3)")
|
||||
?.getAttribute("content");
|
||||
|
||||
if (!csrfToken) throw new Error("Unable to find CSRF token");
|
||||
|
||||
const videoId = streamlareUrl.match("/[ve]/([^?#&/]+)")?.[1];
|
||||
if (!videoId) throw new Error("Unable to get streamlare video id");
|
||||
|
||||
const streamRes = await proxiedFetch<IStreamData>(
|
||||
`${new URL(streamlareUrl).origin}/api/video/download/get`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
id: videoId,
|
||||
}),
|
||||
headers: {
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"X-CSRF-Token": csrfToken,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (streamRes.message !== "OK") throw new Error("Unable to fetch stream");
|
||||
|
||||
const streamData = Array.isArray(streamRes.result)
|
||||
? streamRes.result[0]
|
||||
: streamRes.result.Original;
|
||||
if (!streamData) throw new Error("Unable to get stream data");
|
||||
|
||||
const followStream = await rawProxiedFetch(streamData.url, {
|
||||
method: "HEAD",
|
||||
referrer: new URL(streamlareUrl).origin,
|
||||
});
|
||||
|
||||
const finalStreamUrl = followStream.headers.get("X-Final-Destination");
|
||||
return { url: finalStreamUrl, subtitles };
|
||||
}
|
||||
|
||||
registerProvider({
|
||||
id: "2embed",
|
||||
displayName: "2Embed",
|
||||
rank: 125,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
disabled: true, // Disabled, not working
|
||||
async scrape({ media, episode, progress }) {
|
||||
let embedUrl = `${twoEmbedBase}/embed/tmdb/movie?id=${media.tmdbId}`;
|
||||
|
||||
if (media.meta.type === MWMediaType.SERIES) {
|
||||
const seasonNumber = media.meta.seasonData.number;
|
||||
const episodeNumber = media.meta.seasonData.episodes.find(
|
||||
(e) => e.id === episode
|
||||
)?.number;
|
||||
|
||||
embedUrl = `${twoEmbedBase}/embed/tmdb/tv?id=${media.tmdbId}&s=${seasonNumber}&e=${episodeNumber}`;
|
||||
}
|
||||
|
||||
const embed = await proxiedFetch<any>(embedUrl);
|
||||
progress(20);
|
||||
|
||||
const embedPage = new DOMParser().parseFromString(embed, "text/html");
|
||||
|
||||
const pageServerItems = Array.from(
|
||||
embedPage.querySelectorAll(".item-server")
|
||||
);
|
||||
const pageStreamItem = pageServerItems.find((item) =>
|
||||
item.textContent?.includes("Vidcloud")
|
||||
);
|
||||
|
||||
const sourceId = pageStreamItem
|
||||
? pageStreamItem.getAttribute("data-id")
|
||||
: null;
|
||||
if (!sourceId) throw new Error("Unable to get source id");
|
||||
|
||||
const siteKey = embedPage
|
||||
.querySelector("body")
|
||||
?.getAttribute("data-recaptcha-key");
|
||||
if (!siteKey) throw new Error("Unable to get site key");
|
||||
|
||||
const captchaToken = await fetchCaptchaToken(siteKey);
|
||||
if (!captchaToken) throw new Error("Unable to fetch captcha token");
|
||||
progress(35);
|
||||
|
||||
const stream = await fetchStream(sourceId, captchaToken);
|
||||
if (!stream.url) throw new Error("Unable to find stream url");
|
||||
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: stream.url,
|
||||
quality: MWStreamQuality.QUNKNOWN,
|
||||
type: MWStreamType.MP4,
|
||||
captions: stream.subtitles.map((sub) => {
|
||||
return {
|
||||
langIso: sub.lang,
|
||||
url: `https://cc.2cdns.com${new URL(sub.url).pathname}`,
|
||||
type: MWCaptionType.VTT,
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
@ -1 +0,0 @@
|
||||
export const flixHqBase = "https://flixhq.to";
|
@ -1,36 +0,0 @@
|
||||
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),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
@ -1,41 +0,0 @@
|
||||
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;
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
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;
|
||||
}
|
@ -1,107 +0,0 @@
|
||||
import CryptoJS from "crypto-js";
|
||||
import { unpack } from "unpacker";
|
||||
|
||||
import { registerProvider } from "@/backend/helpers/register";
|
||||
import { MWStreamQuality } from "@/backend/helpers/streams";
|
||||
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
|
||||
const format = {
|
||||
stringify: (cipher: any) => {
|
||||
const ct = cipher.ciphertext.toString(CryptoJS.enc.Base64);
|
||||
const iv = cipher.iv.toString() || "";
|
||||
const salt = cipher.salt.toString() || "";
|
||||
return JSON.stringify({
|
||||
ct,
|
||||
iv,
|
||||
salt,
|
||||
});
|
||||
},
|
||||
parse: (jsonStr: string) => {
|
||||
const json = JSON.parse(jsonStr);
|
||||
const ciphertext = CryptoJS.enc.Base64.parse(json.ct);
|
||||
const iv = CryptoJS.enc.Hex.parse(json.iv) || "";
|
||||
const salt = CryptoJS.enc.Hex.parse(json.s) || "";
|
||||
|
||||
const cipher = CryptoJS.lib.CipherParams.create({
|
||||
ciphertext,
|
||||
iv,
|
||||
salt,
|
||||
});
|
||||
return cipher;
|
||||
},
|
||||
};
|
||||
|
||||
registerProvider({
|
||||
id: "gdriveplayer",
|
||||
displayName: "gdriveplayer",
|
||||
disabled: true,
|
||||
rank: 69,
|
||||
type: [MWMediaType.MOVIE],
|
||||
|
||||
async scrape({ progress, media: { imdbId } }) {
|
||||
if (!imdbId) throw new Error("not enough info");
|
||||
progress(10);
|
||||
const streamRes = await proxiedFetch<string>(
|
||||
"https://database.gdriveplayer.us/player.php",
|
||||
{
|
||||
params: {
|
||||
imdb: imdbId,
|
||||
},
|
||||
}
|
||||
);
|
||||
progress(90);
|
||||
const page = new DOMParser().parseFromString(streamRes, "text/html");
|
||||
|
||||
const script: HTMLElement | undefined = Array.from(
|
||||
page.querySelectorAll("script")
|
||||
).find((e) => e.textContent?.includes("eval"));
|
||||
|
||||
if (!script || !script.textContent) {
|
||||
throw new Error("Could not find stream");
|
||||
}
|
||||
|
||||
/// NOTE: this code requires re-write, it's not safe
|
||||
const data = unpack(script.textContent)
|
||||
.split("var data=\\'")[1]
|
||||
.split("\\'")[0]
|
||||
.replace(/\\/g, "");
|
||||
const decryptedData = unpack(
|
||||
CryptoJS.AES.decrypt(
|
||||
data,
|
||||
"alsfheafsjklNIWORNiolNIOWNKLNXakjsfwnBdwjbwfkjbJjkopfjweopjASoiwnrflakefneiofrt",
|
||||
{ format }
|
||||
).toString(CryptoJS.enc.Utf8)
|
||||
);
|
||||
|
||||
// eslint-disable-next-line
|
||||
const sources = JSON.parse(
|
||||
JSON.stringify(
|
||||
eval(
|
||||
decryptedData
|
||||
.split("sources:")[1]
|
||||
.split(",image")[0]
|
||||
.replace(/\\/g, "")
|
||||
.replace(/document\.referrer/g, '""')
|
||||
)
|
||||
)
|
||||
);
|
||||
const source = sources[sources.length - 1];
|
||||
/// END
|
||||
|
||||
let quality;
|
||||
if (source.label === "720p") quality = MWStreamQuality.Q720P;
|
||||
else quality = MWStreamQuality.QUNKNOWN;
|
||||
|
||||
return {
|
||||
stream: {
|
||||
streamUrl: `https:${source.file}`,
|
||||
type: source.type,
|
||||
quality,
|
||||
captions: [],
|
||||
},
|
||||
embeds: [],
|
||||
};
|
||||
},
|
||||
});
|
@ -1,162 +0,0 @@
|
||||
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,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
@ -1,198 +0,0 @@
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
import { MWProviderContext } from "../helpers/provider";
|
||||
import { registerProvider } from "../helpers/register";
|
||||
import { MWStreamQuality, MWStreamType } from "../helpers/streams";
|
||||
import { MWMediaType } from "../metadata/types/mw";
|
||||
|
||||
const hdwatchedBase = "https://www.hdwatched.xyz";
|
||||
|
||||
const qualityMap: Record<number, MWStreamQuality> = {
|
||||
360: MWStreamQuality.Q360P,
|
||||
540: MWStreamQuality.Q540P,
|
||||
480: MWStreamQuality.Q480P,
|
||||
720: MWStreamQuality.Q720P,
|
||||
1080: MWStreamQuality.Q1080P,
|
||||
};
|
||||
|
||||
interface SearchRes {
|
||||
title: string;
|
||||
year?: number;
|
||||
href: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
function getStreamFromEmbed(stream: string) {
|
||||
const embedPage = new DOMParser().parseFromString(stream, "text/html");
|
||||
const source = embedPage.querySelector("#vjsplayer > source");
|
||||
if (!source) {
|
||||
throw new Error("Unable to fetch stream");
|
||||
}
|
||||
|
||||
const streamSrc = source.getAttribute("src");
|
||||
const streamRes = source.getAttribute("res");
|
||||
|
||||
if (!streamSrc || !streamRes) throw new Error("Unable to find stream");
|
||||
|
||||
return {
|
||||
streamUrl: streamSrc,
|
||||
quality:
|
||||
streamRes && typeof +streamRes === "number"
|
||||
? qualityMap[+streamRes]
|
||||
: MWStreamQuality.QUNKNOWN,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchMovie(targetSource: SearchRes) {
|
||||
const stream = await proxiedFetch<any>(`/embed/${targetSource.id}`, {
|
||||
baseURL: hdwatchedBase,
|
||||
});
|
||||
|
||||
const embedPage = new DOMParser().parseFromString(stream, "text/html");
|
||||
const source = embedPage.querySelector("#vjsplayer > source");
|
||||
if (!source) {
|
||||
throw new Error("Unable to fetch movie stream");
|
||||
}
|
||||
|
||||
return getStreamFromEmbed(stream);
|
||||
}
|
||||
|
||||
async function fetchSeries(
|
||||
targetSource: SearchRes,
|
||||
{ media, episode, progress }: MWProviderContext
|
||||
) {
|
||||
if (media.meta.type !== MWMediaType.SERIES)
|
||||
throw new Error("Media type mismatch");
|
||||
|
||||
const seasonNumber = media.meta.seasonData.number;
|
||||
const episodeNumber = media.meta.seasonData.episodes.find(
|
||||
(e) => e.id === episode
|
||||
)?.number;
|
||||
|
||||
if (!seasonNumber || !episodeNumber)
|
||||
throw new Error("Unable to get season or episode number");
|
||||
|
||||
const seriesPage = await proxiedFetch<any>(
|
||||
`${targetSource.href}?season=${media.meta.seasonData.number}`,
|
||||
{
|
||||
baseURL: hdwatchedBase,
|
||||
}
|
||||
);
|
||||
|
||||
const seasonPage = new DOMParser().parseFromString(seriesPage, "text/html");
|
||||
const pageElements = seasonPage.querySelectorAll("div.i-container");
|
||||
|
||||
const seriesList: SearchRes[] = [];
|
||||
pageElements.forEach((pageElement) => {
|
||||
const href = pageElement.querySelector("a")?.getAttribute("href") || "";
|
||||
const title =
|
||||
pageElement?.querySelector("span.content-title")?.textContent || "";
|
||||
|
||||
seriesList.push({
|
||||
title,
|
||||
href,
|
||||
id: href.split("/")[2], // Format: /free/{id}/{series-slug}-season-{season-number}-episode-{episode-number}
|
||||
});
|
||||
});
|
||||
|
||||
const targetEpisode = seriesList.find(
|
||||
(episodeEl) =>
|
||||
episodeEl.title.trim().toLowerCase() === `episode ${episodeNumber}`
|
||||
);
|
||||
|
||||
if (!targetEpisode) throw new Error("Unable to find episode");
|
||||
|
||||
progress(70);
|
||||
|
||||
const stream = await proxiedFetch<any>(`/embed/${targetEpisode.id}`, {
|
||||
baseURL: hdwatchedBase,
|
||||
});
|
||||
|
||||
const embedPage = new DOMParser().parseFromString(stream, "text/html");
|
||||
const source = embedPage.querySelector("#vjsplayer > source");
|
||||
if (!source) {
|
||||
throw new Error("Unable to fetch movie stream");
|
||||
}
|
||||
|
||||
return getStreamFromEmbed(stream);
|
||||
}
|
||||
|
||||
registerProvider({
|
||||
id: "hdwatched",
|
||||
displayName: "HDwatched",
|
||||
rank: 150,
|
||||
disabled: true, // very slow, haven't seen it work for a while
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
async scrape(options) {
|
||||
const { media, progress } = options;
|
||||
if (!media.imdbId) throw new Error("not enough info");
|
||||
if (!this.type.includes(media.meta.type)) {
|
||||
throw new Error("Unsupported type");
|
||||
}
|
||||
|
||||
const search = await proxiedFetch<any>(`/search/${media.imdbId}`, {
|
||||
baseURL: hdwatchedBase,
|
||||
});
|
||||
|
||||
const searchPage = new DOMParser().parseFromString(search, "text/html");
|
||||
const pageElements = searchPage.querySelectorAll("div.i-container");
|
||||
|
||||
const searchList: SearchRes[] = [];
|
||||
pageElements.forEach((pageElement) => {
|
||||
const href = pageElement.querySelector("a")?.getAttribute("href") || "";
|
||||
const title =
|
||||
pageElement?.querySelector("span.content-title")?.textContent || "";
|
||||
const year =
|
||||
parseInt(
|
||||
pageElement
|
||||
?.querySelector("div.duration")
|
||||
?.textContent?.trim()
|
||||
?.split(" ")
|
||||
?.pop() || "",
|
||||
10
|
||||
) || 0;
|
||||
|
||||
searchList.push({
|
||||
title,
|
||||
year,
|
||||
href,
|
||||
id: href.split("/")[2], // Format: /free/{id}/{movie-slug} or /series/{id}/{series-slug}
|
||||
});
|
||||
});
|
||||
|
||||
progress(20);
|
||||
|
||||
const targetSource = searchList.find(
|
||||
(source) => source.year === (media.meta.year ? +media.meta.year : 0) // Compare year to make the search more robust
|
||||
);
|
||||
|
||||
if (!targetSource) {
|
||||
throw new Error("Could not find stream");
|
||||
}
|
||||
|
||||
progress(40);
|
||||
|
||||
if (media.meta.type === MWMediaType.SERIES) {
|
||||
const series = await fetchSeries(targetSource, options);
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: series.streamUrl,
|
||||
quality: series.quality,
|
||||
type: MWStreamType.MP4,
|
||||
captions: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const movie = await fetchMovie(targetSource);
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: movie.streamUrl,
|
||||
quality: movie.quality,
|
||||
type: MWStreamType.MP4,
|
||||
captions: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user