mirror of
https://github.com/Radiquum/AniX.git
synced 2025-09-03 21:15:35 +05:00
Compare commits
39 commits
9931962a6b
...
30285e84c6
Author | SHA1 | Date | |
---|---|---|---|
30285e84c6 | |||
1a1b548d39 | |||
4701f6f62e | |||
144ef94f88 | |||
7ce63e4fe5 | |||
f2a5054f76 | |||
a3f1457721 | |||
f87ee16cbb | |||
9254703c08 | |||
faad506c25 | |||
cc9a9c3a2c | |||
51c5bf01da | |||
|
6599861eef | ||
ba73fc3308 | |||
2f928579f9 | |||
3762e7e58f | |||
d23703b212 | |||
155146c5a0 | |||
d71ae186a2 | |||
dd4772c5cf | |||
901e51a9da | |||
4100133d56 | |||
37d6d4df5b | |||
d4f297e3fb | |||
6eb5288f5c | |||
260a30d94d | |||
a8a7266464 | |||
869c934af2 | |||
bfef935fd6 | |||
948f962825 | |||
e6cbaa93d5 | |||
80115f35d6 | |||
8c441074dd | |||
d646a946cc | |||
648486e992 | |||
c8b9295ebf | |||
760798faa3 | |||
bfe932d86c | |||
6f45876240 |
49 changed files with 4278 additions and 368 deletions
|
@ -64,5 +64,6 @@ API-Trace/*
|
|||
.env
|
||||
|
||||
player-parsers
|
||||
api-prox
|
||||
docs
|
||||
.git
|
|
@ -1,5 +1,4 @@
|
|||
# пример заполнения: https://example.com, http://0.0.0.0:80
|
||||
NEXT_PUBLIC_KODIK_PARSER_URL= # Домен парсера кодика, требуется для просмотра с данного источника
|
||||
NEXT_PUBLIC_ANILIBRIA_PARSER_URL= # Домен парсера анилибрии, если не заполнено, используется официальное апи
|
||||
NEXT_PUBLIC_SIBNET_PARSER_URL= # Домен парсера сибнет, требуется для просмотра с данного источника
|
||||
NEXT_PUBLIC_PLAYER_PARSER_URL= # Домен сервиса player-parsers, требуется для работы встроенного плеера
|
||||
NEXT_PUBLIC_API_URL= # Домен сервиса api-prox, для использования своего сервера API вместо встроенного middleware
|
||||
# ---
|
31
.github/workflows/docker-anix-api-prox.yml
vendored
Normal file
31
.github/workflows/docker-anix-api-prox.yml
vendored
Normal file
|
@ -0,0 +1,31 @@
|
|||
name: Build and Publish 'anix-api-prox' to Docker Hub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- V3
|
||||
paths:
|
||||
- "api-prox/*.ts"
|
||||
- "!api-prox/hooks/*"
|
||||
- "!api-prox/episode/*"
|
||||
- "api-prox/Dockerfile"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Dynamically set 'tag' environment variable
|
||||
run: echo "tag=$(date +'%d-%m-%Y')" >> $GITHUB_ENV
|
||||
- name: Build Image
|
||||
run: |
|
||||
cd ./api-prox
|
||||
docker build . -t radiquum/anix-api-prox:latest
|
||||
docker tag radiquum/anix-api-prox:latest radiquum/anix-api-prox:${{ env.tag }}
|
||||
- name: Publish Image
|
||||
run: |
|
||||
docker login -u radiquum -p ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
docker push radiquum/anix-api-prox:latest
|
||||
docker push radiquum/anix-api-prox:${{ env.tag }}
|
29
.github/workflows/docker-anix-player-parsers.yml
vendored
Normal file
29
.github/workflows/docker-anix-player-parsers.yml
vendored
Normal file
|
@ -0,0 +1,29 @@
|
|||
name: Build and Publish 'anix-player-parsers' to Docker Hub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- V3
|
||||
paths:
|
||||
- "player-parser/*.ts"
|
||||
- "player-parser/Dockerfile"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Dynamically set 'tag' environment variable
|
||||
run: echo "tag=$(date +'%d-%m-%Y')" >> $GITHUB_ENV
|
||||
- name: Build Image
|
||||
run: |
|
||||
cd ./player-parser
|
||||
docker build . -t radiquum/anix-player-parser:latest
|
||||
docker tag radiquum/anix-player-parser:latest radiquum/anix-player-parser:${{ env.tag }}
|
||||
- name: Publish Image
|
||||
run: |
|
||||
docker login -u radiquum -p ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
docker push radiquum/anix-player-parser:latest
|
||||
docker push radiquum/anix-player-parser:${{ env.tag }}
|
30
.github/workflows/docker-anix.yml
vendored
Normal file
30
.github/workflows/docker-anix.yml
vendored
Normal file
|
@ -0,0 +1,30 @@
|
|||
name: Build and Publish 'anix' to Docker Hub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- V3
|
||||
paths:
|
||||
- 'app/**'
|
||||
- 'Dockerfile'
|
||||
- 'middleware.ts'
|
||||
- 'next.config.js'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Dynamically set 'tag' environment variable
|
||||
run: echo "tag=$(date +'%d-%m-%Y')" >> $GITHUB_ENV
|
||||
- name: Build Image
|
||||
run: |
|
||||
docker build . -t radiquum/anix:latest
|
||||
docker tag radiquum/anix:latest radiquum/anix:${{ env.tag }}
|
||||
- name: Publish Image
|
||||
run: |
|
||||
docker login -u radiquum -p ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
docker push radiquum/anix:latest
|
||||
docker push radiquum/anix:${{ env.tag }}
|
|
@ -26,13 +26,12 @@
|
|||
|
||||

|
||||
|
||||
5. (опционально) добавьте переменные для использования своего плеера:
|
||||
5. (опционально) добавьте переменные для использования своего плеера и\или API прокси:
|
||||
|
||||
- NEXT_PUBLIC_KODIK_PARSER_URL
|
||||
- NEXT_PUBLIC_ANILIBRIA_PARSER_URL
|
||||
- NEXT_PUBLIC_SIBNET_PARSER_URL
|
||||
- NEXT_PUBLIC_PLAYER_PARSER_URL
|
||||
- NEXT_PUBLIC_API_URL
|
||||
|
||||
на те которые вы получили, если развёртывали [anix-player-parsers](./player-parsers/README.RU.md)
|
||||
на те которые вы получили, если развёртывали [anix-player-parser](./player-parser/README.RU.md) и/или [anix-api-prox](./api-prox/README.RU.md)
|
||||
|
||||

|
||||
|
||||
|
@ -75,13 +74,12 @@
|
|||
|
||||

|
||||
|
||||
7. (опционально) добавьте переменные для использования своего плеера:
|
||||
7. (опционально) добавьте переменную для использования своего плеера и\или API прокси::
|
||||
|
||||
- NEXT_PUBLIC_KODIK_PARSER_URL
|
||||
- NEXT_PUBLIC_ANILIBRIA_PARSER_URL
|
||||
- NEXT_PUBLIC_SIBNET_PARSER_URL
|
||||
- NEXT_PUBLIC_PLAYER_PARSER_URL
|
||||
- NEXT_PUBLIC_API_URL
|
||||
|
||||
на те которые вы получили, если развёртывали [anix-player-parsers](./player-parsers/README.RU.md)
|
||||
на те которые вы получили, если развёртывали [anix-player-parser](./player-parser/README.RU.md) и/или [anix-api-prox](./api-prox/README.RU.md)
|
||||
|
||||
1. 
|
||||
|
||||
|
@ -124,7 +122,7 @@
|
|||
- -p - порт контейнера который будет доступен извне. ПОРТ:3000
|
||||
|
||||
> [!NOTE]
|
||||
> для переменных которые вы получили, если развёртывали [anix-player-parsers](./player-parsers/README.RU.md), необходимо использовать `-e ПЕРЕМЕННАЯ=ЗНАЧЕНИЕ` до слова anix
|
||||
> для переменных которые вы получили, если развёртывали [anix-player-parser](./player-parser/README.RU.md) и/или [anix-api-prox](./api-prox/README.RU.md), необходимо использовать `-e ПЕРЕМЕННАЯ=ЗНАЧЕНИЕ` до последнего слова anix
|
||||
|
||||
[команда docker run](https://docs.docker.com/reference/cli/docker/container/run/)
|
||||
|
||||
|
@ -154,7 +152,7 @@
|
|||
1. Клонируйте репозиторий `git clone https://github.com/Radiquum/AniX`
|
||||
2. Переместитесь в директорию репозитория `cd AniX`
|
||||
3. Выполните команду `npm install`
|
||||
4. (опционально) скопируйте .env.sample как .env и заполните его переменными которые вы получили, если развёртывали [anix-player-parsers](./player-parsers/README.RU.md)
|
||||
4. (опционально) скопируйте .env.sample как .env и заполните его переменными которые вы получили, если развёртывали [anix-player-parser](./player-parser/README.RU.md) и/или [anix-api-prox](./api-prox/README.RU.md)
|
||||
5. Выполните команду `npm run build`
|
||||
6. создайте новую директорию (далее будем использовать `<имя_новой_директории>` как её имя)
|
||||
7. переместите в созданную директорию (`<имя_новой_директории>`)
|
||||
|
|
|
@ -26,13 +26,12 @@ Requirements:
|
|||
|
||||

|
||||
|
||||
5. (optional) Add variables to use your own player:
|
||||
5. (optional) Add variable to use your own player and/or api-proxy:
|
||||
|
||||
- NEXT_PUBLIC_KODIK_PARSER_URL
|
||||
- NEXT_PUBLIC_ANILIBRIA_PARSER_URL
|
||||
- NEXT_PUBLIC_SIBNET_PARSER_URL
|
||||
- NEXT_PUBLIC_PLAYER_PARSER_URL
|
||||
- NEXT_PUBLIC_API_URL
|
||||
|
||||
Use the ones you received if you deployed [anix-player-parsers](./player-parsers/README.md)
|
||||
Use the ones you received if you deployed [anix-player-parser](./player-parser/README.md) and/or [anix-api-prox](./api-prox/README.md)
|
||||
|
||||

|
||||
|
||||
|
@ -75,13 +74,12 @@ Requirements:
|
|||
|
||||

|
||||
|
||||
7. (optional) Add variables to use your own player:
|
||||
7. (optional) Add variables to use your own player and/or api-proxy:
|
||||
|
||||
- NEXT_PUBLIC_KODIK_PARSER_URL
|
||||
- NEXT_PUBLIC_ANILIBRIA_PARSER_URL
|
||||
- NEXT_PUBLIC_SIBNET_PARSER_URL
|
||||
- NEXT_PUBLIC_PLAYER_PARSER_URL
|
||||
- NEXT_PUBLIC_API_URL
|
||||
|
||||
Use the ones you received if you deployed [anix-player-parsers](./player-parsers/README.md)
|
||||
Use the ones you received if you deployed [anix-player-parser](./player-parser/README.md) and/or [anix-api-prox](./api-prox/README.md)
|
||||
|
||||
1. 
|
||||
|
||||
|
@ -124,7 +122,7 @@ Additional Requirements:
|
|||
- -p - container port to be exposed externally. PORT:3000
|
||||
|
||||
> [!NOTE]
|
||||
> For variables you received if you deployed [anix-player-parsers](./player-parsers/README.md), you need to use `-e VARIABLE=VALUE` before the word anix
|
||||
> For variables you received if you deployed [anix-player-parser](./player-parser/README.md) and/or [anix-api-prox](./api-prox/README.md), you need to use `-e VARIABLE=VALUE` before the last word anix
|
||||
|
||||
[docker run command](https://docs.docker.com/reference/cli/docker/container/run/)
|
||||
|
||||
|
@ -154,7 +152,7 @@ Instructions:
|
|||
1. Clone the repository `git clone https://github.com/Radiquum/AniX`
|
||||
2. Navigate to the repository directory `cd AniX`
|
||||
3. Run the command `npm install`
|
||||
4. (optional) copy `.env.sample` as `.env` and fill it with variables you received if you deployed [anix-player-parsers](./player-parsers/README.md)
|
||||
4. (optional) copy `.env.sample` as `.env` and fill it with variables you received if you deployed [anix-player-parser](./player-parser/README.md) and/or [anix-api-prox](./api-prox/README.md)
|
||||
5. Run the command `npm run build`
|
||||
6. Create a new directory (next we will be refer to its name as `<new_dir>`)
|
||||
7. Move the following files into the new directory (`<new_dir>`):
|
||||
|
|
5
api-prox/.dockerignore
Normal file
5
api-prox/.dockerignore
Normal file
|
@ -0,0 +1,5 @@
|
|||
episode
|
||||
hooks
|
||||
node_modules
|
||||
README.md
|
||||
README.RU.md
|
17
api-prox/Dockerfile
Normal file
17
api-prox/Dockerfile
Normal file
|
@ -0,0 +1,17 @@
|
|||
FROM node:23-alpine
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/radiquum/anix
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY *.ts ./
|
||||
COPY tsconfig.json ./
|
||||
|
||||
RUN mkdir -p /app/hooks
|
||||
|
||||
EXPOSE 7001
|
||||
|
||||
CMD ["npm", "run", "serve"]
|
111
api-prox/README.RU.md
Normal file
111
api-prox/README.RU.md
Normal file
|
@ -0,0 +1,111 @@
|
|||
# AniX - Api Proxy
|
||||
|
||||
Данный под-проект позволяет проксировать запросы к API Anixart и изменять их ответы с помощью хуков
|
||||
|
||||
Он может использоваться как для основного проекта AniX, так и как отдельный сервис для андроид приложения с изменённой ссылкой на API.
|
||||
|
||||
Лицензия: [MIT](../LICENSE)
|
||||
|
||||
## Доступные хуки
|
||||
|
||||
- release.ts: добавляет рейтинг с shikimori в строку доп. информации
|
||||
- profile.example.ts: меняет ник официального аккаунта anixart (пример работы с хуком)
|
||||
- profile.sponsor.ts: включает спонсорку (отключает рекламу) после входа в аккаунт в android приложении
|
||||
- toggles.ts: заменяет ответ конфигурации, если присутствует переменная окружения `HOST_URL` заменяет ссылку веб плеера, на встроенную, при совместном использовании с переменной окружения `PLAYER_PARSER_URL`, включает собственный веб-плеер, как в веб клиенте AniX
|
||||
- episode.disabled.ts: позволяет изменять/добавлять озвучки, источники и эпизоды с помощью файла json, в папке `episode`.
|
||||
|
||||
## Использование
|
||||
|
||||
В строке веб-браузера необходимо ввести:
|
||||
|
||||
`<http|https>://<ip|domain><:port>/<ENDPOINT>[?<QUERY_PARAMS>]`
|
||||
|
||||
Ответ:
|
||||
|
||||
- 500: произошла ошибка, подробнее в строке `reason` в теле ответа
|
||||
- 200: запрос прошёл успешно (если произошла ошибка на стороне API Anixart, смотри строку `code`)
|
||||
|
||||
## Развёртывание
|
||||
|
||||
### Docker
|
||||
|
||||
Требования:
|
||||
|
||||
- [docker](https://docs.docker.com/engine/install/)
|
||||
|
||||
### Пре-билд
|
||||
|
||||
1. выполните команду:
|
||||
|
||||
`docker run -d --name anix-api -p 7001:7001 radiquum/anix-api-prox:latest`
|
||||
|
||||
для использования хуков необходимо создать папку `hooks` и добавить флаг `-v ./hooks:/app/hooks` перед флагом `-p`.
|
||||
(так-же и для папки `episode`, если требуется)
|
||||
|
||||
### Ручной билд
|
||||
|
||||
Доп. Требования:
|
||||
|
||||
- [git](https://git-scm.com/)
|
||||
|
||||
1. Клонируйте репозиторий `git clone https://github.com/Radiquum/AniX`
|
||||
2. Переместитесь в директорию репозитория `cd AniX`
|
||||
3. Переместитесь в директорию сервиса `cd api-prox`
|
||||
4. Выполните команду `docker build -t anix-api-prox .`
|
||||
5. После окончания, выполните команду: `docker run -d --restart always --name anix-player -p 7001:7001 anix-api-prox`
|
||||
|
||||
для использования хуков необходимо добавить флаг `-v ./hooks:/app/hooks` перед флагом `-p`.
|
||||
(так-же и для папки `episode`, если требуется)
|
||||
|
||||
### docker/Обозначения
|
||||
|
||||
- -d - запустить контейнер в фоне
|
||||
- --restart always - всегда запускать после перезагрузки сервера
|
||||
- --name - название контейнера
|
||||
- -p - порт контейнера который будет доступен извне. ПОРТ:7000
|
||||
- -v - добавить папку с хоста в контейнер
|
||||
|
||||
### docker/После развёртывания
|
||||
|
||||
Сервис будет доступен по адресу: `http://<ВАШ IP><:ВАШ ПОРТ>/`
|
||||
|
||||
### docker/Примечание
|
||||
|
||||
Для использования своего домена и поддержки протокола HTTPS, вы можете использовать Traefik или другой reverse-proxy, с сертификатом SSL.
|
||||
|
||||
Полезные ссылки:
|
||||
|
||||
- [Конвертер из команды docker run в синтакс для docker compose](https://it-tools.tech/docker-run-to-docker-compose-converter)
|
||||
- [Как настроить Traefik + свой домен + SSL](https://letmegooglethat.com/?q=how+to+setup+traefik+with+custom+domain+and+ssl+certificate+from+lets+encrypt%3F)
|
||||
|
||||
### pm2
|
||||
|
||||
Требования:
|
||||
|
||||
- [git](https://git-scm.com/)
|
||||
- [nodejs 23+ с npm](http://nodejs.org/)
|
||||
- [pm2](https://pm2.keymetrics.io/)
|
||||
|
||||
Инструкция:
|
||||
|
||||
1. Клонируйте репозиторий `git clone https://github.com/Radiquum/AniX`
|
||||
2. Переместитесь в директорию репозитория `cd AniX`
|
||||
3. Переместитесь в директорию сервиса `cd api-prox`
|
||||
4. Выполните команду `npm install`
|
||||
5. После окончания и выполните команду `pm2 start index.ts -n anix-api-prox`
|
||||
|
||||
### pm2/Обозначения
|
||||
|
||||
- -n - название сервиса в pm2
|
||||
|
||||
### pm2/После развёртывания
|
||||
|
||||
Сервис будет доступен по адресу: `http://<ВАШ IP>:7001/`
|
||||
|
||||
### pm2/Примечание
|
||||
|
||||
Для автоматического запуска приложения, рекомендуется настроить pm2 на автозапуск, с помощью команды: `pm2 startup`
|
||||
|
||||
Полезные ссылки:
|
||||
|
||||
- [PM2: подходим к вопросу процесс-менеджмента с умом @ Habr](https://habr.com/ru/articles/480670/)
|
112
api-prox/README.md
Normal file
112
api-prox/README.md
Normal file
|
@ -0,0 +1,112 @@
|
|||
|
||||
# AniX - Api Proxy
|
||||
|
||||
This sub-project allows proxying requests to the Anixart API and modifying their responses using hooks.
|
||||
|
||||
It can be used both for the main AniX project and as a standalone service for the Android app with a modified API link.
|
||||
|
||||
License: [MIT](../LICENSE)
|
||||
|
||||
## Available Hooks
|
||||
|
||||
- release.ts: adds a rating from Shikimori to the additional info line
|
||||
- profile.example.ts: changes the nickname of the official Anixart account (an example of using a hook)
|
||||
- profile.sponsor.ts: enables sponsorship (disables ads) after logging into the account in the Android app
|
||||
- toggles.ts: replaces the configuration response; if the `HOST_URL` environment variable is present, it replaces the web player link with an embedded one; when used together with the `PLAYER_PARSER_URL` variable, enables the custom web player, as in the AniX web client
|
||||
- episode.disabled.ts: allows modifying/adding voiceovers, sources, and episodes using a JSON file in the `episode` folder.
|
||||
|
||||
## Usage
|
||||
|
||||
In the web browser address bar, enter:
|
||||
|
||||
`<http|https>://<ip|domain><:port>/<ENDPOINT>[?<QUERY_PARAMS>]`
|
||||
|
||||
Response:
|
||||
|
||||
- 500: an error occurred, see the `reason` field in the response body for more details
|
||||
- 200: request was successful (if there was an error on the Anixart API side, see the `code` field)
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker
|
||||
|
||||
Requirements:
|
||||
|
||||
- [docker](https://docs.docker.com/engine/install/)
|
||||
|
||||
### Pre-built
|
||||
|
||||
1. Run the command:
|
||||
|
||||
`docker run -d --name anix-api -p 7001:7001 radiquum/anix-api-prox:latest`
|
||||
|
||||
To use hooks, create a `hooks` folder and add the flag `-v ./hooks:/app/hooks` before the `-p` flag.
|
||||
(The same applies to the `episode` folder if needed)
|
||||
|
||||
### Manual Build
|
||||
|
||||
Additional Requirements:
|
||||
|
||||
- [git](https://git-scm.com/)
|
||||
|
||||
1. Clone the repository: `git clone https://github.com/Radiquum/AniX`
|
||||
2. Navigate to the repository directory: `cd AniX`
|
||||
3. Navigate to the service directory: `cd api-prox`
|
||||
4. Run the command: `docker build -t anix-api-prox .`
|
||||
5. After completion, run: `docker run -d --restart always --name anix-player -p 7001:7001 anix-api-prox`
|
||||
|
||||
To use hooks, add the flag `-v ./hooks:/app/hooks` before the `-p` flag.
|
||||
(The same applies to the `episode` folder if needed)
|
||||
|
||||
### docker/Flags
|
||||
|
||||
- -d - run the container in background
|
||||
- --restart always - always start after server reboot
|
||||
- --name - container name
|
||||
- -p - container port that will be accessible from outside. PORT:7000
|
||||
- -v - mount a folder from host into the container
|
||||
|
||||
### docker/After Deployment
|
||||
|
||||
The service will be available at: `http://<YOUR IP><:YOUR PORT>/`
|
||||
|
||||
### docker/Note
|
||||
|
||||
To use your own domain and support HTTPS, you can use Traefik or another reverse proxy with an SSL certificate.
|
||||
|
||||
Useful links:
|
||||
|
||||
- [Converter from docker run command to docker compose syntax](https://it-tools.tech/docker-run-to-docker-compose-converter)
|
||||
- [How to setup Traefik + custom domain + SSL](https://letmegooglethat.com/?q=how+to+setup+traefik+with+custom+domain+and+ssl+certificate+from+lets+encrypt%3F)
|
||||
|
||||
### pm2
|
||||
|
||||
Requirements:
|
||||
|
||||
- [git](https://git-scm.com/)
|
||||
- [nodejs 23+ with npm](http://nodejs.org/)
|
||||
- [pm2](https://pm2.keymetrics.io/)
|
||||
|
||||
Instructions:
|
||||
|
||||
1. Clone the repository: `git clone https://github.com/Radiquum/AniX`
|
||||
2. Navigate to the repository directory: `cd AniX`
|
||||
3. Navigate to the service directory: `cd api-prox`
|
||||
4. Run: `npm install`
|
||||
5. After completion, run: `pm2 start index.ts -n anix-api-prox`
|
||||
|
||||
### pm2/Flags
|
||||
|
||||
- -n - service name in pm2
|
||||
|
||||
### pm2/After Deployment
|
||||
|
||||
The service will be available at: `http://<YOUR IP>:7001/`
|
||||
|
||||
### pm2/Note
|
||||
|
||||
For automatic app startup, it is recommended to set up pm2 autostart using the command: `pm2 startup`
|
||||
|
||||
Useful links:
|
||||
|
||||
- [PM2: a smart approach to process management @ Habr](https://habr.com/ru/articles/480670/)
|
53
api-prox/episode/841.example.json
Normal file
53
api-prox/episode/841.example.json
Normal file
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"$schema": "https://gist.githubusercontent.com/Radiquum/7f33c09be293233b831c841b69f24608/raw/anix-api-prox-custom-episode-schema.json",
|
||||
"comment": [
|
||||
"пример для добавления и изменения озвучек, источников и эпизодов",
|
||||
"для аниме Initial D - First Stage, ID: 841",
|
||||
"кстати раздел 'comment' совсем не нужен, так что это буквально коммент"
|
||||
],
|
||||
"types": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "NyaniDUB (Заменено с AniDub)",
|
||||
"sources": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Sibnet",
|
||||
"episodes": [
|
||||
{
|
||||
"position": 0,
|
||||
"name": "Первая серия"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 999,
|
||||
"name": "By Blender Foundation",
|
||||
"icon": "https://download.blender.org/branding/community/blender_community_badge_white.png",
|
||||
"episodes_count": 2,
|
||||
"sources": [
|
||||
{
|
||||
"id": 998,
|
||||
"name": "Public Test Videos",
|
||||
"episodes_count": 2,
|
||||
"episodes": [
|
||||
{
|
||||
"position": 1,
|
||||
"name": "Big Buck Bunny",
|
||||
"url": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
|
||||
"is_filler": true
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"name": "Elephant Dream",
|
||||
"url": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4",
|
||||
"is_filler": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
297
api-prox/hooks/episode.disabled.ts
Normal file
297
api-prox/hooks/episode.disabled.ts
Normal file
|
@ -0,0 +1,297 @@
|
|||
// хук добавляет ссылки на кастомные источники
|
||||
// а так-же позволяет добавлять собственные озвучки
|
||||
// с помощью json (<api-prox>/episode/<id релиза>.json)
|
||||
// пример находится в файле 841.example.json в папке episode
|
||||
//
|
||||
// сами видео файлы эпизодов необходимо хостить отдельно (например с помощью nginx),
|
||||
// хуку требуется переменная среды HOST_URL, которая ведёт на сервис api-prox
|
||||
|
||||
import { logger } from "../shared";
|
||||
import fs from "fs/promises";
|
||||
|
||||
let HOSTNAME: null | string = null;
|
||||
if (process.env.HOST_URL) {
|
||||
HOSTNAME = process.env.HOST_URL;
|
||||
}
|
||||
|
||||
export function match(path: string): boolean {
|
||||
// если не установлен хост, не запускаем хук
|
||||
if (!HOSTNAME) return false;
|
||||
// используем только страницы с путём /episode/*
|
||||
const pathRe = /^\/episode\/\d+/;
|
||||
if (pathRe.test(path)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface VoiceoverInfo {
|
||||
"@id": number;
|
||||
id: number;
|
||||
name: string;
|
||||
icon: null | string;
|
||||
workers: null | string;
|
||||
is_sub: boolean;
|
||||
episodes_count: number;
|
||||
view_count: number;
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
export interface SourceInfo {
|
||||
"@id": number;
|
||||
id: number;
|
||||
type: number | VoiceoverInfo;
|
||||
name: string;
|
||||
episodes_count: number;
|
||||
}
|
||||
|
||||
export interface EpisodeInfo {
|
||||
"@id": number;
|
||||
position: number;
|
||||
release: number | any;
|
||||
source: number | SourceInfo;
|
||||
name: null | string;
|
||||
url: string;
|
||||
iframe: boolean;
|
||||
addedDate: number;
|
||||
is_filler: boolean;
|
||||
is_watched: boolean;
|
||||
}
|
||||
|
||||
export async function get(data: any, url: URL) {
|
||||
const base = "./episode";
|
||||
|
||||
let releaseId = null;
|
||||
let voiceOverId = null;
|
||||
let sourceId = null;
|
||||
let info: any = null;
|
||||
|
||||
const path = url.pathname.split("/").filter((item) => {
|
||||
return !["", "episode"].includes(item);
|
||||
});
|
||||
|
||||
logger.consoleHook("debug", `Received request for:`, url.pathname);
|
||||
logger.consoleHook("debug", `Decoded pathname:`, path);
|
||||
|
||||
releaseId = Number(path[0]);
|
||||
voiceOverId = Number(path[1]);
|
||||
sourceId = Number(path[2]);
|
||||
logger.consoleHook("debug", `Release ID:`, releaseId);
|
||||
logger.consoleHook("debug", `Voiceover ID:`, voiceOverId);
|
||||
logger.consoleHook("debug", `Source ID:`, sourceId);
|
||||
|
||||
try {
|
||||
info = JSON.parse(
|
||||
await fs.readFile(`${base}/${releaseId}.json`, {
|
||||
encoding: "utf8",
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (path.length == 1) {
|
||||
if (!info || !data.hasOwnProperty("types")) {
|
||||
return data;
|
||||
}
|
||||
|
||||
for (let i = 0; i < info.types.length; i++) {
|
||||
const type: VoiceoverInfo = info.types[i];
|
||||
const existingType: VoiceoverInfo = data.types.find(
|
||||
(item: VoiceoverInfo) => item.id == type.id
|
||||
);
|
||||
|
||||
if (existingType) {
|
||||
type.name ? (existingType.name = type.name) : null;
|
||||
type.icon ? (existingType.icon = type.icon) : null;
|
||||
type.workers ? (existingType.workers = type.workers) : "";
|
||||
type.is_sub ? (existingType.is_sub = type.is_sub) : null;
|
||||
type.episodes_count ?
|
||||
(existingType.episodes_count = type.episodes_count)
|
||||
: null;
|
||||
} else {
|
||||
data.types = [
|
||||
...data.types,
|
||||
{
|
||||
"@id": data.types.length + 1,
|
||||
id: type.id,
|
||||
name: type.name || "Неизвестная Озвучка",
|
||||
icon: type.icon || "",
|
||||
workers: type.workers || "",
|
||||
is_sub: type.is_sub || false,
|
||||
episodes_count: type.episodes_count || 0,
|
||||
view_count: 0,
|
||||
pinned: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (path.length == 2) {
|
||||
if (!info || !data.hasOwnProperty("sources")) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const apiResponse = await fetch(`${HOSTNAME}/episode/${releaseId}`);
|
||||
if (!apiResponse.ok) {
|
||||
return data;
|
||||
}
|
||||
const types = await apiResponse.json();
|
||||
const type: VoiceoverInfo = types.types.find(
|
||||
(item: VoiceoverInfo) => item.id == voiceOverId
|
||||
);
|
||||
type["@id"] = 2;
|
||||
type.episodes_count = 0;
|
||||
type.view_count = 0;
|
||||
|
||||
if (data.sources.length > 0) {
|
||||
data.sources[0].type = type;
|
||||
}
|
||||
|
||||
const sources = info.types.find(
|
||||
(item: VoiceoverInfo) => item.id == type.id
|
||||
);
|
||||
if (!sources || !sources.sources || sources.sources.length == 0) {
|
||||
return data;
|
||||
}
|
||||
|
||||
for (let i = 0; i < sources.sources.length; i++) {
|
||||
const source: SourceInfo = sources.sources[i];
|
||||
const existingSource: SourceInfo = data.sources.find(
|
||||
(item: SourceInfo) => item.id == source.id
|
||||
);
|
||||
|
||||
if (existingSource) {
|
||||
source.name ? (existingSource.name = source.name) : null;
|
||||
source.episodes_count ?
|
||||
(existingSource.episodes_count = source.episodes_count)
|
||||
: null;
|
||||
} else {
|
||||
data.sources = [
|
||||
...data.sources,
|
||||
{
|
||||
"@id": data.sources.length == 0 ? 1 : 2 + data.sources.length,
|
||||
id: source.id,
|
||||
type: data.sources.length > 0 ? 2 : type,
|
||||
name: source.name || "Неизвестный Источник",
|
||||
episodes_count: source.episodes_count || 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (path.length == 3) {
|
||||
if (!info || !data.hasOwnProperty("episodes")) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const apiSourceResponse = await fetch(
|
||||
`${HOSTNAME}/episode/${releaseId}/${voiceOverId}`
|
||||
);
|
||||
if (!apiSourceResponse.ok) {
|
||||
return data;
|
||||
}
|
||||
const sources = await apiSourceResponse.json();
|
||||
const source = sources.sources.find(
|
||||
(item: SourceInfo) => item.id == sourceId
|
||||
);
|
||||
|
||||
source["@id"] = 3;
|
||||
if (isNaN(source.type["@id"])) {
|
||||
source.type = sources.sources[0].type;
|
||||
}
|
||||
source.type["@id"] = 4;
|
||||
|
||||
const apiReleaseResponse = await fetch(`${HOSTNAME}/release/${releaseId}`);
|
||||
if (!apiReleaseResponse.ok) {
|
||||
return data;
|
||||
}
|
||||
const release = await apiReleaseResponse.json();
|
||||
release.release["@id"] = 2;
|
||||
release.release.screenshots = [];
|
||||
release.release.comments = [];
|
||||
release.release.screenshot_images = [];
|
||||
release.release.related_releases = [];
|
||||
release.release.recommended_releases = [];
|
||||
release.release.video_banners = [];
|
||||
release.release.your_vote = 0;
|
||||
release.release.related_count = 0;
|
||||
release.release.comment_count = 0;
|
||||
release.release.comments_count = 0;
|
||||
release.release.collection_count = 0;
|
||||
release.release.profile_list_status = 0;
|
||||
|
||||
if (data.episodes.length > 0) {
|
||||
data.episodes[0].release = release.release;
|
||||
data.episodes[0].source = source;
|
||||
data.episodes[0].source.episodes_count = 0;
|
||||
data.episodes[0].source.type.workers ?
|
||||
null
|
||||
: (data.episodes[0].source.type.workers = "");
|
||||
}
|
||||
|
||||
const ctypes = info.types;
|
||||
if (!ctypes || ctypes.length == 0) return data;
|
||||
const ctype = info.types.find(
|
||||
(item: VoiceoverInfo) => item.id == voiceOverId
|
||||
);
|
||||
if (!ctype) return data;
|
||||
const csource = ctype.sources.find(
|
||||
(item: SourceInfo) => item.id == sourceId
|
||||
);
|
||||
if (!csource || !csource.episodes) return data;
|
||||
const episodes = csource.episodes;
|
||||
if (!episodes || episodes.length == 0) return data;
|
||||
|
||||
if (
|
||||
data.episodes &&
|
||||
data.episodes.length > 0 &&
|
||||
data.episodes[0].source &&
|
||||
data.episodes[0].source.name == "Sibnet"
|
||||
) {
|
||||
data.episodes.forEach((item: EpisodeInfo, index: number) => {
|
||||
item.name ? null : (
|
||||
(data.episodes[index].name = `${item.position + 1} серия`)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < episodes.length; i++) {
|
||||
const episode: EpisodeInfo = episodes[i];
|
||||
const existingEpisode: EpisodeInfo = data.episodes.find(
|
||||
(item: EpisodeInfo) => item.position == episode.position
|
||||
);
|
||||
|
||||
if (existingEpisode) {
|
||||
episode.position ? (existingEpisode.position = episode.position) : null;
|
||||
episode.name ? (existingEpisode.name = episode.name) : null;
|
||||
episode.url ? (existingEpisode.url = episode.url) : null;
|
||||
episode.iframe !== undefined ?
|
||||
(existingEpisode.iframe = episode.iframe)
|
||||
: null;
|
||||
episode.is_filler !== undefined ?
|
||||
(existingEpisode.is_filler = episode.is_filler)
|
||||
: null;
|
||||
} else {
|
||||
data.episodes = [
|
||||
...data.episodes,
|
||||
{
|
||||
"@id": data.episodes.length == 0 ? 1 : 4 + data.episodes.length,
|
||||
position: episode.position || data.episodes.length,
|
||||
release: data.episodes.length > 0 ? 2 : release.release,
|
||||
source: data.episodes.length > 0 ? 3 : source,
|
||||
name: episode.name || "Неизвестная Серия",
|
||||
url: episode.url || "",
|
||||
iframe: episode.iframe !== undefined ? episode.iframe : true,
|
||||
addedDate: 0,
|
||||
is_filler:
|
||||
episode.is_filler !== undefined ? episode.is_filler : false,
|
||||
is_watched: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
28
api-prox/hooks/profile.example.ts
Normal file
28
api-prox/hooks/profile.example.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
// хук меняет юзернейм 'Anixart' на 'Anixartiki'
|
||||
|
||||
import { logger } from "../shared";
|
||||
|
||||
export function match(path: string): boolean {
|
||||
// id профиля 1, это профиль Anixart (разработчиков)
|
||||
if (path == "/profile/1") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function get(data: any, url: URL) {
|
||||
const newUname = "Anixartiki";
|
||||
|
||||
// проверяем что есть поле 'profile' и оно не равно 'null', что значит что мы получили данные с апи и можно двигаться дальше
|
||||
// иначе возвращаем оригинальные данные
|
||||
if (!data.hasOwnProperty("profile") || !data.profile) return data;
|
||||
|
||||
// выводим сообщение в лог, если уровень логгера 'debug'
|
||||
logger.debugHook(
|
||||
`Changed username of '${data["profile"]["login"]}' (${data["profile"]["id"]}) to ${newUname}`
|
||||
);
|
||||
|
||||
// меняем поле на новый юзернейм
|
||||
data["profile"]["login"] = newUname;
|
||||
|
||||
// возвращаем изменённые данные
|
||||
return data;
|
||||
}
|
12
api-prox/hooks/profile.sponsor.ts
Normal file
12
api-prox/hooks/profile.sponsor.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
// хук включает "вечную" спонсорку, отключая рекламу после входа в профиль, в официальном приложении
|
||||
|
||||
export function match(path: string): boolean {
|
||||
if (path == "/profile/info") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function get(data: any, url: URL) {
|
||||
data["is_sponsor"] = true;
|
||||
data["sponsorship_expires"] = 2147483647;
|
||||
return data;
|
||||
}
|
82
api-prox/hooks/release.ts
Normal file
82
api-prox/hooks/release.ts
Normal file
|
@ -0,0 +1,82 @@
|
|||
// хук добавляет рейтинг шикимори в поле note релиза
|
||||
|
||||
export function match(path: string): boolean {
|
||||
// используем только страницы с путём /release/<id>
|
||||
const pathRe = /\/release\/\d+/;
|
||||
if (pathRe.test(path)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const timeout = 5000; // таймаут запроса к внешнему апи на 5 секунд
|
||||
|
||||
async function fetchShikiRating(title: string) {
|
||||
try {
|
||||
// ищём аниме на шикимори по названию, т.к. ид аниме аниксарт и шикимори разные и нет никакого референса друг на друга
|
||||
const shikiIdRes = await fetch(
|
||||
`https://shikimori.one/api/animes?search=${title}`,
|
||||
{ signal: AbortSignal.timeout(timeout) }
|
||||
);
|
||||
if (!shikiIdRes.ok) throw new Error(); // если при поиске произошла ошибка, то возвращаем null
|
||||
|
||||
const shikiIdJson = await shikiIdRes.json();
|
||||
if (shikiIdJson.length == 0) throw new Error(); // если нет результатов, то возвращаем null
|
||||
|
||||
// берём ид от первого результата
|
||||
const shikiId = shikiIdJson[0]["id"];
|
||||
|
||||
// повторяем процесс, уже с ид от шикимори
|
||||
const shikiAnimRes = await fetch(
|
||||
`https://shikimori.one/api/animes/${shikiId}`,
|
||||
{ signal: AbortSignal.timeout(timeout) }
|
||||
);
|
||||
if (!shikiAnimRes.ok) throw new Error(); // если при произошла ошибка, то возвращаем null
|
||||
const shikiAnimJson = await shikiAnimRes.json();
|
||||
|
||||
// возвращаем рейтинг
|
||||
return Number(shikiAnimJson.score);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMALRating(title: string) {
|
||||
try {
|
||||
// ищём аниме на MAL по названию, через API Jikan, т.к. ид аниме аниксарт и шикимори разные и нет никакого референса друг на друга
|
||||
const malRes = await fetch(`https://api.jikan.moe/v4/anime?q=${title}`, {
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
});
|
||||
if (!malRes.ok) throw new Error(); // если при поиске произошла ошибка, то возвращаем null
|
||||
|
||||
const malJson = await malRes.json();
|
||||
if (malJson.data.length == 0) throw new Error(); // если нет результатов, то возвращаем null
|
||||
// возвращаем рейтинг от первого результата
|
||||
return Number(malJson.data[0].score);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function get(data: any, url: URL) {
|
||||
// проверяем что есть поле 'release'
|
||||
// иначе возвращаем оригинальные данные
|
||||
if (!data.hasOwnProperty("release")) return data;
|
||||
|
||||
const shikimoriRating = await fetchShikiRating(
|
||||
data["release"]["title_original"]
|
||||
);
|
||||
const malRating = await fetchMALRating(data["release"]["title_original"]);
|
||||
|
||||
// пушим строки в список, что-бы было легче их объединить
|
||||
const noteBuilder = [];
|
||||
if (data["release"]["note"]) noteBuilder.push(`${data.release.note}`); // первым добавляем оригинальное значение примечания, если оно есть
|
||||
data["release"]["note"] &&
|
||||
(shikimoriRating || malRating) &&
|
||||
noteBuilder.push("------"); // добавляем разделитель, если есть рейтинг и оригинальное примечание
|
||||
shikimoriRating &&
|
||||
noteBuilder.push(`<b>Рейтинг Shikimori:</b> ${shikimoriRating}★`); // добавляем рейтинг от шикимори
|
||||
malRating && noteBuilder.push(`<b>Рейтинг My Anime List:</b> ${malRating}★`); // добавляем рейтинг от MAL
|
||||
data["release"]["note"] = noteBuilder.join("<br/>"); // заменяем оригинальное поле нашей строкой
|
||||
|
||||
// возвращаем изменённые данные
|
||||
return data;
|
||||
}
|
76
api-prox/hooks/toggles.ts
Normal file
76
api-prox/hooks/toggles.ts
Normal file
|
@ -0,0 +1,76 @@
|
|||
// хук изменяет ответ config/toggles
|
||||
|
||||
export interface Toggles {
|
||||
minVersionCode: number;
|
||||
lastVersionCode: number;
|
||||
whatsNew: string;
|
||||
downloadLink: string;
|
||||
minGPVersionCode: number;
|
||||
lastGPVersionCode: number;
|
||||
gpWhatsNew: string;
|
||||
gpDownloadLink: string;
|
||||
overrideGPVersion: boolean;
|
||||
inAppUpdates: boolean;
|
||||
inAppUpdatesImmediate: boolean;
|
||||
inAppUpdatesFlexibleDelay: number;
|
||||
impMessageEnabled: boolean;
|
||||
impMessageText: string;
|
||||
impMessageBackgroundColor: string;
|
||||
impMessageTextColor: string;
|
||||
impMessageLink: string;
|
||||
adBannerBlockId: string;
|
||||
adBannerSizeType: number;
|
||||
adInterstitialBlockId: string;
|
||||
adBannerDelay: number;
|
||||
adInterstitialDelay: number;
|
||||
kodikVideoLinksUrl: string;
|
||||
kodikIframeAd: boolean;
|
||||
sibnetRandUserAgent: boolean;
|
||||
sibnetUserAgent: string;
|
||||
torlookUrl: string;
|
||||
baseUrl: string;
|
||||
apiUrl: string;
|
||||
apiAltUrl: string;
|
||||
apiAltAvailable: boolean;
|
||||
iframeEmbedUrl: string;
|
||||
kodikAdIframeUrl: string;
|
||||
sponsorshipPromotion: boolean;
|
||||
sponsorshipText: string;
|
||||
sponsorshipAvailable: boolean;
|
||||
pageNoConnectionUrl: string;
|
||||
snowfall: boolean;
|
||||
searchBarIconUrl: string;
|
||||
searchBarIconTint: string;
|
||||
searchBarIconAction: string;
|
||||
searchBarIconValue: string;
|
||||
min_blog_create_rating_score: number;
|
||||
}
|
||||
|
||||
export function match(path: string): boolean {
|
||||
if (path == "/config/toggles") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function get(data: Toggles, url: URL) {
|
||||
data.lastVersionCode = 25062200;
|
||||
|
||||
data.impMessageEnabled = true;
|
||||
data.impMessageText = "разработчик AniX / Api-Prox-Service";
|
||||
data.impMessageLink = "https://wah.su/radiquum";
|
||||
data.impMessageBackgroundColor = "ffb3d0";
|
||||
data.impMessageTextColor = "ffffff";
|
||||
|
||||
data.apiAltAvailable = false;
|
||||
data.apiAltUrl = "";
|
||||
|
||||
data.sponsorshipAvailable = false;
|
||||
data.sponsorshipPromotion = false;
|
||||
data.kodikIframeAd = false;
|
||||
data.kodikAdIframeUrl = "";
|
||||
|
||||
if (process.env.HOST_URL) {
|
||||
data.iframeEmbedUrl = `${process.env.HOST_URL}/player?url=`;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
14
api-prox/iframe.ts
Normal file
14
api-prox/iframe.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
export const Iframe = (url: string) => {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Веб-плеер</title>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=yes' />
|
||||
<style>body, html {height: 100%; width: 100%; margin: 0px;padding: 0px;border: 0px;}</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe src="${url}" width='100%' height='100%' frameborder='0' AllowFullScreen allow="autoplay"></iframe>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
366
api-prox/index.ts
Normal file
366
api-prox/index.ts
Normal file
|
@ -0,0 +1,366 @@
|
|||
import {
|
||||
ANIXART_API,
|
||||
ANIXART_HEADERS,
|
||||
ANIXART_HEADERST,
|
||||
asJSON,
|
||||
GetHook,
|
||||
logger,
|
||||
PostHook,
|
||||
} from "./shared";
|
||||
import express from "express";
|
||||
import fs from "fs/promises";
|
||||
import { MediaChromeTheme } from "./media-chrome";
|
||||
import { Iframe } from "./iframe";
|
||||
|
||||
const app = express();
|
||||
app.use(function (req, res, next) {
|
||||
res.header("Access-Control-Allow-Origin", req.headers.origin || "*");
|
||||
res.header(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Origin, X-Requested-With, Content-Type, Accept, Sign"
|
||||
);
|
||||
res.header("Access-Control-Allow-Methods", "GET,HEAD,POST,OPTIONS");
|
||||
next();
|
||||
});
|
||||
app.use(
|
||||
express.raw({ inflate: true, limit: "50mb", type: "multipart/form-data" })
|
||||
);
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
const HOST = process.env.HOST || "0.0.0.0";
|
||||
const PORT = 7001;
|
||||
|
||||
let hooks: string[] = [];
|
||||
|
||||
async function loadHooks() {
|
||||
let hooksDir: string[] = [];
|
||||
try {
|
||||
hooksDir = await fs.readdir("./hooks");
|
||||
} catch (err) {
|
||||
logger.error("'hooks' directory not found");
|
||||
}
|
||||
|
||||
for (let i = 0; i < hooksDir.length; i++) {
|
||||
const name = hooksDir[i];
|
||||
if (
|
||||
!name.endsWith(".ts") ||
|
||||
name.includes("example") ||
|
||||
name.includes("disabled")
|
||||
)
|
||||
continue;
|
||||
|
||||
require(`./hooks/${name}`);
|
||||
logger.infoHook(`Loaded "./hooks/${name}"`);
|
||||
hooks.push(name);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const watcher = fs.watch(`./hooks/${name}`);
|
||||
for await (const event of watcher) {
|
||||
if (event.eventType === "change") {
|
||||
logger.infoHook(`Updated "./hooks/${event.filename}"`);
|
||||
delete require.cache[require.resolve(`./hooks/${event.filename}`)];
|
||||
require(`./hooks/${event.filename}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
app.get("/player", async (req, res) => {
|
||||
let url = req.query.url || null;
|
||||
|
||||
res.status(200);
|
||||
res.set({
|
||||
"Access-Control-Allow-Origin": req.headers.origin || "*",
|
||||
"Access-Control-Allow-Methods": "GET,HEAD,POST,OPTIONS",
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
});
|
||||
if (!url) {
|
||||
res.send("<h1>No url query found!</h1>");
|
||||
return;
|
||||
}
|
||||
|
||||
let player = "";
|
||||
let poster = "";
|
||||
|
||||
const CUSTOM_PLAYER_DOMAINS = [
|
||||
"video.sibnet.ru",
|
||||
"anixart.libria.fun",
|
||||
"kodik.info",
|
||||
"aniqit.com",
|
||||
"kodik.cc",
|
||||
"kodik.biz",
|
||||
];
|
||||
const urlDomain = new URL(url.toString());
|
||||
const PLAYER_PARSER_URL = process.env.PLAYER_PARSER_URL || null;
|
||||
|
||||
if (CUSTOM_PLAYER_DOMAINS.includes(urlDomain.hostname)) {
|
||||
try {
|
||||
if (!PLAYER_PARSER_URL) throw new Error();
|
||||
|
||||
if (
|
||||
["kodik.info", "aniqit.com", "kodik.cc", "kodik.biz"].includes(
|
||||
urlDomain.hostname
|
||||
)
|
||||
) {
|
||||
player = "kodik";
|
||||
}
|
||||
if ("anixart.libria.fun" == urlDomain.hostname) {
|
||||
player = "libria";
|
||||
}
|
||||
if ("video.sibnet.ru" == urlDomain.hostname) {
|
||||
player = "sibnet";
|
||||
}
|
||||
|
||||
const playerParserRes = await fetch(
|
||||
`${PLAYER_PARSER_URL}?url=${encodeURIComponent(url.toString())}&player=${player}`
|
||||
);
|
||||
|
||||
if (!playerParserRes.ok) throw new Error();
|
||||
|
||||
const playerParserData: { manifest: string; poster: string } =
|
||||
await playerParserRes.json();
|
||||
|
||||
poster = playerParserData.poster;
|
||||
if (playerParserData.manifest.startsWith("#EXTM3U")) {
|
||||
const playerUrlArray = playerParserData.manifest.split("\n");
|
||||
url = playerUrlArray.join("\\n");
|
||||
} else {
|
||||
url = playerParserData.manifest;
|
||||
}
|
||||
} catch {
|
||||
res.send(Iframe(url.toString()));
|
||||
return;
|
||||
}
|
||||
} else if (url.toString().toLowerCase().endsWith("mp4")) {
|
||||
player = "mp4";
|
||||
} else if (url.toString().toLowerCase().endsWith(".m3u8")) {
|
||||
player = "hls";
|
||||
} else {
|
||||
res.send(Iframe(url.toString()));
|
||||
return;
|
||||
}
|
||||
|
||||
res.send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Веб-плеер</title>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=yes' />
|
||||
<style>body, html {height: 100%; width: 100%; margin: 0px;padding: 0px;border: 0px;}</style>
|
||||
${["kodik", "libria", "hls"].includes(player) ? '<script type="module" src="https://cdn.jsdelivr.net/npm/hls-video-element@1.2/+esm"></script>' : ""}
|
||||
<script>
|
||||
window.onload = () => {
|
||||
const url = "${url}";
|
||||
const poster = "${poster}";
|
||||
const element = document.querySelector("#video-element");
|
||||
|
||||
element.poster = poster;
|
||||
if (url.startsWith("http")) {
|
||||
element.src = url;
|
||||
} else {
|
||||
let file = new File([url], "manifest.m3u8", {
|
||||
type: "application/x-mpegURL",
|
||||
});
|
||||
element.src = URL.createObjectURL(file);
|
||||
};
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
${MediaChromeTheme()}
|
||||
|
||||
<media-theme
|
||||
template="media-theme-sutro"
|
||||
style="width:100%;height:100%;">
|
||||
${
|
||||
["kodik", "libria", "hls"].includes(player) ?
|
||||
`<hls-video slot="media" playsinline id="video-element"></hls-video>`
|
||||
: `<video slot="media" playsinline id="video-element"></video>`
|
||||
}
|
||||
</media-theme>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
});
|
||||
|
||||
app.get("/*path", async (req, res) => {
|
||||
if (req.path == "/favicon.ico") return asJSON(req, res, {}, 404);
|
||||
|
||||
const url = new URL(`${ANIXART_API}${req.url}`);
|
||||
logger.debug(
|
||||
`[${req.method}] ${url.protocol}//${url.hostname}${url.pathname}`
|
||||
);
|
||||
// logger.debug(` ↳ [QUERY] ${url.searchParams.toString()}`);
|
||||
|
||||
if (
|
||||
url.searchParams.get("API-Version") == "v2" ||
|
||||
req.headers["api-version"] == "v2"
|
||||
) {
|
||||
// logger.debug(` ↳ Force API V2`);
|
||||
ANIXART_HEADERS["Api-Version"] = "v2";
|
||||
url.searchParams.delete("API-Version");
|
||||
}
|
||||
|
||||
const apiResponse = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: ANIXART_HEADERS,
|
||||
});
|
||||
|
||||
if (
|
||||
!apiResponse ||
|
||||
!apiResponse.ok ||
|
||||
apiResponse.headers.get("content-type") != "application/json"
|
||||
) {
|
||||
logger.error(
|
||||
`Failed to fetch: '${url.protocol}//${url.hostname}${url.pathname}', Path probably doesn't exist`
|
||||
);
|
||||
asJSON(
|
||||
req,
|
||||
res,
|
||||
{
|
||||
code: 99,
|
||||
returned_value: {
|
||||
request_status: apiResponse ? apiResponse.status : null,
|
||||
request_content_type:
|
||||
apiResponse ? apiResponse.headers.get("content-type") : null,
|
||||
},
|
||||
reason: "Path probably doesn't exist",
|
||||
},
|
||||
500
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let data = await apiResponse.json();
|
||||
for (let i = 0; i < hooks.length; i++) {
|
||||
const name = hooks[i];
|
||||
const hook: GetHook = require(`./hooks/${name}`);
|
||||
if (!hook.hasOwnProperty("match") || !hook.hasOwnProperty("get")) continue;
|
||||
if (!hook.match(req.path)) continue;
|
||||
data = await hook.get(data, url);
|
||||
}
|
||||
|
||||
asJSON(req, res, data, 200);
|
||||
return;
|
||||
});
|
||||
|
||||
app.post("/*path", async (req, res) => {
|
||||
const url = new URL(`${ANIXART_API}${req.url}`);
|
||||
logger.debug(
|
||||
`[${req.method}] ${url.protocol}//${url.hostname}${url.pathname}`
|
||||
);
|
||||
// logger.debug(` ↳ [QUERY] ${url.searchParams.toString()}`);
|
||||
|
||||
let reqContentType =
|
||||
req.headers["content-type"] ?
|
||||
req.headers["content-type"].split(";")[0]
|
||||
: "x-unknown/unknown";
|
||||
|
||||
const supportedContentTypes = [
|
||||
"application/json",
|
||||
"application/x-www-form-urlencoded",
|
||||
"multipart/form-data",
|
||||
];
|
||||
|
||||
const isSupported = supportedContentTypes.includes(
|
||||
reqContentType.toLowerCase()
|
||||
);
|
||||
|
||||
if (!isSupported) {
|
||||
res.status(500).json({
|
||||
code: 99,
|
||||
error: "Unsupported Media Type",
|
||||
reason: `Content-Type '${reqContentType}' is not supported.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let apiResponse: null | Response = null;
|
||||
const apiHeaders: ANIXART_HEADERST = {
|
||||
"User-Agent": ANIXART_HEADERS["User-Agent"],
|
||||
"Content-Type": req.headers["content-type"] || "application/json"
|
||||
};
|
||||
|
||||
if (
|
||||
url.searchParams.get("API-Version") == "v2" ||
|
||||
req.headers["api-version"] == "v2"
|
||||
) {
|
||||
// logger.debug(` ↳ Force API V2`);
|
||||
apiHeaders["Api-Version"] = "v2";
|
||||
url.searchParams.delete("API-Version");
|
||||
}
|
||||
|
||||
switch (reqContentType) {
|
||||
case "multipart/form-data":
|
||||
apiResponse = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers: apiHeaders,
|
||||
body: req.body,
|
||||
});
|
||||
break;
|
||||
case "application/x-www-form-urlencoded":
|
||||
apiResponse = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers: apiHeaders,
|
||||
body: new URLSearchParams(req.body),
|
||||
});
|
||||
break;
|
||||
case "application/json":
|
||||
apiResponse = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers: apiHeaders,
|
||||
body: JSON.stringify(req.body),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
!apiResponse ||
|
||||
!apiResponse.ok ||
|
||||
apiResponse.headers.get("content-type") != "application/json"
|
||||
) {
|
||||
logger.error(
|
||||
`Failed to post: '${url.protocol}//${url.hostname}${url.pathname}', Path probably doesn't exist`
|
||||
);
|
||||
asJSON(
|
||||
req,
|
||||
res,
|
||||
{
|
||||
code: 99,
|
||||
returned_value: {
|
||||
request_status: apiResponse ? apiResponse.status : null,
|
||||
request_content_type:
|
||||
apiResponse ? apiResponse.headers.get("content-type") : null,
|
||||
},
|
||||
reason: "Path probably doesn't exist",
|
||||
},
|
||||
500
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let data = await apiResponse.json();
|
||||
for (let i = 0; i < hooks.length; i++) {
|
||||
const name = hooks[i];
|
||||
const hook: PostHook = require(`./hooks/${name}`);
|
||||
if (!hook.hasOwnProperty("match") || !hook.hasOwnProperty("post")) continue;
|
||||
if (!hook.match(req.path)) continue;
|
||||
data = await hook.post(data, url);
|
||||
}
|
||||
|
||||
asJSON(req, res, data, 200);
|
||||
return;
|
||||
});
|
||||
|
||||
app.listen(PORT, HOST, function () {
|
||||
loadHooks();
|
||||
logger.info(`Server listen: http://${HOST}:${PORT}`);
|
||||
});
|
813
api-prox/media-chrome.ts
Normal file
813
api-prox/media-chrome.ts
Normal file
|
@ -0,0 +1,813 @@
|
|||
export const MediaChromeTheme = () => {
|
||||
return `
|
||||
<script type="module" src="https://cdn.jsdelivr.net/npm/media-chrome/+esm"></script>
|
||||
<script type="module" src="https://cdn.jsdelivr.net/npm/media-chrome/menu/+esm"></script>
|
||||
<script type="module" src="https://cdn.jsdelivr.net/npm/media-chrome/media-theme-element/+esm"></script>
|
||||
|
||||
<template id="media-theme-sutro">
|
||||
<!-- Sutro -->
|
||||
<style>
|
||||
:host {
|
||||
--_primary-color: var(--media-primary-color, #fff);
|
||||
--_secondary-color: var(--media-secondary-color, transparent);
|
||||
--_accent-color: var(--media-accent-color, #fff);
|
||||
}
|
||||
|
||||
media-controller {
|
||||
--base: 18px;
|
||||
|
||||
font-size: calc(0.75 * var(--base));
|
||||
font-family: Roboto, Arial, sans-serif;
|
||||
--media-font-family: Roboto, helvetica neue, segoe ui, arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
||||
--media-primary-color: #fff;
|
||||
--media-secondary-color: transparent;
|
||||
--media-menu-background: rgba(28, 28, 28, 0.6);
|
||||
--media-text-color: var(--_primary-color);
|
||||
--media-control-hover-background: var(--media-secondary-color);
|
||||
|
||||
--media-range-track-height: calc(0.125 * var(--base));
|
||||
--media-range-thumb-height: var(--base);
|
||||
--media-range-thumb-width: var(--base);
|
||||
--media-range-thumb-border-radius: var(--base);
|
||||
|
||||
--media-control-height: calc(2 * var(--base));
|
||||
}
|
||||
|
||||
media-controller[breakpointmd] {
|
||||
--base: 20px;
|
||||
}
|
||||
|
||||
/* The biggest size controller is tied to going fullscreen
|
||||
instead of a player width */
|
||||
media-controller[mediaisfullscreen] {
|
||||
--base: 24px;
|
||||
}
|
||||
|
||||
.media-button {
|
||||
--media-control-hover-background: var(--_secondary-color);
|
||||
--media-tooltip-background: rgb(28 28 28 / .24);
|
||||
--media-text-content-height: 1.2;
|
||||
--media-tooltip-padding: .7em 1em;
|
||||
--media-tooltip-distance: 8px;
|
||||
--media-tooltip-container-margin: 18px;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
opacity: 0.9;
|
||||
transition: opacity 0.1s cubic-bezier(0.4, 0, 1, 1);
|
||||
}
|
||||
|
||||
.media-button svg {
|
||||
fill: none;
|
||||
stroke: var(--_primary-color);
|
||||
stroke-width: 1;
|
||||
stroke-linecap: 'round';
|
||||
stroke-linejoin: 'round';
|
||||
}
|
||||
|
||||
svg .svg-shadow {
|
||||
stroke: #000;
|
||||
stroke-opacity: 0.15;
|
||||
stroke-width: 2px;
|
||||
fill: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<media-controller
|
||||
breakpoints="md:480"
|
||||
defaultsubtitles="{{defaultsubtitles}}"
|
||||
defaultduration="{{defaultduration}}"
|
||||
gesturesdisabled="{{disabled}}"
|
||||
hotkeys="{{hotkeys}}"
|
||||
nohotkeys="{{nohotkeys}}"
|
||||
defaultstreamtype="on-demand"
|
||||
>
|
||||
<slot name="media" slot="media"></slot>
|
||||
<slot name="poster" slot="poster"></slot>
|
||||
<media-error-dialog slot="dialog"></media-error-dialog>
|
||||
|
||||
<!-- Controls Gradient -->
|
||||
<style>
|
||||
.media-gradient-bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: calc(8 * var(--base));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.media-gradient-bottom::before {
|
||||
content: '';
|
||||
--gradient-steps: hsl(0 0% 0% / 0) 0%, hsl(0 0% 0% / 0.013) 8.1%, hsl(0 0% 0% / 0.049) 15.5%,
|
||||
hsl(0 0% 0% / 0.104) 22.5%, hsl(0 0% 0% / 0.175) 29%, hsl(0 0% 0% / 0.259) 35.3%, hsl(0 0% 0% / 0.352) 41.2%,
|
||||
hsl(0 0% 0% / 0.45) 47.1%, hsl(0 0% 0% / 0.55) 52.9%, hsl(0 0% 0% / 0.648) 58.8%, hsl(0 0% 0% / 0.741) 64.7%,
|
||||
hsl(0 0% 0% / 0.825) 71%, hsl(0 0% 0% / 0.896) 77.5%, hsl(0 0% 0% / 0.951) 84.5%, hsl(0 0% 0% / 0.987) 91.9%,
|
||||
hsl(0 0% 0%) 100%;
|
||||
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.7;
|
||||
background: linear-gradient(to bottom, var(--gradient-steps));
|
||||
}
|
||||
</style>
|
||||
<div class="media-gradient-bottom"></div>
|
||||
|
||||
<!-- Settings Menu -->
|
||||
<style>
|
||||
media-settings-menu {
|
||||
--media-menu-icon-height: 20px;
|
||||
--media-menu-item-icon-height: 20px;
|
||||
--media-settings-menu-min-width: calc(10 * var(--base));
|
||||
--media-menu-transform-in: translateY(0) scale(1);
|
||||
--media-menu-transform-out: translateY(20px) rotate(3deg) scale(1);
|
||||
padding-block: calc(0.15 * var(--base));
|
||||
margin-right: 10px;
|
||||
margin-bottom: 17px;
|
||||
border-radius: 8px;
|
||||
z-index: 2;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
media-settings-menu-item,
|
||||
[role='menu']::part(menu-item) {
|
||||
--media-icon-color: var(--_primary-color);
|
||||
margin-inline: calc(0.45 * var(--base));
|
||||
height: calc(1.6 * var(--base));
|
||||
font-size: calc(0.7 * var(--base));
|
||||
font-weight: 400;
|
||||
padding: 0;
|
||||
padding-left: calc(0.4 * var(--base));
|
||||
padding-right: calc(0.1 * var(--base));
|
||||
border-radius: 6px;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
[slot='submenu']::part(back button) {
|
||||
font-size: calc(0.7 * var(--base));
|
||||
}
|
||||
|
||||
media-settings-menu-item:hover {
|
||||
--media-icon-color: #000;
|
||||
color: #000;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
media-settings-menu-item:hover [slot='submenu']::part(menu-item),
|
||||
[slot='submenu']::part(back indicator) {
|
||||
--media-icon-color: var(--_primary-color);
|
||||
}
|
||||
|
||||
media-settings-menu-item:hover [slot='submenu']::part(menu-item):hover {
|
||||
--media-icon-color: #000;
|
||||
color: #000;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
media-settings-menu-item[submenusize='0'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Also hide if only Auto is added. */
|
||||
.quality-settings[submenusize='1'] {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<media-settings-menu hidden anchor="auto">
|
||||
<media-settings-menu-item>
|
||||
Playback Speed
|
||||
<media-playback-rate-menu slot="submenu" hidden rates="0.5 0.75 1 1.25 1.5 1.75 2">
|
||||
<div slot="title">Playback Speed</div>
|
||||
</media-playback-rate-menu>
|
||||
</media-settings-menu-item>
|
||||
<media-settings-menu-item class="quality-settings">
|
||||
Quality
|
||||
<media-rendition-menu slot="submenu" hidden>
|
||||
<div slot="title">Quality</div>
|
||||
</media-rendition-menu>
|
||||
</media-settings-menu-item>
|
||||
<media-settings-menu-item>
|
||||
Subtitles/CC
|
||||
<media-captions-menu slot="submenu" hidden>
|
||||
<div slot="title">Subtitles/CC</div>
|
||||
</media-captions-menu>
|
||||
</media-settings-menu-item>
|
||||
</media-settings-menu>
|
||||
|
||||
<!-- Control Bar -->
|
||||
<style>
|
||||
media-control-bar {
|
||||
position: absolute;
|
||||
height: calc(2 * var(--base));
|
||||
line-height: calc(2 * var(--base));
|
||||
bottom: var(--base);
|
||||
left: var(--base);
|
||||
right: var(--base);
|
||||
}
|
||||
</style>
|
||||
|
||||
<media-control-bar>
|
||||
<!-- Play/Pause -->
|
||||
<style>
|
||||
@keyframes bounce-scale-play {
|
||||
0% {
|
||||
transform: scale(0.75, 0.75);
|
||||
}
|
||||
50% {
|
||||
transform: scale(115%, 115%);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.media-button {
|
||||
border-radius: 25%;
|
||||
backdrop-filter: blur(10px) invert(15%) brightness(80%) opacity(0);
|
||||
-webkit-backdrop-filter: blur(10px) invert(15%) brightness(80%) opacity(0);
|
||||
transition: backdrop-filter 0.3s, -webkit-backdrop-filter 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.media-button:hover {
|
||||
/* background-color: rgba(0, 0, 0, 0.05); */
|
||||
box-shadow: rgba(0, 0, 0, 0.3) 0px 0px 5px;
|
||||
/* hue-rotate(120deg) */
|
||||
backdrop-filter: blur(10px) invert(15%) brightness(80%) opacity(1);
|
||||
-webkit-backdrop-filter: blur(10px) invert(15%) brightness(80%) opacity(1);
|
||||
transition: backdrop-filter 0.3s, -webkit-backdrop-filter 0.3s;
|
||||
}
|
||||
|
||||
media-play-button #icon-play {
|
||||
opacity: 0;
|
||||
transform-box: view-box;
|
||||
transform-origin: center center;
|
||||
transform: scale(0.5, 0.5);
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
media-play-button[mediapaused] #icon-play {
|
||||
opacity: 1;
|
||||
transform: scale(1, 1);
|
||||
animation: 0.35s bounce-scale-play ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes bounce-pause-left {
|
||||
0% {
|
||||
font-size: 10px;
|
||||
}
|
||||
50% {
|
||||
font-size: 3px;
|
||||
}
|
||||
100% {
|
||||
font-size: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce-pause-right {
|
||||
0% {
|
||||
font-size: 10px;
|
||||
transform: translateX(-8px);
|
||||
}
|
||||
50% {
|
||||
font-size: 3px;
|
||||
transform: translateX(1px);
|
||||
}
|
||||
100% {
|
||||
font-size: 4px;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
media-play-button #pause-left,
|
||||
media-play-button #pause-right {
|
||||
/* Using font-size to animate height because using scale was resulting in unexpected positioning */
|
||||
font-size: 4px;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
transform-box: view-box;
|
||||
}
|
||||
|
||||
media-play-button:not([mediapaused]) #pause-left {
|
||||
animation: 0.3s bounce-pause-left ease-out;
|
||||
}
|
||||
|
||||
media-play-button:not([mediapaused]) #pause-right {
|
||||
animation: 0.3s bounce-pause-right ease-out;
|
||||
}
|
||||
|
||||
media-play-button[mediapaused] #pause-left,
|
||||
media-play-button[mediapaused] #pause-right {
|
||||
opacity: 0;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
media-play-button[mediapaused] #pause-right {
|
||||
transform-origin: right center;
|
||||
transform: translateX(-8px);
|
||||
}
|
||||
</style>
|
||||
|
||||
<media-play-button mediapaused class="media-button">
|
||||
<svg slot="icon" viewBox="0 0 32 32">
|
||||
<!-- <use class="svg-shadow" xlink:href="#icon-play"></use> -->
|
||||
<g>
|
||||
<path
|
||||
id="icon-play"
|
||||
d="M20.7131 14.6976C21.7208 15.2735 21.7208 16.7265 20.7131 17.3024L12.7442 21.856C11.7442 22.4274 10.5 21.7054 10.5 20.5536L10.5 11.4464C10.5 10.2946 11.7442 9.57257 12.7442 10.144L20.7131 14.6976Z"
|
||||
/>
|
||||
</g>
|
||||
<!-- <use class="svg-shadow" xlink:href="#icon-pause"></use> -->
|
||||
<g id="icon-pause">
|
||||
<rect id="pause-left" x="10.5" width="1em" y="10.5" height="11" rx="0.5" />
|
||||
<rect id="pause-right" x="17.5" width="1em" y="10.5" height="11" rx="0.5" />
|
||||
</g>
|
||||
</svg>
|
||||
</media-play-button>
|
||||
|
||||
<!-- Volume/Mute -->
|
||||
<style>
|
||||
media-mute-button {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
media-mute-button .muted-path {
|
||||
transition: clip-path 0.2s ease-out;
|
||||
}
|
||||
|
||||
media-mute-button #muted-path-2 {
|
||||
transition-delay: 0.2s;
|
||||
}
|
||||
|
||||
media-mute-button .muted-path {
|
||||
clip-path: inset(0);
|
||||
}
|
||||
|
||||
media-mute-button:not([mediavolumelevel='off']) #muted-path-1 {
|
||||
clip-path: inset(0 0 100% 0);
|
||||
}
|
||||
|
||||
media-mute-button:not([mediavolumelevel='off']) #muted-path-2 {
|
||||
clip-path: inset(0 0 100% 0);
|
||||
}
|
||||
|
||||
media-mute-button .muted-path {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
media-mute-button[mediavolumelevel='off'] .muted-path {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
media-mute-button .vol-path {
|
||||
opacity: 1;
|
||||
transition: opacity 0.4s;
|
||||
}
|
||||
|
||||
media-mute-button[mediavolumelevel='off'] .vol-path {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
media-mute-button[mediavolumelevel='low'] #vol-high-path,
|
||||
media-mute-button[mediavolumelevel='medium'] #vol-high-path {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
media-volume-range {
|
||||
--media-range-track-background: rgba(255, 255, 255, 0.2);
|
||||
--media-range-thumb-opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes volume-in {
|
||||
0% {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transform: translateY(50%) rotate(1deg);
|
||||
}
|
||||
50% {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
100% {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transform: translateY(0) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes volume-out {
|
||||
0% {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transform: translateY(0) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transform: translateY(50%) rotate(1deg);
|
||||
}
|
||||
}
|
||||
|
||||
.media-volume-range-wrapper {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
|
||||
position: absolute;
|
||||
top: -100%;
|
||||
left: calc(2 * var(--base));
|
||||
|
||||
width: calc(10 * var(--base));
|
||||
height: calc(2.5 * var(--base));
|
||||
transform-origin: center left;
|
||||
}
|
||||
|
||||
media-volume-range {
|
||||
/*
|
||||
Hide range and animation until mediavolume attribute is set.
|
||||
visibility didn't work, hovering over media-volume-range-wrapper
|
||||
caused it to show. Should require mute-button:hover.
|
||||
*/
|
||||
opacity: 0;
|
||||
transition: opacity 0s 1s;
|
||||
|
||||
width: calc(10 * var(--base));
|
||||
height: var(--base);
|
||||
padding: 0;
|
||||
border-radius: calc(0.25 * var(--base));
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
|
||||
--media-range-bar-color: var(--media-accent-color);
|
||||
|
||||
--media-range-padding-left: 0;
|
||||
--media-range-padding-right: 0;
|
||||
|
||||
--media-range-track-width: calc(10 * var(--base));
|
||||
--media-range-track-height: var(--base);
|
||||
--media-range-track-border-radius: calc(0.25 * var(--base));
|
||||
--media-range-track-backdrop-filter: blur(10px) brightness(80%);
|
||||
|
||||
/* This makes zero volume still show some of the bar.
|
||||
I can't make the bar have curved corners otherwise though. */
|
||||
--media-range-thumb-width: var(--base);
|
||||
--media-range-thumb-border-radius: calc(0.25 * var(--base));
|
||||
|
||||
/* The Sutro design has a gradient like this, but not sure I like it */
|
||||
/* --media-range-thumb-box-shadow: 10px 0px 20px rgba(255, 255, 255, 0.5); */
|
||||
}
|
||||
|
||||
media-volume-range[mediavolume] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[keyboardcontrol] media-volume-range:focus {
|
||||
/* TODO: This appears to be creating a think outline */
|
||||
outline: 1px solid rgba(27, 127, 204, 0.9);
|
||||
}
|
||||
|
||||
media-mute-button:hover + .media-volume-range-wrapper,
|
||||
media-mute-button:focus + .media-volume-range-wrapper,
|
||||
media-mute-button:focus-within + .media-volume-range-wrapper,
|
||||
.media-volume-range-wrapper:hover,
|
||||
.media-volume-range-wrapper:focus,
|
||||
.media-volume-range-wrapper:focus-within {
|
||||
animation: 0.3s volume-in forwards ease-out;
|
||||
}
|
||||
|
||||
.media-volume-range-wrapper:not(:hover, :focus-within) {
|
||||
animation: 0.3s volume-out ease-out;
|
||||
}
|
||||
|
||||
/* When keyboard navigating the volume range and wrapper need to always be visible
|
||||
otherwise focus state can't land on it. This is ok when keyboard navigating because
|
||||
the hovering issues aren't a concern, unless you happen to be keyboard AND mouse navigating.
|
||||
*/
|
||||
[keyboardcontrol] .media-volume-range-wrapper,
|
||||
[keyboardcontrol] .media-volume-range-wrapper:focus-within,
|
||||
[keyboardcontrol] .media-volume-range-wrapper:focus-within media-volume-range {
|
||||
visibility: visible;
|
||||
}
|
||||
</style>
|
||||
|
||||
<media-mute-button class="media-button" notooltip>
|
||||
<use class="svg-shadow" xlink:href="#vol-paths"></use>
|
||||
<svg slot="icon" viewBox="0 0 32 32">
|
||||
<g id="vol-paths">
|
||||
<path
|
||||
id="speaker-path"
|
||||
d="M16.5 20.486v-8.972c0-1.537-2.037-2.08-2.802-.745l-1.026 1.79a2.5 2.5 0 0 1-.8.85l-1.194.78A1.5 1.5 0 0 0 10 15.446v1.11c0 .506.255.978.678 1.255l1.194.782a2.5 2.5 0 0 1 .8.849l1.026 1.79c.765 1.334 2.802.792 2.802-.745Z"
|
||||
/>
|
||||
<path
|
||||
id="vol-low-path"
|
||||
class="vol-path"
|
||||
d="M18.5 18C19.6046 18 20.5 17.1046 20.5 16C20.5 14.8954 19.6046 14 18.5 14"
|
||||
/>
|
||||
<path
|
||||
id="vol-high-path"
|
||||
class="vol-path"
|
||||
d="M18 21C20.7614 21 23 18.7614 23 16C23 13.2386 20.7614 11 18 11"
|
||||
/>
|
||||
<path id="muted-path-1" class="muted-path" d="M23 18L19 14" />
|
||||
<path id="muted-path-2" class="muted-path" d="M23 14L19 18" />
|
||||
</g>
|
||||
</svg>
|
||||
</media-mute-button>
|
||||
<div class="media-volume-range-wrapper">
|
||||
<media-volume-range></media-volume-range>
|
||||
</div>
|
||||
|
||||
<!-- Time Display -->
|
||||
<style>
|
||||
media-time-display {
|
||||
position: relative;
|
||||
padding: calc(0.5 * var(--base));
|
||||
font-size: calc(0.7 * var(--base));
|
||||
border-radius: calc(0.5 * var(--base));
|
||||
}
|
||||
|
||||
media-controller[breakpointmd] media-time-display:not([showduration]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
media-controller:not([breakpointmd]) media-time-display[showduration] {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
<media-time-display></media-time-display>
|
||||
<media-time-display showduration></media-time-display>
|
||||
|
||||
<!-- Time Range / Progress Bar -->
|
||||
<style>
|
||||
media-time-range {
|
||||
height: calc(2 * var(--base));
|
||||
border-radius: calc(0.25 * var(--base));
|
||||
|
||||
--media-range-track-backdrop-filter: invert(10%) blur(5px) brightness(110%);
|
||||
--media-range-track-background: rgba(255, 255, 255, 0.2);
|
||||
--media-range-track-pointer-background: rgba(255, 255, 255, 0.5);
|
||||
--media-range-track-border-radius: calc(0.25 * var(--base));
|
||||
|
||||
--media-time-range-buffered-color: rgba(255, 255, 255, 0.4);
|
||||
--media-range-bar-color: var(--media-accent-color);
|
||||
|
||||
--media-range-thumb-background: var(--media-accent-color);
|
||||
--media-range-thumb-transition: opacity 0.1s linear;
|
||||
--media-range-thumb-opacity: 0;
|
||||
|
||||
--media-preview-thumbnail-border: calc(0.125 * var(--base)) solid #fff;
|
||||
--media-preview-thumbnail-border-radius: calc(0.5 * var(--base));
|
||||
--media-preview-thumbnail-min-width: calc(8 * var(--base));
|
||||
--media-preview-thumbnail-max-width: calc(10 * var(--base));
|
||||
--media-preview-thumbnail-min-height: calc(5 * var(--base));
|
||||
--media-preview-thumbnail-max-height: calc(7 * var(--base));
|
||||
--media-preview-box-margin: 0 0 -10px;
|
||||
}
|
||||
media-time-range:hover {
|
||||
--media-range-thumb-opacity: 1;
|
||||
--media-range-track-height: calc(0.25 * var(--base));
|
||||
}
|
||||
|
||||
media-preview-thumbnail {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
media-preview-chapter-display {
|
||||
font-size: calc(0.6 * var(--base));
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
media-preview-time-display {
|
||||
font-size: calc(0.65 * var(--base));
|
||||
padding-top: 0;
|
||||
}
|
||||
</style>
|
||||
<media-time-range>
|
||||
<media-preview-thumbnail slot="preview"></media-preview-thumbnail>
|
||||
<media-preview-chapter-display slot="preview"></media-preview-chapter-display>
|
||||
<media-preview-time-display slot="preview"></media-preview-time-display>
|
||||
</media-time-range>
|
||||
|
||||
<media-seek-backward-button class="media-button" seekoffset="10">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
slot="icon"
|
||||
class="svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="-8 -8 40 40"
|
||||
>
|
||||
<g
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path
|
||||
strokeLinejoin="round"
|
||||
d="M14 4.5L12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12c0-4.1 2.468-7.625 6-9.168"
|
||||
/>
|
||||
<path strokeLinejoin="round" d="m7.5 10.5l2.5-2v7" />
|
||||
<path d="M12.5 13.75v-3.5a1.75 1.75 0 1 1 3.5 0v3.5a1.75 1.75 0 1 1-3.5 0Z" />
|
||||
</g>
|
||||
</svg>
|
||||
</media-seek-backward-button>
|
||||
|
||||
<media-seek-forward-button class="media-button" seekoffset="10">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
slot="icon"
|
||||
class="svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="-8 -8 40 40"
|
||||
>
|
||||
<g
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path
|
||||
strokeLinejoin="round"
|
||||
d="M10 4.5L12 2C6.477 2 2 6.477 2 12s4.477 10 10 10s10-4.477 10-10c0-4.1-2.468-7.625-6-9.168"
|
||||
/>
|
||||
<path strokeLinejoin="round" d="m7.5 10.5l2.5-2v7" />
|
||||
<path d="M12.5 13.75v-3.5a1.75 1.75 0 1 1 3.5 0v3.5a1.75 1.75 0 1 1-3.5 0Z" />
|
||||
</g>
|
||||
</svg>
|
||||
</media-seek-forward-button>
|
||||
|
||||
<media-seek-forward-button class="media-button" seekoffset="90">
|
||||
<svg
|
||||
slot="icon"
|
||||
class="svg"
|
||||
width="256"
|
||||
height="256"
|
||||
viewBox="-65 -75 400 400"
|
||||
>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="m246.52 118l-88.19-56.13a12 12 0 0 0-12.18-.39A11.66 11.66 0 0 0 140 71.84v44.59L54.33 61.87a12 12 0 0 0-12.18-.39A11.66 11.66 0 0 0 36 71.84v112.32a11.66 11.66 0 0 0 6.15 10.36a12 12 0 0 0 12.18-.39L140 139.57v44.59a11.66 11.66 0 0 0 6.15 10.36a12 12 0 0 0 12.18-.39L246.52 138a11.81 11.81 0 0 0 0-19.94Zm-108.3 13.19L50 187.38a3.91 3.91 0 0 1-4 .13a3.76 3.76 0 0 1-2-3.35V71.84a3.76 3.76 0 0 1 2-3.35a4 4 0 0 1 1.91-.5a3.94 3.94 0 0 1 2.13.63l88.18 56.16a3.8 3.8 0 0 1 0 6.44Zm104 0L154 187.38a3.91 3.91 0 0 1-4 .13a3.76 3.76 0 0 1-2-3.35V71.84a3.76 3.76 0 0 1 2-3.35a4 4 0 0 1 1.91-.5a3.94 3.94 0 0 1 2.13.63l88.18 56.16a3.8 3.8 0 0 1 0 6.44Z"
|
||||
/>
|
||||
</svg>
|
||||
</media-seek-forward-button>
|
||||
|
||||
<!-- Settings Menu Button -->
|
||||
<style>
|
||||
media-settings-menu-button svg {
|
||||
transition: transform 0.1s cubic-bezier(0.4, 0, 1, 1);
|
||||
transform: rotateZ(0deg);
|
||||
}
|
||||
media-settings-menu-button[aria-expanded='true'] svg {
|
||||
transform: rotateZ(30deg);
|
||||
}
|
||||
</style>
|
||||
<media-settings-menu-button class="media-button">
|
||||
<svg slot="icon" viewBox="0 0 32 32">
|
||||
<use class="svg-shadow" xlink:href="#settings-icon"></use>
|
||||
<g id="settings-icon">
|
||||
<path
|
||||
d="M16 18C17.1046 18 18 17.1046 18 16C18 14.8954 17.1046 14 16 14C14.8954 14 14 14.8954 14 16C14 17.1046 14.8954 18 16 18Z"
|
||||
/>
|
||||
<path
|
||||
d="M21.0176 13.0362L20.9715 12.9531C20.8445 12.7239 20.7797 12.4629 20.784 12.1982L20.8049 10.8997C20.8092 10.6343 20.675 10.3874 20.4545 10.2549L18.5385 9.10362C18.3186 8.97143 18.0472 8.9738 17.8293 9.10981L16.7658 9.77382C16.5485 9.90953 16.2999 9.98121 16.0465 9.98121H15.9543C15.7004 9.98121 15.4513 9.90922 15.2336 9.77295L14.1652 9.10413C13.9467 8.96728 13.674 8.96518 13.4535 9.09864L11.5436 10.2545C11.3242 10.3873 11.1908 10.6336 11.1951 10.8981L11.216 12.1982C11.2203 12.4629 11.1555 12.7239 11.0285 12.9531L10.9831 13.0351C10.856 13.2645 10.6715 13.4535 10.4493 13.5819L9.36075 14.2109C9.13763 14.3398 8.99942 14.5851 9 14.8511L9.00501 17.152C9.00559 17.4163 9.1432 17.6597 9.36476 17.7883L10.4481 18.4167C10.671 18.546 10.8559 18.7364 10.9826 18.9673L11.0313 19.0559C11.1565 19.284 11.2203 19.5431 11.2161 19.8059L11.1951 21.1003C11.1908 21.3657 11.325 21.6126 11.5456 21.7452L13.4615 22.8964C13.6814 23.0286 13.9528 23.0262 14.1707 22.8902L15.2342 22.2262C15.4515 22.0905 15.7001 22.0188 15.9535 22.0188H16.0457C16.2996 22.0188 16.5487 22.0908 16.7664 22.227L17.8348 22.8959C18.0534 23.0327 18.326 23.0348 18.5465 22.9014L20.4564 21.7455C20.6758 21.6127 20.8092 21.3664 20.8049 21.1019L20.784 19.8018C20.7797 19.5371 20.8445 19.2761 20.9715 19.0469L21.0169 18.9649C21.144 18.7355 21.3285 18.5465 21.5507 18.4181L22.6393 17.7891C22.8624 17.6602 23.0006 17.4149 23 17.1489L22.995 14.848C22.9944 14.5837 22.8568 14.3403 22.6352 14.2117L21.5493 13.5818C21.328 13.4534 21.1442 13.2649 21.0176 13.0362Z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</media-settings-menu-button>
|
||||
|
||||
<!-- PIP/Mini Player Button -->
|
||||
<style>
|
||||
media-controller:not([breakpointmd]) media-pip-button {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
<media-pip-button class="media-button">
|
||||
<svg slot="icon" viewBox="0 0 32 32">
|
||||
<use class="svg-shadow" xlink:href="#pip-icon"></use>
|
||||
<g id="pip-icon">
|
||||
<path
|
||||
d="M12 22H9.77778C9.34822 22 9 21.6162 9 21.1429V10.8571C9 10.3838 9.34822 10 9.77778 10L22.2222 10C22.6518 10 23 10.3838 23 10.8571V12.5714"
|
||||
/>
|
||||
<path
|
||||
d="M15 21.5714V16.4286C15 16.1919 15.199 16 15.4444 16H22.5556C22.801 16 23 16.1919 23 16.4286V17V21.5714C23 21.8081 22.801 22 22.5556 22H20.3333H17.6667H15.4444C15.199 22 15 21.8081 15 21.5714Z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</media-pip-button>
|
||||
|
||||
<!-- Airplay Button -->
|
||||
<media-airplay-button class="media-button">
|
||||
<svg viewBox="0 0 32 32" aria-hidden="true" slot="icon">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.5 20h1.722c.43 0 .778-.32.778-.714v-8.572c0-.394-.348-.714-.778-.714H9.778c-.43 0-.778.32-.778.714v1.429"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.5 20H9.778c-.43 0-.778-.32-.778-.714v-8.572c0-.394.348-.714.778-.714h12.444c.43 0 .778.32.778.714v1.429"/>
|
||||
<path stroke-linejoin="round" d="m16 19 3.464 3.75h-6.928L16 19Z"/>
|
||||
</svg>
|
||||
</media-airplay-button>
|
||||
|
||||
<!-- Cast Button -->
|
||||
<media-cast-button class="media-button">
|
||||
<svg slot="icon" viewBox="0 0 32 32">
|
||||
<use class="svg-shadow" xlink:href="#cast-icon"></use>
|
||||
<g id="cast-icon">
|
||||
<path
|
||||
d="M18.5 21.833h4.167c.46 0 .833-.373.833-.833V11a.833.833 0 0 0-.833-.833H9.333A.833.833 0 0 0 8.5 11v1.111m0 8.056c.92 0 1.667.746 1.667 1.666M8.5 17.667a4.167 4.167 0 0 1 4.167 4.166"
|
||||
/>
|
||||
<path d="M8.5 15.167a6.667 6.667 0 0 1 6.667 6.666" />
|
||||
</g>
|
||||
</svg>
|
||||
</media-cast-button>
|
||||
|
||||
<!-- Fullscreen Button -->
|
||||
<style>
|
||||
/* Having trouble getting @property to work in the shadow dom
|
||||
to clean this up. Like https://codepen.io/luwes/pen/oNRyZyx */
|
||||
|
||||
media-fullscreen-button .fs-arrow {
|
||||
translate: 0% 0%;
|
||||
}
|
||||
media-fullscreen-button:hover .fs-arrow {
|
||||
animation: 0.35s up-left-bounce cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
media-fullscreen-button:hover #fs-enter-top,
|
||||
media-fullscreen-button:hover #fs-exit-bottom {
|
||||
animation-name: up-right-bounce;
|
||||
}
|
||||
|
||||
media-fullscreen-button:hover #fs-enter-bottom,
|
||||
media-fullscreen-button:hover #fs-exit-top {
|
||||
animation-name: down-left-bounce;
|
||||
}
|
||||
|
||||
@keyframes up-left-bounce {
|
||||
0% {
|
||||
translate: 0 0;
|
||||
}
|
||||
50% {
|
||||
translate: -4% -4%;
|
||||
}
|
||||
}
|
||||
@keyframes up-right-bounce {
|
||||
0% {
|
||||
translate: 0 0;
|
||||
}
|
||||
50% {
|
||||
translate: 4% -4%;
|
||||
}
|
||||
}
|
||||
@keyframes down-left-bounce {
|
||||
0% {
|
||||
translate: 0 0;
|
||||
}
|
||||
50% {
|
||||
translate: -4% 4%;
|
||||
}
|
||||
}
|
||||
@keyframes down-right-bounce {
|
||||
0% {
|
||||
translate: 0 0;
|
||||
}
|
||||
50% {
|
||||
translate: 4% 4%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<media-fullscreen-button class="media-button">
|
||||
<svg slot="enter" viewBox="0 0 32 32">
|
||||
<use class="svg-shadow" xlink:href="#fs-enter-paths"></use>
|
||||
<g id="fs-enter-paths">
|
||||
<g id="fs-enter-top" class="fs-arrow">
|
||||
<path d="M18 10H22V14" />
|
||||
<path d="M22 10L18 14" />
|
||||
</g>
|
||||
<g id="fs-enter-bottom" class="fs-arrow">
|
||||
<path d="M14 22L10 22V18" />
|
||||
<path d="M10 22L14 18" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<svg slot="exit" viewBox="0 0 32 32">
|
||||
<use class="svg-shadow" xlink:href="#fs-exit-paths"></use>
|
||||
<g id="fs-exit-paths">
|
||||
<g id="fs-exit-top" class="fs-arrow">
|
||||
<path d="M22 14H18V10" />
|
||||
<path d="M22 10L18 14" />
|
||||
</g>
|
||||
<g id="fs-exit-bottom" class="fs-arrow">
|
||||
<path d="M10 18L14 18V22" />
|
||||
<path d="M14 18L10 22" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</media-fullscreen-button>
|
||||
</media-control-bar>
|
||||
</media-controller>
|
||||
</template>
|
||||
`
|
||||
}
|
1453
api-prox/package-lock.json
generated
Normal file
1453
api-prox/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
20
api-prox/package.json
Normal file
20
api-prox/package.json
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "anix-api-proxy",
|
||||
"version": "1.0.0",
|
||||
"description": "Proxy and Hook requests from anix to anixart api",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"serve": "npx tsx ./index.ts"
|
||||
},
|
||||
"author": "Radiquum",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^24.0.4",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"tsx": "^4.20.3"
|
||||
}
|
||||
}
|
124
api-prox/shared.ts
Normal file
124
api-prox/shared.ts
Normal file
|
@ -0,0 +1,124 @@
|
|||
export const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Origin, X-Requested-With, Content-Type, Accept, Sign",
|
||||
"Access-Control-Allow-Methods": "GET,HEAD,POST,OPTIONS",
|
||||
"Cache-Control": "no-cache",
|
||||
};
|
||||
|
||||
import { Request, Response } from "express";
|
||||
export function asJSON(req: Request,res: Response, object: any, status: number) {
|
||||
corsHeaders["Access-Control-Allow-Origin"] = req.headers.origin || "*";
|
||||
|
||||
res.status(status).type("application/json");
|
||||
res.set(corsHeaders);
|
||||
res.send(JSON.stringify(object));
|
||||
}
|
||||
|
||||
export const ANIXART_UA =
|
||||
"AnixartApp/9.0 BETA 5-25062213 (Android 9; SDK 28; arm64-v8a; samsung SM-G975N; en)";
|
||||
export const ANIXART_API = "https://api.anixart.app";
|
||||
|
||||
export type ANIXART_HEADERST = {
|
||||
"User-Agent": string;
|
||||
"Content-Type": string;
|
||||
"Api-Version"?: string;
|
||||
};
|
||||
export const ANIXART_HEADERS: ANIXART_HEADERST = {
|
||||
"User-Agent": ANIXART_UA,
|
||||
"Content-Type": "application/json; charset=UTF-8",
|
||||
};
|
||||
|
||||
type LogLevel = "debug" | "info" | "warn" | "error" | "disable";
|
||||
export class Log {
|
||||
level: LogLevel;
|
||||
levelInt = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
disable: 4,
|
||||
};
|
||||
|
||||
constructor(level: LogLevel = "info") {
|
||||
this.level = level;
|
||||
}
|
||||
|
||||
getString(...args: string[]): string {
|
||||
return args.toString();
|
||||
}
|
||||
|
||||
getTime(): string {
|
||||
const datetime = new Date();
|
||||
return `${datetime.getHours().toString().padStart(2, "0")}:${datetime.getMinutes().toString().padStart(2, "0")}:${datetime.getSeconds().toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
console(logLevel: LogLevel = "info", ...msg: any[]) {
|
||||
if (this.levelInt[this.level] <= this.levelInt[logLevel])
|
||||
console.log(`[${logLevel.toUpperCase()}](${this.getTime()}) -> `, ...msg);
|
||||
}
|
||||
debug(...msg: string[]) {
|
||||
if (this.levelInt[this.level] <= 0)
|
||||
console.log(`[DEBUG](${this.getTime()}) -> ${this.getString(...msg)}`);
|
||||
}
|
||||
info(...msg: string[]) {
|
||||
if (this.levelInt[this.level] <= 1)
|
||||
console.log(`[INFO](${this.getTime()}) -> ${this.getString(...msg)}`);
|
||||
}
|
||||
warn(...msg: string[]) {
|
||||
if (this.levelInt[this.level] <= 2)
|
||||
console.log(`[WARN](${this.getTime()}) -> ${this.getString(...msg)}`);
|
||||
}
|
||||
error(...msg: string[]) {
|
||||
if (this.levelInt[this.level] <= 3)
|
||||
console.log(`[ERROR](${this.getTime()}) -> ${this.getString(...msg)}`);
|
||||
}
|
||||
|
||||
consoleHook(logLevel: LogLevel = "info", ...msg: any[]) {
|
||||
if (this.levelInt[this.level] <= this.levelInt[logLevel])
|
||||
console.log(
|
||||
`[${logLevel.toUpperCase()}|HOOK](${this.getTime()}) -> `,
|
||||
...msg
|
||||
);
|
||||
}
|
||||
debugHook(...msg: string[]) {
|
||||
if (this.levelInt[this.level] <= 0)
|
||||
console.log(
|
||||
`[DEBUG|HOOK](${this.getTime()}) -> ${this.getString(...msg)}`
|
||||
);
|
||||
}
|
||||
infoHook(...msg: string[]) {
|
||||
if (this.levelInt[this.level] <= 1)
|
||||
console.log(
|
||||
`[INFO|HOOK](${this.getTime()}) -> ${this.getString(...msg)}`
|
||||
);
|
||||
}
|
||||
warnHook(...msg: string[]) {
|
||||
if (this.levelInt[this.level] <= 2)
|
||||
console.log(
|
||||
`[WARN|HOOK](${this.getTime()}) -> ${this.getString(...msg)}`
|
||||
);
|
||||
}
|
||||
errorHook(...msg: string[]) {
|
||||
if (this.levelInt[this.level] <= 3)
|
||||
console.log(
|
||||
`[ERROR|HOOK](${this.getTime()}) -> ${this.getString(...msg)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Log((process.env.LOG_LEVEL as LogLevel) || "info");
|
||||
|
||||
export interface GetHook {
|
||||
match: (path: string) => boolean;
|
||||
get: (data: any, url: URL) => any;
|
||||
}
|
||||
|
||||
export interface PostHook {
|
||||
match: (path: string) => boolean;
|
||||
post: (body: any, url: URL) => any;
|
||||
}
|
||||
|
||||
export interface LoadedHook {
|
||||
path: string;
|
||||
mtime: string;
|
||||
}
|
113
api-prox/tsconfig.json
Normal file
113
api-prox/tsconfig.json
Normal file
|
@ -0,0 +1,113 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "libReplacement": true, /* Enable lib replacement. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "commonjs", /* Specify what module code is generated. */
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
||||
// "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
|
||||
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
||||
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
||||
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
||||
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
// "outDir": "./", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
||||
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
|
||||
// "erasableSyntaxOnly": true, /* Do not allow runtime constructs that are not part of ECMAScript. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
}
|
|
@ -1,9 +1,11 @@
|
|||
export const CURRENT_APP_VERSION = "3.7.0";
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
export const API_URL = "https://api.anixart.tv";
|
||||
export const API_PREFIX = "/api/proxy";
|
||||
const NEXT_PUBLIC_API_URL = env("NEXT_PUBLIC_API_URL") || null;
|
||||
export const API_URL = NEXT_PUBLIC_API_URL ||"https://api.anixart.app";
|
||||
export const API_PREFIX = NEXT_PUBLIC_API_URL || "/api/proxy";
|
||||
export const USER_AGENT =
|
||||
"AnixartApp/8.2.1-23121216 (Android 9; SDK 28; arm64-v8a; samsung SM-G975N; en)";
|
||||
"AnixartApp/9.0 BETA 5-25062213 (Android 9; SDK 28; arm64-v8a; samsung SM-G975N; en)";
|
||||
|
||||
export const ENDPOINTS = {
|
||||
release: {
|
||||
|
|
|
@ -323,6 +323,7 @@ export async function _FetchHomePageReleases(
|
|||
|
||||
const data: Object = fetch(url, {
|
||||
method: "POST",
|
||||
headers: HEADERS,
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then((response) => {
|
||||
|
|
|
@ -42,6 +42,7 @@ export const CommentsAddModal = (props: {
|
|||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
|
|
|
@ -32,6 +32,7 @@ export const CommentsEditModal = (props: {
|
|||
}
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { FileInput, Label, Modal, ModalBody, ModalHeader, useThemeMode } from "flowbite-react";
|
||||
import {
|
||||
FileInput,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalHeader,
|
||||
useThemeMode,
|
||||
} from "flowbite-react";
|
||||
import { Spinner } from "../Spinner/Spinner";
|
||||
import useSWR from "swr";
|
||||
import { ENDPOINTS } from "#/api/config";
|
||||
|
@ -143,6 +150,7 @@ export const ProfileEditModal = (props: {
|
|||
const { data, error } = await tryCatchAPI(
|
||||
fetch(`${ENDPOINTS.user.settings.avatar}?token=${props.token}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: formData,
|
||||
})
|
||||
);
|
||||
|
@ -188,7 +196,7 @@ export const ProfileEditModal = (props: {
|
|||
if (avatarModalProps.croppedImage) {
|
||||
_uploadAvatar();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [avatarModalProps.croppedImage]);
|
||||
|
||||
if (!prefData || !loginData || prefError || loginError) {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { tryCatchPlayer, tryCatchAPI } from "#/api/utils";
|
||||
import { env } from 'next-runtime-env';
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
export async function _fetchAPI(
|
||||
url: string,
|
||||
|
@ -62,147 +62,33 @@ export async function _fetchPlayer(
|
|||
return data;
|
||||
}
|
||||
|
||||
function decryptKodikLink(enc: string) {
|
||||
const decryptedBase64 = enc.replace(/[a-zA-Z]/g, (e: any) => {
|
||||
return String.fromCharCode(
|
||||
(e <= "Z" ? 90 : 122) >= (e = e.charCodeAt(0) + 18) ? e : e - 26
|
||||
);
|
||||
});
|
||||
return atob(decryptedBase64);
|
||||
}
|
||||
|
||||
export const _fetchKodikManifest = async (
|
||||
url: string,
|
||||
setPlayerError: (state) => void
|
||||
) => {
|
||||
// Fetch episode links via edge function
|
||||
const NEXT_PUBLIC_KODIK_PARSER_URL = env("NEXT_PUBLIC_KODIK_PARSER_URL")
|
||||
if (!NEXT_PUBLIC_KODIK_PARSER_URL) {
|
||||
const NEXT_PUBLIC_PLAYER_PARSER_URL = env("NEXT_PUBLIC_PLAYER_PARSER_URL");
|
||||
if (!NEXT_PUBLIC_PLAYER_PARSER_URL) {
|
||||
setPlayerError({
|
||||
message: "Источник не настроен",
|
||||
detail: "переменная 'NEXT_PUBLIC_KODIK_PARSER_URL' не обнаружена",
|
||||
message: "Плеер не настроен",
|
||||
detail: "переменная 'NEXT_PUBLIC_PLAYER_PARSER_URL' не обнаружена",
|
||||
});
|
||||
return { manifest: null, poster: null };
|
||||
}
|
||||
|
||||
const data = await _fetchPlayer(
|
||||
`${NEXT_PUBLIC_KODIK_PARSER_URL}/?url=${url}&player=kodik`,
|
||||
`${NEXT_PUBLIC_PLAYER_PARSER_URL}/?url=${url}&player=kodik`,
|
||||
setPlayerError
|
||||
);
|
||||
|
||||
if (data) {
|
||||
let lowQualityLink = data.links["360"][0].src; // we assume that 360p is always present
|
||||
|
||||
if (!lowQualityLink.includes("//")) {
|
||||
// check if link is encrypted, else do nothing
|
||||
lowQualityLink = decryptKodikLink(lowQualityLink);
|
||||
}
|
||||
|
||||
if (lowQualityLink.includes("https://")) {
|
||||
// strip the https prefix, since we add it manually
|
||||
lowQualityLink = lowQualityLink.replace("https://", "//");
|
||||
}
|
||||
|
||||
let manifest = `https:${lowQualityLink.replace("360.mp4:hls:", "")}`;
|
||||
let poster = `https:${lowQualityLink.replace("360.mp4:hls:manifest.m3u8", "thumb001.jpg")}`;
|
||||
|
||||
if (
|
||||
lowQualityLink.includes("animetvseries") ||
|
||||
lowQualityLink.includes("tvseries")
|
||||
) {
|
||||
// if link includes "animetvseries" or "tvseries" we need to construct manifest ourselves
|
||||
let blobTxt = "#EXTM3U\n";
|
||||
|
||||
if (data.links.hasOwnProperty("240")) {
|
||||
blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=427x240,BANDWIDTH=200000\n";
|
||||
let link = data.links["240"][0].src;
|
||||
let dec = null;
|
||||
link.includes("//") ?
|
||||
link.startsWith("https:") ?
|
||||
(blobTxt += `${link}\n`)
|
||||
: (blobTxt += `https:${link}\n`)
|
||||
: (dec = decryptKodikLink(link));
|
||||
|
||||
dec ?
|
||||
dec.startsWith("https:") ?
|
||||
(blobTxt += `${dec}\n`)
|
||||
: (blobTxt += `https:${dec}\n`)
|
||||
: null;
|
||||
}
|
||||
|
||||
if (data.links.hasOwnProperty("360")) {
|
||||
blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=578x360,BANDWIDTH=400000\n";
|
||||
let link = data.links["360"][0].src;
|
||||
let dec = null;
|
||||
link.includes("//") ?
|
||||
link.startsWith("https:") ?
|
||||
(blobTxt += `${link}\n`)
|
||||
: (blobTxt += `https:${link}\n`)
|
||||
: (dec = decryptKodikLink(link));
|
||||
|
||||
dec ?
|
||||
dec.startsWith("https:") ?
|
||||
(blobTxt += `${dec}\n`)
|
||||
: (blobTxt += `https:${dec}\n`)
|
||||
: null;
|
||||
}
|
||||
|
||||
if (data.links.hasOwnProperty("480")) {
|
||||
blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=854x480,BANDWIDTH=596000\n";
|
||||
let link = data.links["480"][0].src;
|
||||
let dec = null;
|
||||
link.includes("//") ?
|
||||
link.startsWith("https:") ?
|
||||
(blobTxt += `${link}\n`)
|
||||
: (blobTxt += `https:${link}\n`)
|
||||
: (dec = decryptKodikLink(link));
|
||||
|
||||
dec ?
|
||||
dec.startsWith("https:") ?
|
||||
(blobTxt += `${dec}\n`)
|
||||
: (blobTxt += `https:${dec}\n`)
|
||||
: null;
|
||||
}
|
||||
|
||||
if (data.links.hasOwnProperty("720")) {
|
||||
blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=1280x720,BANDWIDTH=1280000\n";
|
||||
let link = data.links["720"][0].src;
|
||||
let dec = null;
|
||||
link.includes("//") ?
|
||||
link.startsWith("https:") ?
|
||||
(blobTxt += `${link}\n`)
|
||||
: (blobTxt += `https:${link}\n`)
|
||||
: (dec = decryptKodikLink(link));
|
||||
|
||||
dec ?
|
||||
dec.startsWith("https:") ?
|
||||
(blobTxt += `${dec}\n`)
|
||||
: (blobTxt += `https:${dec}\n`)
|
||||
: null;
|
||||
}
|
||||
|
||||
if (data.links.hasOwnProperty("1080")) {
|
||||
blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=1920x1080,BANDWIDTH=2560000\n";
|
||||
let link = data.links["1080"][0].src;
|
||||
let dec = null;
|
||||
link.includes("//") ?
|
||||
link.startsWith("https:") ?
|
||||
(blobTxt += `${link}\n`)
|
||||
: (blobTxt += `https:${link}\n`)
|
||||
: (dec = decryptKodikLink(link));
|
||||
|
||||
dec ?
|
||||
dec.startsWith("https:") ?
|
||||
(blobTxt += `${dec}\n`)
|
||||
: (blobTxt += `https:${dec}\n`)
|
||||
: null;
|
||||
}
|
||||
|
||||
let file = new File([blobTxt], "manifest.m3u8", {
|
||||
let manifest: string = data.manifest;
|
||||
if (!manifest.startsWith("http")) {
|
||||
let file = new File([manifest], "manifest.m3u8", {
|
||||
type: "application/x-mpegURL",
|
||||
});
|
||||
manifest = URL.createObjectURL(file);
|
||||
}
|
||||
return { manifest, poster };
|
||||
return { manifest, poster: data.poster };
|
||||
}
|
||||
return { manifest: null, poster: null };
|
||||
};
|
||||
|
@ -211,32 +97,26 @@ export const _fetchAnilibriaManifest = async (
|
|||
url: string,
|
||||
setPlayerError: (state) => void
|
||||
) => {
|
||||
const id = url.split("?id=")[1].split("&ep=")[0];
|
||||
const epid = url.split("?id=")[1].split("&ep=")[1];
|
||||
const _url = `https://api.anilibria.tv/v3/title?id=${id}`;
|
||||
let data = null;
|
||||
const NEXT_PUBLIC_ANILIBRIA_PARSER_URL = env("NEXT_PUBLIC_ANILIBRIA_PARSER_URL")
|
||||
if (NEXT_PUBLIC_ANILIBRIA_PARSER_URL) {
|
||||
data = await _fetchPlayer(
|
||||
`${NEXT_PUBLIC_ANILIBRIA_PARSER_URL}/?url=${_url}&player=libria`,
|
||||
setPlayerError
|
||||
);
|
||||
} else {
|
||||
data = await _fetchPlayer(_url, setPlayerError);
|
||||
const NEXT_PUBLIC_PLAYER_PARSER_URL = env("NEXT_PUBLIC_PLAYER_PARSER_URL");
|
||||
if (!NEXT_PUBLIC_PLAYER_PARSER_URL) {
|
||||
setPlayerError({
|
||||
message: "Плеер не настроен",
|
||||
detail: "переменная 'NEXT_PUBLIC_PLAYER_PARSER_URL' не обнаружена",
|
||||
});
|
||||
return { manifest: null, poster: null };
|
||||
}
|
||||
|
||||
if (data) {
|
||||
const host = `https://${data.player.host}`;
|
||||
const ep = data.player.list[epid];
|
||||
const data = await _fetchPlayer(
|
||||
`${NEXT_PUBLIC_PLAYER_PARSER_URL}/?url=${encodeURIComponent(url)}&player=libria`,
|
||||
setPlayerError
|
||||
);
|
||||
|
||||
// we need to manually construct a manifest file for a hls player
|
||||
const blobTxt = `#EXTM3U\n${ep.hls.sd && `#EXT-X-STREAM-INF:RESOLUTION=854x480,BANDWIDTH=596000\n${host}${ep.hls.sd}\n`}${ep.hls.hd && `#EXT-X-STREAM-INF:RESOLUTION=1280x720,BANDWIDTH=1280000\n${host}${ep.hls.hd}\n`}${ep.hls.fhd && `#EXT-X-STREAM-INF:RESOLUTION=1920x1080,BANDWIDTH=2560000\n${host}${ep.hls.fhd}\n`}`;
|
||||
let file = new File([blobTxt], "manifest.m3u8", {
|
||||
if (data) {
|
||||
let file = new File([data.manifest], "manifest.m3u8", {
|
||||
type: "application/x-mpegURL",
|
||||
});
|
||||
let manifest = URL.createObjectURL(file);
|
||||
let poster = `https://anixart.libria.fun${ep.preview}`;
|
||||
return { manifest, poster };
|
||||
return { manifest, poster: data.poster };
|
||||
}
|
||||
return { manifest: null, poster: null };
|
||||
};
|
||||
|
@ -245,23 +125,22 @@ export const _fetchSibnetManifest = async (
|
|||
url: string,
|
||||
setPlayerError: (state) => void
|
||||
) => {
|
||||
// Fetch data via cloud endpoint
|
||||
const NEXT_PUBLIC_SIBNET_PARSER_URL = env("NEXT_PUBLIC_SIBNET_PARSER_URL")
|
||||
if (!NEXT_PUBLIC_SIBNET_PARSER_URL) {
|
||||
const NEXT_PUBLIC_PLAYER_PARSER_URL = env("NEXT_PUBLIC_PLAYER_PARSER_URL");
|
||||
if (!NEXT_PUBLIC_PLAYER_PARSER_URL) {
|
||||
setPlayerError({
|
||||
message: "Источник не настроен",
|
||||
detail: "переменная 'NEXT_PUBLIC_SIBNET_PARSER_URL' не обнаружена",
|
||||
message: "Плеер не настроен",
|
||||
detail: "переменная 'NEXT_PUBLIC_PLAYER_PARSER_URL' не обнаружена",
|
||||
});
|
||||
return { manifest: null, poster: null };
|
||||
}
|
||||
|
||||
const data = await _fetchPlayer(
|
||||
`${NEXT_PUBLIC_SIBNET_PARSER_URL}/?url=${url}&player=sibnet`,
|
||||
`${NEXT_PUBLIC_PLAYER_PARSER_URL}/?url=${url}&player=sibnet`,
|
||||
setPlayerError
|
||||
);
|
||||
|
||||
if (data) {
|
||||
let manifest = data.video;
|
||||
let poster = data.poster;
|
||||
return { manifest, poster };
|
||||
return { manifest: data.manifest, poster: data.poster };
|
||||
}
|
||||
return { manifest: null, poster: null };
|
||||
};
|
||||
|
|
|
@ -125,6 +125,22 @@ export const ReleasePlayerCustom = (props: {
|
|||
}
|
||||
return;
|
||||
}
|
||||
if (episode.selected.url.toLowerCase().endsWith(".mp4")) {
|
||||
SetPlayerProps({
|
||||
src: episode.selected.url,
|
||||
poster: null,
|
||||
type: "mp4",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (episode.selected.url.toLowerCase().endsWith(".m3u8")) {
|
||||
SetPlayerProps({
|
||||
src: episode.selected.url,
|
||||
poster: null,
|
||||
type: "hls",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPlayerError({
|
||||
message: `Источник "${source.selected.name}" не поддерживается`,
|
||||
detail: null,
|
||||
|
|
|
@ -16,6 +16,8 @@ import {
|
|||
useThemeMode,
|
||||
} from "flowbite-react";
|
||||
import Link from "next/link";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const HomeCategory = {
|
||||
last: "Последние релизы",
|
||||
|
@ -51,6 +53,14 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
|
|||
const userStore = useUserStore();
|
||||
|
||||
const { computedMode, setMode } = useThemeMode();
|
||||
const [isPlayerConfigured, setIsPlayerConfigured] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const NEXT_PUBLIC_PLAYER_PARSER_URL = env("NEXT_PUBLIC_PLAYER_PARSER_URL") || null;
|
||||
if (NEXT_PUBLIC_PLAYER_PARSER_URL) {
|
||||
setIsPlayerConfigured(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
|
@ -290,6 +300,7 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
|
|||
})
|
||||
}
|
||||
checked={preferenceStore.params.experimental.newPlayer}
|
||||
disabled={!isPlayerConfigured}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -188,6 +188,7 @@ export const CreateCollectionPage = () => {
|
|||
const { data, error } = await tryCatchAPI(
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...collectionInfo,
|
||||
is_private: isPrivate,
|
||||
|
|
3
player-parser/.dockerignore
Normal file
3
player-parser/.dockerignore
Normal file
|
@ -0,0 +1,3 @@
|
|||
node_modules
|
||||
README.md
|
||||
README.RU.md
|
|
@ -10,6 +10,5 @@ RUN npm ci
|
|||
COPY *.ts ./
|
||||
|
||||
EXPOSE 7000
|
||||
ENV PORT=7000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["npm", "run", "serve"]
|
|
@ -22,9 +22,6 @@
|
|||
- VIDEO_URL - ссылка на видео от источника
|
||||
- PLAYER_SOURCE - источник, один из: kodik, sibnet, libria
|
||||
|
||||
> [!NOTE]
|
||||
> Если используется источник libria, ссылка должна быть ссылкой на API anilibria, а не на плеер
|
||||
|
||||
Ответ:
|
||||
|
||||
- 500|400: произошла ошибка, подробнее в строке `message` в теле ответа
|
|
@ -22,9 +22,6 @@ where:
|
|||
- VIDEO_URL - the link to the video from the source
|
||||
- PLAYER_SOURCE - the source, one of: kodik, sibnet, libria
|
||||
|
||||
> [!NOTE]
|
||||
> When using libria source, url should be the url to the anilibria api, not player directly
|
||||
|
||||
Response:
|
||||
|
||||
- 500|400: an error occurred, see the `message` field in the response body for details
|
62
player-parser/index.ts
Normal file
62
player-parser/index.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { asJSON } from "./shared";
|
||||
import { getAnilibriaURL } from "./libria";
|
||||
import { getSibnetURL } from "./sibnet";
|
||||
import { getKodikURL } from "./kodik";
|
||||
|
||||
import express from "express";
|
||||
const app = express();
|
||||
app.use(function (req, res, next) {
|
||||
res.header("Access-Control-Allow-Origin", req.headers.origin || "*");
|
||||
res.header(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Origin, X-Requested-With, Content-Type, Accept"
|
||||
);
|
||||
res.header("Access-Control-Allow-Methods", "GET,HEAD,POST,OPTIONS");
|
||||
next();
|
||||
});
|
||||
|
||||
const HOST = process.env.HOST || "0.0.0.0";
|
||||
const PORT = 7000;
|
||||
const allowedPlayers = ["kodik", "libria", "sibnet"];
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
const urlParams = new URLSearchParams(req.query);
|
||||
const url = urlParams.get("url");
|
||||
const player = urlParams.get("player");
|
||||
|
||||
if (!url) {
|
||||
asJSON(req, res, { message: "no 'url' query provided" }, 400);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!player) {
|
||||
asJSON(req, res, { message: "no 'player' query provided" }, 400);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (player) {
|
||||
case "libria":
|
||||
getAnilibriaURL(req, res, url);
|
||||
return;
|
||||
case "sibnet":
|
||||
getSibnetURL(req, res, url);
|
||||
return;
|
||||
case "kodik":
|
||||
getKodikURL(req, res, url);
|
||||
return;
|
||||
default:
|
||||
asJSON(
|
||||
req,
|
||||
res,
|
||||
{
|
||||
message: `player '${player}' is not supported. choose one of: ${allowedPlayers.join(", ")}`,
|
||||
},
|
||||
400
|
||||
);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, HOST, function () {
|
||||
console.log(`Server listens http://${HOST}:${PORT}`);
|
||||
});
|
177
player-parser/kodik.ts
Normal file
177
player-parser/kodik.ts
Normal file
|
@ -0,0 +1,177 @@
|
|||
import { asJSON, randomUA } from "./shared";
|
||||
const altDomains = ["kodik.info", "aniqit.com", "kodik.cc", "kodik.biz"];
|
||||
|
||||
export async function getKodikURL(req, res, url: string) {
|
||||
const origDomain = url.replace("https://", "").split("/")[0];
|
||||
let domain = url.replace("https://", "").split("/")[0];
|
||||
|
||||
if (!altDomains.includes(domain)) {
|
||||
asJSON(req, res, { message: "KODIK: Неправильная ссылка на плеер" }, 400);
|
||||
return;
|
||||
}
|
||||
|
||||
let user_agent = randomUA();
|
||||
|
||||
let pageRes = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": user_agent,
|
||||
},
|
||||
});
|
||||
|
||||
if (!pageRes.ok) {
|
||||
for (let i = 0; i < altDomains.length; i++) {
|
||||
if (url.includes(altDomains[i])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
user_agent = randomUA();
|
||||
const altDomain = altDomains[i];
|
||||
const altUrl = url.replace(
|
||||
`https://${origDomain}/`,
|
||||
`https://${altDomain}/`
|
||||
);
|
||||
|
||||
domain = altDomain;
|
||||
pageRes = await fetch(altUrl, {
|
||||
headers: {
|
||||
"User-Agent": user_agent,
|
||||
},
|
||||
});
|
||||
|
||||
if (pageRes.ok) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!pageRes.ok) {
|
||||
asJSON(req, res, { message: "KODIK: Не удалось загрузить страницу с плеером" }, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
const pageData = await pageRes.text();
|
||||
const urlParamsRe = /var urlParams = .*;$/m;
|
||||
const urlParamsMatch = urlParamsRe.exec(pageData);
|
||||
|
||||
if (!urlParamsMatch || urlParamsMatch.length == 0) {
|
||||
asJSON(req, res, { message: `KODIK: Не удалось найти данные эпизода` }, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
const urlParamsStr = urlParamsMatch[0]
|
||||
.replace("var urlParams = '", "")
|
||||
.replace("';", "");
|
||||
|
||||
const urlStr = url.replace(`https://${origDomain}/`, "");
|
||||
const type = urlStr.split("/")[0];
|
||||
const id = urlStr.split("/")[1];
|
||||
const hash = urlStr.split("/")[2];
|
||||
|
||||
const urlParams = JSON.parse(urlParamsStr);
|
||||
urlParams["type"] = type;
|
||||
urlParams["id"] = id;
|
||||
urlParams["hash"] = hash;
|
||||
|
||||
const formData = new FormData();
|
||||
for (const [key, value] of Object.entries(urlParams)) {
|
||||
formData.append(key, value as any);
|
||||
}
|
||||
|
||||
const linksRes = await fetch(`https://${domain}/ftor`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers: {
|
||||
"User-Agent": user_agent,
|
||||
},
|
||||
});
|
||||
|
||||
if (!linksRes.ok) {
|
||||
asJSON(req, res, { message: `KODIK: Не удалось получить прямую ссылку` }, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
let data = stripResponse(await linksRes.json());
|
||||
if (isEncrypted(data)) {
|
||||
for (const [key] of Object.entries(data.links)) {
|
||||
data.links[key][0].src = decryptSrc(data.links[key][0].src);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasProto(data)) {
|
||||
for (const [key] of Object.entries(data.links)) {
|
||||
data.links[key][0].src = addProto(data.links[key][0].src);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAnimeTvSeries(data)) {
|
||||
data["manifest"] = data.links[data.default][0].src.replace(
|
||||
`${data.default}.mp4:hls:`,
|
||||
""
|
||||
);
|
||||
} else {
|
||||
data["manifest"] = createManifest(data);
|
||||
}
|
||||
|
||||
data["poster"] = data.links[data.default][0].src.replace(
|
||||
`${data.default}.mp4:hls:manifest.m3u8`,
|
||||
"thumb001.jpg"
|
||||
);
|
||||
|
||||
asJSON(req, res, data, 200);
|
||||
return;
|
||||
}
|
||||
|
||||
function stripResponse(data) {
|
||||
return {
|
||||
default: data.default,
|
||||
links: data.links,
|
||||
};
|
||||
}
|
||||
|
||||
function isEncrypted(data) {
|
||||
return !data.links[data.default][0].src.includes("//");
|
||||
}
|
||||
|
||||
function decryptSrc(enc: string) {
|
||||
const decryptedBase64 = enc.replace(/[a-zA-Z]/g, (e: any) => {
|
||||
return String.fromCharCode(
|
||||
(e <= "Z" ? 90 : 122) >= (e = e.charCodeAt(0) + 18) ? e : e - 26
|
||||
);
|
||||
});
|
||||
return atob(decryptedBase64);
|
||||
}
|
||||
|
||||
function hasProto(data) {
|
||||
return data.links[data.default][0].src.startsWith("http");
|
||||
}
|
||||
|
||||
function addProto(string) {
|
||||
return `https:${string}`;
|
||||
}
|
||||
|
||||
function isAnimeTvSeries(data) {
|
||||
return (
|
||||
data.links[data.default][0].src.includes("animetvseries") ||
|
||||
data.links[data.default][0].src.includes("tvseries")
|
||||
);
|
||||
}
|
||||
|
||||
function createManifest(data) {
|
||||
const resolutions = {
|
||||
240: "427x240",
|
||||
360: "578x360",
|
||||
480: "854x480",
|
||||
720: "1280x720",
|
||||
1080: "1920x1080",
|
||||
};
|
||||
|
||||
const stringBuilder: string[] = [];
|
||||
|
||||
stringBuilder.push("#EXTM3U");
|
||||
for (const [key] of Object.entries(data.links)) {
|
||||
stringBuilder.push(`#EXT-X-STREAM-INF:RESOLUTION=${resolutions[key]}`);
|
||||
stringBuilder.push(data.links[key][0].src);
|
||||
}
|
||||
|
||||
return stringBuilder.join("\n");
|
||||
}
|
135
player-parser/libria.ts
Normal file
135
player-parser/libria.ts
Normal file
|
@ -0,0 +1,135 @@
|
|||
import { asJSON } from "./shared";
|
||||
|
||||
export interface APIStatusResponse {
|
||||
request: Request;
|
||||
is_alive: boolean;
|
||||
available_api_endpoints: string[];
|
||||
}
|
||||
|
||||
export interface Request {
|
||||
ip: string;
|
||||
country: string;
|
||||
iso_code: string;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
async function checkApiStatus(req, res) {
|
||||
const endpoints = ["https://anilibria.top", "https://anilibria.wtf"];
|
||||
let selectedEndpoint: string | null = null;
|
||||
|
||||
for (let i = 0; i < endpoints.length; i++) {
|
||||
const endpoint = endpoints[i];
|
||||
const apiRes = await fetch(`${endpoint}/api/v1/app/status`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (apiRes.ok) {
|
||||
const data: APIStatusResponse = await apiRes.json();
|
||||
if (data.is_alive != true) {
|
||||
asJSON(req, res, { message: "LIBRIA: API сервер не доступен" }, 500);
|
||||
return null;
|
||||
}
|
||||
selectedEndpoint = endpoint;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedEndpoint) {
|
||||
asJSON(req, res, { message: "LIBRIA: Нет доступных эндпоинтов API" }, 500);
|
||||
return null;
|
||||
}
|
||||
|
||||
return selectedEndpoint;
|
||||
}
|
||||
|
||||
export async function getAnilibriaURL(req, res, url: string) {
|
||||
if (!url.includes("libria")) {
|
||||
asJSON(req, res, { message: "LIBRIA: Неправильная ссылка на плеер" }, 400);
|
||||
return;
|
||||
}
|
||||
|
||||
const apiEndpoint = await checkApiStatus(req, res);
|
||||
if (!apiEndpoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
const decodedUrl = new URL(url);
|
||||
|
||||
const releaseId = decodedUrl.searchParams.get("id") || null;
|
||||
const releaseEp = decodedUrl.searchParams.get("ep") || null;
|
||||
|
||||
let apiRes = await fetch(`${apiEndpoint}/api/v1/anime/releases/${releaseId}`);
|
||||
if (!apiRes.ok) {
|
||||
if (apiRes.status == 404) {
|
||||
asJSON(req, res, { message: "LIBRIA: Релиз не найден" }, 404);
|
||||
return;
|
||||
}
|
||||
|
||||
asJSON(
|
||||
req,
|
||||
res,
|
||||
{ message: "LIBRIA: Ошибка получения ответа от API" },
|
||||
500
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let data = stripResponse(req, res, await apiRes.json(), releaseEp);
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (releaseEp) {
|
||||
data["manifest"] = createManifest(data);
|
||||
data["poster"] = getPoster(data);
|
||||
}
|
||||
|
||||
asJSON(req, res, data, 200);
|
||||
return;
|
||||
}
|
||||
|
||||
function stripResponse(req, res, data, releaseEp) {
|
||||
const resp = {};
|
||||
resp["posters"] = data.poster;
|
||||
resp["episodes"] = data.episodes;
|
||||
|
||||
if (releaseEp) {
|
||||
const episode = data.episodes.find((item) => item.ordinal == releaseEp);
|
||||
if (!episode) {
|
||||
asJSON(req, res, { message: "LIBRIA: Эпизод не найден" }, 404);
|
||||
return null;
|
||||
}
|
||||
resp["episodes"] = [episode];
|
||||
}
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
function createManifest(data) {
|
||||
const episode = data.episodes[0];
|
||||
const resolutions = {
|
||||
hls_480: "854x480",
|
||||
hls_720: "1280x720",
|
||||
hls_1080: "1920x1080",
|
||||
};
|
||||
|
||||
const stringBuilder: string[] = [];
|
||||
|
||||
stringBuilder.push("#EXTM3U");
|
||||
for (const [key, value] of Object.entries(resolutions)) {
|
||||
if (!episode[key]) continue;
|
||||
stringBuilder.push(`#EXT-X-STREAM-INF:RESOLUTION=${value}`);
|
||||
const url = new URL(episode[key]);
|
||||
url.search = "";
|
||||
stringBuilder.push(url.toString());
|
||||
}
|
||||
|
||||
return stringBuilder.join("\n");
|
||||
}
|
||||
|
||||
function getPoster(data) {
|
||||
const episode = data.episodes[0];
|
||||
|
||||
if (episode.preview && episode.preview.preview)
|
||||
return `https://anixart.libria.fun${episode.preview.preview}`;
|
||||
return `https://anilibria.top${data.poster.preview}`;
|
||||
}
|
|
@ -1,14 +1,10 @@
|
|||
export const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Origin, X-Requested-With, Content-Type, Accept",
|
||||
"Access-Control-Allow-Methods": "GET,HEAD,POST,OPTIONS",
|
||||
"Cache-Control": "no-cache",
|
||||
};
|
||||
|
||||
export const resHeaders = {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
export const USERAGENTS = [
|
||||
"Mozilla/5.0 (Linux; Android 12.0; LG G8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.2.7124.71 Mobile Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.5.1269.13 Safari/537.36",
|
||||
|
@ -37,7 +33,9 @@ export const USERAGENTS = [
|
|||
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:128.0) Gecko/128.0 Firefox/128.0",
|
||||
];
|
||||
|
||||
export function asJSON(res, object: any, status: number) {
|
||||
export function asJSON(req, res, object: any, status: number) {
|
||||
corsHeaders["Access-Control-Allow-Origin"] = req.headers.origin || "*";
|
||||
|
||||
res.status(status).type("application/json");
|
||||
res.set(corsHeaders);
|
||||
res.send(JSON.stringify(object));
|
|
@ -1,9 +1,9 @@
|
|||
import { asJSON, randomUA } from "./shared";
|
||||
|
||||
export async function getSibnetURL(res, url: string) {
|
||||
export async function getSibnetURL(req, res, url: string) {
|
||||
|
||||
if (!url.includes("sibnet")) {
|
||||
asJSON(res, { message: "Wrong url provided for player sibnet" }, 400);
|
||||
asJSON(req, res, { message: "SIBNET: Неправильная ссылка на плеер" }, 400);
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -15,7 +15,7 @@ export async function getSibnetURL(res, url: string) {
|
|||
},
|
||||
});
|
||||
if (!pageRes.ok) {
|
||||
asJSON(res, { message: `SIBNET:${pageRes.status}: failed to load page` }, 500)
|
||||
asJSON(req, res, { message: `SIBNET: Не удалось загрузить страницу с плеером` }, 500)
|
||||
return
|
||||
}
|
||||
const pageData = await pageRes.text();
|
||||
|
@ -23,7 +23,7 @@ export async function getSibnetURL(res, url: string) {
|
|||
const videoMatch = videoRe.exec(pageData);
|
||||
|
||||
if (!videoMatch || videoMatch.length == 0) {
|
||||
asJSON(res, { message: `SIBNET: failed to find data to parse` }, 500)
|
||||
asJSON(req, res, { message: `SIBNET: Не удалось найти данные эпизода` }, 500)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -42,11 +42,11 @@ export async function getSibnetURL(res, url: string) {
|
|||
);
|
||||
|
||||
if (!actualVideoRes.headers.get("location")) {
|
||||
asJSON(res, { message: `SIBNET: failed to get video link` }, 500)
|
||||
asJSON(req, res, { message: `SIBNET: Не удалось получить прямую ссылку` }, 500)
|
||||
return
|
||||
}
|
||||
|
||||
const video = actualVideoRes.headers.get("location");
|
||||
const video = `https:${actualVideoRes.headers.get("location")}`;
|
||||
const poster =
|
||||
posterMatch ?
|
||||
posterMatch.length > 0 ?
|
||||
|
@ -54,6 +54,6 @@ export async function getSibnetURL(res, url: string) {
|
|||
: null
|
||||
: null;
|
||||
|
||||
asJSON(res, { video, poster }, 200)
|
||||
asJSON(req, res, { manifest: video, poster }, 200)
|
||||
return
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
node_modules
|
||||
README.md
|
|
@ -1,46 +0,0 @@
|
|||
import { asJSON } from "./shared";
|
||||
import { getAnilibriaURL } from "./libria";
|
||||
import { getSibnetURL } from "./sibnet";
|
||||
import { getKodikURL } from "./kodik";
|
||||
|
||||
import express from "express";
|
||||
const app = express();
|
||||
|
||||
const host = "0.0.0.0";
|
||||
const port = 7000;
|
||||
const allowedPlayers = ["kodik", "libria", "sibnet"];
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
const urlParams = new URLSearchParams(req.query)
|
||||
const url = urlParams.get("url");
|
||||
const player = urlParams.get("player");
|
||||
|
||||
if (!url) {
|
||||
asJSON(res, { message: "no 'url' query provided" }, 400)
|
||||
return
|
||||
}
|
||||
|
||||
if (!player) {
|
||||
asJSON(res, { message: "no 'player' query provided" }, 400)
|
||||
return
|
||||
}
|
||||
|
||||
switch (player) {
|
||||
case "libria":
|
||||
getAnilibriaURL(res, url)
|
||||
return
|
||||
case "sibnet":
|
||||
getSibnetURL(res, url)
|
||||
return
|
||||
case "kodik":
|
||||
getKodikURL(res, url)
|
||||
return
|
||||
default:
|
||||
asJSON(res, { message: `player '${player}' is not supported. choose one of: ${allowedPlayers.join(", ")}` }, 400)
|
||||
return
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(port, host, function () {
|
||||
console.log(`Server listens http://${host}:${port}`);
|
||||
});
|
|
@ -1,95 +0,0 @@
|
|||
import { asJSON, randomUA } from "./shared";
|
||||
const altDomains = ["kodik.info", "aniqit.com", "kodik.cc", "kodik.biz"];
|
||||
|
||||
export async function getKodikURL(res, url: string) {
|
||||
const origDomain = url.replace("https://", "").split("/")[0];
|
||||
let domain = url.replace("https://", "").split("/")[0];
|
||||
|
||||
if (!altDomains.includes(domain)) {
|
||||
asJSON(res, { message: "Wrong url provided for player kodik" }, 400);
|
||||
return;
|
||||
}
|
||||
|
||||
let user_agent = randomUA();
|
||||
|
||||
let pageRes = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": user_agent,
|
||||
},
|
||||
});
|
||||
|
||||
if (!pageRes.ok) {
|
||||
for (let i = 0; i < altDomains.length; i++) {
|
||||
if (url.includes(altDomains[i])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
user_agent = randomUA();
|
||||
const altDomain = altDomains[i];
|
||||
const altUrl = url.replace(
|
||||
`https://${origDomain}/`,
|
||||
`https://${altDomain}/`
|
||||
);
|
||||
|
||||
domain = altDomain;
|
||||
pageRes = await fetch(altUrl, {
|
||||
headers: {
|
||||
"User-Agent": user_agent,
|
||||
},
|
||||
});
|
||||
|
||||
if (pageRes.ok) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!pageRes.ok) {
|
||||
asJSON(res, { message: "KODIK: failed to load page" }, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
const pageData = await pageRes.text();
|
||||
const urlParamsRe = /var urlParams = .*;$/m;
|
||||
const urlParamsMatch = urlParamsRe.exec(pageData);
|
||||
|
||||
if (!urlParamsMatch || urlParamsMatch.length == 0) {
|
||||
asJSON(res, { message: `KODIK: failed to find data to parse` }, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
const urlParamsStr = urlParamsMatch[0]
|
||||
.replace("var urlParams = '", "")
|
||||
.replace("';", "");
|
||||
|
||||
const urlStr = url.replace(`https://${origDomain}/`, "");
|
||||
const type = urlStr.split("/")[0];
|
||||
const id = urlStr.split("/")[1];
|
||||
const hash = urlStr.split("/")[2];
|
||||
|
||||
const urlParams = JSON.parse(urlParamsStr);
|
||||
urlParams["type"] = type;
|
||||
urlParams["id"] = id;
|
||||
urlParams["hash"] = hash;
|
||||
|
||||
const formData = new FormData();
|
||||
for (const [key, value] of Object.entries(urlParams)) {
|
||||
formData.append(key, value as any);
|
||||
}
|
||||
|
||||
const linksRes = await fetch(`https://${domain}/ftor`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers: {
|
||||
"User-Agent": user_agent,
|
||||
},
|
||||
});
|
||||
|
||||
if (!linksRes.ok) {
|
||||
asJSON(res, { message: `KODIK: failed to get links` }, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
asJSON(res, await linksRes.json(), 200);
|
||||
return;
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
import { asJSON } from "./shared";
|
||||
|
||||
export async function getAnilibriaURL(res, url: string) {
|
||||
|
||||
if (!url.includes("anilibria")) {
|
||||
asJSON(res, { message: "Wrong url provided for player libria" }, 400);
|
||||
return
|
||||
}
|
||||
|
||||
let apiRes = await fetch(url);
|
||||
if (!apiRes.ok) {
|
||||
asJSON(res, { message: "LIBRIA: failed to get api response" }, 500);
|
||||
return
|
||||
}
|
||||
asJSON(res, await apiRes.json(), 200);
|
||||
return
|
||||
}
|
|
@ -33,5 +33,5 @@
|
|||
"**/*.tsx",
|
||||
"next.config.js"
|
||||
],
|
||||
"exclude": ["node_modules", "player-parsers"]
|
||||
"exclude": ["node_modules", "player-parser", "api-prox"]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue